Compare commits

...
9 Commits
Author SHA1 Message Date
lucianoandClaude Opus 4.7 14e8e0fff5 tools: MUSICTOOLS_SSH env override per gen-license.sh
Build MusicTools / build-windows (push) Waiting to run
Build MusicTools / build-macos (push) Waiting to run
Server musictools.djluza.com può essere irraggiungibile via internet
mentre la LAN sì. Nuovo override:
  MUSICTOOLS_SSH="musictools@192.168.30.250" ./gen-license.sh ...
Senza override continua a usare il dominio pubblico come default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-09 14:41:00 +02:00
lucianoandClaude Opus 4.7 8266722654 ui: tab Diritti con tabella streaming + export CSV + settings Jamendo
- Nav item "⚖️ Diritti" nel gruppo Organizza (dopo Upgrade), feature=audio
- View #view-royalty: hero amber, folder picker, checkbox recursive,
  progress bar, tabella streaming (file · artista · titolo · stato ·
  match+licenza) con badge colorati
- Hint esplicito: "Non trovato" NON implica SIAE
- Modulo RoyaltyUI (pattern CatalogUI/DedupUI): streaming via
  royalty:progress / royalty:entry / royalty:done, link ai match
  aperti via open_external_url, export CSV client-side (Blob + download)
- Settings: nuovo campo jamendo_client_id (opzionale) + hint link
  devportal.jamendo.com; save/load via save_settings
- CSS: .royalty-scroll, .royalty-table, .royalty-badge-*,
  .royalty-row-rf/error, .progress-fill.amber, .percent.amber

node --check webui/js/app.js OK · 184 test verdi.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-09 14:29:45 +02:00
lucianoandClaude Opus 4.7 ddacd050d8 bridge: metodi Api per tab Diritti + bump v1.14.0 + config jamendo_client_id
- VERSION v1.13.0 -> v1.14.0
- DEFAULTS: royalty_last_folder, royalty_recursive, jamendo_client_id
- save_settings: persiste jamendo_client_id
- Nuovi metodi Api:
    - royalty_pick_folder
    - royalty_start_scan {directory, recursive}
    - royalty_stop_scan
  piu' worker _royalty_worker che emette:
    - royalty:progress (per file: idx/total/filename/status + overall%)
    - royalty:entry (streaming per ogni risultato)
    - royalty:done (summary {ok, total, royalty_free_count,
                              unknown_count, error_count})

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-09 14:26:22 +02:00
lucianoandClaude Opus 4.7 018d310f53 royalty: modulo check ccMixter + Jamendo con cache SQLite
Nuova feature "Diritti": verifica royalty-free status di brani locali
contro DB CC/open (ccMixter sempre attivo, Jamendo opzionale via
jamendo_client_id in config). Approccio honest-only:

- status 'royalty_free' SOLO con match Levenshtein combinato >= 0.80
- status 'unknown' NON implica automaticamente SIAE (= da verificare)
- status 'error' per fingerprint fail / network / tag illeggibili

Pipeline:
- Scan AUDIO_EXTENSIONS da core.upgrader (ricorsivo opzionale)
- Legge artist/title da ID3 (core.metadata.read_metadata), fallback
  a core.catalog._parse_filename sul nome file
- Query parallele ccMixter + Jamendo con throttle 0.3s
- Cache SQLite in <config_dir>/royalty_cache.db

21 test verdi (mock requests via responses, cache isolata in tmp_path).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-09 14:25:10 +02:00
lucianoandClaude Opus 4.7 9644ffec35 feat: metadati AI detection + prompt/lyrics + pannello frame grezzi (v1.13.0)
Metadati tab estesa per audit e cleanup file AI-generated:

- Nuovi campi read/write: source_url (WOAS ID3), lyrics (USLT), plus
  \xa9lyr per M4A
- Badge AI con riconoscimento: Suno, Udio, Riffusion, Mubert, AIVA,
  Boomy, SoundRAW, Loudly + presenza C2PA manifest (GEOB frame)
- Pannello "Metadati avanzati" collapsable: tabella di tutti i frame
  ID3 grezzi (chiave, tipo text/binary, preview, size)
- Rimozione selettiva frame custom via bridge remove_metadata_frames:
  ID3.__delitem__() + save() per MP3, MP4.tags per M4A. Frame standard
  protetti (TIT2/TPE1/APIC ecc.) marcati 🔒 nella UI, si svuotano dai
  campi form
- Rilevamento AI riusabile via core.metadata._detect_ai(comment,
  source_url, extra) — case-insensitive, returns (is_ai, source_name)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-24 11:13:58 +02:00
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
12 changed files with 3532 additions and 26 deletions

No files matched your search

+251
View File
@@ -49,6 +49,8 @@ from core.upgrader import (
from core import dedup from core import dedup
from core import catalog from core import catalog
from core import flatten from core import flatten
from core import charts
from core import royalty_check as royalty
SPOTIFY_GUIDE_TEXT = """\ SPOTIFY_GUIDE_TEXT = """\
@@ -117,6 +119,7 @@ class Api:
self._dedup_thread: Optional[threading.Thread] = None self._dedup_thread: Optional[threading.Thread] = None
self._catalog_thread: Optional[threading.Thread] = None self._catalog_thread: Optional[threading.Thread] = None
self._flatten_thread: Optional[threading.Thread] = None self._flatten_thread: Optional[threading.Thread] = None
self._royalty_thread: Optional[threading.Thread] = None
# Coda usata dal resolve_callback per attendere la scelta utente # Coda usata dal resolve_callback per attendere la scelta utente
# sul modal "match locali multipli" della tab Upgrade. # sul modal "match locali multipli" della tab Upgrade.
self._upgrade_resolve_q: "queue.Queue[dict]" = queue.Queue(1) self._upgrade_resolve_q: "queue.Queue[dict]" = queue.Queue(1)
@@ -210,6 +213,7 @@ class Api:
"cookies_browser": (payload.get("cookies_browser") or "").strip().lower(), "cookies_browser": (payload.get("cookies_browser") or "").strip().lower(),
"output_dir": (payload.get("output_dir") or "").strip(), "output_dir": (payload.get("output_dir") or "").strip(),
"theme": payload.get("theme", "dark"), "theme": payload.get("theme", "dark"),
"jamendo_client_id": (payload.get("jamendo_client_id") or "").strip(),
}) })
save_config(config) save_config(config)
return {"ok": True} return {"ok": True}
@@ -445,6 +449,21 @@ class Api:
except Exception as e: except Exception as e:
return {"ok": False, "error": str(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: def save_metadata(self, payload: dict) -> dict:
path = (payload.get("path") or "").strip() path = (payload.get("path") or "").strip()
if not path: if not path:
@@ -2263,3 +2282,235 @@ class Api:
"n_matched": n_matched, "n_matched": n_matched,
"n_unmatched": n_unmatched, "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)}
# ================================================================
# DIRITTI — verifica royalty-free status (ccMixter + Jamendo)
# ================================================================
def royalty_pick_folder(self) -> str:
"""Folder picker per la cartella da analizzare."""
return self.browse_directory()
def royalty_start_scan(self, payload: dict) -> dict:
"""Avvia worker di scansione royalty-free.
Payload: {directory, recursive}.
Persiste `royalty_last_folder` e `royalty_recursive` in config.
"""
if self._royalty_thread and self._royalty_thread.is_alive():
return {"ok": False, "error": "Analisi diritti gia in corso"}
directory = (payload.get("directory") or "").strip()
recursive = bool(payload.get("recursive", True))
if not directory:
return {"ok": False, "error": "Cartella non impostata"}
if not os.path.isdir(directory):
return {"ok": False, "error": "Cartella non trovata"}
try:
cfg = load_config()
cfg["royalty_last_folder"] = directory
cfg["royalty_recursive"] = recursive
save_config(cfg)
except Exception:
pass
self._royalty_thread = threading.Thread(
target=self._royalty_worker,
args=(directory, recursive),
daemon=True,
)
self._royalty_thread.start()
return {"ok": True}
def royalty_stop_scan(self) -> dict:
royalty.request_stop()
self._log("royalty", "[INFO] Interruzione richiesta...")
return {"ok": True}
def _royalty_worker(self, directory: str, recursive: bool) -> None:
"""Esegue scan royalty-free in background, emette streaming."""
view = "royalty"
royalty.reset_stop()
self._log(view,
f"[INFO] Analisi diritti: {directory} (recursive={recursive})")
_last = [0.0]
_THROTTLE = 0.05
def progress_cb(idx: int, total: int, filename: str,
status: str, err_msg: str = "") -> None:
if status == "checking":
now = time.monotonic()
if now - _last[0] < _THROTTLE and idx != total:
return
_last[0] = now
payload_evt = {
"idx": idx, "total": total,
"filename": filename, "status": status,
}
if err_msg:
payload_evt["error_msg"] = err_msg
if total > 0:
payload_evt["overall"] = min(idx / total, 1.0)
if status == "completed":
payload_evt["overall"] = 1.0
self._log(view, f"[INFO] Analisi completata ({total} file).")
elif status == "stopped":
self._log(view, "[INFO] Analisi interrotta.")
elif status == "error" and filename:
detail = f": {err_msg}" if err_msg else ""
self._log(view, f"[ERRORE] {filename}{detail}")
self._emit("royalty:progress", payload_evt)
def entry_cb(entry: dict) -> None:
"""Streaming: appena un file e' processato, emit alla UI."""
try:
self._emit("royalty:entry", entry)
except Exception:
pass
# Log per file: solo quando trova un match RF
try:
st = entry.get("status", "")
if st == "royalty_free":
matches = entry.get("matches") or []
src = matches[0].get("source", "?") if matches else "?"
lic = matches[0].get("license", "") if matches else ""
from pathlib import Path as _P
name = _P(entry.get("path", "")).name
self._log(view, f"[OK] {name} → {src} ({lic})")
except Exception:
pass
try:
entries = royalty.scan_folder(
directory,
recursive=recursive,
progress_callback=progress_cb,
entry_callback=entry_cb,
)
except Exception as e:
self._log(view, f"[ERRORE] {e}")
self._emit("royalty:done", {"ok": False, "error": str(e),
"entries": []})
return
n_rf = sum(1 for e in entries if e.get("status") == "royalty_free")
n_unk = sum(1 for e in entries if e.get("status") == "unknown")
n_err = sum(1 for e in entries if e.get("status") == "error")
self._log(view,
f"[INFO] Royalty-free: {n_rf} · sconosciuti: {n_unk} · "
f"errori: {n_err} (totale {len(entries)})")
self._emit("royalty:done", {
"ok": True,
"entries": entries,
"total": len(entries),
"royalty_free_count": n_rf,
"unknown_count": n_unk,
"error_count": n_err,
})
+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()
+12 -1
View File
@@ -5,7 +5,7 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
VERSION = "v1.11.3" VERSION = "v1.14.0"
APP_NAME = "MusicTools" APP_NAME = "MusicTools"
@@ -88,6 +88,17 @@ DEFAULTS = {
"catalog_last_target": "", "catalog_last_target": "",
"catalog_recursive": True, "catalog_recursive": True,
"acoustid_app_key": "", # opzionale: se vuoto usa default in core/acoustid.py "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
# ---- Diritti / Royalty-free check ----
"royalty_last_folder": "",
"royalty_recursive": True,
"jamendo_client_id": "", # opzionale: registrati su devportal.jamendo.com
# ---- Licenza ---- # ---- Licenza ----
"license_key": "", # chiave fornita all'utente via email "license_key": "", # chiave fornita all'utente via email
"license_email": "", # email associata all'acquisto "license_email": "", # email associata all'acquisto
+173
View File
@@ -14,6 +14,7 @@ from mutagen import File as MutagenFile
from mutagen.id3 import ( from mutagen.id3 import (
ID3, ID3NoHeaderError, ID3, ID3NoHeaderError,
APIC, TIT2, TPE1, TPE2, TALB, TDRC, TRCK, TCON, COMM, TBPM, TKEY, APIC, TIT2, TPE1, TPE2, TALB, TDRC, TRCK, TCON, COMM, TBPM, TKEY,
USLT, WOAS,
) )
from mutagen.mp4 import MP4, MP4Cover from mutagen.mp4 import MP4, MP4Cover
from mutagen.flac import FLAC, Picture from mutagen.flac import FLAC, Picture
@@ -84,6 +85,69 @@ def _text(value) -> str:
return str(value) 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: def _read_mp3(path: str, result: dict) -> dict:
result["format"] = "MP3" result["format"] = "MP3"
try: try:
@@ -91,6 +155,8 @@ def _read_mp3(path: str, result: dict) -> dict:
except ID3NoHeaderError: except ID3NoHeaderError:
return result return result
result["raw_frames"] = _dump_id3_frames(tags)
result["title"] = _text(tags.get("TIT2")) result["title"] = _text(tags.get("TIT2"))
result["artist"] = _text(tags.get("TPE1")) result["artist"] = _text(tags.get("TPE1"))
result["album_artist"] = _text(tags.get("TPE2")) 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["genre"] = _text(tags.get("TCON"))
result["bpm"] = _text(tags.get("TBPM")) result["bpm"] = _text(tags.get("TBPM"))
result["key"] = _text(tags.get("TKEY")) result["key"] = _text(tags.get("TKEY"))
result["source_url"] = _text(tags.get("WOAS"))
for k in tags.keys(): for k in tags.keys():
if k.startswith("COMM"): if k.startswith("COMM"):
result["comment"] = _text(tags[k]) result["comment"] = _text(tags[k])
break 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(): for k in tags.keys():
if k.startswith("APIC"): if k.startswith("APIC"):
apic = tags[k] apic = tags[k]
@@ -113,13 +186,44 @@ def _read_mp3(path: str, result: dict) -> dict:
result["cover_mime"] = apic.mime or "image/jpeg" result["cover_mime"] = apic.mime or "image/jpeg"
break 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 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: def _read_mp4(path: str, result: dict) -> dict:
result["format"] = "M4A/MP4" result["format"] = "M4A/MP4"
m = MP4(path) m = MP4(path)
tags = m.tags or {} tags = m.tags or {}
result["raw_frames"] = _dump_mp4_atoms(tags)
result["title"] = _text(tags.get("\xa9nam")) result["title"] = _text(tags.get("\xa9nam"))
result["artist"] = _text(tags.get("\xa9ART")) result["artist"] = _text(tags.get("\xa9ART"))
result["album_artist"] = _text(tags.get("aART")) 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["genre"] = _text(tags.get("\xa9gen"))
result["comment"] = _text(tags.get("\xa9cmt")) result["comment"] = _text(tags.get("\xa9cmt"))
result["bpm"] = _text(tags.get("tmpo")) 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") covers = tags.get("covr")
if covers: if covers:
c = covers[0] c = covers[0]
result["cover_base64"] = base64.b64encode(bytes(c)).decode("ascii") result["cover_base64"] = base64.b64encode(bytes(c)).decode("ascii")
result["cover_mime"] = "image/jpeg" if c.imageformat == MP4Cover.FORMAT_JPEG else "image/png" 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 return result
@@ -292,8 +408,13 @@ def read_metadata(path: str) -> dict:
"bpm": "", "key": "", "bpm": "", "key": "",
"duration": 0, "bitrate": 0, "duration": 0, "bitrate": 0,
"cover_base64": "", "cover_mime": "", "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)), "where_from": _read_where_from(str(p)),
"is_macos": sys.platform == "darwin", "is_macos": sys.platform == "darwin",
"raw_frames": [], # list of {key, preview, size, kind} — tutti i frame ID3/atomi
} }
audio = MutagenFile(str(p)) audio = MutagenFile(str(p))
@@ -346,6 +467,22 @@ def _write_mp3(path: str, data: dict, cover_path: Optional[str], remove_cover: b
if comment: if comment:
tags.add(COMM(encoding=3, lang="ita", desc="", text=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 # Cover
if remove_cover or cover_path: if remove_cover or cover_path:
for k in list(tags.keys()): 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("\xa9day", data.get("year", ""))
set_or_del("\xa9gen", data.get("genre", "")) set_or_del("\xa9gen", data.get("genre", ""))
set_or_del("\xa9cmt", data.get("comment", "")) set_or_del("\xa9cmt", data.get("comment", ""))
set_or_del("\xa9lyr", data.get("lyrics", ""))
track = data.get("track", "") track = data.get("track", "")
if 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) # macOS extended attribute: kMDItemWhereFroms (lista URL/origini)
if "where_from" in data: if "where_from" in data:
_write_where_from(path, data.get("where_from") or []) _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
+398
View File
@@ -0,0 +1,398 @@
"""Verifica royalty-free status di brani locali contro DB CC/open.
Approccio onesto: whitelist royalty-free. L'app segnala un brano come
"royalty_free" SOLO se trova un match affidabile in uno dei DB CC/open.
"Non trovato" (status 'unknown') NON implica automaticamente SIAE —
significa semplicemente "da verificare manualmente".
Pipeline:
1. Scansiona la cartella (filtra per `AUDIO_EXTENSIONS` di
`core.upgrader`).
2. Per ogni file legge i tag ID3 (artist + title) via
`core.metadata.read_metadata`; fallback a
`core.catalog._parse_filename` se i tag sono mancanti.
3. Query sorgenti royalty-free:
- ccMixter (nessuna auth, sempre attivo)
- Jamendo (richiede `jamendo_client_id` in config; se vuoto, skip)
4. Levenshtein combinato artist+title >= `_SIMILARITY_THRESHOLD` →
status 'royalty_free'. Altrimenti 'unknown'.
Progress callback firma:
(processed, total, filename, status[, err_msg])
Status: 'checking' | 'stopped' | 'completed' | 'error'.
`entry_callback(entry)` viene chiamato per ogni file processato
(streaming alla UI).
"""
from __future__ import annotations
import json
import sqlite3
import threading
import time
from difflib import SequenceMatcher
from pathlib import Path
from typing import Callable, Optional
import requests
from core.upgrader import AUDIO_EXTENSIONS
# ------------------------------------------------------------------
# Costanti
# ------------------------------------------------------------------
_CCMIXTER_URL = "https://ccmixter.org/api/query"
_JAMENDO_URL = "https://api.jamendo.com/v3.0/tracks/"
_HTTP_TIMEOUT = 15
_RATE_LIMIT_SEC = 0.3
_SIMILARITY_THRESHOLD = 0.80
# ------------------------------------------------------------------
# Stop / interrupt
# ------------------------------------------------------------------
_stop_event = threading.Event()
def request_stop() -> None:
"""Segnala al worker di interrompere la scansione al prossimo file."""
_stop_event.set()
def reset_stop() -> None:
"""Azzera il flag di stop prima di iniziare una nuova scansione."""
_stop_event.clear()
def is_stopped() -> bool:
return _stop_event.is_set()
# ------------------------------------------------------------------
# Cache SQLite
# ------------------------------------------------------------------
def _cache_db_path() -> Path:
"""Path del file di cache SQLite (patchable nei test)."""
from core.config import _get_config_dir
return _get_config_dir() / "royalty_cache.db"
def _init_db(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS lookups(
key TEXT PRIMARY KEY,
payload TEXT,
cached_at REAL
);
"""
)
# ------------------------------------------------------------------
# Similarity
# ------------------------------------------------------------------
def _similarity(a: str, b: str) -> float:
"""Ritorna SequenceMatcher ratio (0.0 - 1.0) case-insensitive."""
if not a or not b:
return 0.0
return SequenceMatcher(None, a.lower().strip(), b.lower().strip()).ratio()
# ------------------------------------------------------------------
# Rate limit (globale)
# ------------------------------------------------------------------
_last_req = [0.0]
def _throttle() -> None:
now = time.monotonic()
elapsed = now - _last_req[0]
if elapsed < _RATE_LIMIT_SEC:
time.sleep(_RATE_LIMIT_SEC - elapsed)
_last_req[0] = time.monotonic()
# ------------------------------------------------------------------
# Query sorgenti
# ------------------------------------------------------------------
def _query_ccmixter(query: str) -> list:
"""Query ccMixter API. Ritorna lista di candidati normalizzati."""
if not query:
return []
_throttle()
try:
resp = requests.get(
_CCMIXTER_URL,
params={"f": "json", "search": query, "limit": 10},
timeout=_HTTP_TIMEOUT,
)
if resp.status_code != 200:
return []
data = resp.json()
except Exception:
return []
if not isinstance(data, list):
return []
out = []
for item in data:
if not isinstance(item, dict):
continue
out.append({
"source": "ccmixter",
"title": item.get("upload_name", "") or "",
"artist": (item.get("user_real_name")
or item.get("user_name", "")
or ""),
"license": item.get("license_name", "") or "",
"license_url": item.get("license_url", "") or "",
"url": item.get("file_page_url", "") or "",
})
return out
def _query_jamendo(query: str, client_id: str) -> list:
"""Query Jamendo API. Senza client_id ritorna []."""
if not client_id or not query:
return []
_throttle()
try:
resp = requests.get(
_JAMENDO_URL,
params={
"client_id": client_id,
"format": "json",
"limit": 5,
"search": query,
},
timeout=_HTTP_TIMEOUT,
)
if resp.status_code != 200:
return []
data = resp.json()
except Exception:
return []
if not isinstance(data, dict):
return []
status = (data.get("headers", {}) or {}).get("status", "")
if status and status != "success":
return []
out = []
for item in data.get("results", []) or []:
if not isinstance(item, dict):
continue
out.append({
"source": "jamendo",
"title": item.get("name", "") or "",
"artist": item.get("artist_name", "") or "",
"license": item.get("license_name") or "Creative Commons",
"license_url": item.get("license_ccurl", "") or "",
"url": item.get("shareurl", "") or "",
})
return out
# ------------------------------------------------------------------
# Check singolo file
# ------------------------------------------------------------------
def check_file(path: str, jamendo_client_id: str = "") -> dict:
"""Verifica un singolo file. Ritorna:
{path, status, matches, query, artist, title[, error]}
status: 'royalty_free' | 'unknown' | 'error'.
"""
from core.metadata import read_metadata
from core.catalog import _parse_filename
try:
md = read_metadata(path)
except Exception as e:
return {
"path": path,
"status": "error",
"matches": [],
"query": "",
"artist": "",
"title": "",
"error": str(e),
}
artist = (md.get("artist") or "").strip()
title = (md.get("title") or "").strip()
if not artist or not title:
parsed_artist, parsed_title = _parse_filename(Path(path).stem)
artist = artist or parsed_artist
title = title or parsed_title
query = f"{artist} {title}".strip() if artist else title
if not query:
return {
"path": path,
"status": "unknown",
"matches": [],
"query": "",
"artist": artist,
"title": title,
"error": "no query (tag + filename vuoti)",
}
# Cache lookup
conn = sqlite3.connect(str(_cache_db_path()))
try:
_init_db(conn)
row = conn.execute(
"SELECT payload FROM lookups WHERE key=?", (query,)
).fetchone()
if row:
try:
cached = json.loads(row[0])
cached["path"] = path
# Preserva artist/title correnti nel caso siano stati
# ricavati dal filename ma la cache li aveva salvati gia'
return cached
except Exception:
pass
candidates = _query_ccmixter(query)
candidates += _query_jamendo(query, jamendo_client_id)
matches = []
for c in candidates:
score_title = _similarity(title, c.get("title", ""))
score_artist = (
_similarity(artist, c.get("artist", ""))
if artist and c.get("artist") else 0.0
)
if artist:
combined = score_title * 0.6 + score_artist * 0.4
else:
combined = score_title
if combined >= _SIMILARITY_THRESHOLD:
enriched = dict(c)
enriched["score"] = round(combined, 2)
matches.append(enriched)
# Ordina per score desc
matches.sort(key=lambda m: m.get("score", 0.0), reverse=True)
status = "royalty_free" if matches else "unknown"
result = {
"path": path,
"status": status,
"matches": matches,
"query": query,
"artist": artist,
"title": title,
}
# Scrivi in cache (senza il path, che e' per-file)
try:
payload = {k: v for k, v in result.items() if k != "path"}
conn.execute(
"INSERT OR REPLACE INTO lookups(key, payload, cached_at) "
"VALUES (?, ?, ?)",
(query, json.dumps(payload, ensure_ascii=False), time.time()),
)
conn.commit()
except Exception:
pass
return result
finally:
try:
conn.close()
except Exception:
pass
# ------------------------------------------------------------------
# Scan folder
# ------------------------------------------------------------------
def _emit_progress(cb: Optional[Callable], idx: int, total: int,
name: str, status: str, err: str = "") -> None:
"""Chiama progress_callback in modo retrocompatibile (4 o 5 args)."""
if not cb:
return
try:
cb(idx, total, name, status, err)
except TypeError:
try:
cb(idx, total, name, status)
except Exception:
pass
except Exception:
pass
def scan_folder(
directory: str,
recursive: bool = True,
progress_callback: Optional[Callable] = None,
entry_callback: Optional[Callable] = None,
) -> list:
"""Scansiona la cartella e verifica ogni file audio.
Ritorna la lista dei risultati per file (vedi `check_file`).
`entry_callback(entry)` e' chiamato appena ogni file e' processato.
"""
reset_stop()
base = Path(directory)
if not base.exists() or not base.is_dir():
_emit_progress(progress_callback, 0, 0, "", "completed", "")
return []
files: list = []
iterator = base.rglob("*") if recursive else base.iterdir()
for f in iterator:
try:
if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS:
files.append(f)
except OSError:
continue
files.sort()
total = len(files)
if total == 0:
_emit_progress(progress_callback, 0, 0, "", "completed", "")
return []
# Jamendo client_id (opzionale)
jamendo_id = ""
try:
from core.config import load_config
jamendo_id = (load_config().get("jamendo_client_id") or "").strip()
except Exception:
jamendo_id = ""
results: list = []
for i, fp in enumerate(files, start=1):
if is_stopped():
_emit_progress(progress_callback, i - 1, total, "", "stopped", "")
return results
_emit_progress(progress_callback, i, total, fp.name, "checking", "")
try:
r = check_file(str(fp), jamendo_client_id=jamendo_id)
except Exception as e:
r = {
"path": str(fp),
"status": "error",
"matches": [],
"query": "",
"artist": "",
"title": "",
"error": str(e),
}
_emit_progress(progress_callback, i, total, fp.name, "error", str(e))
results.append(r)
if entry_callback:
try:
entry_callback(r)
except Exception:
pass
if not is_stopped():
_emit_progress(progress_callback, total, total, "", "completed", "")
return results
+4 -10
View File
@@ -14,30 +14,24 @@ from dataclasses import dataclass
# Generi musicali Traxsource (slug -> (id, display_name)). # Generi musicali Traxsource (slug -> (id, display_name)).
# Aggiornato via scripts/refresh_traxsource_genres.py. Esclusi "sounds/samples/loops", # Aggiornato via scripts/refresh_traxsource_genres.py. Esclusi "sounds/samples/loops",
# "acapella", "beats", "efx-dj-tools", "stems" (non generi ma tipi di prodotto). # "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 = { GENRES: dict = {
"afro-house": (27, "Afro House"), "afro-house": (27, "Afro House"),
"afro-latin-brazilian": (23, "Afro / Latin / Brazilian"), "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"), "deep-house": (13, "Deep House"),
"drum-and-bass": (31, "Drum & Bass"),
"electro-house": (11, "Electro House"),
"electronica": (5, "Electronica"),
"garage": (29, "Garage"), "garage": (29, "Garage"),
"house": (4, "House"), "house": (4, "House"),
"jackin-house": (15, "Jackin House"), "jackin-house": (15, "Jackin House"),
"leftfield": (14, "Leftfield"),
"lounge-chill-out": (1, "Lounge / Chill Out"),
"melodic-progressive-house": (19, "Melodic / Progressive House"), "melodic-progressive-house": (19, "Melodic / Progressive House"),
"minimal-deep-tech": (16, "Minimal / Deep Tech"), "minimal-deep-tech": (16, "Minimal / Deep Tech"),
"nu-disco-indie-dance": (17, "Nu Disco / Indie Dance"), "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"), "soul-funk-disco": (3, "Soul / Funk / Disco"),
"soulful-house": (24, "Soulful House"), "soulful-house": (24, "Soulful House"),
"tech-house": (18, "Tech House"), "tech-house": (18, "Tech House"),
"techno": (20, "Techno"), "techno": (20, "Techno"),
"world": (30, "World"),
} }
+1 -1
View File
@@ -22,7 +22,7 @@ if [ -z "$EMAIL" ]; then
exit 1 exit 1
fi fi
SSH_TARGET="musictools@musictools.djluza.com" SSH_TARGET="${MUSICTOOLS_SSH:-musictools@musictools.djluza.com}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ssh "$SSH_TARGET" 'mkdir -p ~/api/scripts' ssh "$SSH_TARGET" 'mkdir -p ~/api/scripts'
+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) == []
+373
View File
@@ -0,0 +1,373 @@
"""Test per core.royalty_check — ccMixter + Jamendo + cache SQLite.
Nessuna integrazione reale: tutte le chiamate di rete sono mockate con
`responses`. La cache SQLite e' isolata in tmp_path.
"""
from __future__ import annotations
from pathlib import Path
from unittest import mock
import pytest
import responses
from core import royalty_check as rc
# ------------------------------------------------------------------
# Fixture: cache isolata per test
# ------------------------------------------------------------------
@pytest.fixture
def patched_cache(tmp_path, monkeypatch):
db = tmp_path / "royalty_cache_test.db"
monkeypatch.setattr(rc, "_cache_db_path", lambda: db)
# Azzera il throttle globale per non rallentare i test
monkeypatch.setattr(rc, "_last_req", [0.0])
monkeypatch.setattr(rc, "_RATE_LIMIT_SEC", 0.0)
return db
# ==================================================================
# _similarity
# ==================================================================
class TestSimilarity:
def test_similarity_perfect_match(self):
assert rc._similarity("hello world", "hello world") == pytest.approx(1.0)
def test_similarity_case_insensitive(self):
assert rc._similarity("Hello World", "hello world") == pytest.approx(1.0)
def test_similarity_empty_returns_zero(self):
assert rc._similarity("", "foo") == 0.0
assert rc._similarity("foo", "") == 0.0
assert rc._similarity("", "") == 0.0
def test_similarity_partial(self):
s = rc._similarity("Daft Punk - Around The World", "Around The World")
# SequenceMatcher usa chunks comuni: valore stabile e > 0.5
assert 0.5 < s < 1.0
# ==================================================================
# _query_ccmixter
# ==================================================================
class TestQueryCcmixter:
_SAMPLE = [
{
"upload_name": "Around The World",
"user_real_name": "Jane Doe",
"user_name": "jane",
"license_name": "Attribution Noncommercial (3.0)",
"license_url": "https://creativecommons.org/licenses/by-nc/3.0/",
"file_page_url": "https://ccmixter.org/files/jane/1",
},
{
"upload_name": "Other Song",
"user_real_name": "",
"user_name": "bob",
"license_name": "CC BY 4.0",
"license_url": "https://creativecommons.org/licenses/by/4.0/",
"file_page_url": "https://ccmixter.org/files/bob/2",
},
]
@responses.activate
def test_query_ccmixter_parses_response(self, patched_cache):
responses.add(
responses.GET,
rc._CCMIXTER_URL,
json=self._SAMPLE,
status=200,
)
res = rc._query_ccmixter("Around The World")
assert len(res) == 2
assert res[0]["source"] == "ccmixter"
assert res[0]["title"] == "Around The World"
assert res[0]["artist"] == "Jane Doe"
assert "licenses" in res[0]["license_url"]
# Fallback a user_name quando user_real_name e' vuoto
assert res[1]["artist"] == "bob"
@responses.activate
def test_query_ccmixter_empty_on_http_error(self, patched_cache):
responses.add(
responses.GET,
rc._CCMIXTER_URL,
json={"error": "boom"},
status=500,
)
assert rc._query_ccmixter("whatever") == []
@responses.activate
def test_query_ccmixter_empty_on_bad_json(self, patched_cache):
responses.add(
responses.GET,
rc._CCMIXTER_URL,
json={"not": "a list"},
status=200,
)
assert rc._query_ccmixter("whatever") == []
def test_query_ccmixter_empty_query(self, patched_cache):
# Nessuna chiamata di rete attesa — se la facesse responses fallirebbe
assert rc._query_ccmixter("") == []
# ==================================================================
# _query_jamendo
# ==================================================================
class TestQueryJamendo:
_SAMPLE = {
"headers": {"status": "success"},
"results": [
{
"id": "123",
"name": "Freedom Song",
"artist_name": "Open Artist",
"license_name": "CC BY-SA",
"license_ccurl": "https://creativecommons.org/licenses/by-sa/4.0/",
"shareurl": "https://www.jamendo.com/track/123",
"audio": "https://.../123.mp3",
}
],
}
def test_query_jamendo_requires_client_id(self, patched_cache):
# Nessun client_id -> ritorno vuoto senza chiamata di rete
# (responses non e' attivato: una chiamata farebbe raise)
assert rc._query_jamendo("Freedom Song", client_id="") == []
@responses.activate
def test_query_jamendo_parses_response(self, patched_cache):
responses.add(
responses.GET,
rc._JAMENDO_URL,
json=self._SAMPLE,
status=200,
)
res = rc._query_jamendo("Freedom Song", client_id="abcd1234")
assert len(res) == 1
assert res[0]["source"] == "jamendo"
assert res[0]["title"] == "Freedom Song"
assert res[0]["artist"] == "Open Artist"
assert res[0]["license"] == "CC BY-SA"
assert res[0]["url"].startswith("https://www.jamendo.com/")
@responses.activate
def test_query_jamendo_status_failure_returns_empty(self, patched_cache):
responses.add(
responses.GET,
rc._JAMENDO_URL,
json={"headers": {"status": "failed", "error_message": "bad key"},
"results": []},
status=200,
)
assert rc._query_jamendo("Freedom Song", client_id="bad") == []
# ==================================================================
# check_file
# ==================================================================
def _fake_metadata(artist: str, title: str):
"""Factory per un dict compatibile con core.metadata.read_metadata."""
return {"artist": artist, "title": title}
class TestCheckFile:
def test_check_file_matches_royalty_free(self, patched_cache, tmp_path):
# Prepara un path "esistente" solo per plausibilita' (il
# read_metadata e' mockato: non viene mai letto davvero).
p = tmp_path / "track.mp3"
p.write_bytes(b"\x00")
cc_hit = [{
"upload_name": "Around The World",
"user_real_name": "Jane Doe",
"user_name": "jane",
"license_name": "CC BY 3.0",
"license_url": "https://creativecommons.org/licenses/by/3.0/",
"file_page_url": "https://ccmixter.org/x/1",
}]
with mock.patch.object(rc, "_query_ccmixter", return_value=[{
"source": "ccmixter",
"title": "Around The World",
"artist": "Jane Doe",
"license": "CC BY 3.0",
"license_url": cc_hit[0]["license_url"],
"url": cc_hit[0]["file_page_url"],
}]), mock.patch.object(rc, "_query_jamendo", return_value=[]), \
mock.patch("core.metadata.read_metadata",
return_value=_fake_metadata("Jane Doe",
"Around The World")):
res = rc.check_file(str(p))
assert res["status"] == "royalty_free"
assert len(res["matches"]) == 1
assert res["matches"][0]["source"] == "ccmixter"
assert res["matches"][0]["score"] >= 0.8
assert res["artist"] == "Jane Doe"
assert res["title"] == "Around The World"
def test_check_file_no_match_returns_unknown(self, patched_cache, tmp_path):
p = tmp_path / "unknown.mp3"
p.write_bytes(b"\x00")
with mock.patch.object(rc, "_query_ccmixter", return_value=[{
"source": "ccmixter",
"title": "Completamente Diverso",
"artist": "Qualcun Altro",
"license": "CC BY",
"license_url": "",
"url": "",
}]), mock.patch.object(rc, "_query_jamendo", return_value=[]), \
mock.patch("core.metadata.read_metadata",
return_value=_fake_metadata("Daft Punk",
"Harder Better Faster")):
res = rc.check_file(str(p))
assert res["status"] == "unknown"
assert res["matches"] == []
# artist/title conservati dai tag
assert res["artist"] == "Daft Punk"
def test_check_file_uses_cache(self, patched_cache, tmp_path):
p = tmp_path / "cached.mp3"
p.write_bytes(b"\x00")
cc_mock = mock.MagicMock(return_value=[{
"source": "ccmixter",
"title": "Hello",
"artist": "World",
"license": "CC BY",
"license_url": "",
"url": "",
}])
jm_mock = mock.MagicMock(return_value=[])
md_mock = mock.MagicMock(return_value=_fake_metadata("World", "Hello"))
with mock.patch.object(rc, "_query_ccmixter", cc_mock), \
mock.patch.object(rc, "_query_jamendo", jm_mock), \
mock.patch("core.metadata.read_metadata", md_mock):
res1 = rc.check_file(str(p))
res2 = rc.check_file(str(p))
assert res1["status"] == "royalty_free"
assert res2["status"] == "royalty_free"
# Seconda chiamata hitta la cache: le query API non vengono richiamate
assert cc_mock.call_count == 1
assert jm_mock.call_count == 1
def test_check_file_fallback_to_filename_when_tags_missing(
self, patched_cache, tmp_path):
"""Senza tag ID3, usa _parse_filename sul nome del file."""
# Filename stile "Artista - Titolo.mp3"
p = tmp_path / "Jane Doe - Around The World.mp3"
p.write_bytes(b"\x00")
with mock.patch.object(rc, "_query_ccmixter", return_value=[{
"source": "ccmixter",
"title": "Around The World",
"artist": "Jane Doe",
"license": "CC BY",
"license_url": "",
"url": "",
}]), mock.patch.object(rc, "_query_jamendo", return_value=[]), \
mock.patch("core.metadata.read_metadata",
return_value=_fake_metadata("", "")):
res = rc.check_file(str(p))
assert res["status"] == "royalty_free"
assert res["artist"] == "Jane Doe"
assert res["title"] == "Around The World"
def test_check_file_metadata_error_returns_error_status(
self, patched_cache, tmp_path):
p = tmp_path / "broken.mp3"
p.write_bytes(b"\x00")
with mock.patch("core.metadata.read_metadata",
side_effect=ValueError("formato illeggibile")):
res = rc.check_file(str(p))
assert res["status"] == "error"
assert "formato illeggibile" in res.get("error", "")
# ==================================================================
# scan_folder
# ==================================================================
class TestScanFolder:
def test_scan_folder_streams_entries(self, patched_cache, tmp_path):
# Crea 3 file audio nella cartella
for name in ("a.mp3", "b.mp3", "c.flac"):
(tmp_path / name).write_bytes(b"\x00")
fake_result = {
"path": "",
"status": "unknown",
"matches": [],
"query": "q",
"artist": "A",
"title": "T",
}
collected = []
def _entry_cb(entry):
collected.append(entry)
with mock.patch.object(rc, "check_file", return_value=fake_result):
results = rc.scan_folder(
str(tmp_path),
recursive=False,
entry_callback=_entry_cb,
)
assert len(results) == 3
assert len(collected) == 3
def test_scan_folder_filters_non_audio(self, patched_cache, tmp_path):
(tmp_path / "ok.mp3").write_bytes(b"\x00")
(tmp_path / "note.txt").write_text("hello")
(tmp_path / "image.png").write_bytes(b"\x00")
with mock.patch.object(rc, "check_file",
return_value={"path": "", "status": "unknown",
"matches": [], "query": "",
"artist": "", "title": ""}):
results = rc.scan_folder(str(tmp_path), recursive=False)
assert len(results) == 1
def test_scan_folder_empty_directory(self, patched_cache, tmp_path):
empty = tmp_path / "empty"
empty.mkdir()
results = rc.scan_folder(str(empty), recursive=False)
assert results == []
def test_scan_folder_nonexistent_directory(self, patched_cache, tmp_path):
results = rc.scan_folder(str(tmp_path / "missing"), recursive=False)
assert results == []
def test_scan_folder_progress_callback_called(self, patched_cache, tmp_path):
(tmp_path / "a.mp3").write_bytes(b"\x00")
(tmp_path / "b.mp3").write_bytes(b"\x00")
events = []
def _progress(idx, total, name, status, err=""):
events.append((idx, total, status))
fake_result = {
"path": "", "status": "unknown", "matches": [],
"query": "", "artist": "", "title": "",
}
with mock.patch.object(rc, "check_file", return_value=fake_result):
rc.scan_folder(str(tmp_path), recursive=False,
progress_callback=_progress)
# Attendo almeno "checking" per ciascun file + "completed" finale
assert any(e[2] == "completed" for e in events)
assert sum(1 for e in events if e[2] == "checking") >= 2
+325
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::-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 { .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -567,6 +578,87 @@ input[type="number"]::-webkit-inner-spin-button {
.path-display:empty, .path-display:empty,
.path-display.empty { color: var(--text-3); } .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 BUTTONS
=============================== */ =============================== */
@@ -701,6 +793,7 @@ input[type="number"]::-webkit-inner-spin-button {
.percent.blue { color: var(--blue-text); } .percent.blue { color: var(--blue-text); }
.percent.pink { color: var(--pink-hover); } .percent.pink { color: var(--pink-hover); }
.percent.purple { color: var(--purple-text); } .percent.purple { color: var(--purple-text); }
.percent.amber { color: var(--amber-text); }
.progress { .progress {
width: 100%; width: 100%;
@@ -733,6 +826,10 @@ input[type="number"]::-webkit-inner-spin-button {
background: linear-gradient(90deg, var(--purple), var(--purple-hover)); background: linear-gradient(90deg, var(--purple), var(--purple-hover));
} }
.progress-fill.amber {
background: linear-gradient(90deg, var(--amber), var(--amber-hover));
}
/* =============================== /* ===============================
LOG BOX LOG BOX
=============================== */ =============================== */
@@ -2116,3 +2213,231 @@ input[type="number"]::-webkit-inner-spin-button {
border: 1px solid var(--border); 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);
}
/* ==================================================================
* Royalty (tab Diritti) — verifica royalty-free status.
* Riusa .catalog-table per lo stile base; override specifici qui.
* ================================================================== */
.royalty-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);
}
.royalty-table tbody td {
padding: 8px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 320px;
}
.royalty-col-file { max-width: 260px; }
.royalty-col-artist { max-width: 160px; }
.royalty-col-title { max-width: 220px; }
.royalty-col-status { width: 120px; }
.royalty-col-match { max-width: 320px; }
/* Badge stato royalty */
.royalty-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.3px;
text-transform: uppercase;
white-space: nowrap;
}
.royalty-badge-rf {
background: var(--green-dim, rgba(16, 185, 129, 0.15));
color: var(--green-text);
}
.royalty-badge-unknown {
background: var(--bg-input);
color: var(--text-3);
}
.royalty-badge-error {
background: rgba(220, 38, 38, 0.15);
color: var(--red);
}
/* Dettaglio match (sorgente + licenza + link) */
.royalty-match-cell {
display: block;
white-space: normal;
line-height: 1.4;
color: var(--text-2);
font-size: 12px;
}
.royalty-match-cell a {
color: var(--amber-text);
text-decoration: none;
}
.royalty-match-cell a:hover {
text-decoration: underline;
}
.royalty-match-source {
font-weight: 700;
color: var(--text-1);
margin-right: 6px;
}
.royalty-match-score {
color: var(--text-3);
font-size: 11px;
margin-left: 6px;
}
/* Riga con stato royalty_free: tinta verde tenue di sfondo */
.royalty-table tbody tr.royalty-row-rf {
background: rgba(16, 185, 129, 0.06);
}
.royalty-table tbody tr.royalty-row-rf:hover {
background: rgba(16, 185, 129, 0.12);
}
.royalty-table tbody tr.royalty-row-error {
background: rgba(220, 38, 38, 0.05);
}
+336 -14
View File
@@ -21,6 +21,7 @@
<div class="divider"></div> <div class="divider"></div>
<nav class="nav"> <nav class="nav">
<div class="nav-section-label">Scarica</div>
<button class="nav-item active" data-view="download" data-feature="audio"> <button class="nav-item active" data-view="download" data-feature="audio">
<span class="nav-icon">⬇</span> <span class="nav-icon">⬇</span>
<span>Scarica</span> <span>Scarica</span>
@@ -29,14 +30,6 @@
<span class="nav-icon">🎬</span> <span class="nav-icon">🎬</span>
<span>Video</span> <span>Video</span>
</button> </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"> <button class="nav-item" data-view="spotify" data-feature="audio">
<span class="nav-icon">🟢</span> <span class="nav-icon">🟢</span>
<span>Spotify</span> <span>Spotify</span>
@@ -45,10 +38,20 @@
<span class="nav-icon">▶</span> <span class="nav-icon">▶</span>
<span>YouTube</span> <span>YouTube</span>
</button> </button>
<button class="nav-item" data-view="convert" data-feature="audio"> <button class="nav-item" data-view="beatport" data-feature="audio">
<span class="nav-icon">🔄</span> <span class="nav-icon">🎧</span>
<span>Converti</span> <span>Beatport</span>
</button> </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"> <button class="nav-item" data-view="dedup" data-feature="audio">
<span class="nav-icon">🗑</span> <span class="nav-icon">🗑</span>
<span>Dedup</span> <span>Dedup</span>
@@ -61,18 +64,30 @@
<span class="nav-icon">📤</span> <span class="nav-icon">📤</span>
<span>Appiattisci</span> <span>Appiattisci</span>
</button> </button>
<button class="nav-item" data-view="upgrade" data-feature="upgrade"> <button class="nav-item" data-view="convert" data-feature="audio">
<span class="nav-icon">⚡</span> <span class="nav-icon">🔄</span>
<span>Upgrade</span> <span>Converti</span>
</button> </button>
<button class="nav-item" data-view="meta" data-feature="metadata"> <button class="nav-item" data-view="meta" data-feature="metadata">
<span class="nav-icon">🏷</span> <span class="nav-icon">🏷</span>
<span>Metadati</span> <span>Metadati</span>
</button> </button>
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
<span class="nav-icon">⚡</span>
<span>Upgrade</span>
</button>
<button class="nav-item" data-view="royalty" data-feature="audio">
<span class="nav-icon">⚖️</span>
<span>Diritti</span>
</button>
<div class="nav-section-label">Altro</div>
<button class="nav-item" data-view="record" data-feature="record"> <button class="nav-item" data-view="record" data-feature="record">
<span class="nav-icon">●</span> <span class="nav-icon">●</span>
<span>Registra</span> <span>Registra</span>
</button> </button>
<div class="nav-section-label">Sistema</div>
<button class="nav-item" data-view="settings"> <button class="nav-item" data-view="settings">
<span class="nav-icon">⚙</span> <span class="nav-icon">⚙</span>
<span>Impostazioni</span> <span>Impostazioni</span>
@@ -433,6 +448,7 @@
<div class="row"> <div class="row">
<button class="btn btn-amber pill" id="metaPickBtn">📂 Apri file audio</button> <button class="btn btn-amber pill" id="metaPickBtn">📂 Apri file audio</button>
<div class="path-display" id="metaPathDisplay">Nessun file selezionato</div> <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>
<div class="meta-info" id="metaInfo" hidden></div> <div class="meta-info" id="metaInfo" hidden></div>
</div> </div>
@@ -481,6 +497,42 @@
<label class="field-label">Commento</label> <label class="field-label">Commento</label>
<textarea id="metaComment" class="input textarea" rows="3"></textarea> <textarea id="metaComment" class="input textarea" rows="3"></textarea>
</div> </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> <div class="field full" id="metaWhereFromField" hidden>
<label class="field-label"> <label class="field-label">
Origine (macOS · kMDItemWhereFroms) Origine (macOS · kMDItemWhereFroms)
@@ -543,6 +595,22 @@
</div> </div>
</div> </div>
<h2 class="section-label">Jamendo (tab Diritti)</h2>
<div class="card">
<div class="field">
<label class="field-label">Jamendo client_id (opzionale, per check royalty-free)</label>
<input type="text" id="jamendoClientIdInput" class="input pill" placeholder="Es: 0e23c33a" />
<div class="hint">
<span class="hint-ico">ⓘ</span>
<div>
Registrati gratis su <code>devportal.jamendo.com</code> per avere una chiave e
verificare contro il catalogo Jamendo (~500k brani Creative Commons).
Senza chiave la tab Diritti usa solo ccMixter.
</div>
</div>
</div>
</div>
<h2 class="section-label">Download</h2> <h2 class="section-label">Download</h2>
<div class="card"> <div class="card">
<div class="field"> <div class="field">
@@ -1497,6 +1565,260 @@
</div> </div>
</section> </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>
<!-- ===== VIEW: DIRITTI (royalty-free check) ===== -->
<section class="view" id="view-royalty">
<header class="hero hero-amber">
<div class="hero-content">
<div class="hero-eyebrow amber">DIRITTI</div>
<h1 class="hero-title">Royalty-free o da verificare</h1>
<p class="hero-subtitle">
Confronta i brani locali con i cataloghi Creative Commons
(ccMixter, Jamendo). Segnala solo i brani con match certo RF —
gli altri richiedono verifica manuale.
</p>
</div>
<div class="hero-deco">⚖️</div>
</header>
<h2 class="section-label">Cartella da analizzare</h2>
<div class="card">
<div class="beatport-header">
<button id="royalty-pick-folder" class="btn btn-primary pill">📁 Scegli cartella</button>
<button id="royalty-start-btn" class="btn btn-amber pill" disabled>
<span class="ico">▶</span> Avvia analisi
</button>
<button id="royalty-stop-btn" class="btn btn-danger pill" hidden>
<span class="ico">◼</span> Interrompi
</button>
</div>
<div class="beatport-header" style="margin-top:12px;">
<span id="royalty-path-display" class="beatport-output-info" style="margin:0;">Nessuna cartella selezionata</span>
</div>
<div class="beatport-header" style="margin-top:12px;gap:16px;">
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
<input type="checkbox" id="royalty-recursive" checked />
<span>Ricerca ricorsiva (include sottocartelle)</span>
</label>
</div>
<div class="hint" style="margin-top:10px;">
<span class="hint-ico">ⓘ</span>
<div>
L'app verifica solo contro DB <strong>Creative Commons / royalty-free</strong>.
"Non trovato" NON implica automaticamente SIAE: significa "da verificare manualmente".
Per abilitare anche Jamendo registra una <code>client_id</code> gratuita su
<code>devportal.jamendo.com</code> e inseriscila in Impostazioni.
</div>
</div>
<div id="royalty-status" class="beatport-status"></div>
</div>
<h2 class="section-label">Progresso</h2>
<div class="card">
<div class="progress">
<div class="progress-bar"><div id="royaltyProgressFill" class="progress-fill amber"></div></div>
<div class="progress-meta">
<span id="royaltyPercent" class="percent amber">0%</span>
<span id="royaltyCounter">In attesa</span>
</div>
</div>
</div>
<h2 class="section-label">Risultati</h2>
<div class="card" id="royalty-summary-card" hidden>
<div class="catalog-summary" id="royalty-summary-line"></div>
</div>
<div id="royalty-table-wrap" class="royalty-scroll" hidden>
<table class="catalog-table royalty-table">
<thead>
<tr>
<th class="royalty-col-file">File</th>
<th class="royalty-col-artist">Artista</th>
<th class="royalty-col-title">Titolo</th>
<th class="royalty-col-status">Stato</th>
<th class="royalty-col-match">Match / Licenza</th>
</tr>
</thead>
<tbody id="royalty-table-body"></tbody>
</table>
</div>
<div class="catalog-footer-bar" id="royalty-footer-bar" hidden>
<span class="counter" id="royalty-footer-count">0 file analizzati</span>
<button id="royalty-export-csv" class="btn btn-ghost pill" disabled>
<span class="ico">📄</span> Esporta CSV
</button>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="royalty-log"></div>
</div>
</section>
</main> </main>
</div> </div>
+788
View File
@@ -411,6 +411,8 @@ const logEls = {
dedup: () => $("#dedup-log"), dedup: () => $("#dedup-log"),
catalog: () => $("#catalog-log"), catalog: () => $("#catalog-log"),
flatten: () => $("#flatten-log"), flatten: () => $("#flatten-log"),
charts: () => $("#charts-log"),
royalty: () => $("#royalty-log"),
}; };
function classifyLog(msg) { function classifyLog(msg) {
@@ -488,6 +490,7 @@ async function init() {
$("#hqThresholdInput").value = state.config.hq_threshold || 310; $("#hqThresholdInput").value = state.config.hq_threshold || 310;
$("#cookiesInput").value = state.config.cookies_path || ""; $("#cookiesInput").value = state.config.cookies_path || "";
if ($("#cookiesBrowserSelect")) $("#cookiesBrowserSelect").value = state.config.cookies_browser || ""; if ($("#cookiesBrowserSelect")) $("#cookiesBrowserSelect").value = state.config.cookies_browser || "";
if ($("#jamendoClientIdInput")) $("#jamendoClientIdInput").value = state.config.jamendo_client_id || "";
$("#outputInput").value = state.config.output_dir || ""; $("#outputInput").value = state.config.output_dir || "";
$("#themeSelect").value = state.config.theme || "dark"; $("#themeSelect").value = state.config.theme || "dark";
// Popola path del log corrente // Popola path del log corrente
@@ -542,6 +545,12 @@ async function init() {
// Cataloga tab (AcoustID -> <Anno>/<Genere>/) // Cataloga tab (AcoustID -> <Anno>/<Genere>/)
await CatalogUI.init(); await CatalogUI.init();
await FlattenUI.init(); await FlattenUI.init();
// Charts tab (iTunes / Spotify / M2O / Last.fm)
await ChartsUI.init();
// Diritti tab (royalty-free check ccMixter + Jamendo)
await RoyaltyUI.init();
} }
async function refreshRecDevices() { async function refreshRecDevices() {
@@ -1143,6 +1152,51 @@ function populateMetaForm(d) {
$("#metaBpm").value = d.bpm || ""; $("#metaBpm").value = d.bpm || "";
$("#metaKey").value = d.key || ""; $("#metaKey").value = d.key || "";
$("#metaComment").value = d.comment || ""; $("#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) // WhereFroms (macOS only)
if (d.is_macos) { if (d.is_macos) {
@@ -1200,6 +1254,51 @@ $("#metaPickBtn").addEventListener("click", async () => {
if (path) await loadMetaFile(path); 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 () => { $("#metaCoverBtn").addEventListener("click", async () => {
const path = await window.pywebview.api.pick_image_file(); const path = await window.pywebview.api.pick_image_file();
if (!path) return; if (!path) return;
@@ -1250,6 +1349,8 @@ $("#metaSaveBtn").addEventListener("click", async () => {
bpm: $("#metaBpm").value, bpm: $("#metaBpm").value,
key: $("#metaKey").value, key: $("#metaKey").value,
comment: $("#metaComment").value, comment: $("#metaComment").value,
source_url: $("#metaSourceUrl").value.trim(),
lyrics: $("#metaLyrics").value,
where_from: $("#metaWhereFrom").value where_from: $("#metaWhereFrom").value
.split("\n") .split("\n")
.map(s => s.trim()) .map(s => s.trim())
@@ -1316,6 +1417,7 @@ $("#saveBtn").addEventListener("click", async () => {
hq_threshold: parseInt($("#hqThresholdInput").value, 10) || 310, hq_threshold: parseInt($("#hqThresholdInput").value, 10) || 310,
cookies_path: $("#cookiesInput").value, cookies_path: $("#cookiesInput").value,
cookies_browser: $("#cookiesBrowserSelect") ? $("#cookiesBrowserSelect").value : "", cookies_browser: $("#cookiesBrowserSelect") ? $("#cookiesBrowserSelect").value : "",
jamendo_client_id: $("#jamendoClientIdInput") ? $("#jamendoClientIdInput").value : "",
output_dir: $("#outputInput").value, output_dir: $("#outputInput").value,
theme: $("#themeSelect").value, theme: $("#themeSelect").value,
}; };
@@ -3770,6 +3872,692 @@ const FlattenUI = (() => {
return { init }; 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 };
})();
// ============================================================
// RoyaltyUI — verifica royalty-free (ccMixter + Jamendo)
// ============================================================
const RoyaltyUI = (() => {
const rstate = {
folder: "",
recursive: true,
scanning: false,
// Map path -> entry
entries: new Map(),
};
function setStatus(text, kind) {
const el = $("#royalty-status");
if (!el) return;
el.textContent = text || "";
el.className = "beatport-status" + (kind ? " " + kind : "");
}
function _statusBadge(entry) {
const s = entry.status || "";
if (s === "royalty_free") {
return `<span class="royalty-badge royalty-badge-rf">🟢 Royalty-free</span>`;
}
if (s === "error") {
const err = entry.error ? ` title="${_escapeHtml(entry.error)}"` : "";
return `<span class="royalty-badge royalty-badge-error"${err}>🔴 Errore</span>`;
}
return `<span class="royalty-badge royalty-badge-unknown">⚪ Non trovato</span>`;
}
function _matchCell(entry) {
const matches = entry.matches || [];
if (!matches.length) {
if (entry.status === "error") {
return `<span class="royalty-match-cell" style="color:var(--red);">${_escapeHtml(entry.error || "errore")}</span>`;
}
return `<span class="royalty-match-cell" style="color:var(--text-3);">—</span>`;
}
const m = matches[0];
const src = _escapeHtml(m.source || "?");
const lic = _escapeHtml(m.license || "Creative Commons");
const url = m.url || "";
const licUrl = m.license_url || "";
const score = (typeof m.score === "number")
? `<span class="royalty-match-score">score ${m.score}</span>`
: "";
const linkTrack = url
? `<a href="#" data-ext-url="${_escapeHtml(url)}">traccia</a>`
: "";
const linkLic = licUrl
? `<a href="#" data-ext-url="${_escapeHtml(licUrl)}">${lic}</a>`
: lic;
const links = [linkTrack, linkLic].filter(Boolean).join(" · ");
const extra = matches.length > 1
? `<div style="color:var(--text-3);font-size:11px;margin-top:2px;">+${matches.length - 1} altri match</div>`
: "";
return `<span class="royalty-match-cell"><span class="royalty-match-source">${src}</span>${links}${score}</span>${extra}`;
}
function _renderRow(entry) {
const path = entry.path || "";
const tr = document.createElement("tr");
tr.dataset.path = path;
if (entry.status === "royalty_free") tr.classList.add("royalty-row-rf");
else if (entry.status === "error") tr.classList.add("royalty-row-error");
// File
const tdFile = document.createElement("td");
tdFile.className = "royalty-col-file";
tdFile.innerHTML = `<span class="catalog-file-cell" title="${_escapeHtml(path)}">${_escapeHtml(_basename(path))}</span>`;
tr.appendChild(tdFile);
// Artista
const tdArtist = document.createElement("td");
tdArtist.className = "royalty-col-artist";
tdArtist.textContent = entry.artist || "—";
tdArtist.title = entry.artist || "";
tr.appendChild(tdArtist);
// Titolo
const tdTitle = document.createElement("td");
tdTitle.className = "royalty-col-title";
tdTitle.textContent = entry.title || "—";
tdTitle.title = entry.title || "";
tr.appendChild(tdTitle);
// Stato
const tdStatus = document.createElement("td");
tdStatus.className = "royalty-col-status";
tdStatus.innerHTML = _statusBadge(entry);
tr.appendChild(tdStatus);
// Match / licenza
const tdMatch = document.createElement("td");
tdMatch.className = "royalty-col-match";
tdMatch.innerHTML = _matchCell(entry);
tr.appendChild(tdMatch);
return tr;
}
function _updateSummary() {
const line = $("#royalty-summary-line");
const card = $("#royalty-summary-card");
if (!line || !card) return;
const n = rstate.entries.size;
if (n === 0) { card.hidden = true; return; }
let rf = 0, unk = 0, errs = 0;
rstate.entries.forEach((e) => {
if (e.status === "royalty_free") rf++;
else if (e.status === "error") errs++;
else unk++;
});
line.textContent =
`${n} file · ${rf} royalty-free · ${unk} da verificare · ${errs} errori`;
card.hidden = false;
const foot = $("#royalty-footer-bar");
const cnt = $("#royalty-footer-count");
const exp = $("#royalty-export-csv");
if (foot) foot.hidden = false;
if (cnt) cnt.textContent = `${n} file analizzati`;
if (exp) exp.disabled = n === 0;
}
function _upsertEntry(entry) {
if (!entry || !entry.path) return;
const body = $("#royalty-table-body");
if (!body) return;
const existed = rstate.entries.has(entry.path);
rstate.entries.set(entry.path, entry);
const row = _renderRow(entry);
if (existed) {
const old = body.querySelector(`tr[data-path="${CSS.escape(entry.path)}"]`);
if (old) body.replaceChild(row, old);
else body.appendChild(row);
} else {
body.appendChild(row);
}
const wrap = $("#royalty-table-wrap");
if (wrap) wrap.hidden = false;
_updateSummary();
}
function _clearTable() {
const body = $("#royalty-table-body");
if (body) body.innerHTML = "";
const wrap = $("#royalty-table-wrap");
if (wrap) wrap.hidden = true;
const card = $("#royalty-summary-card");
if (card) card.hidden = true;
const foot = $("#royalty-footer-bar");
if (foot) foot.hidden = true;
const exp = $("#royalty-export-csv");
if (exp) exp.disabled = true;
rstate.entries.clear();
}
async function pickFolder() {
const p = await window.pywebview.api.royalty_pick_folder();
if (p && typeof p === "string") {
rstate.folder = p;
$("#royalty-path-display").textContent = p;
$("#royalty-start-btn").disabled = false;
setStatus("", "");
}
}
async function startScan() {
if (!rstate.folder) return;
rstate.recursive = $("#royalty-recursive").checked;
rstate.scanning = true;
_clearTable();
$("#royalty-start-btn").disabled = true;
$("#royalty-pick-folder").disabled = true;
$("#royalty-stop-btn").hidden = false;
$("#royalty-log").innerHTML = "";
$("#royaltyProgressFill").style.width = "0%";
$("#royaltyPercent").textContent = "0%";
$("#royaltyCounter").textContent = "Analisi in corso…";
setStatus("Analisi in corso...", "loading");
let res;
try {
res = await window.pywebview.api.royalty_start_scan({
directory: rstate.folder,
recursive: rstate.recursive,
});
} catch (e) {
setStatus("Errore avvio: " + ((e && e.message) || e), "error");
finishScan();
return;
}
if (!res || !res.ok) {
const msg = (res && res.error) || "Impossibile avviare l'analisi";
setStatus(msg, "error");
if (!handleGateBlock(res || {})) toast(msg, "error");
finishScan();
}
}
async function stopScan() {
try { await window.pywebview.api.royalty_stop_scan(); } catch (e) { console.error(e); }
}
function finishScan() {
rstate.scanning = false;
$("#royalty-start-btn").disabled = !rstate.folder;
$("#royalty-pick-folder").disabled = false;
$("#royalty-stop-btn").hidden = true;
}
function _csvEscape(s) {
const str = (s == null) ? "" : String(s);
if (/[",\n;]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
function exportCsv() {
if (rstate.entries.size === 0) return;
const rows = [["file", "artista", "titolo", "stato", "sorgente", "licenza", "licenza_url", "track_url", "score"]];
rstate.entries.forEach((e) => {
const m = (e.matches && e.matches[0]) || {};
rows.push([
_basename(e.path || ""),
e.artist || "",
e.title || "",
e.status || "",
m.source || "",
m.license || "",
m.license_url || "",
m.url || "",
(typeof m.score === "number") ? m.score : "",
]);
});
const csv = rows.map((r) => r.map(_csvEscape).join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `royalty_report_${Date.now()}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => window.URL.revokeObjectURL(url), 1500);
toast("CSV esportato", "success");
}
async function init() {
$("#royalty-pick-folder").addEventListener("click", pickFolder);
$("#royalty-start-btn").addEventListener("click", startScan);
$("#royalty-stop-btn").addEventListener("click", stopScan);
$("#royalty-export-csv").addEventListener("click", exportCsv);
$("#royalty-recursive").addEventListener("change", (e) => {
rstate.recursive = e.target.checked;
});
// Delegazione: link "traccia" / licenza aprono in browser esterno
const body = $("#royalty-table-body");
if (body) {
body.addEventListener("click", (ev) => {
const a = ev.target && ev.target.closest("a[data-ext-url]");
if (!a) return;
ev.preventDefault();
const u = a.getAttribute("data-ext-url");
if (u && window.pywebview && window.pywebview.api
&& window.pywebview.api.open_external_url) {
window.pywebview.api.open_external_url(u);
}
});
}
// Ripristina ultimo state da config
const cfg = state.config || {};
if (cfg.royalty_last_folder) {
rstate.folder = cfg.royalty_last_folder;
$("#royalty-path-display").textContent = cfg.royalty_last_folder;
$("#royalty-start-btn").disabled = false;
}
if (typeof cfg.royalty_recursive === "boolean") {
$("#royalty-recursive").checked = cfg.royalty_recursive;
rstate.recursive = cfg.royalty_recursive;
}
// Bridge handlers
bridgeHandlers["royalty:progress"] = (p) => {
if (!p) return;
if (typeof p.overall === "number") {
const pct = Math.round(p.overall * 100);
$("#royaltyProgressFill").style.width = pct + "%";
$("#royaltyPercent").textContent = pct + "%";
}
if (typeof p.idx === "number" && typeof p.total === "number" && p.total > 0) {
const s = p.status || "";
let label;
if (s === "checking") label = `File ${p.idx}/${p.total} · verifica`;
else if (s === "completed") label = `Completato: ${p.total} file`;
else if (s === "stopped") label = "Interrotta";
else if (s === "error") label = `File ${p.idx}/${p.total} · errore`;
else label = `File ${p.idx}/${p.total}`;
$("#royaltyCounter").textContent = label;
}
};
bridgeHandlers["royalty:entry"] = (entry) => {
_upsertEntry(entry);
setStatus(`Elaborati ${rstate.entries.size} file...`, "loading");
};
bridgeHandlers["royalty:done"] = (p) => {
if (rstate.entries.size === 0 && p && Array.isArray(p.entries)) {
p.entries.forEach(_upsertEntry);
}
if (p && p.ok === false) {
setStatus(p.error || "Errore analisi", "error");
} else if (rstate.entries.size === 0) {
setStatus("Nessun file audio trovato", "ok");
} else {
const rf = (p && p.royalty_free_count) || 0;
const tot = (p && p.total) || rstate.entries.size;
setStatus(`Analisi completata · ${rf}/${tot} royalty-free`, "ok");
}
finishScan();
};
}
return { init };
})();
// ============================================================ // ============================================================
// Boot // Boot
// ============================================================ // ============================================================