Compare commits

...
12 Commits
Author SHA1 Message Date
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
luciano 29b9753aa3 fix: mostra causa reale dietro "yt-dlp exit 1" (v1.11.3)
In download_playlist il codice legge stdout/stderr merged per parsare
il progress ma poi butta via tutte le righe. Se return_code != 0
l'utente vedeva solo "error: yt-dlp exit 1" senza sapere il perche'
(video bloccato? age-gate? 403? region-lock?).

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

Applicato solo al pattern download_playlist per ora (quello che usa il
utente sul batch Spotify). Direct URL e batch tags idem se emergono.
2026-08-28 10:08:52 +02:00
luciano c88e6d359c fix: switch build_* a yt-dlp nightly channel (v1.11.2)
Fix strutturale ricorrente errore "yt-dlp exit 1" nel download.
YouTube rompe API ogni 1-2 settimane; yt-dlp stable esce ogni ~4
settimane, quindi la build imbarcava binari gia' obsoleti al rilascio.

- build_macos.py + build_windows.py: URL switch da
  yt-dlp/yt-dlp/releases/latest → yt-dlp/yt-dlp-nightly-builds
- Sempre re-download (niente skip se bundle_bin/yt-dlp esiste) —
  garantisce binario 24-48h, non 3-4 settimane
2026-08-28 00:41:40 +02:00
luciano ed1059bf8c feat: sidebar scroll + log persistente (v1.11.1)
- Sidebar diventata troppo lunga (14+ tab): overflow-y auto sulla nav,
  brand e footer restano sticky. Scrollbar 6px tema-coerente
- Nuovo core/applog.py: TimedRotatingFileHandler daily con 7 giorni
  di retention. NON tocca sys.stdout/stderr (causa segfault WebKit
  su macOS); cattura tracebacks via sys.excepthook + threading.excepthook
- bridge._log mirror su logger persistente (ui.<view>) → tutto quello
  che vedi nei log delle tab e' anche su file
- Bridge: get_log_file_path() + open_log_folder() per la nuova sezione
  Impostazioni con path visibile e bottone "Apri cartella"
2026-08-27 11:27:57 +02:00
luciano cdbb1d85d5 feat: fallback Spotify + layout selector + tab Appiattisci (v1.11.0)
Cataloga:
- Fallback Spotify quando AcoustID non trova (copertura ottima per
  dance/electronic mashup/bootleg/remix)
- Dropdown "Struttura cartelle" per scegliere tra <Anno>/<Genere>/ e
  <Genere>/<Anno>/
- Chiave AcoustID default sostituita da placeholder invalido a quella
  pubblica di MusicBrainz Picard (valida)

Nuova tab Appiattisci:
- Sposta tutti i file audio delle sotto-cartelle nella cartella padre
- Utile come undo della catalogazione o merge di archivi frammentati
- Rimuove sotto-cartelle vuote dopo il move (opzionale)
- Suffisso _1, _2 su nomi duplicati (no overwrite)
2026-08-27 10:57:04 +02:00
luciano 401bed967a ui: tab Cataloga con streaming table + modal conferma move 2026-08-27 10:04:57 +02:00
luciano 9b711fd545 bridge: metodi Api per tab Cataloga + bump v1.11.0 2026-08-27 10:01:57 +02:00
luciano 9f9e4281b7 catalog: modulo AcoustID lookup + move to <year>/<genre>/ 2026-08-27 10:00:32 +02:00
20 changed files with 4691 additions and 40 deletions

No files matched your search

+5
View File
@@ -53,3 +53,8 @@ server/dist/
# Dedup local cache (SQLite fingerprint cache, per macchina dell'utente)
dedup_cache.db
dedup_cache.db-journal
catalog_cache.db
catalog_cache.db-journal
# Log persistente (rotating file)
logs/
+421
View File
@@ -47,6 +47,9 @@ from core.upgrader import (
count_files_info,
)
from core import dedup
from core import catalog
from core import flatten
from core import charts
SPOTIFY_GUIDE_TEXT = """\
@@ -113,6 +116,8 @@ class Api:
self._upgrade_thread: Optional[threading.Thread] = None
self._video_thread: Optional[threading.Thread] = None
self._dedup_thread: Optional[threading.Thread] = None
self._catalog_thread: Optional[threading.Thread] = None
self._flatten_thread: Optional[threading.Thread] = None
# Coda usata dal resolve_callback per attendere la scelta utente
# sul modal "match locali multipli" della tab Upgrade.
self._upgrade_resolve_q: "queue.Queue[dict]" = queue.Queue(1)
@@ -132,7 +137,40 @@ class Api:
pass
def _log(self, view: str, msg: str) -> None:
# Emit alla UI + mirror sul file di log persistente
self._emit("log", {"view": view, "msg": msg})
try:
import logging
logging.getLogger(f"ui.{view}").info(msg)
except Exception:
pass
# ------------------------------------------------------------------
# Log persistente
# ------------------------------------------------------------------
def get_log_file_path(self) -> str:
"""Ritorna il path del file di log corrente (per apertura da UI)."""
try:
from core.applog import log_file_path
return str(log_file_path())
except Exception:
return ""
def open_log_folder(self) -> dict:
"""Apre la cartella dei log nel file manager del sistema."""
try:
from core.applog import log_file_path
import subprocess
folder = log_file_path().parent
if sys.platform == "darwin":
subprocess.Popen(["open", str(folder)])
elif sys.platform == "win32":
os.startfile(str(folder)) # type: ignore[attr-defined]
else:
subprocess.Popen(["xdg-open", str(folder)])
return {"ok": True, "path": str(folder)}
except Exception as e:
return {"ok": False, "error": str(e)}
# ------------------------------------------------------------------
# Bootstrap
@@ -408,6 +446,21 @@ class Api:
except Exception as e:
return {"ok": False, "error": str(e)}
def remove_metadata_frames(self, path: str, frame_keys: list) -> dict:
"""Rimuove i frame ID3/atomi indicati dal file. Usato dal pannello
'Metadati avanzati' per pulire TXXX, GEOB (C2PA), USLT extra, ecc."""
if not path or not os.path.exists(path):
return {"ok": False, "error": "File non trovato"}
gate = self._gate("metadata")
if gate:
return gate
try:
from core.metadata import remove_frames
n = remove_frames(path, frame_keys or [])
return {"ok": True, "removed": n}
except Exception as e:
return {"ok": False, "error": str(e)}
def save_metadata(self, payload: dict) -> dict:
path = (payload.get("path") or "").strip()
if not path:
@@ -1963,3 +2016,371 @@ class Api:
"n_dupes": n_dupes,
"reclaimable_bytes": total_bytes,
})
# ================================================================
# CATALOGA — sposta file audio in <output>/<Anno>/<Genere>/
# via Chromaprint + AcoustID.
# ================================================================
def catalog_pick_source_folder(self) -> str:
"""Folder picker per la cartella sorgente da catalogare."""
return self.browse_directory()
def catalog_pick_target_folder(self) -> str:
"""Folder picker per la cartella target (dove finiscono i file
organizzati). Se l'utente lascia vuoto → si usa la source."""
return self.browse_directory()
def catalog_start_scan(self, payload: dict) -> dict:
"""Avvia worker di scansione + lookup AcoustID. Payload:
{source, target?, recursive}. Persiste last source/target/recursive.
"""
if self._catalog_thread and self._catalog_thread.is_alive():
return {"ok": False, "error": "Scansione catalogo gia in corso"}
source = (payload.get("source") or "").strip()
target = (payload.get("target") or "").strip()
recursive = bool(payload.get("recursive", True))
if not source:
return {"ok": False, "error": "Cartella input non impostata"}
if not os.path.isdir(source):
return {"ok": False, "error": "Cartella input non trovata"}
# target vuoto → sposta in-place nella source
if not target:
target = source
# Persist last source/target/recursive
try:
cfg = load_config()
cfg["catalog_last_source"] = source
cfg["catalog_last_target"] = target
cfg["catalog_recursive"] = recursive
save_config(cfg)
except Exception:
pass
self._catalog_thread = threading.Thread(
target=self._catalog_worker,
args=(source, recursive),
daemon=True,
)
self._catalog_thread.start()
return {"ok": True}
def catalog_stop_scan(self) -> dict:
catalog.request_stop()
self._log("catalog", "[INFO] Interruzione richiesta...")
return {"ok": True}
def catalog_move_files(self, entries: list, target_root: str, layout: str = "year_genre") -> dict:
"""Sposta le entry selezionate in <target_root>/<year>/<genre>/.
Non consuma quota (e' una riorganizzazione di file locali,
non un download). Ritorna il summary con moved/failed/operations.
"""
if not isinstance(entries, list):
return {"ok": False, "error": "entries deve essere una lista"}
target = (target_root or "").strip()
if not target:
return {"ok": False, "error": "Cartella destinazione mancante"}
def log_cb(op: dict) -> None:
src = op.get("src", "")
dst = op.get("dst", "")
self._log("catalog", f"[MOVE] {src} → {dst}")
try:
self._emit("catalog:log", op)
except Exception:
pass
try:
safe_layout = layout if layout in ("year_genre", "genre_year") else "year_genre"
result = catalog.move_files(entries, target, log_callback=log_cb, layout=safe_layout)
except Exception as e:
self._log("catalog", f"[ERRORE] move: {e}")
return {"ok": False, "error": str(e)}
moved_n = result.get("moved", 0)
failed = result.get("failed", []) or []
if moved_n:
self._log("catalog", f"[OK] {moved_n} file organizzati")
for f in failed:
self._log("catalog",
f"[ERRORE] {f.get('path')}: {f.get('error')}")
return {
"ok": True,
"moved": moved_n,
"failed_count": len(failed),
"failed": failed,
"operations": result.get("operations", []),
}
# ------------------------------------------------------------------
# FLATTEN — sposta tutti i file audio delle sotto-cartelle nella root
# ------------------------------------------------------------------
def flatten_pick_folder(self) -> str:
if not self.window:
return ""
try:
r = self.window.create_file_dialog(self._folder_dialog_type())
except Exception:
return ""
if not r:
return ""
return str(r[0]) if isinstance(r, (list, tuple)) else str(r)
def flatten_start(self, payload: dict) -> dict:
"""Payload: {directory, remove_empty}."""
if self._flatten_thread and self._flatten_thread.is_alive():
return {"ok": False, "error": "Operazione gia' in corso"}
directory = (payload.get("directory") or "").strip()
if not directory:
return {"ok": False, "error": "Cartella non impostata"}
if not os.path.isdir(directory):
return {"ok": False, "error": "Cartella non trovata"}
remove_empty = bool(payload.get("remove_empty", True))
self._flatten_thread = threading.Thread(
target=self._flatten_worker,
args=(directory, remove_empty),
daemon=True,
)
self._flatten_thread.start()
return {"ok": True}
def flatten_stop(self) -> dict:
flatten.request_stop()
return {"ok": True}
def _flatten_worker(self, directory: str, remove_empty: bool) -> None:
view = "flatten"
self._log(view, f"[INFO] Appiattisco: {directory} (remove_empty={remove_empty})")
def progress_cb(idx, total, name, status, err=""):
payload = {"idx": idx, "total": total, "filename": name, "status": status}
if err:
payload["error_msg"] = err
if total > 0:
payload["overall"] = min(idx / total, 1.0)
if status == "completed":
payload["overall"] = 1.0
self._log(view, f"[INFO] Completato: {total} file processati.")
elif status == "stopped":
self._log(view, "[INFO] Operazione interrotta.")
elif status == "error" and name:
self._log(view, f"[ERRORE] {name}: {err}")
self._emit("flatten:progress", payload)
def log_cb(op):
self._log(view, f"[MOVE] {op.get('src','')} → {op.get('dst','')}")
try:
result = flatten.flatten_folder(
directory,
remove_empty=remove_empty,
progress_callback=progress_cb,
log_callback=log_cb,
)
except Exception as e:
self._log(view, f"[ERRORE] {e}")
self._emit("flatten:done", {"ok": False, "error": str(e)})
return
moved = result.get("moved", 0)
failed = result.get("failed", []) or []
dirs_removed = result.get("dirs_removed", 0)
self._log(view, f"[OK] Spostati {moved} file. Cartelle rimosse: {dirs_removed}. Falliti: {len(failed)}.")
self._emit("flatten:done", {
"ok": True,
"moved": moved,
"failed_count": len(failed),
"failed": failed,
"dirs_removed": dirs_removed,
})
def _catalog_worker(self, directory: str, recursive: bool) -> None:
"""Esegue scan + lookup AcoustID in background, emette streaming."""
view = "catalog"
catalog.reset_stop()
self._log(view,
f"[INFO] Scansione: {directory} (recursive={recursive})")
_last = [0.0]
_THROTTLE = 0.05
def progress_cb(idx: int, total: int, filename: str,
status: str, err_msg: str = "") -> None:
# Throttle solo eventi "computing"/"lookup" (possono essere tanti)
if status in ("computing", "lookup"):
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] Scansione completata ({total} file).")
elif status == "stopped":
self._log(view, "[INFO] Scansione interrotta.")
elif status == "error" and filename:
detail = f": {err_msg}" if err_msg else ""
self._log(view, f"[ERRORE] {filename}{detail}")
self._emit("catalog:progress", payload_evt)
def entry_cb(entry: dict) -> None:
"""Streaming: appena un file è processato, emit alla UI."""
try:
self._emit("catalog:entry", entry)
except Exception:
pass
# Prova a ottenere un token Spotify per il fallback (facoltativo).
# Se non ci sono creds o l'auth fallisce, procediamo senza fallback.
spotify_token = None
try:
cfg = load_config()
cid = (cfg.get("client_id") or "").strip()
csecret = (cfg.get("client_secret") or "").strip()
if cid and csecret:
spotify_token = get_access_token(cid, csecret)
except Exception:
spotify_token = None
try:
entries = catalog.scan_folder(
directory,
recursive=recursive,
progress_callback=progress_cb,
entry_callback=entry_cb,
spotify_token=spotify_token,
)
except Exception as e:
self._log(view, f"[ERRORE] {e}")
self._emit("catalog:done", {"ok": False, "error": str(e),
"entries": []})
return
n_matched = sum(1 for e in entries if e.get("matched"))
n_unmatched = len(entries) - n_matched
self._log(view,
f"[INFO] Match AcoustID: {n_matched} · non identificati: "
f"{n_unmatched} (totale {len(entries)})")
self._emit("catalog:done", {
"ok": True,
"entries": entries,
"n_total": len(entries),
"n_matched": n_matched,
"n_unmatched": n_unmatched,
})
# ================================================================
# CHARTS — classifiche musicali (iTunes / Spotify / M2O / Last.fm)
# ================================================================
def charts_fetch(self, payload: dict) -> dict:
"""Ritorna le entry di una chart per la sorgente/params richiesti.
Payload:
{
source: "itunes" | "spotify" | "m2o" | "lastfm",
country?: "it"|"us"|"gb"|"ww", # solo itunes
playlist_key?: str, # solo spotify (chiave _SPOTIFY_CHARTS)
decade?: str, # solo lastfm ("" | "70s"|"80s"|...)
genre?: str, # solo lastfm ("" | pop|rock|dance|...)
force?: bool, # bypass cache
}
Persiste anche la scelta corrente in config.
"""
payload = payload or {}
source = (payload.get("source") or "itunes").strip().lower()
force = bool(payload.get("force", False))
# Persist scelta corrente (best-effort, non blocca la fetch)
try:
cfg = load_config()
cfg["charts_last_source"] = source
if source == "itunes":
cfg["charts_itunes_country"] = (
payload.get("country") or cfg.get("charts_itunes_country") or "it"
)
elif source == "lastfm":
cfg["charts_lastfm_decade"] = (
payload.get("decade") if payload.get("decade") is not None
else cfg.get("charts_lastfm_decade", "90s")
)
cfg["charts_lastfm_genre"] = (
payload.get("genre") if payload.get("genre") is not None
else cfg.get("charts_lastfm_genre", "dance")
)
elif source == "spotify":
cfg["charts_spotify_playlist"] = (
payload.get("playlist_key")
or cfg.get("charts_spotify_playlist")
or "top50_global"
)
save_config(cfg)
except Exception:
pass
try:
if source == "itunes":
country = (payload.get("country") or "it").strip().lower() or "it"
entries = charts.fetch_itunes(country, force=force)
elif source == "lastfm":
decade = payload.get("decade") or ""
genre = payload.get("genre") or ""
entries = charts.fetch_lastfm(decade, genre, force=force)
elif source == "spotify":
playlist_key = (payload.get("playlist_key") or "top50_global").strip()
entries = charts.fetch_spotify(playlist_key, force=force)
elif source == "m2o":
entries = charts.fetch_m2o(force=force)
else:
return {"ok": False, "error": f"Sorgente sconosciuta: {source}"}
except Exception as e:
self._log("charts", f"[ERRORE] fetch {source}: {e}")
return {"ok": False, "error": str(e)}
self._log("charts", f"[INFO] {source}: {len(entries)} brani ricevuti")
return {"ok": True, "source": source, "entries": entries}
def charts_add_to_queue(self, entries: list) -> dict:
"""Riceve una lista di entry `{artist, title}` selezionate dalla UI
e le converte in una tracklist compatibile con la tab Scarica.
Emette l'evento `charts:add_to_queue` col testo formattato
(`Artista - Titolo` una per riga) e la lista strutturata. La UI
popola l'input della tab Scarica e commuta view.
"""
if not isinstance(entries, list):
return {"ok": False, "error": "entries deve essere una lista"}
tracks = []
lines = []
for e in entries:
if not isinstance(e, dict):
continue
artist = (e.get("artist") or "").strip()
title = (e.get("title") or "").strip()
if not title:
continue
tracks.append({"artist": artist, "name": title})
if artist:
lines.append(f"{artist} - {title}")
else:
lines.append(title)
if not tracks:
return {"ok": False, "error": "Nessun brano valido selezionato"}
text = "\n".join(lines)
payload = {"text": text, "count": len(tracks), "tracks": tracks}
self._emit("charts:add_to_queue", payload)
self._log("charts", f"[INFO] {len(tracks)} brani inviati alla coda Scarica")
return {"ok": True, "count": len(tracks)}
+10 -8
View File
@@ -39,21 +39,23 @@ def log(msg):
# 1. Scarica yt-dlp standalone
# =========================================================================
def download_ytdlp():
"""Scarica il binario standalone di yt-dlp per macOS."""
"""Scarica il binario standalone di yt-dlp per macOS.
Usa il NIGHTLY channel invece dello stable perche' YouTube rompe le
API ogni 1-2 settimane e la stable resta indietro di 3-4 settimane
tipicamente. La nightly esce quasi ogni giorno con i fix.
Sempre re-download: garantisce che ogni build imbarca yt-dlp fresco.
"""
arch = platform.machine() # arm64 o x86_64
if arch == "arm64":
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
url = "https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download/yt-dlp_macos"
else:
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos_legacy"
url = "https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download/yt-dlp_macos_legacy"
dest = BUNDLE_DIR / "yt-dlp"
if dest.exists():
log(f"yt-dlp gia presente: {dest}")
return dest
log(f"Scarico yt-dlp da {url} ...")
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
log(f"Scarico yt-dlp nightly da {url} ...")
# Usa curl perche urllib di Python 3.8 ha problemi SSL su macOS
subprocess.run(
["curl", "-L", "-o", str(dest), url],
+4 -6
View File
@@ -29,7 +29,7 @@ BUILD_DIR = ROOT / "build"
DIST_DIR = ROOT / "dist"
BUNDLE_DIR = ROOT / "bundle_bin"
YTDLP_URL = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
YTDLP_URL = "https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download/yt-dlp.exe"
FFMPEG_URL = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
FPCALC_URL = ("https://github.com/acoustid/chromaprint/releases/download/"
"v1.5.1/chromaprint-fpcalc-1.5.1-windows-x86_64.zip")
@@ -43,13 +43,11 @@ def log(msg):
# 1. Scarica yt-dlp.exe
# =========================================================================
def download_ytdlp():
"""Scarica sempre il nightly fresco: la stable resta indietro di
3-4 settimane rispetto alle rotture API YouTube."""
dest = BUNDLE_DIR / "yt-dlp.exe"
if dest.exists():
log(f"yt-dlp.exe gia presente: {dest}")
return dest
log(f"Scarico yt-dlp.exe ...")
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
log(f"Scarico yt-dlp.exe nightly ...")
urllib.request.urlretrieve(YTDLP_URL, dest)
log(f"yt-dlp.exe scaricato ({dest.stat().st_size // 1024 // 1024} MB)")
return dest
+240
View File
@@ -0,0 +1,240 @@
"""AcoustID + MusicBrainz lookup con cache SQLite.
Data un fingerprint Chromaprint (calcolato via `fpcalc`), interroga il
servizio pubblico AcoustID (https://acoustid.org) per recuperare
metadati sulla traccia: artista, titolo, anno di rilascio piu' antico
e (best-effort) un genere estratto dai tag dei release group MusicBrainz.
I risultati vengono cacheati su SQLite (chiave = fingerprint) per non
sprecare quota API su scan ripetute. La rate limit e' auto-imposta a
~3 req/s per rispettare i limiti pubblici AcoustID.
Nota: l'API AcoustID base NON restituisce sempre il genere — dipende
dai tag presenti nel release group MusicBrainz. Molte tracce dance
electronic hanno pochi tag; il campo `genre` puo' quindi essere vuoto
anche con un match valido. In quel caso il chiamante rimapa in
"Unknown Genre".
"""
from __future__ import annotations
import json
import sqlite3
import time
from pathlib import Path
from typing import Optional
import requests
# App key pubblica per MusicTools. Puo' essere sovrascritta passando
# `app_key` esplicito o via config. Chiavi si ottengono gratis su
# https://acoustid.org/api-key (max ~3 req/s).
# Default: chiave pubblica di MusicBrainz Picard (open-source tagger ufficiale).
# Per uso intenso registra una app dedicata su https://acoustid.org/api-key
# e settala in Impostazioni → acoustid_app_key.
_APP_KEY = "v8pQ6oyB"
_API_URL = "https://api.acoustid.org/v2/lookup"
_REQUEST_TIMEOUT = 20
_RATE_LIMIT_SEC = 0.35 # ~3 req/s max
def _cache_db_path() -> Path:
"""Path del DB di cache dei lookup AcoustID.
Riusa `_get_config_dir` di core.config cosi' finisce nella stessa
cartella di config.json e dedup_cache.db.
"""
from core.config import _get_config_dir
return _get_config_dir() / "catalog_cache.db"
def _init_db(conn: sqlite3.Connection) -> None:
"""Crea (idempotente) lo schema della cache."""
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS lookups(
fingerprint TEXT PRIMARY KEY,
payload TEXT NOT NULL,
cached_at REAL NOT NULL
);
"""
)
conn.commit()
def _open_cache() -> sqlite3.Connection:
"""Apre (creando se serve) la connessione alla cache."""
p = _cache_db_path()
p.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(p))
_init_db(conn)
return conn
def _get_cached(conn: sqlite3.Connection, fingerprint: str) -> Optional[dict]:
row = conn.execute(
"SELECT payload FROM lookups WHERE fingerprint = ?", (fingerprint,)
).fetchone()
if not row:
return None
try:
return json.loads(row[0])
except Exception:
return None
def _put_cache(conn: sqlite3.Connection, fingerprint: str, data: dict) -> None:
conn.execute(
"INSERT OR REPLACE INTO lookups(fingerprint, payload, cached_at)"
" VALUES (?, ?, ?)",
(fingerprint, json.dumps(data, ensure_ascii=False), time.time()),
)
conn.commit()
# Rate-limit state (globale al processo — vale anche se lookup chiamato
# da thread diversi: non e' esattamente thread-safe ma il worst-case e'
# una richiesta leggermente troppo veloce, non un ban).
_last_request_at = [0.0]
def _throttle() -> None:
"""Attende quel tanto che basta per rispettare _RATE_LIMIT_SEC."""
now = time.monotonic()
elapsed = now - _last_request_at[0]
if elapsed < _RATE_LIMIT_SEC:
time.sleep(_RATE_LIMIT_SEC - elapsed)
_last_request_at[0] = time.monotonic()
def _extract_min_year(releases: list) -> Optional[int]:
"""Trova l'anno piu' antico tra i release. Ignora date invalide."""
year: Optional[int] = None
for rel in releases or []:
date = rel.get("date") if isinstance(rel, dict) else None
if not isinstance(date, dict):
continue
y = date.get("year")
if isinstance(y, int) and y > 0:
if year is None or y < year:
year = y
return year
def _extract_top_genre(releases: list) -> str:
"""Sceglie il tag piu' rilevante dai releasegroup (max `count`)."""
for rel in releases or []:
rg = rel.get("releasegroup") if isinstance(rel, dict) else None
if not isinstance(rg, dict):
continue
tags = rg.get("tags") or []
if not isinstance(tags, list) or not tags:
continue
try:
top = max(tags, key=lambda t: int(t.get("count", 0) or 0))
except (TypeError, ValueError):
top = tags[0]
name = (top.get("name") or "").strip() if isinstance(top, dict) else ""
if name:
return name
return ""
def lookup(fingerprint: str, duration: float,
app_key: str = _APP_KEY) -> dict:
"""Interroga AcoustID (o cache) per una tripla (year, genre, title).
Ritorna sempre un dict con almeno il campo `matched: bool`. In caso
di match valido, aggiunge `year: int|None`, `genre: str`,
`artist: str`, `title: str`. In caso di errore aggiunge `error: str`
ma NON alza eccezione: chiamanti di batch (Cataloga) devono poter
continuare anche se una chiamata singola fallisce.
Cache: risultati validi E "no match" vengono cacheati sul
fingerprint — cosi' scansioni ripetute non re-interrogano l'API.
Errori transitori (rete/HTTP) NON vengono cacheati.
"""
if not fingerprint:
return {"matched": False, "error": "fingerprint vuoto"}
if not app_key:
app_key = _APP_KEY
conn = _open_cache()
try:
cached = _get_cached(conn, fingerprint)
if cached is not None:
return cached
_throttle()
try:
resp = requests.get(
_API_URL,
params={
"client": app_key,
"meta": "recordings+releases+releasegroups",
"duration": int(duration or 0),
"fingerprint": fingerprint,
},
timeout=_REQUEST_TIMEOUT,
)
except requests.RequestException as e:
return {"matched": False, "error": f"network: {e}"}
except Exception as e:
return {"matched": False, "error": str(e)}
if resp.status_code != 200:
return {"matched": False, "error": f"HTTP {resp.status_code}"}
try:
data = resp.json()
except Exception as e:
return {"matched": False, "error": f"JSON malformato: {e}"}
if data.get("status") != "ok":
err = data.get("error", {}) or {}
msg = err.get("message") if isinstance(err, dict) else str(err)
return {"matched": False, "error": msg or "AcoustID status non-ok"}
results = data.get("results") or []
if not results:
result = {"matched": False}
_put_cache(conn, fingerprint, result)
return result
# Match col miglior score
best = max(results, key=lambda r: r.get("score", 0) or 0)
recordings = best.get("recordings") or []
if not recordings:
result = {"matched": False}
_put_cache(conn, fingerprint, result)
return result
rec = recordings[0] or {}
title = (rec.get("title") or "").strip()
artists = rec.get("artists") or []
artist_names = [
(a.get("name") or "").strip()
for a in artists
if isinstance(a, dict) and a.get("name")
]
artist = ", ".join(n for n in artist_names if n)
releases = rec.get("releases") or []
year = _extract_min_year(releases)
genre = _extract_top_genre(releases)
result = {
"matched": bool(year or genre or title),
"year": year,
"genre": genre,
"artist": artist,
"title": title,
}
_put_cache(conn, fingerprint, result)
return result
finally:
try:
conn.close()
except Exception:
pass
+134
View File
@@ -0,0 +1,134 @@
"""Setup logging persistente su file rotante.
Chiamato in `main.py` prima di qualsiasi import pesante. Il file di log
vive in:
- macOS: ~/Library/Application Support/MusicTools/logs/app.log
- Windows: %APPDATA%/MusicTools/logs/app.log
- Dev: <project_root>/logs/app.log
Rotazione: 1 file al giorno, mantiene gli ultimi 7. Cattura anche stdout
e stderr per non perdere `print()` o traceback che l'app windowed
altrimenti scarterebbe.
"""
from __future__ import annotations
import logging
import logging.handlers
import os
import sys
from pathlib import Path
_setup_done = False
def _logs_dir() -> Path:
"""Directory dei log — parallela a config.json."""
if getattr(sys, "frozen", False):
if sys.platform == "win32":
base = Path(os.environ.get("APPDATA", Path.home()))
else:
base = Path.home() / "Library" / "Application Support"
d = base / "MusicTools" / "logs"
else:
d = Path(__file__).resolve().parent.parent / "logs"
d.mkdir(parents=True, exist_ok=True)
return d
def log_file_path() -> Path:
return _logs_dir() / "app.log"
def _install_excepthook() -> None:
"""Cattura ogni uncaught exception dentro il file di log.
NON tocca sys.stdout/stderr (romperebbe WebKit)."""
import traceback
logger = logging.getLogger("uncaught")
_orig = sys.excepthook
def _hook(exc_type, exc_value, tb):
try:
logger.error(
"Uncaught: %s: %s\n%s",
exc_type.__name__,
exc_value,
"".join(traceback.format_exception(exc_type, exc_value, tb)),
)
except Exception:
pass
try:
_orig(exc_type, exc_value, tb)
except Exception:
pass
sys.excepthook = _hook
# Anche per exception in threading (Python 3.8+)
try:
_orig_thread = threading_excepthook = getattr(__import__("threading"), "excepthook", None)
def _thread_hook(args):
try:
logger.error(
"Thread uncaught: %s: %s\n%s",
args.exc_type.__name__,
args.exc_value,
"".join(traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback)),
)
except Exception:
pass
if _orig_thread:
try:
_orig_thread(args)
except Exception:
pass
if threading_excepthook is not None:
__import__("threading").excepthook = _thread_hook
except Exception:
pass
def setup() -> Path:
"""Configura il logger root — NON tocca stdout/stderr per non rompere
WebKit (segfault). Cattura traceback via sys.excepthook + logger.
"""
global _setup_done
path = log_file_path()
if _setup_done:
return path
_setup_done = True
root = logging.getLogger()
root.setLevel(logging.INFO)
fmt = logging.Formatter(
fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# File handler con rotazione giornaliera (max 7 file = 7 giorni)
fh = logging.handlers.TimedRotatingFileHandler(
str(path),
when="midnight",
backupCount=7,
encoding="utf-8",
utc=False,
)
fh.setFormatter(fmt)
fh.setLevel(logging.INFO)
root.addHandler(fh)
# Se in dev, mantieni anche console (utile durante lavoro)
if not getattr(sys, "frozen", False):
ch = logging.StreamHandler(sys.__stderr__)
ch.setFormatter(fmt)
ch.setLevel(logging.INFO)
root.addHandler(ch)
_install_excepthook()
root.info(f"MusicTools log start — file: {path}")
return path
+390
View File
@@ -0,0 +1,390 @@
"""Cataloga file audio in sottocartelle <Anno>/<Genere>/ via AcoustID.
Pipeline:
1. Scansiona la cartella (opzionalmente ricorsivo) filtrando per
estensioni audio (AUDIO_EXTENSIONS di core.upgrader).
2. Per ogni file calcola il fingerprint Chromaprint (`fpcalc`,
riusa `core.dedup.compute_fingerprint`).
3. Lookup AcoustID (cache SQLite) per estrarre year + genre +
artist + title.
4. `move_files` sposta le entry selezionate in
`<target>/<Year>/<Genre>/<filename>`. Se manca year/genre usa
"Unknown Year" / "Unknown Genre".
Progress callback firma:
(processed, total, filename, status[, err_msg])
Status: 'computing' | 'lookup' | 'error' | 'stopped' | 'completed'.
`entry_callback(entry)` viene chiamato per ogni file processato,
permettendo alla UI di aggiornare la tabella in streaming.
"""
from __future__ import annotations
import re
import shutil
import threading
from pathlib import Path
from typing import Callable, Optional
from core.acoustid import lookup
_NUM_PREFIX = re.compile(r"^\s*\d+\s*[.\-)]\s*")
_PAREN_TAIL = re.compile(r"\s*[\[(].*?[\])]\s*$")
_DASH_SPLIT = re.compile(r"\s+[-–—]\s+")
def _parse_filename(stem: str) -> tuple:
"""Estrae (artist, title) da nome file tipo 'Artista - Titolo (Mix)'.
Ritorna ('', stem) se non c'è dash separator."""
s = stem.strip()
s = _NUM_PREFIX.sub("", s)
# Rimuove parentesi finali (Extended Mix), [Original Mix] ecc.
for _ in range(3):
s = _PAREN_TAIL.sub("", s).strip()
parts = _DASH_SPLIT.split(s, maxsplit=1)
if len(parts) == 2:
artist, title = parts[0].strip(), parts[1].strip()
if artist and title:
return artist, title
return "", s
def _spotify_lookup_fallback(stem: str, token: str, genre_cache: dict) -> Optional[dict]:
"""Fallback quando AcoustID non trova nulla: search Spotify col filename.
Ritorna {matched, year, genre, artist, title} o None su errore.
Popola `genre_cache` (dict artist_id → primo genere) per non chiamare
l'API `/artists/<id>` per ogni traccia dello stesso artista.
"""
from core.spotify_client import search_tracks, get_artist_genres
artist_hint, title_hint = _parse_filename(stem)
query = f"{artist_hint} {title_hint}".strip() if artist_hint else title_hint
if not query:
return None
try:
results = search_tracks(token, query, limit=1)
except Exception:
return None
if not results:
return None
top = results[0]
release_date = (top.get("release_date") or "").strip()
year = None
if len(release_date) >= 4 and release_date[:4].isdigit():
year = int(release_date[:4])
# Prendi il primo genere dell'artista (con cache per non abusare l'API)
genre = ""
artist_id = top.get("artist_id") or ""
if artist_id:
if artist_id in genre_cache:
genres = genre_cache[artist_id]
else:
try:
genres = get_artist_genres(token, artist_id) or []
except Exception:
genres = []
genre_cache[artist_id] = genres
if genres:
genre = genres[0]
return {
"matched": bool(year or genre or top.get("name")),
"year": year,
"genre": genre,
"artist": top.get("artists", "") or "",
"title": top.get("name", "") or "",
"_source": "spotify",
}
from core.dedup import compute_fingerprint
from core.paths import find_fpcalc
from core.upgrader import AUDIO_EXTENSIONS
# ------------------------------------------------------------------
# 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()
# ------------------------------------------------------------------
# Sanitize path
# ------------------------------------------------------------------
# Caratteri vietati o problematici in nomi cartella cross-platform.
# `/` e `\` sono trattati a parte perche' MusicBrainz usa spesso
# genre-name-style come "electronic/house": vogliamo rimpiazzarli con
# `_` (non con lo split, che creerebbe path nesting indesiderati).
_FORBIDDEN = re.compile(r'[<>:"|?*\\/]+')
_MULTI_SPACE = re.compile(r"\s+")
def _sanitize_folder(name: str) -> str:
"""Sanitize per path filesystem: rimpiazza chars non validi con `_`.
- Chars vietati Windows (<>:"|?*) + slash → `_`
- Spazi multipli collassati in uno solo
- Trim finale
- Troncamento a 100 char (limite pratico per path lunghi cumulati)
"""
if not name:
return ""
s = _FORBIDDEN.sub("_", name).strip()
s = _MULTI_SPACE.sub(" ", s)
if len(s) > 100:
s = s[:100].rstrip()
return s
# ------------------------------------------------------------------
# Scan
# ------------------------------------------------------------------
def scan_folder(
directory: str,
recursive: bool = True,
progress_callback: Optional[Callable] = None,
entry_callback: Optional[Callable] = None,
spotify_token: Optional[str] = None,
) -> list:
"""Scansiona la cartella e ritorna la lista di entry con metadata.
Ogni entry:
{path, size, fingerprint, matched, year, genre, artist, title, error?}
- `progress_callback(idx, total, filename, status[, err])`:
chiamato con status 'computing' | 'lookup' | 'error' | 'stopped'
| 'completed'. Firma retrocompatibile (4 args) supportata.
- `entry_callback(entry)`: chiamato appena ogni file e' processato
(streaming alla UI).
"""
reset_stop()
base = Path(directory)
if not base.exists() or not base.is_dir():
if progress_callback:
_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:
if progress_callback:
_emit_progress(progress_callback, 0, 0, "", "completed", "")
return []
fpcalc = find_fpcalc()
entries: list = []
# Cache locale per non re-fetchare i generi dello stesso artista Spotify
_spotify_genre_cache: dict = {}
for i, fp_path in enumerate(files, start=1):
if is_stopped():
_emit_progress(progress_callback, i - 1, total, "", "stopped", "")
return entries
try:
size = fp_path.stat().st_size
except OSError as e:
_emit_progress(progress_callback, i, total, fp_path.name,
"error", f"stat: {e}")
continue
# Fingerprint
_emit_progress(progress_callback, i, total, fp_path.name, "computing", "")
res = compute_fingerprint(fpcalc, str(fp_path)) if fpcalc else None
if not res or not res.get("fingerprint"):
err = (res or {}).get("_error") or "fpcalc non disponibile"
entry = {
"path": str(fp_path), "size": size, "fingerprint": "",
"matched": False, "error": err,
"year": None, "genre": "", "artist": "", "title": "",
}
entries.append(entry)
if entry_callback:
try:
entry_callback(entry)
except Exception:
pass
_emit_progress(progress_callback, i, total, fp_path.name,
"error", err)
continue
fp_hash = res["fingerprint"]
duration = float(res.get("duration") or 0)
# AcoustID lookup (cache SQLite dentro core.acoustid)
_emit_progress(progress_callback, i, total, fp_path.name, "lookup", "")
info = lookup(fp_hash, duration)
matched = bool(info.get("matched"))
source = "acoustid"
# Fallback su Spotify se AcoustID non ha trovato niente e abbiamo il token.
# Molti file (mashup, bootleg, extended mix) non sono in MusicBrainz ma Spotify
# ha ottima copertura per dance/electronic.
if not matched and spotify_token:
_emit_progress(progress_callback, i, total, fp_path.name, "spotify_fallback", "")
fb = _spotify_lookup_fallback(fp_path.stem, spotify_token, _spotify_genre_cache)
if fb and fb.get("matched"):
info = fb
matched = True
source = "spotify"
entry = {
"path": str(fp_path),
"size": size,
"fingerprint": fp_hash,
"matched": matched,
"year": info.get("year"),
"genre": (info.get("genre") or "").strip(),
"artist": (info.get("artist") or "").strip(),
"title": (info.get("title") or "").strip(),
"source": source,
}
if info.get("error"):
entry["error"] = info["error"]
entries.append(entry)
if entry_callback:
try:
entry_callback(entry)
except Exception:
pass
_emit_progress(progress_callback, total, total, "", "completed", "")
return entries
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
# ------------------------------------------------------------------
# Move
# ------------------------------------------------------------------
def move_files(entries: list, target_root: str,
log_callback: Optional[Callable] = None,
layout: str = "year_genre") -> dict:
"""Sposta i file elencati in `<target_root>/<year>/<genre>/<filename>`.
- Se `year` manca → cartella "Unknown Year"
- Se `genre` manca → cartella "Unknown Genre"
- Se il file destinazione esiste gia', aggiunge suffisso _1, _2...
(non sovrascrive mai).
Ritorna un dict:
{
"moved": int, # numero file spostati con successo
"skipped": int, # entries scartate (0 per ora)
"failed": [{path, error}], # errori per file
"operations": [{src, dst}], # log ops riuscite (utile per undo)
}
`log_callback(op)` viene chiamato per ogni operazione riuscita
(streaming alla UI).
"""
target_base = Path(target_root)
try:
target_base.mkdir(parents=True, exist_ok=True)
except OSError as e:
return {"moved": 0, "skipped": 0,
"failed": [{"path": target_root,
"error": f"impossibile creare target: {e}"}],
"operations": []}
result = {"moved": 0, "skipped": 0, "failed": [], "operations": []}
for entry in entries or []:
src_str = (entry or {}).get("path") or ""
src = Path(src_str)
if not src_str:
result["failed"].append({"path": "",
"error": "path mancante"})
continue
if not src.exists():
result["failed"].append({"path": src_str,
"error": "file non esiste"})
continue
year = entry.get("year")
genre = (entry.get("genre") or "").strip()
year_folder = str(year) if year else "Unknown Year"
genre_folder = _sanitize_folder(genre) or "Unknown Genre"
if layout == "genre_year":
dst_dir = target_base / genre_folder / year_folder
else: # year_genre (default)
dst_dir = target_base / year_folder / genre_folder
try:
dst_dir.mkdir(parents=True, exist_ok=True)
except OSError as e:
result["failed"].append({"path": src_str,
"error": f"mkdir: {e}"})
continue
dst = dst_dir / src.name
# Anti-overwrite: se il target esiste, aggiungi _1, _2, ...
if dst.exists():
stem = dst.stem
suffix = dst.suffix
i = 1
while dst.exists():
dst = dst_dir / f"{stem}_{i}{suffix}"
i += 1
try:
shutil.move(str(src), str(dst))
except Exception as e:
result["failed"].append({"path": src_str, "error": str(e)})
continue
result["moved"] += 1
op = {"src": src_str, "dst": str(dst)}
result["operations"].append(op)
if log_callback:
try:
log_callback(op)
except Exception:
pass
return result
+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()
+13 -1
View File
@@ -5,7 +5,7 @@ import os
import sys
from pathlib import Path
VERSION = "v1.10.1"
VERSION = "v1.13.0"
APP_NAME = "MusicTools"
@@ -83,6 +83,18 @@ DEFAULTS = {
"dedup_last_folder": "",
"dedup_recursive": True,
"dedup_method": "fingerprint", # "fingerprint" | "filename"
# ---- Cataloga (AcoustID -> <Anno>/<Genere>/) ----
"catalog_last_source": "",
"catalog_last_target": "",
"catalog_recursive": True,
"acoustid_app_key": "", # opzionale: se vuoto usa default in core/acoustid.py
# ---- Charts (iTunes RSS + Last.fm + Spotify + M2O) ----
"charts_last_source": "itunes", # "itunes" | "spotify" | "m2o" | "lastfm"
"charts_itunes_country": "it", # "it" | "us" | "gb" | "ww"
"charts_lastfm_decade": "90s", # "" | "70s" | "80s" | "90s" | "2000s" | "2010s"
"charts_lastfm_genre": "dance", # "" | pop, rock, dance, ...
"charts_spotify_playlist": "top50_global", # chiave in charts._SPOTIFY_CHARTS
"lastfm_api_key": "", # opzionale: override della chiave demo
# ---- Licenza ----
"license_key": "", # chiave fornita all'utente via email
"license_email": "", # email associata all'acquisto
+18 -1
View File
@@ -247,6 +247,7 @@ def download_playlist(
**subprocess_flags(),
)
last_lines: list = [] # buffer per capire l'errore se exit != 0
for line in _current_process.stdout:
if is_stopped():
_current_process.terminate()
@@ -254,6 +255,13 @@ def download_playlist(
progress_callback(i, total, query, "stopped", 0)
return
# Tieni le ultime 10 righe non-progress per il report d'errore
stripped = line.strip()
if stripped and not re.match(r"^\[download\]\s+\d", stripped):
last_lines.append(stripped)
if len(last_lines) > 10:
last_lines.pop(0)
# Parse progress da output yt-dlp
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
if pct_match and progress_callback:
@@ -278,8 +286,17 @@ def download_playlist(
if progress_callback:
progress_callback(i, total, query, "done", 100)
else:
# Estrai la vera causa da last_lines: cerca la riga ERROR di yt-dlp
err_hint = ""
for l in reversed(last_lines):
if "ERROR" in l or "error" in l:
err_hint = l[:220]
break
if not err_hint and last_lines:
err_hint = last_lines[-1][:220]
detail = f" — {err_hint}" if err_hint else ""
if progress_callback:
progress_callback(i, total, query, f"error: yt-dlp exit {return_code}", 0)
progress_callback(i, total, query, f"error: yt-dlp exit {return_code}{detail}", 0)
except Exception as e:
with _process_lock:
+156
View File
@@ -0,0 +1,156 @@
"""Appiattisce una cartella: sposta tutti i file audio delle sotto-cartelle
nella cartella padre. Utile come 'undo' di una catalogazione o per unire
archivi frammentati.
Non tocca i file gia' nella root della cartella padre.
Conflitti filename: aggiunge suffisso _1, _2... (mai overwrite).
Opzionalmente rimuove le sotto-cartelle rimaste vuote dopo il move.
"""
from __future__ import annotations
import shutil
import threading
from pathlib import Path
from typing import Callable, Optional
from core.upgrader import AUDIO_EXTENSIONS
_stop_event = threading.Event()
def request_stop() -> None:
_stop_event.set()
def reset_stop() -> None:
_stop_event.clear()
def is_stopped() -> bool:
return _stop_event.is_set()
def _iter_audio_in_subfolders(parent: Path) -> list:
"""Enumera i file audio in TUTTE le sotto-cartelle (ricorsivo) escludendo
quelli gia' nella root del `parent`."""
if not parent.is_dir():
return []
files: list = []
for f in parent.rglob("*"):
if not f.is_file():
continue
if f.suffix.lower() not in AUDIO_EXTENSIONS:
continue
if f.parent.resolve() == parent.resolve():
continue # gia' nella root
files.append(f)
files.sort()
return files
def _unique_dst(dst: Path) -> Path:
"""Restituisce un path non-esistente aggiungendo `_1`, `_2`... se serve."""
if not dst.exists():
return dst
stem = dst.stem
suffix = dst.suffix
i = 1
while True:
candidate = dst.parent / f"{stem}_{i}{suffix}"
if not candidate.exists():
return candidate
i += 1
def _remove_empty_dirs(parent: Path) -> int:
"""Rimuove ricorsivamente tutte le sotto-cartelle vuote di `parent`.
Non tocca `parent` stessa. Ritorna il numero di dir rimosse."""
removed = 0
for p in sorted(parent.rglob("*"), key=lambda x: len(str(x)), reverse=True):
if not p.is_dir():
continue
if p.resolve() == parent.resolve():
continue
try:
# rimuove .DS_Store residui che impediscono rmdir
for junk in p.glob(".DS_Store"):
try: junk.unlink()
except Exception: pass
p.rmdir()
removed += 1
except OSError:
pass # non vuota o senza permessi
return removed
def flatten_folder(
parent_dir: str,
remove_empty: bool = True,
progress_callback: Optional[Callable] = None,
log_callback: Optional[Callable] = None,
) -> dict:
"""Sposta tutti i file audio delle sotto-cartelle in `parent_dir`.
Args:
parent_dir: cartella padre di destinazione
remove_empty: se True, elimina le sotto-cartelle rimaste vuote
progress_callback: (idx, total, filename, status[, err]) — status:
'scanning' | 'moving' | 'skipped' | 'error' | 'stopped' | 'completed'
log_callback: chiamato con {src, dst} per ogni move riuscito
Ritorna:
{moved, skipped, failed: [{path, error}], operations: [{src, dst}],
dirs_removed}
"""
reset_stop()
parent = Path(parent_dir)
result = {
"moved": 0,
"skipped": 0,
"failed": [],
"operations": [],
"dirs_removed": 0,
}
if not parent.is_dir():
return result
files = _iter_audio_in_subfolders(parent)
total = len(files)
def _pc(idx, name, status, err=""):
if not progress_callback:
return
try:
progress_callback(idx, total, name, status, err)
except TypeError:
progress_callback(idx, total, name, status)
for i, src in enumerate(files, start=1):
if is_stopped():
_pc(i - 1, "", "stopped")
break
dst = _unique_dst(parent / src.name)
try:
shutil.move(str(src), str(dst))
result["moved"] += 1
op = {"src": str(src), "dst": str(dst)}
result["operations"].append(op)
if log_callback:
try:
log_callback(op)
except Exception:
pass
_pc(i, src.name, "moving")
except Exception as e:
result["failed"].append({"path": str(src), "error": str(e)})
_pc(i, src.name, "error", str(e))
if remove_empty and not is_stopped():
result["dirs_removed"] = _remove_empty_dirs(parent)
_pc(total, "", "completed")
return result
+173
View File
@@ -14,6 +14,7 @@ from mutagen import File as MutagenFile
from mutagen.id3 import (
ID3, ID3NoHeaderError,
APIC, TIT2, TPE1, TPE2, TALB, TDRC, TRCK, TCON, COMM, TBPM, TKEY,
USLT, WOAS,
)
from mutagen.mp4 import MP4, MP4Cover
from mutagen.flac import FLAC, Picture
@@ -84,6 +85,69 @@ def _text(value) -> str:
return str(value)
_AI_MARKERS = {
"suno": ["suno.com", "made with suno", "sunoai"],
"udio": ["udio.com", "made with udio"],
"riffusion": ["riffusion.com", "made with riffusion"],
"mubert": ["mubert.com", "made with mubert"],
"aiva": ["aiva.ai"],
"boomy": ["boomy.com", "made with boomy"],
"soundraw": ["soundraw.io"],
"loudly": ["loudly.com"],
}
def _detect_ai(comment: str, source_url: str, extra: str = "") -> tuple:
"""Ritorna (is_ai: bool, source_name: str). Case-insensitive.
`extra` = altre stringhe da controllare (es. presenza C2PA manifest)."""
blob = f"{comment} {source_url} {extra}".lower()
for source, markers in _AI_MARKERS.items():
for m in markers:
if m in blob:
return True, source
if "c2pa" in blob:
return True, ""
return False, ""
def _dump_id3_frames(tags) -> list:
"""Lista compatta di tutti i frame ID3 (esclusa APIC binaria).
Ogni voce: {key, preview, size, kind}. Utile per audit AI-generated."""
out = []
for key in tags.keys():
try:
frame = tags[key]
except Exception:
continue
kind = "text"
preview = ""
size = 0
if key.startswith("APIC"):
kind = "binary"
preview = f"{frame.mime or 'image'} ({len(frame.data)} bytes)"
size = len(frame.data)
elif key.startswith("GEOB"):
kind = "binary"
data = getattr(frame, "data", b"") or b""
desc = getattr(frame, "desc", "") or ""
mime = getattr(frame, "mime", "") or ""
preview = f"{desc or mime or 'blob'} ({len(data)} bytes)"
size = len(data)
elif key.startswith("USLT"):
txt = getattr(frame, "text", "") or ""
preview = txt[:180] + ("…" if len(txt) > 180 else "")
size = len(txt)
elif key.startswith("WOAS") or key.startswith("WXXX") or key.startswith("WOAR"):
preview = getattr(frame, "url", "") or str(frame)
size = len(preview)
else:
txt = str(frame)
preview = txt[:180] + ("…" if len(txt) > 180 else "")
size = len(txt)
out.append({"key": key, "preview": preview, "size": size, "kind": kind})
return out
def _read_mp3(path: str, result: dict) -> dict:
result["format"] = "MP3"
try:
@@ -91,6 +155,8 @@ def _read_mp3(path: str, result: dict) -> dict:
except ID3NoHeaderError:
return result
result["raw_frames"] = _dump_id3_frames(tags)
result["title"] = _text(tags.get("TIT2"))
result["artist"] = _text(tags.get("TPE1"))
result["album_artist"] = _text(tags.get("TPE2"))
@@ -100,12 +166,19 @@ def _read_mp3(path: str, result: dict) -> dict:
result["genre"] = _text(tags.get("TCON"))
result["bpm"] = _text(tags.get("TBPM"))
result["key"] = _text(tags.get("TKEY"))
result["source_url"] = _text(tags.get("WOAS"))
for k in tags.keys():
if k.startswith("COMM"):
result["comment"] = _text(tags[k])
break
for k in tags.keys():
if k.startswith("USLT"):
frame = tags[k]
result["lyrics"] = getattr(frame, "text", "") or ""
break
for k in tags.keys():
if k.startswith("APIC"):
apic = tags[k]
@@ -113,13 +186,44 @@ def _read_mp3(path: str, result: dict) -> dict:
result["cover_mime"] = apic.mime or "image/jpeg"
break
# AI detection: C2PA manifest (GEOB frame) o markers testuali
has_c2pa = any("c2pa" in k.lower() for k in tags.keys())
result["ai_generated"], result["ai_source"] = _detect_ai(
result["comment"], result["source_url"], "c2pa" if has_c2pa else "",
)
return result
def _dump_mp4_atoms(tags) -> list:
out = []
for key, val in tags.items():
preview = ""
kind = "text"
size = 0
if key == "covr":
kind = "binary"
data = bytes(val[0]) if val else b""
preview = f"cover ({len(data)} bytes)"
size = len(data)
else:
try:
preview = str(val[0] if isinstance(val, list) and val else val)
except Exception:
preview = "<binary>"
if isinstance(preview, str):
size = len(preview)
if len(preview) > 180:
preview = preview[:180] + "…"
out.append({"key": key, "preview": preview, "size": size, "kind": kind})
return out
def _read_mp4(path: str, result: dict) -> dict:
result["format"] = "M4A/MP4"
m = MP4(path)
tags = m.tags or {}
result["raw_frames"] = _dump_mp4_atoms(tags)
result["title"] = _text(tags.get("\xa9nam"))
result["artist"] = _text(tags.get("\xa9ART"))
result["album_artist"] = _text(tags.get("aART"))
@@ -132,12 +236,24 @@ def _read_mp4(path: str, result: dict) -> dict:
result["genre"] = _text(tags.get("\xa9gen"))
result["comment"] = _text(tags.get("\xa9cmt"))
result["bpm"] = _text(tags.get("tmpo"))
result["lyrics"] = _text(tags.get("\xa9lyr"))
# M4A non ha WOAS: cerchiamo in freeform ----:com.apple.iTunes:URL
for k in tags.keys():
if isinstance(k, str) and k.startswith("----") and "URL" in k.upper():
v = tags[k]
if v:
result["source_url"] = v[0].decode("utf-8", errors="ignore") if isinstance(v[0], bytes) else str(v[0])
break
covers = tags.get("covr")
if covers:
c = covers[0]
result["cover_base64"] = base64.b64encode(bytes(c)).decode("ascii")
result["cover_mime"] = "image/jpeg" if c.imageformat == MP4Cover.FORMAT_JPEG else "image/png"
result["ai_generated"], result["ai_source"] = _detect_ai(
result["comment"], result["source_url"],
)
return result
@@ -292,8 +408,13 @@ def read_metadata(path: str) -> dict:
"bpm": "", "key": "",
"duration": 0, "bitrate": 0,
"cover_base64": "", "cover_mime": "",
"lyrics": "", # USLT (MP3), \xa9lyr (M4A), UNSYNCEDLYRICS (FLAC)
"source_url": "", # WOAS (MP3), custom (altri) — es. URL Suno
"ai_generated": False, # True se rilevato marker AI (C2PA, "made with suno"...)
"ai_source": "", # es. "suno", "udio", "riffusion" — sorgente dedotta
"where_from": _read_where_from(str(p)),
"is_macos": sys.platform == "darwin",
"raw_frames": [], # list of {key, preview, size, kind} — tutti i frame ID3/atomi
}
audio = MutagenFile(str(p))
@@ -346,6 +467,22 @@ def _write_mp3(path: str, data: dict, cover_path: Optional[str], remove_cover: b
if comment:
tags.add(COMM(encoding=3, lang="ita", desc="", text=comment))
# Lyrics (USLT). Rimuovi tutti gli USLT esistenti, poi (ri)crea se non vuoto
for k in list(tags.keys()):
if k.startswith("USLT"):
del tags[k]
lyrics = data.get("lyrics", "")
if lyrics:
tags.add(USLT(encoding=3, lang="eng", desc="", text=lyrics))
# Source URL (WOAS)
if "source_url" in data:
src = data.get("source_url", "")
if "WOAS" in tags:
del tags["WOAS"]
if src:
tags.add(WOAS(url=src))
# Cover
if remove_cover or cover_path:
for k in list(tags.keys()):
@@ -379,6 +516,7 @@ def _write_mp4(path: str, data: dict, cover_path: Optional[str], remove_cover: b
set_or_del("\xa9day", data.get("year", ""))
set_or_del("\xa9gen", data.get("genre", ""))
set_or_del("\xa9cmt", data.get("comment", ""))
set_or_del("\xa9lyr", data.get("lyrics", ""))
track = data.get("track", "")
if track:
@@ -518,3 +656,38 @@ def write_metadata(path: str, data: dict, cover_path: Optional[str] = None,
# macOS extended attribute: kMDItemWhereFroms (lista URL/origini)
if "where_from" in data:
_write_where_from(path, data.get("where_from") or [])
def remove_frames(path: str, frame_keys: list) -> int:
"""Rimuove specifici frame ID3 (MP3) o atomi (MP4) dal file.
Ritorna il numero di frame rimossi. `frame_keys` sono le chiavi
esatte così come tornano da `raw_frames` (es. "TXXX:comment",
"GEOB:c2pa manifest store", "\xa9lyr")."""
if not frame_keys:
return 0
ext = Path(path).suffix.lower()
removed = 0
if ext == ".mp3":
try:
tags = ID3(path)
except ID3NoHeaderError:
return 0
for key in frame_keys:
if key in tags:
del tags[key]
removed += 1
if removed:
tags.save(path)
elif ext in (".m4a", ".mp4", ".aac"):
m = MP4(path)
if m.tags is None:
return 0
for key in frame_keys:
if key in m.tags:
del m.tags[key]
removed += 1
if removed:
m.save()
else:
raise ValueError(f"Rimozione frame non supportata per: {ext}")
return removed
+4 -10
View File
@@ -14,30 +14,24 @@ from dataclasses import dataclass
# Generi musicali Traxsource (slug -> (id, display_name)).
# Aggiornato via scripts/refresh_traxsource_genres.py. Esclusi "sounds/samples/loops",
# "acapella", "beats", "efx-dj-tools", "stems" (non generi ma tipi di prodotto).
# NOTA (ago 2026): Traxsource ha rimosso la "Top 100 <genre>" per 10 generi minori
# (broken-beat-nu-jazz, classic-house, drum-and-bass, electro-house, electronica,
# leftfield, lounge-chill-out, pop-dance, r-and-b-hip-hop, world). Rimossi dal
# dropdown finché non ripristinano la playlist canonica.
GENRES: dict = {
"afro-house": (27, "Afro House"),
"afro-latin-brazilian": (23, "Afro / Latin / Brazilian"),
"broken-beat-nu-jazz": (2, "Broken Beat / Nu Jazz"),
"classic-house": (12, "Classic House"),
"deep-house": (13, "Deep House"),
"drum-and-bass": (31, "Drum & Bass"),
"electro-house": (11, "Electro House"),
"electronica": (5, "Electronica"),
"garage": (29, "Garage"),
"house": (4, "House"),
"jackin-house": (15, "Jackin House"),
"leftfield": (14, "Leftfield"),
"lounge-chill-out": (1, "Lounge / Chill Out"),
"melodic-progressive-house": (19, "Melodic / Progressive House"),
"minimal-deep-tech": (16, "Minimal / Deep Tech"),
"nu-disco-indie-dance": (17, "Nu Disco / Indie Dance"),
"pop-dance": (32, "Pop Dance"),
"r-and-b-hip-hop": (6, "R&B / Hip Hop"),
"soul-funk-disco": (3, "Soul / Funk / Disco"),
"soulful-house": (24, "Soulful House"),
"tech-house": (18, "Tech House"),
"techno": (20, "Techno"),
"world": (30, "World"),
}
+11
View File
@@ -4,6 +4,17 @@ import os
import sys
from pathlib import Path
# ============================================================
# Logging persistente su file (rotazione giornaliera, 7 giorni).
# Chiamato SUBITO cosi' ogni traceback o print da qui in poi
# finisce nel file, anche in modalita' windowed.
# ============================================================
try:
from core.applog import setup as _setup_log
_setup_log()
except Exception as _e:
print(f"[bootstrap] log setup failed: {_e}")
# ============================================================
# Fix SSL CA bundle: quando l'app e' frozen (PyInstaller) i moduli
# ssl e requests non trovano nessun CA bundle di default -> tutte
+204
View File
@@ -0,0 +1,204 @@
"""Test per core.acoustid — lookup AcoustID + cache SQLite.
Mock su `requests.get` in modo che i test siano offline. Ogni test
isola la cache SQLite in tmp_path via monkeypatch di
`_cache_db_path`.
"""
from __future__ import annotations
from unittest import mock
import pytest
from core import acoustid
# ------------------------------------------------------------------
# Fixture: cache isolata + reset del rate-limit
# ------------------------------------------------------------------
@pytest.fixture
def patched_cache(tmp_path, monkeypatch):
db = tmp_path / "acoustid_cache_test.db"
monkeypatch.setattr(acoustid, "_cache_db_path", lambda: db)
# Rate-limit: azzera cosi' i test non aspettano throttle
monkeypatch.setattr(acoustid, "_RATE_LIMIT_SEC", 0.0)
monkeypatch.setattr(acoustid, "_last_request_at", [0.0])
return db
def _mock_resp(status_code: int = 200, json_data: dict = None):
"""Costruisce un mock di response `requests`."""
m = mock.Mock()
m.status_code = status_code
m.json.return_value = json_data or {}
return m
# ------------------------------------------------------------------
# lookup — cache
# ------------------------------------------------------------------
class TestLookupCache:
def test_lookup_uses_cache(self, patched_cache):
"""Seconda chiamata con lo stesso fingerprint riusa la cache."""
payload = {
"status": "ok",
"results": [{
"score": 0.99,
"recordings": [{
"title": "Some Song",
"artists": [{"name": "Artist X"}],
"releases": [
{"date": {"year": 2005},
"releasegroup": {"tags": [{"name": "House", "count": 5}]}},
],
}],
}],
}
with mock.patch.object(acoustid.requests, "get",
return_value=_mock_resp(200, payload)) as m:
r1 = acoustid.lookup("FP-1", 180.0)
r2 = acoustid.lookup("FP-1", 180.0)
assert m.call_count == 1, "la seconda chiamata deve venire dalla cache"
assert r1 == r2
assert r1["matched"] is True
assert r1["year"] == 2005
assert r1["genre"].lower() == "house"
assert r1["title"] == "Some Song"
assert r1["artist"] == "Artist X"
# ------------------------------------------------------------------
# lookup — no results / errori
# ------------------------------------------------------------------
class TestLookupNoResults:
def test_lookup_no_results_returns_unmatched(self, patched_cache):
payload = {"status": "ok", "results": []}
with mock.patch.object(acoustid.requests, "get",
return_value=_mock_resp(200, payload)):
r = acoustid.lookup("FP-NORESULT", 100.0)
assert r["matched"] is False
# deve essere cacheato
with mock.patch.object(acoustid.requests, "get") as m:
r2 = acoustid.lookup("FP-NORESULT", 100.0)
assert m.call_count == 0
assert r2 == r
def test_lookup_no_recordings_returns_unmatched(self, patched_cache):
"""results presenti ma senza recordings -> matched=False."""
payload = {
"status": "ok",
"results": [{"score": 0.5, "recordings": []}],
}
with mock.patch.object(acoustid.requests, "get",
return_value=_mock_resp(200, payload)):
r = acoustid.lookup("FP-EMPTYREC", 200)
assert r["matched"] is False
# ------------------------------------------------------------------
# lookup — anno minimo
# ------------------------------------------------------------------
class TestLookupYearExtraction:
def test_lookup_extracts_min_year(self, patched_cache):
"""3 releases (2003, 1998, 2010) -> year=1998."""
payload = {
"status": "ok",
"results": [{
"score": 0.95,
"recordings": [{
"title": "Classic",
"artists": [{"name": "Artist"}],
"releases": [
{"date": {"year": 2003}},
{"date": {"year": 1998}},
{"date": {"year": 2010}},
],
}],
}],
}
with mock.patch.object(acoustid.requests, "get",
return_value=_mock_resp(200, payload)):
r = acoustid.lookup("FP-YEAR", 180)
assert r["matched"] is True
assert r["year"] == 1998
def test_lookup_missing_year_becomes_none(self, patched_cache):
"""Nessuna release con year valido -> year=None (matched se ha titolo)."""
payload = {
"status": "ok",
"results": [{
"score": 0.9,
"recordings": [{
"title": "T",
"artists": [{"name": "A"}],
"releases": [{"date": {}}, {"other": 1}],
}],
}],
}
with mock.patch.object(acoustid.requests, "get",
return_value=_mock_resp(200, payload)):
r = acoustid.lookup("FP-NOY", 100)
# matched puo' essere True se ha almeno un titolo
assert r["year"] is None
assert r["title"] == "T"
# ------------------------------------------------------------------
# lookup — errori HTTP / rete
# ------------------------------------------------------------------
class TestLookupErrors:
def test_lookup_returns_error_on_http_failure(self, patched_cache):
with mock.patch.object(acoustid.requests, "get",
return_value=_mock_resp(500, {})):
r = acoustid.lookup("FP-HTTP", 100)
assert r["matched"] is False
assert "HTTP 500" in r.get("error", "")
def test_lookup_returns_error_on_network_failure(self, patched_cache):
with mock.patch.object(acoustid.requests, "get",
side_effect=acoustid.requests.ConnectionError("boom")):
r = acoustid.lookup("FP-NET", 100)
assert r["matched"] is False
assert "network" in r.get("error", "").lower() or "boom" in r.get("error", "")
def test_lookup_empty_fingerprint_returns_unmatched(self, patched_cache):
r = acoustid.lookup("", 100)
assert r["matched"] is False
assert "fingerprint" in r.get("error", "").lower()
def test_lookup_api_status_error(self, patched_cache):
payload = {"status": "error",
"error": {"message": "invalid fingerprint"}}
with mock.patch.object(acoustid.requests, "get",
return_value=_mock_resp(200, payload)):
r = acoustid.lookup("FP-BAD", 100)
assert r["matched"] is False
assert "invalid" in r.get("error", "").lower()
# ------------------------------------------------------------------
# helper: _extract_top_genre
# ------------------------------------------------------------------
class TestGenreExtraction:
def test_top_genre_picks_highest_count(self):
releases = [{
"releasegroup": {
"tags": [
{"name": "electronic", "count": 3},
{"name": "house", "count": 12},
{"name": "dance", "count": 7},
]
}
}]
assert acoustid._extract_top_genre(releases).lower() == "house"
def test_top_genre_no_tags_returns_empty(self):
releases = [{"releasegroup": {"tags": []}}]
assert acoustid._extract_top_genre(releases) == ""
def test_top_genre_no_releasegroup_returns_empty(self):
assert acoustid._extract_top_genre([{}]) == ""
+205
View File
@@ -0,0 +1,205 @@
"""Test per core.catalog — sanitize + move_files + scan_folder.
Focus principale sui casi in cui il piano ha promesso comportamento
esplicito: sanitize dei chars vietati, struttura year/genre, gestione
di anno/genere mancanti, conflitto di filename.
"""
from __future__ import annotations
from pathlib import Path
from unittest import mock
import pytest
from core import catalog
# ------------------------------------------------------------------
# Sanitize
# ------------------------------------------------------------------
class TestSanitizeFolder:
def test_sanitize_folder_removes_forbidden_chars(self):
# `/` (dai tag musicbrainz), `:` (Windows), `?`, `*`, `|`, ecc.
s = catalog._sanitize_folder("electronic/house")
assert "/" not in s
assert "electronic" in s and "house" in s
s2 = catalog._sanitize_folder("prog:rock?")
for ch in '<>:"|?*\\/':
assert ch not in s2
def test_sanitize_folder_collapses_spaces(self):
assert catalog._sanitize_folder(" tech house ") == "tech house"
def test_sanitize_folder_empty_returns_empty(self):
assert catalog._sanitize_folder("") == ""
assert catalog._sanitize_folder(None) == ""
def test_sanitize_folder_truncates_long(self):
s = catalog._sanitize_folder("a" * 300)
assert len(s) <= 100
# ------------------------------------------------------------------
# move_files
# ------------------------------------------------------------------
def _touch(path: Path, size: int = 8) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"\x00" * size)
return path
class TestMoveFiles:
def test_move_files_creates_year_genre_structure(self, tmp_path):
"""Un file matched → finisce in <target>/<year>/<genre>/<filename>."""
src = _touch(tmp_path / "source" / "song.mp3")
target = tmp_path / "cat"
entries = [{
"path": str(src), "size": src.stat().st_size,
"matched": True, "year": 2005, "genre": "House",
"artist": "X", "title": "T", "fingerprint": "FP",
}]
res = catalog.move_files(entries, str(target))
assert res["moved"] == 1
assert res["failed"] == []
dst = target / "2005" / "House" / "song.mp3"
assert dst.exists()
assert not src.exists()
# Log operations popolato
assert res["operations"] and res["operations"][0]["src"] == str(src)
assert res["operations"][0]["dst"] == str(dst)
def test_move_files_handles_missing_year_or_genre(self, tmp_path):
"""Year/genre mancanti → Unknown Year / Unknown Genre."""
no_year = _touch(tmp_path / "src" / "no_year.mp3")
no_genre = _touch(tmp_path / "src" / "no_genre.mp3")
no_both = _touch(tmp_path / "src" / "no_both.mp3")
target = tmp_path / "cat"
entries = [
{"path": str(no_year), "matched": True, "year": None,
"genre": "House"},
{"path": str(no_genre), "matched": True, "year": 2010,
"genre": ""},
{"path": str(no_both), "matched": False, "year": None,
"genre": ""},
]
res = catalog.move_files(entries, str(target))
assert res["moved"] == 3
assert (target / "Unknown Year" / "House" / "no_year.mp3").exists()
assert (target / "2010" / "Unknown Genre" / "no_genre.mp3").exists()
assert (target / "Unknown Year" / "Unknown Genre"
/ "no_both.mp3").exists()
def test_move_files_dedup_conflicting_filenames(self, tmp_path):
"""Se un file con lo stesso nome esiste gia' nel target, aggiungi
suffisso _1, _2, ... (mai overwrite)."""
src1 = _touch(tmp_path / "srcA" / "song.mp3", size=10)
src2 = _touch(tmp_path / "srcB" / "song.mp3", size=20)
target = tmp_path / "cat"
entries = [
{"path": str(src1), "matched": True, "year": 2000,
"genre": "Rock"},
{"path": str(src2), "matched": True, "year": 2000,
"genre": "Rock"},
]
res = catalog.move_files(entries, str(target))
assert res["moved"] == 2
d1 = target / "2000" / "Rock" / "song.mp3"
d2 = target / "2000" / "Rock" / "song_1.mp3"
assert d1.exists() and d2.exists()
# Contenuto preservato dal move (src1 = 10 byte, src2 = 20 byte).
# L'ordine di iterazione garantisce che song.mp3 = src1.
assert d1.stat().st_size == 10
assert d2.stat().st_size == 20
def test_move_files_sanitizes_forbidden_genre(self, tmp_path):
"""Genere con `/` (tipico di musicbrainz) va sanitizzato in `_`."""
src = _touch(tmp_path / "src" / "song.mp3")
target = tmp_path / "cat"
entries = [{
"path": str(src), "matched": True, "year": 2020,
"genre": "electronic/house",
}]
res = catalog.move_files(entries, str(target))
assert res["moved"] == 1
# NON deve creare "electronic" e dentro "house": e' un solo nome.
assert not (target / "2020" / "electronic").is_dir()
# La cartella deve contenere entrambe le parti sanitizzate
year_dir = target / "2020"
subdirs = [p.name for p in year_dir.iterdir() if p.is_dir()]
assert len(subdirs) == 1
assert "/" not in subdirs[0]
assert "electronic" in subdirs[0] and "house" in subdirs[0]
def test_move_files_missing_source_reports_failed(self, tmp_path):
"""File che non esiste piu' → finisce in failed, non alza."""
target = tmp_path / "cat"
entries = [{
"path": str(tmp_path / "does_not_exist.mp3"),
"matched": True, "year": 2000, "genre": "X",
}]
res = catalog.move_files(entries, str(target))
assert res["moved"] == 0
assert len(res["failed"]) == 1
assert "non esiste" in res["failed"][0]["error"].lower()
def test_move_files_empty_list_returns_zero(self, tmp_path):
res = catalog.move_files([], str(tmp_path / "cat"))
assert res["moved"] == 0
assert res["failed"] == []
assert res["operations"] == []
# ------------------------------------------------------------------
# scan_folder — smoke integration test (mock fpcalc + lookup)
# ------------------------------------------------------------------
class TestScanFolder:
def test_scan_folder_empty_directory(self, tmp_path):
# Cartella senza file audio → lista vuota, no crash
with mock.patch.object(catalog, "find_fpcalc",
return_value="/fake/fpcalc"):
entries = catalog.scan_folder(str(tmp_path), recursive=False)
assert entries == []
def test_scan_folder_processes_files(self, tmp_path):
# Un file audio fake + mock di fpcalc + lookup
(tmp_path / "a.mp3").write_bytes(b"\x00" * 100)
(tmp_path / "readme.txt").write_text("hi")
with mock.patch.object(catalog, "find_fpcalc",
return_value="/fake/fpcalc"), \
mock.patch.object(catalog, "compute_fingerprint",
return_value={"fingerprint": "FP", "duration": 100}), \
mock.patch.object(catalog, "lookup",
return_value={
"matched": True, "year": 2018,
"genre": "House", "artist": "A",
"title": "T",
}):
entries = catalog.scan_folder(str(tmp_path), recursive=False)
assert len(entries) == 1
e = entries[0]
assert e["matched"] is True
assert e["year"] == 2018
assert e["genre"] == "House"
assert e["fingerprint"] == "FP"
def test_scan_folder_records_fpcalc_error(self, tmp_path):
"""fpcalc fallito → entry con matched=False + error."""
(tmp_path / "broken.mp3").write_bytes(b"\x00" * 10)
with mock.patch.object(catalog, "find_fpcalc",
return_value="/fake/fpcalc"), \
mock.patch.object(catalog, "compute_fingerprint",
return_value={"_error": "fingerprint vuoto"}):
entries = catalog.scan_folder(str(tmp_path), recursive=False)
assert len(entries) == 1
assert entries[0]["matched"] is False
assert "vuoto" in entries[0]["error"]
+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) == []
+375
View File
@@ -151,6 +151,9 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
flex-direction: column;
padding: 24px 12px;
border-right: 1px solid var(--border);
height: 100vh;
min-height: 0; /* consente ai flex children di rimpicciolirsi sotto il contenuto */
overflow: hidden;
}
.brand {
@@ -192,7 +195,28 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
flex-direction: column;
gap: 2px;
flex: 1;
min-height: 0; /* per far funzionare overflow su flex child */
overflow-y: auto; /* scrolla se ci sono troppi tab e la finestra è corta */
overflow-x: hidden;
scrollbar-width: thin;
}
.nav::-webkit-scrollbar { width: 6px; }
.nav::-webkit-scrollbar-thumb {
background: var(--bg-input);
border-radius: 3px;
}
.nav::-webkit-scrollbar-thumb:hover { background: var(--bg-input-hover); }
.nav-section-label {
padding: 12px 14px 6px;
font-size: 10px;
font-weight: 800;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--text-3);
user-select: none;
}
.nav-section-label:first-child { padding-top: 4px; }
.nav-item {
display: flex;
@@ -554,6 +578,87 @@ input[type="number"]::-webkit-inner-spin-button {
.path-display:empty,
.path-display.empty { color: var(--text-3); }
.ai-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.5px;
text-transform: uppercase;
background: linear-gradient(135deg, var(--purple), var(--pink));
color: #fff;
box-shadow: 0 2px 6px rgba(168, 85, 247, 0.35);
white-space: nowrap;
cursor: default;
}
.ai-badge[hidden] { display: none !important; }
.ai-badge::before { content: "🤖"; }
.raw-frames-details {
margin-top: 8px;
padding: 12px;
background: var(--bg-card-2);
border-radius: var(--r-sm);
border: 1px solid var(--border);
}
.raw-frames-details summary {
cursor: pointer;
font-weight: 700;
font-size: 13px;
color: var(--text-2);
padding: 2px 0;
user-select: none;
}
.raw-frames-details summary:hover { color: var(--text); }
.raw-frames-details[open] summary { margin-bottom: 10px; color: var(--text); }
.raw-frames-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.raw-frames-table th,
.raw-frames-table td {
padding: 6px 8px;
border-bottom: 1px solid var(--divider);
text-align: left;
vertical-align: top;
}
.raw-frames-table th {
color: var(--text-3);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 10px;
}
.raw-frames-table td.raw-key {
font-family: ui-monospace, SF Mono, Menlo, monospace;
color: var(--indigo-text);
white-space: nowrap;
}
.raw-frames-table td.raw-preview {
word-break: break-word;
color: var(--text);
}
.raw-frames-table td.raw-size {
text-align: right;
color: var(--text-3);
white-space: nowrap;
}
.raw-frames-table .kind-binary {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
background: var(--amber-dim);
color: var(--amber-text);
text-transform: uppercase;
}
/* ===============================
BUTTONS
=============================== */
@@ -687,6 +792,7 @@ input[type="number"]::-webkit-inner-spin-button {
.percent.green { color: var(--green-text); }
.percent.blue { color: var(--blue-text); }
.percent.pink { color: var(--pink-hover); }
.percent.purple { color: var(--purple-text); }
.progress {
width: 100%;
@@ -715,6 +821,10 @@ input[type="number"]::-webkit-inner-spin-button {
background: linear-gradient(90deg, var(--pink), var(--pink-hover));
}
.progress-fill.purple {
background: linear-gradient(90deg, var(--purple), var(--purple-hover));
}
/* ===============================
LOG BOX
=============================== */
@@ -1970,3 +2080,268 @@ input[type="number"]::-webkit-inner-spin-button {
padding-right: 6px; /* spazio per la scrollbar sul bordo */
}
/* ===============================
CATALOGA tab
=============================== */
.catalog-summary {
font-size: 14px;
color: var(--text-2);
font-weight: 600;
}
/* Scroll container per la tabella (analogo a dedup-groups-scroll) */
.catalog-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);
}
.catalog-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 12px;
color: var(--text-2);
}
.catalog-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;
}
.catalog-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;
}
.catalog-table tbody tr:hover {
background: var(--bg-elev);
}
/* Righe senza match AcoustID: sfondo rosa tenue */
.catalog-table tbody tr.catalog-row-unmatched {
background: rgba(236, 72, 153, 0.06);
}
.catalog-table tbody tr.catalog-row-unmatched:hover {
background: rgba(236, 72, 153, 0.12);
}
.catalog-col-check { width: 30px; text-align: center; }
.catalog-col-year { width: 60px; text-align: center; font-variant-numeric: tabular-nums; }
.catalog-col-status { width: 110px; }
.catalog-file-cell {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-1);
font-weight: 500;
}
.catalog-check {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--purple);
}
.catalog-status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.3px;
text-transform: uppercase;
}
.catalog-status-badge.ok {
background: var(--purple-dim);
color: var(--purple-text);
}
.catalog-status-badge.warn {
background: var(--amber-dim, rgba(245, 158, 11, 0.15));
color: var(--amber-text);
}
.catalog-status-badge.err {
background: rgba(220, 38, 38, 0.15);
color: var(--red);
}
.catalog-status-badge.busy {
background: var(--bg-input);
color: var(--text-3);
}
.catalog-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);
}
/* ==================================================================
* 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);
}
+442 -14
View File
@@ -21,6 +21,7 @@
<div class="divider"></div>
<nav class="nav">
<div class="nav-section-label">Scarica</div>
<button class="nav-item active" data-view="download" data-feature="audio">
<span class="nav-icon">⬇</span>
<span>Scarica</span>
@@ -29,14 +30,6 @@
<span class="nav-icon">🎬</span>
<span>Video</span>
</button>
<button class="nav-item" data-view="beatport" data-feature="audio">
<span class="nav-icon">🎧</span>
<span>Beatport</span>
</button>
<button class="nav-item" data-view="traxsource" data-feature="audio">
<span class="nav-icon">▲</span>
<span>Traxsource</span>
</button>
<button class="nav-item" data-view="spotify" data-feature="audio">
<span class="nav-icon">🟢</span>
<span>Spotify</span>
@@ -45,26 +38,52 @@
<span class="nav-icon">▶</span>
<span>YouTube</span>
</button>
<button class="nav-item" data-view="convert" data-feature="audio">
<span class="nav-icon">🔄</span>
<span>Converti</span>
<button class="nav-item" data-view="beatport" data-feature="audio">
<span class="nav-icon">🎧</span>
<span>Beatport</span>
</button>
<button class="nav-item" data-view="traxsource" data-feature="audio">
<span class="nav-icon">▲</span>
<span>Traxsource</span>
</button>
<button class="nav-item" data-view="charts" data-feature="audio">
<span class="nav-icon">📊</span>
<span>Charts</span>
</button>
<div class="nav-section-label">Organizza</div>
<button class="nav-item" data-view="dedup" data-feature="audio">
<span class="nav-icon">🗑</span>
<span>Dedup</span>
</button>
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
<span class="nav-icon">⚡</span>
<span>Upgrade</span>
<button class="nav-item" data-view="catalog" data-feature="audio">
<span class="nav-icon">📅</span>
<span>Cataloga</span>
</button>
<button class="nav-item" data-view="flatten" data-feature="audio">
<span class="nav-icon">📤</span>
<span>Appiattisci</span>
</button>
<button class="nav-item" data-view="convert" data-feature="audio">
<span class="nav-icon">🔄</span>
<span>Converti</span>
</button>
<button class="nav-item" data-view="meta" data-feature="metadata">
<span class="nav-icon">🏷</span>
<span>Metadati</span>
</button>
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
<span class="nav-icon">⚡</span>
<span>Upgrade</span>
</button>
<div class="nav-section-label">Altro</div>
<button class="nav-item" data-view="record" data-feature="record">
<span class="nav-icon">●</span>
<span>Registra</span>
</button>
<div class="nav-section-label">Sistema</div>
<button class="nav-item" data-view="settings">
<span class="nav-icon">⚙</span>
<span>Impostazioni</span>
@@ -425,6 +444,7 @@
<div class="row">
<button class="btn btn-amber pill" id="metaPickBtn">📂 Apri file audio</button>
<div class="path-display" id="metaPathDisplay">Nessun file selezionato</div>
<span id="metaAiBadge" class="ai-badge" hidden title="Brano generato con AI"></span>
</div>
<div class="meta-info" id="metaInfo" hidden></div>
</div>
@@ -473,6 +493,42 @@
<label class="field-label">Commento</label>
<textarea id="metaComment" class="input textarea" rows="3"></textarea>
</div>
<div class="field full">
<label class="field-label">URL sorgente (WOAS)</label>
<input type="text" id="metaSourceUrl" class="input pill" placeholder="https://...">
<div class="cover-hint">Link alla pagina d'origine del brano (es. Suno, YouTube, SoundCloud). Sopravvive alle copie del file.</div>
</div>
<div class="field full">
<label class="field-label">Testo / Prompt (USLT)</label>
<textarea id="metaLyrics" class="input textarea" rows="8" placeholder="Lyrics del brano o prompt AI utilizzato..."></textarea>
<div class="cover-hint">Testo o prompt strutturato. Suno lo compila automaticamente al momento della generazione.</div>
</div>
<details class="field full raw-frames-details">
<summary>Metadati avanzati (tutti i frame ID3 grezzi)</summary>
<table class="raw-frames-table" id="metaRawFrames">
<thead>
<tr>
<th style="width:32px;"><input type="checkbox" id="metaRawSelectAll" title="Seleziona tutti"></th>
<th>Frame</th>
<th>Tipo</th>
<th>Contenuto</th>
<th style="text-align:right;">Bytes</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="row" style="margin-top:10px;justify-content:space-between;align-items:center;">
<span id="metaRawSelectedCount" style="font-size:12px;color:var(--text-3);">0 selezionati</span>
<button id="metaRawRemoveBtn" class="btn btn-danger pill btn-sm" disabled>
🗑 Rimuovi selezionati
</button>
</div>
<div class="cover-hint" style="margin-top:8px;">
I frame standard (TIT2 titolo, TPE1 artista, TALB album, APIC cover…) si gestiscono con i campi sopra.
Qui rimuovi frame custom come TXXX, GEOB (C2PA), USLT (lyrics) o COMM extra.
</div>
</details>
<div class="field full" id="metaWhereFromField" hidden>
<label class="field-label">
Origine (macOS · kMDItemWhereFroms)
@@ -590,6 +646,24 @@
</div>
</div>
<h2 class="section-label">Log</h2>
<div class="card">
<div class="field">
<label class="field-label">Cartella dei log</label>
<div class="row">
<div class="path-display" id="logPathDisplay" style="flex:1;">—</div>
<button class="btn btn-ghost pill btn-sm" id="openLogFolderBtn">📁 Apri cartella</button>
</div>
<div class="hint">
<span class="hint-ico">ⓘ</span>
<div>
L'app scrive un log giornaliero (<code>app.log</code>) con rotazione automatica (7 giorni).
Utile per debug: incluide stdout, stderr, log delle tab e traceback.
</div>
</div>
</div>
</div>
<h2 class="section-label">Aspetto</h2>
<div class="card">
<div class="field">
@@ -1304,6 +1378,337 @@
</div>
</section>
<!-- ===== VIEW: CATALOGA (AcoustID -> <Anno>/<Genere>/) ===== -->
<section class="view" id="view-catalog">
<header class="hero hero-purple">
<div class="hero-content">
<div class="hero-eyebrow purple">CATALOGA</div>
<h1 class="hero-title">Organizza per anno e genere</h1>
<p class="hero-subtitle">Identifica ogni brano via audio fingerprint (Chromaprint + AcoustID) e sposta i file in <code>&lt;Anno&gt;/&lt;Genere&gt;/</code>.</p>
</div>
<div class="hero-deco">📅</div>
</header>
<h2 class="section-label">Sorgente</h2>
<div class="card">
<div class="beatport-header">
<button id="catalog-pick-source" class="btn btn-primary pill">📁 Cartella input</button>
<button id="catalog-start-btn" class="btn btn-primary pill" disabled>
<span class="ico">▶</span> Scansiona
</button>
<button id="catalog-stop-btn" class="btn btn-danger pill" hidden>
<span class="ico">◼</span> Interrompi
</button>
</div>
<div class="beatport-header" style="margin-top:12px;gap:16px;">
<span id="catalog-source-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="catalog-recursive" checked />
<span>Ricerca ricorsiva (include sottocartelle)</span>
</label>
</div>
</div>
<h2 class="section-label">Destinazione</h2>
<div class="card">
<div class="beatport-header">
<button id="catalog-pick-target" class="btn btn-ghost pill">📁 Cartella destinazione</button>
<button id="catalog-clear-target" class="btn btn-ghost pill btn-sm">Usa la stessa della sorgente</button>
</div>
<div class="beatport-header" style="margin-top:12px;gap:16px;">
<span id="catalog-target-display" class="beatport-output-info" style="margin:0;">Uguale alla sorgente (in-place)</span>
</div>
<div class="beatport-header" style="margin-top:12px;gap:12px;">
<label style="display:flex;gap:8px;align-items:center;color:var(--text-2);font-size:13px;">
<span>Struttura cartelle:</span>
<select id="catalog-layout" class="input" style="padding:4px 8px;font-size:13px;">
<option value="year_genre">&lt;Anno&gt;/&lt;Genere&gt;/</option>
<option value="genre_year">&lt;Genere&gt;/&lt;Anno&gt;/</option>
</select>
</label>
</div>
<div class="hint" style="margin-top:10px;">
<span class="hint-ico">ⓘ</span>
<div>
Se lasci vuota la destinazione i file vengono organizzati <strong>in-place</strong> nella cartella sorgente.
Le sottocartelle vengono create automaticamente in base allo schema scelto.
File senza match finiscono in <code>Unknown Year</code> / <code>Unknown Genre</code>.
</div>
</div>
<div id="catalog-status" class="beatport-status"></div>
</div>
<h2 class="section-label">Progresso</h2>
<div class="card">
<div class="progress">
<div class="progress-bar"><div id="catalogProgressFill" class="progress-fill purple"></div></div>
<div class="progress-meta">
<span id="catalogPercent" class="percent purple">0%</span>
<span id="catalogCounter">In attesa</span>
</div>
</div>
</div>
<h2 class="section-label">Risultati</h2>
<div class="card" id="catalog-summary-card" hidden>
<div class="catalog-summary" id="catalog-summary-line"></div>
</div>
<div id="catalog-table-wrap" class="catalog-scroll" hidden>
<table class="catalog-table">
<thead>
<tr>
<th class="catalog-col-check">
<input type="checkbox" id="catalog-select-all" title="Seleziona/deseleziona tutti"/>
</th>
<th class="catalog-col-file">File</th>
<th class="catalog-col-artist">Artista</th>
<th class="catalog-col-title">Titolo</th>
<th class="catalog-col-year">Anno</th>
<th class="catalog-col-genre">Genere</th>
<th class="catalog-col-status">Stato</th>
</tr>
</thead>
<tbody id="catalog-table-body"></tbody>
</table>
</div>
<div class="catalog-footer-bar" id="catalog-footer-bar" hidden>
<span class="counter" id="catalog-selected-count">0 file selezionati</span>
<button id="catalog-move-btn" class="btn btn-danger pill" disabled>
<span class="ico">📦</span> Sposta selezionati
</button>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="catalog-log"></div>
</div>
</section>
<!-- ===== VIEW: FLATTEN ===== -->
<section class="view" id="view-flatten">
<header class="hero hero-blue">
<div class="hero-content">
<div class="hero-eyebrow blue">APPIATTISCI</div>
<h1 class="hero-title">Sposta tutti i brani nella cartella padre</h1>
<p class="hero-subtitle">
Scansiona ricorsivamente ogni sotto-cartella e sposta i file audio nella root.
Utile come "undo" della catalogazione o per unire archivi frammentati.
</p>
</div>
<div class="hero-deco">📤</div>
</header>
<h2 class="section-label">Cartella</h2>
<div class="card">
<div class="beatport-header">
<button id="flatten-pick-folder" class="btn btn-primary pill">📁 Scegli cartella</button>
<button id="flatten-start-btn" class="btn btn-blue pill" disabled>▶ Sposta tutto nella root</button>
<button id="flatten-stop-btn" class="btn btn-danger pill" hidden>■ Interrompi</button>
</div>
<div class="beatport-header" style="margin-top:12px;">
<span id="flatten-path-display" class="beatport-output-info" style="margin:0;">Nessuna cartella selezionata</span>
</div>
<div class="beatport-header" style="margin-top:12px;">
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
<input type="checkbox" id="flatten-remove-empty" checked />
<span>Elimina le sotto-cartelle rimaste vuote dopo lo spostamento</span>
</label>
</div>
<div class="hint" style="margin-top:10px;">
<span class="hint-ico">ⓘ</span>
<div>
Ogni file con estensione audio (mp3, m4a, wav, flac...) nelle sotto-cartelle
verrà spostato nella cartella padre. In caso di nomi duplicati viene aggiunto
suffisso <code>_1</code>, <code>_2</code>... (nessun overwrite).
</div>
</div>
<div id="flatten-status" class="beatport-status"></div>
</div>
<h2 class="section-label">Progresso</h2>
<div class="card">
<div class="progress">
<div class="progress-bar"><div id="flattenProgressFill" class="progress-fill blue"></div></div>
<div class="progress-meta">
<span id="flattenPercent" class="percent blue">0%</span>
<span id="flattenCounter">In attesa</span>
</div>
</div>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="flatten-log"></div>
</div>
</section>
<!-- ===== VIEW: CHARTS ===== -->
<section class="view" id="view-charts">
<header class="hero hero-indigo">
<div class="hero-content">
<div class="hero-eyebrow indigo">CHARTS</div>
<h1 class="hero-title">Esplora le classifiche</h1>
<p class="hero-subtitle">Del momento (iTunes/Spotify), dance italiana (M2O) o storiche per genere (Last.fm) — aggiungi in un click alla coda Scarica.</p>
</div>
<div class="hero-deco">📊</div>
</header>
<h2 class="section-label">Sorgente</h2>
<div class="card">
<div class="charts-source-row">
<label class="charts-radio">
<input type="radio" name="charts-source" value="itunes" checked />
<span>🎵 Del momento (iTunes)</span>
</label>
<label class="charts-radio">
<input type="radio" name="charts-source" value="spotify" />
<span>🟢 Spotify Charts</span>
</label>
<label class="charts-radio">
<input type="radio" name="charts-source" value="m2o" />
<span>🔴 M2O Chart (dance italiana)</span>
</label>
<label class="charts-radio">
<input type="radio" name="charts-source" value="lastfm" />
<span>📻 Storiche per genere (Last.fm)</span>
</label>
</div>
<div class="charts-filters" id="charts-filters-itunes">
<div class="field inline">
<label class="field-label">Paese</label>
<select id="charts-itunes-country" class="input pill input-sm">
<option value="it">🇮🇹 Italia</option>
<option value="us">🇺🇸 USA</option>
<option value="gb">🇬🇧 UK</option>
<option value="ww">🌐 Mondo</option>
</select>
</div>
</div>
<div class="charts-filters" id="charts-filters-spotify" hidden>
<div class="field inline">
<label class="field-label">Playlist chart</label>
<select id="charts-spotify-playlist" class="input pill input-sm">
<option value="top50_global">Top 50 Global</option>
<option value="top50_italy">Top 50 Italy</option>
<option value="top50_usa">Top 50 USA</option>
<option value="top50_uk">Top 50 UK</option>
<option value="viral50_global">Viral 50 Global</option>
<option value="viral50_italy">Viral 50 Italy</option>
</select>
</div>
<div class="hint" style="margin-top:8px;">
<span class="hint-ico">ⓘ</span>
<div>
Le playlist Spotify richiedono le tue credenziali API (Impostazioni → Spotify).
Senza credenziali questa sorgente resta vuota.
</div>
</div>
</div>
<div class="charts-filters" id="charts-filters-m2o" hidden>
<div class="hint">
<span class="hint-ico">ⓘ</span>
<div>
Chart dance italiana settimanale di M2O. Nessun filtro: premi "Aggiorna" per caricare la classifica corrente.
</div>
</div>
</div>
<div class="charts-filters" id="charts-filters-lastfm" hidden>
<div class="field inline">
<label class="field-label">Decade</label>
<select id="charts-lastfm-decade" class="input pill input-sm">
<option value="">Nessuna</option>
<option value="70s">Anni 70</option>
<option value="80s">Anni 80</option>
<option value="90s" selected>Anni 90</option>
<option value="2000s">Anni 2000</option>
<option value="2010s">Anni 2010</option>
</select>
</div>
<div class="field inline">
<label class="field-label">Genere</label>
<select id="charts-lastfm-genre" class="input pill input-sm">
<option value="">Nessuno</option>
<option value="pop">Pop</option>
<option value="rock">Rock</option>
<option value="dance" selected>Dance</option>
<option value="house">House</option>
<option value="hip hop">Hip Hop</option>
<option value="latin">Latin</option>
<option value="r&amp;b">R&amp;B</option>
<option value="italo disco">Italo Disco</option>
<option value="reggae">Reggae</option>
<option value="electronic">Electronic</option>
</select>
</div>
<div class="hint" style="margin-top:8px;">
<span class="hint-ico">ⓘ</span>
<div>
Last.fm richiede una API key: nell'app c'è una chiave demo, se non funziona
registrane una gratuita su <code>last.fm/api/account/create</code> e mettila nel campo
<code>lastfm_api_key</code> di <code>config.json</code>.
</div>
</div>
</div>
<div class="beatport-header" style="margin-top:14px;gap:12px;">
<button id="charts-fetch-btn" class="btn btn-primary pill">
<span class="ico">▶</span> Aggiorna classifica
</button>
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
<input type="checkbox" id="charts-force" />
<span>Ignora cache (force refresh)</span>
</label>
<span id="charts-status" class="beatport-status"></span>
</div>
</div>
<div id="charts-results-card" class="card" hidden>
<div class="charts-actions-row">
<span id="charts-summary-line" class="catalog-summary"></span>
<div class="charts-actions-btns">
<button id="charts-select-all-btn" class="btn btn-ghost pill btn-sm">Seleziona tutti</button>
<button id="charts-deselect-all-btn" class="btn btn-ghost pill btn-sm">Deseleziona tutti</button>
</div>
</div>
<div id="charts-table-wrap" class="charts-scroll">
<table class="charts-table">
<thead>
<tr>
<th class="charts-col-check">
<input type="checkbox" id="charts-select-all" title="Seleziona/deseleziona tutti" />
</th>
<th class="charts-col-rank">#</th>
<th class="charts-col-artist">Artista</th>
<th class="charts-col-title">Titolo</th>
<th class="charts-col-album">Album</th>
<th class="charts-col-pop">Popolarità</th>
</tr>
</thead>
<tbody id="charts-table-body"></tbody>
</table>
</div>
</div>
<div class="charts-footer-bar" id="charts-footer-bar" hidden>
<span class="counter" id="charts-selected-count">0 brani selezionati</span>
<button id="charts-add-queue-btn" class="btn btn-primary pill" disabled>
<span class="ico">➕</span> Aggiungi alla coda Scarica
</button>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="charts-log"></div>
</div>
</section>
</main>
</div>
@@ -1397,6 +1802,29 @@
</div>
</div>
<!-- ============== MODAL: Cataloga — conferma spostamento ============== -->
<div class="modal-backdrop" id="catalogConfirmModal" hidden>
<div class="modal">
<div class="modal-head">
<h2>Conferma spostamento</h2>
<button class="modal-close" id="catalogConfirmCloseBtn">×</button>
</div>
<div class="modal-body">
<p id="catalogConfirmMsg">Stai per spostare N file. Procedi?</p>
<p class="modal-hint">
L'operazione riorganizza i file in sottocartelle
<code>&lt;Anno&gt;/&lt;Genere&gt;/</code>. E' reversibile
manualmente dal log ma puo' richiedere tempo su cartelle
grandi.
</p>
</div>
<div class="modal-foot">
<button class="btn btn-ghost pill" id="catalogConfirmCancelBtn">Annulla</button>
<button class="btn btn-danger pill" id="catalogConfirmProceedBtn">Sposta</button>
</div>
</div>
</div>
<!-- ============== TOAST ============== -->
<div class="toast" id="toast"></div>
+1015
View File
File diff suppressed because it is too large. Load diff