Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8432b2a592 | ||
|
|
39e843dc69 | ||
|
|
29c177ff69 | ||
|
|
75e3da5f82 | ||
|
|
29b9753aa3 | ||
|
|
c88e6d359c | ||
|
|
ed1059bf8c | ||
|
|
cdbb1d85d5 | ||
|
|
401bed967a | ||
|
|
9b711fd545 | ||
|
|
9f9e4281b7 | ||
|
|
39a117f08b |
No files matched your search
@@ -53,3 +53,8 @@ server/dist/
|
|||||||
# Dedup local cache (SQLite fingerprint cache, per macchina dell'utente)
|
# Dedup local cache (SQLite fingerprint cache, per macchina dell'utente)
|
||||||
dedup_cache.db
|
dedup_cache.db
|
||||||
dedup_cache.db-journal
|
dedup_cache.db-journal
|
||||||
|
catalog_cache.db
|
||||||
|
catalog_cache.db-journal
|
||||||
|
|
||||||
|
# Log persistente (rotating file)
|
||||||
|
logs/
|
||||||
+407
@@ -47,6 +47,9 @@ from core.upgrader import (
|
|||||||
count_files_info,
|
count_files_info,
|
||||||
)
|
)
|
||||||
from core import dedup
|
from core import dedup
|
||||||
|
from core import catalog
|
||||||
|
from core import flatten
|
||||||
|
from core import charts
|
||||||
|
|
||||||
|
|
||||||
SPOTIFY_GUIDE_TEXT = """\
|
SPOTIFY_GUIDE_TEXT = """\
|
||||||
@@ -113,6 +116,8 @@ class Api:
|
|||||||
self._upgrade_thread: Optional[threading.Thread] = None
|
self._upgrade_thread: Optional[threading.Thread] = None
|
||||||
self._video_thread: Optional[threading.Thread] = None
|
self._video_thread: Optional[threading.Thread] = None
|
||||||
self._dedup_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
|
# Coda usata dal resolve_callback per attendere la scelta utente
|
||||||
# sul modal "match locali multipli" della tab Upgrade.
|
# sul modal "match locali multipli" della tab Upgrade.
|
||||||
self._upgrade_resolve_q: "queue.Queue[dict]" = queue.Queue(1)
|
self._upgrade_resolve_q: "queue.Queue[dict]" = queue.Queue(1)
|
||||||
@@ -132,7 +137,40 @@ class Api:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def _log(self, view: str, msg: str) -> None:
|
def _log(self, view: str, msg: str) -> None:
|
||||||
|
# Emit alla UI + mirror sul file di log persistente
|
||||||
self._emit("log", {"view": view, "msg": msg})
|
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
|
# Bootstrap
|
||||||
@@ -170,6 +208,7 @@ class Api:
|
|||||||
"bitrate": payload.get("bitrate", "320K"),
|
"bitrate": payload.get("bitrate", "320K"),
|
||||||
"hq_threshold": threshold,
|
"hq_threshold": threshold,
|
||||||
"cookies_path": (payload.get("cookies_path") or "").strip(),
|
"cookies_path": (payload.get("cookies_path") or "").strip(),
|
||||||
|
"cookies_browser": (payload.get("cookies_browser") or "").strip().lower(),
|
||||||
"output_dir": (payload.get("output_dir") or "").strip(),
|
"output_dir": (payload.get("output_dir") or "").strip(),
|
||||||
"theme": payload.get("theme", "dark"),
|
"theme": payload.get("theme", "dark"),
|
||||||
})
|
})
|
||||||
@@ -1962,3 +2001,371 @@ class Api:
|
|||||||
"n_dupes": n_dupes,
|
"n_dupes": n_dupes,
|
||||||
"reclaimable_bytes": total_bytes,
|
"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
@@ -39,21 +39,23 @@ def log(msg):
|
|||||||
# 1. Scarica yt-dlp standalone
|
# 1. Scarica yt-dlp standalone
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
def download_ytdlp():
|
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
|
arch = platform.machine() # arm64 o x86_64
|
||||||
if arch == "arm64":
|
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:
|
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"
|
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)
|
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
|
# Usa curl perche urllib di Python 3.8 ha problemi SSL su macOS
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["curl", "-L", "-o", str(dest), url],
|
["curl", "-L", "-o", str(dest), url],
|
||||||
|
|||||||
+4
-6
@@ -29,7 +29,7 @@ BUILD_DIR = ROOT / "build"
|
|||||||
DIST_DIR = ROOT / "dist"
|
DIST_DIR = ROOT / "dist"
|
||||||
BUNDLE_DIR = ROOT / "bundle_bin"
|
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"
|
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/"
|
FPCALC_URL = ("https://github.com/acoustid/chromaprint/releases/download/"
|
||||||
"v1.5.1/chromaprint-fpcalc-1.5.1-windows-x86_64.zip")
|
"v1.5.1/chromaprint-fpcalc-1.5.1-windows-x86_64.zip")
|
||||||
@@ -43,13 +43,11 @@ def log(msg):
|
|||||||
# 1. Scarica yt-dlp.exe
|
# 1. Scarica yt-dlp.exe
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
def download_ytdlp():
|
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"
|
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)
|
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
log(f"Scarico yt-dlp.exe nightly ...")
|
||||||
urllib.request.urlretrieve(YTDLP_URL, dest)
|
urllib.request.urlretrieve(YTDLP_URL, dest)
|
||||||
log(f"yt-dlp.exe scaricato ({dest.stat().st_size // 1024 // 1024} MB)")
|
log(f"yt-dlp.exe scaricato ({dest.stat().st_size // 1024 // 1024} MB)")
|
||||||
return dest
|
return dest
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
+14
-1
@@ -5,7 +5,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VERSION = "v1.10.0"
|
VERSION = "v1.12.0"
|
||||||
|
|
||||||
|
|
||||||
APP_NAME = "MusicTools"
|
APP_NAME = "MusicTools"
|
||||||
@@ -68,6 +68,7 @@ DEFAULTS = {
|
|||||||
"bitrate": "320K",
|
"bitrate": "320K",
|
||||||
"hq_threshold": 310,
|
"hq_threshold": 310,
|
||||||
"cookies_path": str(_project_dir / "cookies.txt"),
|
"cookies_path": str(_project_dir / "cookies.txt"),
|
||||||
|
"cookies_browser": "", # "" | chrome | safari | firefox | edge | brave
|
||||||
"output_dir": str(_project_dir / "MUSICA"),
|
"output_dir": str(_project_dir / "MUSICA"),
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
# ---- Beatport ----
|
# ---- Beatport ----
|
||||||
@@ -82,6 +83,18 @@ DEFAULTS = {
|
|||||||
"dedup_last_folder": "",
|
"dedup_last_folder": "",
|
||||||
"dedup_recursive": True,
|
"dedup_recursive": True,
|
||||||
"dedup_method": "fingerprint", # "fingerprint" | "filename"
|
"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 ----
|
# ---- Licenza ----
|
||||||
"license_key": "", # chiave fornita all'utente via email
|
"license_key": "", # chiave fornita all'utente via email
|
||||||
"license_email": "", # email associata all'acquisto
|
"license_email": "", # email associata all'acquisto
|
||||||
|
|||||||
+48
-15
@@ -12,6 +12,29 @@ from typing import Callable, Optional
|
|||||||
from core.paths import find_ytdlp, find_ffmpeg_dir, subprocess_flags
|
from core.paths import find_ytdlp, find_ffmpeg_dir, subprocess_flags
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOWED_BROWSERS = {"chrome", "safari", "firefox", "edge", "brave", "chromium", "opera", "vivaldi"}
|
||||||
|
|
||||||
|
|
||||||
|
def _cookie_args(cookies_path: Optional[str]) -> list:
|
||||||
|
"""Ritorna gli argomenti yt-dlp per i cookies.
|
||||||
|
|
||||||
|
Priorità: file cookies_path se esiste → altrimenti --cookies-from-browser
|
||||||
|
<name> se `cookies_browser` è settato in config → altrimenti niente.
|
||||||
|
"""
|
||||||
|
if cookies_path and Path(cookies_path).exists():
|
||||||
|
return ["--cookies", cookies_path]
|
||||||
|
# Fallback: legge il browser dalla config al volo (evita di cambiare
|
||||||
|
# firma di tutte le funzioni download_*)
|
||||||
|
try:
|
||||||
|
from core.config import load_config
|
||||||
|
browser = (load_config().get("cookies_browser") or "").strip().lower()
|
||||||
|
except Exception:
|
||||||
|
browser = ""
|
||||||
|
if browser in _ALLOWED_BROWSERS:
|
||||||
|
return ["--cookies-from-browser", browser]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
# Flag globale per interruzione
|
# Flag globale per interruzione
|
||||||
_stop_event = threading.Event()
|
_stop_event = threading.Event()
|
||||||
_current_process: Optional[subprocess.Popen] = None
|
_current_process: Optional[subprocess.Popen] = None
|
||||||
@@ -99,8 +122,7 @@ def _search_youtube(query: str, cookies_path: Optional[str] = None) -> tuple[str
|
|||||||
"--no-warnings",
|
"--no-warnings",
|
||||||
"--flat-playlist",
|
"--flat-playlist",
|
||||||
]
|
]
|
||||||
if cookies_path and Path(cookies_path).exists():
|
cmd.extend(_cookie_args(cookies_path))
|
||||||
cmd.extend(["--cookies", cookies_path])
|
|
||||||
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **subprocess_flags())
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **subprocess_flags())
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
@@ -216,8 +238,7 @@ def download_playlist(
|
|||||||
ffmpeg_dir = find_ffmpeg_dir()
|
ffmpeg_dir = find_ffmpeg_dir()
|
||||||
if ffmpeg_dir:
|
if ffmpeg_dir:
|
||||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||||
if cookies_path and Path(cookies_path).exists():
|
cmd.extend(_cookie_args(cookies_path))
|
||||||
cmd.extend(["--cookies", cookies_path])
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with _process_lock:
|
with _process_lock:
|
||||||
@@ -226,6 +247,7 @@ def download_playlist(
|
|||||||
**subprocess_flags(),
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
last_lines: list = [] # buffer per capire l'errore se exit != 0
|
||||||
for line in _current_process.stdout:
|
for line in _current_process.stdout:
|
||||||
if is_stopped():
|
if is_stopped():
|
||||||
_current_process.terminate()
|
_current_process.terminate()
|
||||||
@@ -233,6 +255,13 @@ def download_playlist(
|
|||||||
progress_callback(i, total, query, "stopped", 0)
|
progress_callback(i, total, query, "stopped", 0)
|
||||||
return
|
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
|
# Parse progress da output yt-dlp
|
||||||
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
|
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
|
||||||
if pct_match and progress_callback:
|
if pct_match and progress_callback:
|
||||||
@@ -257,8 +286,17 @@ def download_playlist(
|
|||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback(i, total, query, "done", 100)
|
progress_callback(i, total, query, "done", 100)
|
||||||
else:
|
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:
|
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:
|
except Exception as e:
|
||||||
with _process_lock:
|
with _process_lock:
|
||||||
@@ -300,8 +338,7 @@ def download_direct_url(
|
|||||||
"--no-warnings",
|
"--no-warnings",
|
||||||
url,
|
url,
|
||||||
]
|
]
|
||||||
if cookies_path and Path(cookies_path).exists():
|
probe_cmd.extend(_cookie_args(cookies_path))
|
||||||
probe_cmd.extend(["--cookies", cookies_path])
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
|
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
|
||||||
@@ -375,8 +412,7 @@ def download_direct_url(
|
|||||||
ffmpeg_dir = find_ffmpeg_dir()
|
ffmpeg_dir = find_ffmpeg_dir()
|
||||||
if ffmpeg_dir:
|
if ffmpeg_dir:
|
||||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||||
if cookies_path and Path(cookies_path).exists():
|
cmd.extend(_cookie_args(cookies_path))
|
||||||
cmd.extend(["--cookies", cookies_path])
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with _process_lock:
|
with _process_lock:
|
||||||
@@ -511,8 +547,7 @@ def download_urls(
|
|||||||
ffmpeg_dir = find_ffmpeg_dir()
|
ffmpeg_dir = find_ffmpeg_dir()
|
||||||
if ffmpeg_dir:
|
if ffmpeg_dir:
|
||||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||||
if cookies_path and Path(cookies_path).exists():
|
cmd.extend(_cookie_args(cookies_path))
|
||||||
cmd.extend(["--cookies", cookies_path])
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with _process_lock:
|
with _process_lock:
|
||||||
@@ -608,8 +643,7 @@ def download_video(
|
|||||||
probe_cmd = [
|
probe_cmd = [
|
||||||
ytdlp, "--dump-json", "--flat-playlist", "--no-download", "--no-warnings", url,
|
ytdlp, "--dump-json", "--flat-playlist", "--no-download", "--no-warnings", url,
|
||||||
]
|
]
|
||||||
if cookies_path and Path(cookies_path).exists():
|
probe_cmd.extend(_cookie_args(cookies_path))
|
||||||
probe_cmd.extend(["--cookies", cookies_path])
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
|
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
|
||||||
@@ -684,8 +718,7 @@ def download_video(
|
|||||||
ffmpeg_dir = find_ffmpeg_dir()
|
ffmpeg_dir = find_ffmpeg_dir()
|
||||||
if ffmpeg_dir:
|
if ffmpeg_dir:
|
||||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||||
if cookies_path and Path(cookies_path).exists():
|
cmd.extend(_cookie_args(cookies_path))
|
||||||
cmd.extend(["--cookies", cookies_path])
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with _process_lock:
|
with _process_lock:
|
||||||
|
|||||||
+156
@@ -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
|
||||||
@@ -45,6 +45,13 @@ def _bundle_dirs() -> list[Path]:
|
|||||||
sub_macos = frameworks / sub / "Contents" / "MacOS"
|
sub_macos = frameworks / sub / "Contents" / "MacOS"
|
||||||
if sub_macos.exists():
|
if sub_macos.exists():
|
||||||
dirs.append(sub_macos)
|
dirs.append(sub_macos)
|
||||||
|
else:
|
||||||
|
# Dev: cerca in <project_root>/bundle_bin/ così `python main.py`
|
||||||
|
# usa gli stessi binari del bundle (aggiornati via build_*.py)
|
||||||
|
# invece di Homebrew/PATH che possono essere obsoleti.
|
||||||
|
project_bundle = Path(__file__).resolve().parent.parent / "bundle_bin"
|
||||||
|
if project_bundle.exists():
|
||||||
|
dirs.append(project_bundle)
|
||||||
return dirs
|
return dirs
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-10
@@ -14,30 +14,24 @@ from dataclasses import dataclass
|
|||||||
# Generi musicali Traxsource (slug -> (id, display_name)).
|
# Generi musicali Traxsource (slug -> (id, display_name)).
|
||||||
# Aggiornato via scripts/refresh_traxsource_genres.py. Esclusi "sounds/samples/loops",
|
# Aggiornato via scripts/refresh_traxsource_genres.py. Esclusi "sounds/samples/loops",
|
||||||
# "acapella", "beats", "efx-dj-tools", "stems" (non generi ma tipi di prodotto).
|
# "acapella", "beats", "efx-dj-tools", "stems" (non generi ma tipi di prodotto).
|
||||||
|
# NOTA (ago 2026): Traxsource ha rimosso la "Top 100 <genre>" per 10 generi minori
|
||||||
|
# (broken-beat-nu-jazz, classic-house, drum-and-bass, electro-house, electronica,
|
||||||
|
# leftfield, lounge-chill-out, pop-dance, r-and-b-hip-hop, world). Rimossi dal
|
||||||
|
# dropdown finché non ripristinano la playlist canonica.
|
||||||
GENRES: dict = {
|
GENRES: dict = {
|
||||||
"afro-house": (27, "Afro House"),
|
"afro-house": (27, "Afro House"),
|
||||||
"afro-latin-brazilian": (23, "Afro / Latin / Brazilian"),
|
"afro-latin-brazilian": (23, "Afro / Latin / Brazilian"),
|
||||||
"broken-beat-nu-jazz": (2, "Broken Beat / Nu Jazz"),
|
|
||||||
"classic-house": (12, "Classic House"),
|
|
||||||
"deep-house": (13, "Deep House"),
|
"deep-house": (13, "Deep House"),
|
||||||
"drum-and-bass": (31, "Drum & Bass"),
|
|
||||||
"electro-house": (11, "Electro House"),
|
|
||||||
"electronica": (5, "Electronica"),
|
|
||||||
"garage": (29, "Garage"),
|
"garage": (29, "Garage"),
|
||||||
"house": (4, "House"),
|
"house": (4, "House"),
|
||||||
"jackin-house": (15, "Jackin House"),
|
"jackin-house": (15, "Jackin House"),
|
||||||
"leftfield": (14, "Leftfield"),
|
|
||||||
"lounge-chill-out": (1, "Lounge / Chill Out"),
|
|
||||||
"melodic-progressive-house": (19, "Melodic / Progressive House"),
|
"melodic-progressive-house": (19, "Melodic / Progressive House"),
|
||||||
"minimal-deep-tech": (16, "Minimal / Deep Tech"),
|
"minimal-deep-tech": (16, "Minimal / Deep Tech"),
|
||||||
"nu-disco-indie-dance": (17, "Nu Disco / Indie Dance"),
|
"nu-disco-indie-dance": (17, "Nu Disco / Indie Dance"),
|
||||||
"pop-dance": (32, "Pop Dance"),
|
|
||||||
"r-and-b-hip-hop": (6, "R&B / Hip Hop"),
|
|
||||||
"soul-funk-disco": (3, "Soul / Funk / Disco"),
|
"soul-funk-disco": (3, "Soul / Funk / Disco"),
|
||||||
"soulful-house": (24, "Soulful House"),
|
"soulful-house": (24, "Soulful House"),
|
||||||
"tech-house": (18, "Tech House"),
|
"tech-house": (18, "Tech House"),
|
||||||
"techno": (20, "Techno"),
|
"techno": (20, "Techno"),
|
||||||
"world": (30, "World"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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
|
# Fix SSL CA bundle: quando l'app e' frozen (PyInstaller) i moduli
|
||||||
# ssl e requests non trovano nessun CA bundle di default -> tutte
|
# ssl e requests non trovano nessun CA bundle di default -> tutte
|
||||||
|
|||||||
@@ -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([{}]) == ""
|
||||||
@@ -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"]
|
||||||
@@ -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) == []
|
||||||
@@ -151,6 +151,9 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 24px 12px;
|
padding: 24px 12px;
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
|
height: 100vh;
|
||||||
|
min-height: 0; /* consente ai flex children di rimpicciolirsi sotto il contenuto */
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -192,7 +195,28 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
flex: 1;
|
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 {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -687,6 +711,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
.percent.green { color: var(--green-text); }
|
.percent.green { color: var(--green-text); }
|
||||||
.percent.blue { color: var(--blue-text); }
|
.percent.blue { color: var(--blue-text); }
|
||||||
.percent.pink { color: var(--pink-hover); }
|
.percent.pink { color: var(--pink-hover); }
|
||||||
|
.percent.purple { color: var(--purple-text); }
|
||||||
|
|
||||||
.progress {
|
.progress {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -715,6 +740,10 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
background: linear-gradient(90deg, var(--pink), var(--pink-hover));
|
background: linear-gradient(90deg, var(--pink), var(--pink-hover));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progress-fill.purple {
|
||||||
|
background: linear-gradient(90deg, var(--purple), var(--purple-hover));
|
||||||
|
}
|
||||||
|
|
||||||
/* ===============================
|
/* ===============================
|
||||||
LOG BOX
|
LOG BOX
|
||||||
=============================== */
|
=============================== */
|
||||||
@@ -1970,3 +1999,268 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
padding-right: 6px; /* spazio per la scrollbar sul bordo */
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
+423
-14
@@ -21,6 +21,7 @@
|
|||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
|
<div class="nav-section-label">Scarica</div>
|
||||||
<button class="nav-item active" data-view="download" data-feature="audio">
|
<button class="nav-item active" data-view="download" data-feature="audio">
|
||||||
<span class="nav-icon">⬇</span>
|
<span class="nav-icon">⬇</span>
|
||||||
<span>Scarica</span>
|
<span>Scarica</span>
|
||||||
@@ -29,14 +30,6 @@
|
|||||||
<span class="nav-icon">🎬</span>
|
<span class="nav-icon">🎬</span>
|
||||||
<span>Video</span>
|
<span>Video</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-item" data-view="beatport" data-feature="audio">
|
|
||||||
<span class="nav-icon">🎧</span>
|
|
||||||
<span>Beatport</span>
|
|
||||||
</button>
|
|
||||||
<button class="nav-item" data-view="traxsource" data-feature="audio">
|
|
||||||
<span class="nav-icon">▲</span>
|
|
||||||
<span>Traxsource</span>
|
|
||||||
</button>
|
|
||||||
<button class="nav-item" data-view="spotify" data-feature="audio">
|
<button class="nav-item" data-view="spotify" data-feature="audio">
|
||||||
<span class="nav-icon">🟢</span>
|
<span class="nav-icon">🟢</span>
|
||||||
<span>Spotify</span>
|
<span>Spotify</span>
|
||||||
@@ -45,26 +38,52 @@
|
|||||||
<span class="nav-icon">▶</span>
|
<span class="nav-icon">▶</span>
|
||||||
<span>YouTube</span>
|
<span>YouTube</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-item" data-view="convert" data-feature="audio">
|
<button class="nav-item" data-view="beatport" data-feature="audio">
|
||||||
<span class="nav-icon">🔄</span>
|
<span class="nav-icon">🎧</span>
|
||||||
<span>Converti</span>
|
<span>Beatport</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-item" data-view="traxsource" data-feature="audio">
|
||||||
|
<span class="nav-icon">▲</span>
|
||||||
|
<span>Traxsource</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-item" data-view="charts" data-feature="audio">
|
||||||
|
<span class="nav-icon">📊</span>
|
||||||
|
<span>Charts</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="nav-section-label">Organizza</div>
|
||||||
<button class="nav-item" data-view="dedup" data-feature="audio">
|
<button class="nav-item" data-view="dedup" data-feature="audio">
|
||||||
<span class="nav-icon">🗑</span>
|
<span class="nav-icon">🗑</span>
|
||||||
<span>Dedup</span>
|
<span>Dedup</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
|
<button class="nav-item" data-view="catalog" data-feature="audio">
|
||||||
<span class="nav-icon">⚡</span>
|
<span class="nav-icon">📅</span>
|
||||||
<span>Upgrade</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>
|
||||||
<button class="nav-item" data-view="meta" data-feature="metadata">
|
<button class="nav-item" data-view="meta" data-feature="metadata">
|
||||||
<span class="nav-icon">🏷</span>
|
<span class="nav-icon">🏷</span>
|
||||||
<span>Metadati</span>
|
<span>Metadati</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
|
||||||
|
<span class="nav-icon">⚡</span>
|
||||||
|
<span>Upgrade</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="nav-section-label">Altro</div>
|
||||||
<button class="nav-item" data-view="record" data-feature="record">
|
<button class="nav-item" data-view="record" data-feature="record">
|
||||||
<span class="nav-icon">●</span>
|
<span class="nav-icon">●</span>
|
||||||
<span>Registra</span>
|
<span>Registra</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="nav-section-label">Sistema</div>
|
||||||
<button class="nav-item" data-view="settings">
|
<button class="nav-item" data-view="settings">
|
||||||
<span class="nav-icon">⚙</span>
|
<span class="nav-icon">⚙</span>
|
||||||
<span>Impostazioni</span>
|
<span>Impostazioni</span>
|
||||||
@@ -563,6 +582,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">Cookies da browser (bypass 403 YouTube)</label>
|
||||||
|
<div class="row">
|
||||||
|
<select id="cookiesBrowserSelect" class="input pill" style="max-width:220px;">
|
||||||
|
<option value="">Nessuno</option>
|
||||||
|
<option value="chrome">Chrome</option>
|
||||||
|
<option value="safari">Safari</option>
|
||||||
|
<option value="firefox">Firefox</option>
|
||||||
|
<option value="edge">Edge</option>
|
||||||
|
<option value="brave">Brave</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
<span class="hint-ico">ⓘ</span>
|
||||||
|
<div>Alcuni video YouTube (musica protetta, region-lock, età) danno HTTP 403 senza cookies autenticati. Selezionando un browser, l'app legge i cookies dal tuo profilo — richiede che tu sia loggato su YouTube in quel browser. Se hai anche cookies.txt sopra, ha priorità il file.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label">Cartella output predefinita</label>
|
<label class="field-label">Cartella output predefinita</label>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -572,6 +609,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<h2 class="section-label">Aspetto</h2>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -1286,6 +1341,337 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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><Anno>/<Genere>/</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"><Anno>/<Genere>/</option>
|
||||||
|
<option value="genre_year"><Genere>/<Anno>/</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&b">R&B</option>
|
||||||
|
<option value="italo disco">Italo Disco</option>
|
||||||
|
<option value="reggae">Reggae</option>
|
||||||
|
<option value="electronic">Electronic</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="hint" style="margin-top:8px;">
|
||||||
|
<span class="hint-ico">ⓘ</span>
|
||||||
|
<div>
|
||||||
|
Last.fm richiede una API key: nell'app c'è una chiave demo, se non funziona
|
||||||
|
registrane una gratuita su <code>last.fm/api/account/create</code> e mettila nel campo
|
||||||
|
<code>lastfm_api_key</code> di <code>config.json</code>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="beatport-header" style="margin-top:14px;gap:12px;">
|
||||||
|
<button id="charts-fetch-btn" class="btn btn-primary pill">
|
||||||
|
<span class="ico">▶</span> Aggiorna classifica
|
||||||
|
</button>
|
||||||
|
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
|
||||||
|
<input type="checkbox" id="charts-force" />
|
||||||
|
<span>Ignora cache (force refresh)</span>
|
||||||
|
</label>
|
||||||
|
<span id="charts-status" class="beatport-status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="charts-results-card" class="card" hidden>
|
||||||
|
<div class="charts-actions-row">
|
||||||
|
<span id="charts-summary-line" class="catalog-summary"></span>
|
||||||
|
<div class="charts-actions-btns">
|
||||||
|
<button id="charts-select-all-btn" class="btn btn-ghost pill btn-sm">Seleziona tutti</button>
|
||||||
|
<button id="charts-deselect-all-btn" class="btn btn-ghost pill btn-sm">Deseleziona tutti</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="charts-table-wrap" class="charts-scroll">
|
||||||
|
<table class="charts-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="charts-col-check">
|
||||||
|
<input type="checkbox" id="charts-select-all" title="Seleziona/deseleziona tutti" />
|
||||||
|
</th>
|
||||||
|
<th class="charts-col-rank">#</th>
|
||||||
|
<th class="charts-col-artist">Artista</th>
|
||||||
|
<th class="charts-col-title">Titolo</th>
|
||||||
|
<th class="charts-col-album">Album</th>
|
||||||
|
<th class="charts-col-pop">Popolarità</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="charts-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="charts-footer-bar" id="charts-footer-bar" hidden>
|
||||||
|
<span class="counter" id="charts-selected-count">0 brani selezionati</span>
|
||||||
|
<button id="charts-add-queue-btn" class="btn btn-primary pill" disabled>
|
||||||
|
<span class="ico">➕</span> Aggiungi alla coda Scarica
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-label">Log</h2>
|
||||||
|
<div class="card log-card">
|
||||||
|
<div class="log" id="charts-log"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1379,6 +1765,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</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><Anno>/<Genere>/</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 ============== -->
|
<!-- ============== TOAST ============== -->
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
|||||||
+925
@@ -409,6 +409,9 @@ const logEls = {
|
|||||||
convert: () => $("#convert-log"),
|
convert: () => $("#convert-log"),
|
||||||
traxsource: () => $("#traxsource-log"),
|
traxsource: () => $("#traxsource-log"),
|
||||||
dedup: () => $("#dedup-log"),
|
dedup: () => $("#dedup-log"),
|
||||||
|
catalog: () => $("#catalog-log"),
|
||||||
|
flatten: () => $("#flatten-log"),
|
||||||
|
charts: () => $("#charts-log"),
|
||||||
};
|
};
|
||||||
|
|
||||||
function classifyLog(msg) {
|
function classifyLog(msg) {
|
||||||
@@ -485,8 +488,15 @@ async function init() {
|
|||||||
$("#bitrateSelect").value = state.config.bitrate || "320K";
|
$("#bitrateSelect").value = state.config.bitrate || "320K";
|
||||||
$("#hqThresholdInput").value = state.config.hq_threshold || 310;
|
$("#hqThresholdInput").value = state.config.hq_threshold || 310;
|
||||||
$("#cookiesInput").value = state.config.cookies_path || "";
|
$("#cookiesInput").value = state.config.cookies_path || "";
|
||||||
|
if ($("#cookiesBrowserSelect")) $("#cookiesBrowserSelect").value = state.config.cookies_browser || "";
|
||||||
$("#outputInput").value = state.config.output_dir || "";
|
$("#outputInput").value = state.config.output_dir || "";
|
||||||
$("#themeSelect").value = state.config.theme || "dark";
|
$("#themeSelect").value = state.config.theme || "dark";
|
||||||
|
// Popola path del log corrente
|
||||||
|
if ($("#logPathDisplay")) {
|
||||||
|
window.pywebview.api.get_log_file_path().then((p) => {
|
||||||
|
if (p) $("#logPathDisplay").textContent = p;
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
applyTheme(state.config.theme);
|
applyTheme(state.config.theme);
|
||||||
|
|
||||||
// Download tab — usa output_dir come default
|
// Download tab — usa output_dir come default
|
||||||
@@ -529,6 +539,13 @@ async function init() {
|
|||||||
|
|
||||||
// Dedup tab (audio duplicati via Chromaprint)
|
// Dedup tab (audio duplicati via Chromaprint)
|
||||||
await DedupUI.init();
|
await DedupUI.init();
|
||||||
|
|
||||||
|
// Cataloga tab (AcoustID -> <Anno>/<Genere>/)
|
||||||
|
await CatalogUI.init();
|
||||||
|
await FlattenUI.init();
|
||||||
|
|
||||||
|
// Charts tab (iTunes / Spotify / M2O / Last.fm)
|
||||||
|
await ChartsUI.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRecDevices() {
|
async function refreshRecDevices() {
|
||||||
@@ -1277,6 +1294,15 @@ $("#browseCookiesBtn").addEventListener("click", async () => {
|
|||||||
if (path) $("#cookiesInput").value = path;
|
if (path) $("#cookiesInput").value = path;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("#openLogFolderBtn")?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const r = await window.pywebview.api.open_log_folder();
|
||||||
|
if (!r || !r.ok) toast("Impossibile aprire la cartella: " + (r?.error || "errore"), "error");
|
||||||
|
} catch (e) {
|
||||||
|
toast("Errore: " + e, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$("#browseOutputBtn").addEventListener("click", async () => {
|
$("#browseOutputBtn").addEventListener("click", async () => {
|
||||||
const path = await window.pywebview.api.browse_directory();
|
const path = await window.pywebview.api.browse_directory();
|
||||||
if (path) $("#outputInput").value = path;
|
if (path) $("#outputInput").value = path;
|
||||||
@@ -1293,6 +1319,7 @@ $("#saveBtn").addEventListener("click", async () => {
|
|||||||
bitrate: $("#bitrateSelect").value,
|
bitrate: $("#bitrateSelect").value,
|
||||||
hq_threshold: parseInt($("#hqThresholdInput").value, 10) || 310,
|
hq_threshold: parseInt($("#hqThresholdInput").value, 10) || 310,
|
||||||
cookies_path: $("#cookiesInput").value,
|
cookies_path: $("#cookiesInput").value,
|
||||||
|
cookies_browser: $("#cookiesBrowserSelect") ? $("#cookiesBrowserSelect").value : "",
|
||||||
output_dir: $("#outputInput").value,
|
output_dir: $("#outputInput").value,
|
||||||
theme: $("#themeSelect").value,
|
theme: $("#themeSelect").value,
|
||||||
};
|
};
|
||||||
@@ -3203,6 +3230,904 @@ const DedupUI = (() => {
|
|||||||
return { init };
|
return { init };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// CatalogUI — AcoustID -> <Anno>/<Genere>/
|
||||||
|
// ============================================================
|
||||||
|
const CatalogUI = (() => {
|
||||||
|
const cstate = {
|
||||||
|
source: "",
|
||||||
|
target: "",
|
||||||
|
recursive: true,
|
||||||
|
scanning: false,
|
||||||
|
// Map path -> entry (mantiene ordine di inserimento = streaming order)
|
||||||
|
entries: new Map(),
|
||||||
|
// Set di path selezionati per il move
|
||||||
|
toMove: new Set(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function setStatus(text, kind) {
|
||||||
|
const el = $("#catalog-status");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text || "";
|
||||||
|
el.className = "beatport-status" + (kind ? " " + kind : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _effectiveTarget() {
|
||||||
|
return cstate.target || cstate.source || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTargetDisplay() {
|
||||||
|
const el = $("#catalog-target-display");
|
||||||
|
if (!el) return;
|
||||||
|
if (cstate.target) {
|
||||||
|
el.textContent = cstate.target;
|
||||||
|
} else if (cstate.source) {
|
||||||
|
el.textContent = "Uguale alla sorgente: " + cstate.source;
|
||||||
|
} else {
|
||||||
|
el.textContent = "Uguale alla sorgente (in-place)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectedCount() {
|
||||||
|
const n = cstate.toMove.size;
|
||||||
|
const cnt = $("#catalog-selected-count");
|
||||||
|
if (cnt) cnt.textContent = n === 1 ? "1 file selezionato" : `${n} file selezionati`;
|
||||||
|
const btn = $("#catalog-move-btn");
|
||||||
|
if (btn) btn.disabled = n === 0 || cstate.scanning;
|
||||||
|
const bar = $("#catalog-footer-bar");
|
||||||
|
if (bar) bar.hidden = cstate.entries.size === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _statusBadge(entry) {
|
||||||
|
if (entry.error) {
|
||||||
|
return `<span class="catalog-status-badge err" title="${_escapeHtml(entry.error)}">errore</span>`;
|
||||||
|
}
|
||||||
|
if (entry.matched && entry.year && entry.genre) {
|
||||||
|
return `<span class="catalog-status-badge ok">match</span>`;
|
||||||
|
}
|
||||||
|
if (entry.matched) {
|
||||||
|
// Match parziale (mancano anno o genere)
|
||||||
|
return `<span class="catalog-status-badge warn">parziale</span>`;
|
||||||
|
}
|
||||||
|
return `<span class="catalog-status-badge warn">sconosciuto</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _rowShouldBePreselected(entry) {
|
||||||
|
// Preselezione: solo match validi (year AND genre presenti).
|
||||||
|
return !!(entry.matched && entry.year && entry.genre);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderRow(entry) {
|
||||||
|
const path = entry.path;
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.dataset.path = path;
|
||||||
|
const unmatched = !entry.matched || !entry.year || !entry.genre;
|
||||||
|
if (unmatched) tr.classList.add("catalog-row-unmatched");
|
||||||
|
|
||||||
|
// Checkbox
|
||||||
|
const tdCheck = document.createElement("td");
|
||||||
|
tdCheck.className = "catalog-col-check";
|
||||||
|
const cb = document.createElement("input");
|
||||||
|
cb.type = "checkbox";
|
||||||
|
cb.className = "catalog-check";
|
||||||
|
const pre = _rowShouldBePreselected(entry);
|
||||||
|
cb.checked = pre;
|
||||||
|
if (pre) cstate.toMove.add(path);
|
||||||
|
cb.addEventListener("change", () => {
|
||||||
|
if (cb.checked) cstate.toMove.add(path);
|
||||||
|
else cstate.toMove.delete(path);
|
||||||
|
updateSelectedCount();
|
||||||
|
});
|
||||||
|
tdCheck.appendChild(cb);
|
||||||
|
tr.appendChild(tdCheck);
|
||||||
|
|
||||||
|
// File (basename)
|
||||||
|
const tdFile = document.createElement("td");
|
||||||
|
tdFile.className = "catalog-col-file";
|
||||||
|
tdFile.innerHTML = `<span class="catalog-file-cell" title="${_escapeHtml(path)}">${_escapeHtml(_basename(path))}</span>`;
|
||||||
|
tr.appendChild(tdFile);
|
||||||
|
|
||||||
|
// Artista
|
||||||
|
const tdArtist = document.createElement("td");
|
||||||
|
tdArtist.className = "catalog-col-artist";
|
||||||
|
tdArtist.textContent = entry.artist || "—";
|
||||||
|
tdArtist.title = entry.artist || "";
|
||||||
|
tr.appendChild(tdArtist);
|
||||||
|
|
||||||
|
// Titolo
|
||||||
|
const tdTitle = document.createElement("td");
|
||||||
|
tdTitle.className = "catalog-col-title";
|
||||||
|
tdTitle.textContent = entry.title || "—";
|
||||||
|
tdTitle.title = entry.title || "";
|
||||||
|
tr.appendChild(tdTitle);
|
||||||
|
|
||||||
|
// Anno
|
||||||
|
const tdYear = document.createElement("td");
|
||||||
|
tdYear.className = "catalog-col-year";
|
||||||
|
tdYear.textContent = entry.year ? String(entry.year) : "—";
|
||||||
|
tr.appendChild(tdYear);
|
||||||
|
|
||||||
|
// Genere
|
||||||
|
const tdGenre = document.createElement("td");
|
||||||
|
tdGenre.className = "catalog-col-genre";
|
||||||
|
tdGenre.textContent = entry.genre || "—";
|
||||||
|
tdGenre.title = entry.genre || "";
|
||||||
|
tr.appendChild(tdGenre);
|
||||||
|
|
||||||
|
// Status
|
||||||
|
const tdStatus = document.createElement("td");
|
||||||
|
tdStatus.className = "catalog-col-status";
|
||||||
|
tdStatus.innerHTML = _statusBadge(entry);
|
||||||
|
tr.appendChild(tdStatus);
|
||||||
|
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _upsertEntry(entry) {
|
||||||
|
if (!entry || !entry.path) return;
|
||||||
|
const wrap = $("#catalog-table-wrap");
|
||||||
|
const body = $("#catalog-table-body");
|
||||||
|
if (!body) return;
|
||||||
|
const existed = cstate.entries.has(entry.path);
|
||||||
|
cstate.entries.set(entry.path, entry);
|
||||||
|
|
||||||
|
if (existed) {
|
||||||
|
// Aggiorna in place: rimuovi vecchia riga e append nuova nella stessa posizione
|
||||||
|
const oldRow = body.querySelector(`tr[data-path="${CSS.escape(entry.path)}"]`);
|
||||||
|
const newRow = _renderRow(entry);
|
||||||
|
if (oldRow) body.replaceChild(newRow, oldRow);
|
||||||
|
else body.appendChild(newRow);
|
||||||
|
} else {
|
||||||
|
body.appendChild(_renderRow(entry));
|
||||||
|
}
|
||||||
|
if (wrap) wrap.hidden = false;
|
||||||
|
_updateSummary();
|
||||||
|
updateSelectedCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateSummary() {
|
||||||
|
const line = $("#catalog-summary-line");
|
||||||
|
const card = $("#catalog-summary-card");
|
||||||
|
if (!line || !card) return;
|
||||||
|
const n = cstate.entries.size;
|
||||||
|
if (n === 0) { card.hidden = true; return; }
|
||||||
|
let matched = 0, unknown = 0, errors = 0;
|
||||||
|
cstate.entries.forEach((e) => {
|
||||||
|
if (e.error) errors++;
|
||||||
|
else if (e.matched && e.year && e.genre) matched++;
|
||||||
|
else unknown++;
|
||||||
|
});
|
||||||
|
line.textContent =
|
||||||
|
`${n} file · ${matched} identificati · ${unknown} parziali/sconosciuti · ${errors} errori`;
|
||||||
|
card.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _clearTable() {
|
||||||
|
const body = $("#catalog-table-body");
|
||||||
|
if (body) body.innerHTML = "";
|
||||||
|
const wrap = $("#catalog-table-wrap");
|
||||||
|
if (wrap) wrap.hidden = true;
|
||||||
|
const card = $("#catalog-summary-card");
|
||||||
|
if (card) card.hidden = true;
|
||||||
|
cstate.entries.clear();
|
||||||
|
cstate.toMove.clear();
|
||||||
|
updateSelectedCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickSource() {
|
||||||
|
const p = await window.pywebview.api.catalog_pick_source_folder();
|
||||||
|
if (p && typeof p === "string") {
|
||||||
|
cstate.source = p;
|
||||||
|
$("#catalog-source-display").textContent = p;
|
||||||
|
$("#catalog-start-btn").disabled = false;
|
||||||
|
updateTargetDisplay();
|
||||||
|
setStatus("", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickTarget() {
|
||||||
|
const p = await window.pywebview.api.catalog_pick_target_folder();
|
||||||
|
if (p && typeof p === "string") {
|
||||||
|
cstate.target = p;
|
||||||
|
updateTargetDisplay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTarget() {
|
||||||
|
cstate.target = "";
|
||||||
|
updateTargetDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startScan() {
|
||||||
|
if (!cstate.source) return;
|
||||||
|
cstate.recursive = $("#catalog-recursive").checked;
|
||||||
|
cstate.scanning = true;
|
||||||
|
_clearTable();
|
||||||
|
$("#catalog-start-btn").disabled = true;
|
||||||
|
$("#catalog-pick-source").disabled = true;
|
||||||
|
$("#catalog-stop-btn").hidden = false;
|
||||||
|
$("#catalog-log").innerHTML = "";
|
||||||
|
$("#catalogProgressFill").style.width = "0%";
|
||||||
|
$("#catalogPercent").textContent = "0%";
|
||||||
|
$("#catalogCounter").textContent = "Scansione in corso…";
|
||||||
|
setStatus("Scansione in corso...", "loading");
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await window.pywebview.api.catalog_start_scan({
|
||||||
|
source: cstate.source,
|
||||||
|
target: cstate.target,
|
||||||
|
recursive: cstate.recursive,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("Errore avvio: " + ((e && e.message) || e), "error");
|
||||||
|
finishScan();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
const msg = (res && res.error) || "Impossibile avviare la scansione";
|
||||||
|
setStatus(msg, "error");
|
||||||
|
if (!handleGateBlock(res || {})) toast(msg, "error");
|
||||||
|
finishScan();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopScan() {
|
||||||
|
try { await window.pywebview.api.catalog_stop_scan(); } catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishScan() {
|
||||||
|
cstate.scanning = false;
|
||||||
|
$("#catalog-start-btn").disabled = !cstate.source;
|
||||||
|
$("#catalog-pick-source").disabled = false;
|
||||||
|
$("#catalog-stop-btn").hidden = true;
|
||||||
|
updateSelectedCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _openConfirmModal(nFiles, target) {
|
||||||
|
const msg = $("#catalogConfirmMsg");
|
||||||
|
if (msg) {
|
||||||
|
const word = nFiles === 1 ? "1 file" : `${nFiles} file`;
|
||||||
|
msg.textContent = `Stai per spostare ${word} in ${target}. Procedi?`;
|
||||||
|
}
|
||||||
|
$("#catalogConfirmModal").hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _closeConfirmModal() {
|
||||||
|
$("#catalogConfirmModal").hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveSelected() {
|
||||||
|
const paths = [...cstate.toMove];
|
||||||
|
if (!paths.length) return;
|
||||||
|
const target = _effectiveTarget();
|
||||||
|
if (!target) { toast("Cartella destinazione mancante", "error"); return; }
|
||||||
|
|
||||||
|
// Costruisci lista entries in ordine di selezione
|
||||||
|
const selectedEntries = paths
|
||||||
|
.map((p) => cstate.entries.get(p))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!selectedEntries.length) return;
|
||||||
|
|
||||||
|
_openConfirmModal(selectedEntries.length, target);
|
||||||
|
|
||||||
|
// Attendi conferma o annulla via bottoni
|
||||||
|
const proceed = await new Promise((resolve) => {
|
||||||
|
const yes = $("#catalogConfirmProceedBtn");
|
||||||
|
const no = $("#catalogConfirmCancelBtn");
|
||||||
|
const close1 = $("#catalogConfirmCloseBtn");
|
||||||
|
const cleanup = () => {
|
||||||
|
yes.removeEventListener("click", onYes);
|
||||||
|
no.removeEventListener("click", onNo);
|
||||||
|
close1.removeEventListener("click", onNo);
|
||||||
|
};
|
||||||
|
const onYes = () => { cleanup(); _closeConfirmModal(); resolve(true); };
|
||||||
|
const onNo = () => { cleanup(); _closeConfirmModal(); resolve(false); };
|
||||||
|
yes.addEventListener("click", onYes);
|
||||||
|
no.addEventListener("click", onNo);
|
||||||
|
close1.addEventListener("click", onNo);
|
||||||
|
});
|
||||||
|
if (!proceed) return;
|
||||||
|
|
||||||
|
const btn = $("#catalog-move-btn");
|
||||||
|
btn.disabled = true;
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
const layout = $("#catalog-layout")?.value || "year_genre";
|
||||||
|
res = await window.pywebview.api.catalog_move_files(selectedEntries, target, layout);
|
||||||
|
} catch (e) {
|
||||||
|
toast("Errore: " + ((e && e.message) || e), "error");
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
toast((res && res.error) || "Errore spostamento", "error");
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rimuovi dalla tabella le entry spostate (usando operations.src)
|
||||||
|
const moved = new Set((res.operations || []).map((o) => o.src));
|
||||||
|
const body = $("#catalog-table-body");
|
||||||
|
moved.forEach((p) => {
|
||||||
|
cstate.entries.delete(p);
|
||||||
|
cstate.toMove.delete(p);
|
||||||
|
if (body) {
|
||||||
|
const row = body.querySelector(`tr[data-path="${CSS.escape(p)}"]`);
|
||||||
|
if (row) row.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_updateSummary();
|
||||||
|
updateSelectedCount();
|
||||||
|
const wrap = $("#catalog-table-wrap");
|
||||||
|
if (wrap && cstate.entries.size === 0) wrap.hidden = true;
|
||||||
|
|
||||||
|
const okN = res.moved || 0;
|
||||||
|
const failN = res.failed_count || 0;
|
||||||
|
if (failN > 0) {
|
||||||
|
toast(`${okN} spostati, ${failN} falliti`, "error");
|
||||||
|
} else {
|
||||||
|
toast(`${okN} file organizzati`, "success");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
// Bind eventi
|
||||||
|
$("#catalog-pick-source").addEventListener("click", pickSource);
|
||||||
|
$("#catalog-pick-target").addEventListener("click", pickTarget);
|
||||||
|
$("#catalog-clear-target").addEventListener("click", clearTarget);
|
||||||
|
$("#catalog-start-btn").addEventListener("click", startScan);
|
||||||
|
$("#catalog-stop-btn").addEventListener("click", stopScan);
|
||||||
|
$("#catalog-move-btn").addEventListener("click", moveSelected);
|
||||||
|
$("#catalog-recursive").addEventListener("change", (e) => {
|
||||||
|
cstate.recursive = e.target.checked;
|
||||||
|
});
|
||||||
|
$("#catalog-select-all").addEventListener("change", (e) => {
|
||||||
|
const on = e.target.checked;
|
||||||
|
const checks = $$("#catalog-table-body .catalog-check");
|
||||||
|
cstate.toMove.clear();
|
||||||
|
checks.forEach((cb) => {
|
||||||
|
cb.checked = on;
|
||||||
|
if (on) {
|
||||||
|
const row = cb.closest("tr");
|
||||||
|
if (row && row.dataset.path) cstate.toMove.add(row.dataset.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateSelectedCount();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ripristina ultimo state da config
|
||||||
|
const cfg = state.config || {};
|
||||||
|
if (cfg.catalog_last_source) {
|
||||||
|
cstate.source = cfg.catalog_last_source;
|
||||||
|
$("#catalog-source-display").textContent = cfg.catalog_last_source;
|
||||||
|
$("#catalog-start-btn").disabled = false;
|
||||||
|
}
|
||||||
|
if (cfg.catalog_last_target) {
|
||||||
|
cstate.target = cfg.catalog_last_target;
|
||||||
|
}
|
||||||
|
if (typeof cfg.catalog_recursive === "boolean") {
|
||||||
|
$("#catalog-recursive").checked = cfg.catalog_recursive;
|
||||||
|
cstate.recursive = cfg.catalog_recursive;
|
||||||
|
}
|
||||||
|
updateTargetDisplay();
|
||||||
|
|
||||||
|
// Bridge handlers
|
||||||
|
bridgeHandlers["catalog:progress"] = (p) => {
|
||||||
|
if (!p) return;
|
||||||
|
if (typeof p.overall === "number") {
|
||||||
|
const pct = Math.round(p.overall * 100);
|
||||||
|
$("#catalogProgressFill").style.width = pct + "%";
|
||||||
|
$("#catalogPercent").textContent = pct + "%";
|
||||||
|
}
|
||||||
|
if (typeof p.idx === "number" && typeof p.total === "number" && p.total > 0) {
|
||||||
|
const status = p.status || "";
|
||||||
|
let label;
|
||||||
|
if (status === "computing") label = `File ${p.idx}/${p.total} · fingerprint`;
|
||||||
|
else if (status === "lookup") label = `File ${p.idx}/${p.total} · AcoustID`;
|
||||||
|
else if (status === "completed") label = `Completato: ${p.total} file`;
|
||||||
|
else if (status === "stopped") label = "Interrotta";
|
||||||
|
else if (status === "error") label = `File ${p.idx}/${p.total} · errore`;
|
||||||
|
else label = `File ${p.idx}/${p.total}`;
|
||||||
|
$("#catalogCounter").textContent = label;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bridgeHandlers["catalog:entry"] = (entry) => {
|
||||||
|
_upsertEntry(entry);
|
||||||
|
setStatus(`Elaborati ${cstate.entries.size} file...`, "loading");
|
||||||
|
};
|
||||||
|
|
||||||
|
bridgeHandlers["catalog:log"] = (_op) => {
|
||||||
|
// Log dettagliato gia' appeso via il canale "log" dal backend.
|
||||||
|
// Handler dedicato riservato per estensioni future (es. undo).
|
||||||
|
};
|
||||||
|
|
||||||
|
bridgeHandlers["catalog:done"] = (p) => {
|
||||||
|
// Se lo streaming non ha popolato nulla (raro, es. errore), usa
|
||||||
|
// p.entries come fallback.
|
||||||
|
if (cstate.entries.size === 0 && p && Array.isArray(p.entries)) {
|
||||||
|
p.entries.forEach(_upsertEntry);
|
||||||
|
}
|
||||||
|
if (p && p.ok === false) {
|
||||||
|
setStatus(p.error || "Errore scansione", "error");
|
||||||
|
} else if (cstate.entries.size === 0) {
|
||||||
|
setStatus("Nessun file audio trovato", "ok");
|
||||||
|
} else {
|
||||||
|
const matched = (p && p.n_matched) || 0;
|
||||||
|
const total = (p && p.n_total) || cstate.entries.size;
|
||||||
|
setStatus(`Scansione completata · ${matched}/${total} identificati`, "ok");
|
||||||
|
}
|
||||||
|
finishScan();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init };
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// FlattenUI — appiattisci sotto-cartelle nella root
|
||||||
|
// ============================================================
|
||||||
|
const FlattenUI = (() => {
|
||||||
|
const mstate = { folder: "", removeEmpty: true, running: false };
|
||||||
|
|
||||||
|
function setStatus(text, kind) {
|
||||||
|
const el = $("#flatten-status");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text || "";
|
||||||
|
el.className = "beatport-status" + (kind ? " " + kind : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickFolder() {
|
||||||
|
const p = await window.pywebview.api.flatten_pick_folder();
|
||||||
|
if (p && typeof p === "string") {
|
||||||
|
mstate.folder = p;
|
||||||
|
$("#flatten-path-display").textContent = p;
|
||||||
|
$("#flatten-start-btn").disabled = false;
|
||||||
|
setStatus("", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFlatten() {
|
||||||
|
if (!mstate.folder) return;
|
||||||
|
mstate.removeEmpty = $("#flatten-remove-empty").checked;
|
||||||
|
if (!window.confirm(`Vuoi spostare TUTTI i file audio delle sotto-cartelle nella cartella:\n\n${mstate.folder}\n\n${mstate.removeEmpty ? "Le sotto-cartelle vuote verranno eliminate." : "Le sotto-cartelle vuote verranno lasciate."}\n\nProseguire?`)) return;
|
||||||
|
|
||||||
|
mstate.running = true;
|
||||||
|
$("#flatten-start-btn").disabled = true;
|
||||||
|
$("#flatten-pick-folder").disabled = true;
|
||||||
|
$("#flatten-stop-btn").hidden = false;
|
||||||
|
$("#flatten-log").innerHTML = "";
|
||||||
|
$("#flattenProgressFill").style.width = "0%";
|
||||||
|
$("#flattenPercent").textContent = "0%";
|
||||||
|
$("#flattenCounter").textContent = "In corso…";
|
||||||
|
setStatus("Operazione in corso...", "loading");
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await window.pywebview.api.flatten_start({
|
||||||
|
directory: mstate.folder,
|
||||||
|
remove_empty: mstate.removeEmpty,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("Errore: " + e, "error");
|
||||||
|
finishFlatten();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
setStatus(res && res.error || "Errore avvio", "error");
|
||||||
|
finishFlatten();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopFlatten() {
|
||||||
|
try { await window.pywebview.api.flatten_stop(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishFlatten() {
|
||||||
|
mstate.running = false;
|
||||||
|
$("#flatten-start-btn").disabled = !mstate.folder;
|
||||||
|
$("#flatten-pick-folder").disabled = false;
|
||||||
|
$("#flatten-stop-btn").hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
$("#flatten-pick-folder").addEventListener("click", pickFolder);
|
||||||
|
$("#flatten-start-btn").addEventListener("click", startFlatten);
|
||||||
|
$("#flatten-stop-btn").addEventListener("click", stopFlatten);
|
||||||
|
|
||||||
|
bridgeHandlers["flatten:progress"] = (p) => {
|
||||||
|
if (!p) return;
|
||||||
|
if (typeof p.overall === "number") {
|
||||||
|
const pct = Math.round(p.overall * 100);
|
||||||
|
$("#flattenProgressFill").style.width = pct + "%";
|
||||||
|
$("#flattenPercent").textContent = pct + "%";
|
||||||
|
}
|
||||||
|
if (typeof p.idx === "number" && typeof p.total === "number" && p.total > 0) {
|
||||||
|
const s = p.status || "";
|
||||||
|
let label;
|
||||||
|
if (s === "moving") label = `${p.idx}/${p.total} · ${p.filename || ""}`;
|
||||||
|
else if (s === "completed") label = `Completato: ${p.total} file`;
|
||||||
|
else if (s === "stopped") label = "Interrotta";
|
||||||
|
else if (s === "error") label = `${p.idx}/${p.total} · errore`;
|
||||||
|
else label = `${p.idx}/${p.total}`;
|
||||||
|
$("#flattenCounter").textContent = label;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bridgeHandlers["flatten:done"] = (p) => {
|
||||||
|
if (p && p.ok) {
|
||||||
|
const moved = p.moved || 0;
|
||||||
|
const dirs = p.dirs_removed || 0;
|
||||||
|
const failed = p.failed_count || 0;
|
||||||
|
const parts = [`${moved} file spostati`];
|
||||||
|
if (dirs) parts.push(`${dirs} cartelle rimosse`);
|
||||||
|
if (failed) parts.push(`${failed} errori`);
|
||||||
|
setStatus(parts.join(" · "), failed ? "error" : "ok");
|
||||||
|
} else if (p) {
|
||||||
|
setStatus(p.error || "Errore", "error");
|
||||||
|
}
|
||||||
|
finishFlatten();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init };
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ChartsUI — classifiche iTunes / Spotify / M2O / Last.fm
|
||||||
|
// ============================================================
|
||||||
|
const ChartsUI = (() => {
|
||||||
|
const cs = {
|
||||||
|
source: "itunes",
|
||||||
|
country: "it",
|
||||||
|
playlist: "top50_global",
|
||||||
|
decade: "90s",
|
||||||
|
genre: "dance",
|
||||||
|
entries: [],
|
||||||
|
selected: new Set(), // Set di rank (int)
|
||||||
|
fetching: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function setStatus(text, kind) {
|
||||||
|
const el = $("#charts-status");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text || "";
|
||||||
|
el.className = "beatport-status" + (kind ? " " + kind : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showFiltersFor(source) {
|
||||||
|
const map = {
|
||||||
|
itunes: "#charts-filters-itunes",
|
||||||
|
spotify: "#charts-filters-spotify",
|
||||||
|
m2o: "#charts-filters-m2o",
|
||||||
|
lastfm: "#charts-filters-lastfm",
|
||||||
|
};
|
||||||
|
Object.values(map).forEach((sel) => {
|
||||||
|
const el = $(sel);
|
||||||
|
if (el) el.hidden = true;
|
||||||
|
});
|
||||||
|
const activeEl = $(map[source]);
|
||||||
|
if (activeEl) activeEl.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateSelectedCount() {
|
||||||
|
const n = cs.selected.size;
|
||||||
|
const cnt = $("#charts-selected-count");
|
||||||
|
if (cnt) {
|
||||||
|
cnt.textContent = n === 1
|
||||||
|
? "1 brano selezionato"
|
||||||
|
: `${n} brani selezionati`;
|
||||||
|
}
|
||||||
|
const btn = $("#charts-add-queue-btn");
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = n === 0 || cs.fetching;
|
||||||
|
const suffix = n > 0
|
||||||
|
? ` Aggiungi ${n} alla coda Scarica`
|
||||||
|
: " Aggiungi alla coda Scarica";
|
||||||
|
btn.innerHTML = `<span class="ico">➕</span>${suffix}`;
|
||||||
|
}
|
||||||
|
const bar = $("#charts-footer-bar");
|
||||||
|
if (bar) bar.hidden = cs.entries.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateSummary() {
|
||||||
|
const line = $("#charts-summary-line");
|
||||||
|
if (!line) return;
|
||||||
|
const n = cs.entries.length;
|
||||||
|
if (n === 0) {
|
||||||
|
line.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const labelBySource = {
|
||||||
|
itunes: "iTunes",
|
||||||
|
spotify: "Spotify Charts",
|
||||||
|
m2o: "M2O Chart",
|
||||||
|
lastfm: "Last.fm",
|
||||||
|
};
|
||||||
|
const src = labelBySource[cs.source] || cs.source;
|
||||||
|
line.textContent = `${n} brani da ${src}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fmtPopularity(entry) {
|
||||||
|
if (entry.popularity === null || entry.popularity === undefined || entry.popularity === "") {
|
||||||
|
return String(entry.rank || "—");
|
||||||
|
}
|
||||||
|
const n = Number(entry.popularity);
|
||||||
|
if (!isFinite(n)) return String(entry.popularity);
|
||||||
|
if (n >= 1000) {
|
||||||
|
// Formatta grandi numeri con separatore
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderRow(entry) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.dataset.rank = String(entry.rank);
|
||||||
|
|
||||||
|
// Checkbox
|
||||||
|
const tdCheck = document.createElement("td");
|
||||||
|
tdCheck.className = "charts-col-check";
|
||||||
|
const cb = document.createElement("input");
|
||||||
|
cb.type = "checkbox";
|
||||||
|
cb.className = "charts-check";
|
||||||
|
cb.checked = cs.selected.has(entry.rank);
|
||||||
|
cb.addEventListener("change", () => {
|
||||||
|
if (cb.checked) cs.selected.add(entry.rank);
|
||||||
|
else cs.selected.delete(entry.rank);
|
||||||
|
_updateSelectedCount();
|
||||||
|
});
|
||||||
|
tdCheck.appendChild(cb);
|
||||||
|
tr.appendChild(tdCheck);
|
||||||
|
|
||||||
|
// Rank
|
||||||
|
const tdRank = document.createElement("td");
|
||||||
|
tdRank.className = "charts-col-rank";
|
||||||
|
tdRank.textContent = String(entry.rank || "—");
|
||||||
|
tr.appendChild(tdRank);
|
||||||
|
|
||||||
|
// Artist
|
||||||
|
const tdArtist = document.createElement("td");
|
||||||
|
tdArtist.className = "charts-col-artist";
|
||||||
|
tdArtist.textContent = entry.artist || "—";
|
||||||
|
tdArtist.title = entry.artist || "";
|
||||||
|
tr.appendChild(tdArtist);
|
||||||
|
|
||||||
|
// Title
|
||||||
|
const tdTitle = document.createElement("td");
|
||||||
|
tdTitle.className = "charts-col-title";
|
||||||
|
tdTitle.textContent = entry.title || "—";
|
||||||
|
tdTitle.title = entry.title || "";
|
||||||
|
tr.appendChild(tdTitle);
|
||||||
|
|
||||||
|
// Album
|
||||||
|
const tdAlbum = document.createElement("td");
|
||||||
|
tdAlbum.className = "charts-col-album";
|
||||||
|
tdAlbum.textContent = entry.album || "—";
|
||||||
|
tdAlbum.title = entry.album || "";
|
||||||
|
tr.appendChild(tdAlbum);
|
||||||
|
|
||||||
|
// Popularity
|
||||||
|
const tdPop = document.createElement("td");
|
||||||
|
tdPop.className = "charts-col-pop";
|
||||||
|
tdPop.textContent = _fmtPopularity(entry);
|
||||||
|
tr.appendChild(tdPop);
|
||||||
|
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderTable() {
|
||||||
|
const body = $("#charts-table-body");
|
||||||
|
const card = $("#charts-results-card");
|
||||||
|
if (!body || !card) return;
|
||||||
|
body.innerHTML = "";
|
||||||
|
if (cs.entries.length === 0) {
|
||||||
|
card.hidden = true;
|
||||||
|
const bar = $("#charts-footer-bar");
|
||||||
|
if (bar) bar.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of cs.entries) {
|
||||||
|
body.appendChild(_renderRow(entry));
|
||||||
|
}
|
||||||
|
card.hidden = false;
|
||||||
|
_updateSummary();
|
||||||
|
_updateSelectedCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _params() {
|
||||||
|
if (cs.source === "itunes") return { country: cs.country };
|
||||||
|
if (cs.source === "spotify") return { playlist_key: cs.playlist };
|
||||||
|
if (cs.source === "m2o") return {};
|
||||||
|
if (cs.source === "lastfm") return { decade: cs.decade, genre: cs.genre };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doFetch() {
|
||||||
|
if (cs.fetching) return;
|
||||||
|
cs.fetching = true;
|
||||||
|
cs.entries = [];
|
||||||
|
cs.selected.clear();
|
||||||
|
_renderTable();
|
||||||
|
|
||||||
|
const btn = $("#charts-fetch-btn");
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
setStatus("Caricamento classifica…", "loading");
|
||||||
|
|
||||||
|
const force = $("#charts-force")?.checked || false;
|
||||||
|
const payload = { source: cs.source, force, ...(_params()) };
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await window.pywebview.api.charts_fetch(payload);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("Errore: " + ((e && e.message) || e), "error");
|
||||||
|
cs.fetching = false;
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cs.fetching = false;
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
const msg = (res && res.error) || "Impossibile caricare la classifica";
|
||||||
|
setStatus(msg, "error");
|
||||||
|
toast(msg, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cs.entries = Array.isArray(res.entries) ? res.entries : [];
|
||||||
|
_renderTable();
|
||||||
|
|
||||||
|
if (cs.entries.length === 0) {
|
||||||
|
setStatus("Nessun risultato. Prova a cambiare filtri o sorgente.", "ok");
|
||||||
|
} else {
|
||||||
|
setStatus(`${cs.entries.length} brani ricevuti`, "ok");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addToQueue() {
|
||||||
|
if (cs.selected.size === 0) return;
|
||||||
|
const rankSet = cs.selected;
|
||||||
|
const selectedEntries = cs.entries
|
||||||
|
.filter((e) => rankSet.has(e.rank))
|
||||||
|
.map((e) => ({ artist: e.artist, title: e.title }));
|
||||||
|
if (selectedEntries.length === 0) return;
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await window.pywebview.api.charts_add_to_queue(selectedEntries);
|
||||||
|
} catch (e) {
|
||||||
|
toast("Errore: " + ((e && e.message) || e), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
toast((res && res.error) || "Errore aggiunta", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Il bridge emette `charts:add_to_queue` che poi popola la tab Scarica.
|
||||||
|
// Reset selezione qui.
|
||||||
|
cs.selected.clear();
|
||||||
|
// Deseleziona checkbox visibili
|
||||||
|
$$("#charts-table-body .charts-check").forEach((cb) => { cb.checked = false; });
|
||||||
|
const master = $("#charts-select-all");
|
||||||
|
if (master) master.checked = false;
|
||||||
|
_updateSelectedCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _selectAll(on) {
|
||||||
|
cs.selected.clear();
|
||||||
|
if (on) {
|
||||||
|
for (const e of cs.entries) cs.selected.add(e.rank);
|
||||||
|
}
|
||||||
|
$$("#charts-table-body .charts-check").forEach((cb) => { cb.checked = on; });
|
||||||
|
const master = $("#charts-select-all");
|
||||||
|
if (master) master.checked = on;
|
||||||
|
_updateSelectedCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
// Ripristina scelta da config
|
||||||
|
const cfg = state.config || {};
|
||||||
|
cs.source = cfg.charts_last_source || "itunes";
|
||||||
|
cs.country = cfg.charts_itunes_country || "it";
|
||||||
|
cs.playlist = cfg.charts_spotify_playlist || "top50_global";
|
||||||
|
cs.decade = cfg.charts_lastfm_decade || "90s";
|
||||||
|
cs.genre = cfg.charts_lastfm_genre || "dance";
|
||||||
|
|
||||||
|
// Applica valori ai controlli
|
||||||
|
const radio = document.querySelector(`input[name="charts-source"][value="${cs.source}"]`);
|
||||||
|
if (radio) radio.checked = true;
|
||||||
|
else {
|
||||||
|
// Fallback: seleziona iTunes
|
||||||
|
const fallback = document.querySelector('input[name="charts-source"][value="itunes"]');
|
||||||
|
if (fallback) fallback.checked = true;
|
||||||
|
cs.source = "itunes";
|
||||||
|
}
|
||||||
|
_showFiltersFor(cs.source);
|
||||||
|
|
||||||
|
if ($("#charts-itunes-country")) $("#charts-itunes-country").value = cs.country;
|
||||||
|
if ($("#charts-spotify-playlist")) $("#charts-spotify-playlist").value = cs.playlist;
|
||||||
|
if ($("#charts-lastfm-decade")) $("#charts-lastfm-decade").value = cs.decade;
|
||||||
|
if ($("#charts-lastfm-genre")) $("#charts-lastfm-genre").value = cs.genre;
|
||||||
|
|
||||||
|
// Bind eventi source radio
|
||||||
|
document.querySelectorAll('input[name="charts-source"]').forEach((r) => {
|
||||||
|
r.addEventListener("change", () => {
|
||||||
|
if (!r.checked) return;
|
||||||
|
cs.source = r.value;
|
||||||
|
_showFiltersFor(cs.source);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bind filtri
|
||||||
|
$("#charts-itunes-country")?.addEventListener("change", (e) => {
|
||||||
|
cs.country = e.target.value;
|
||||||
|
});
|
||||||
|
$("#charts-spotify-playlist")?.addEventListener("change", (e) => {
|
||||||
|
cs.playlist = e.target.value;
|
||||||
|
});
|
||||||
|
$("#charts-lastfm-decade")?.addEventListener("change", (e) => {
|
||||||
|
cs.decade = e.target.value;
|
||||||
|
});
|
||||||
|
$("#charts-lastfm-genre")?.addEventListener("change", (e) => {
|
||||||
|
cs.genre = e.target.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch, seleziona, aggiungi
|
||||||
|
$("#charts-fetch-btn")?.addEventListener("click", doFetch);
|
||||||
|
$("#charts-add-queue-btn")?.addEventListener("click", addToQueue);
|
||||||
|
$("#charts-select-all-btn")?.addEventListener("click", () => _selectAll(true));
|
||||||
|
$("#charts-deselect-all-btn")?.addEventListener("click", () => _selectAll(false));
|
||||||
|
$("#charts-select-all")?.addEventListener("change", (e) => _selectAll(!!e.target.checked));
|
||||||
|
|
||||||
|
// Bridge handler: quando l'utente aggiunge alla coda, popola la tab Scarica.
|
||||||
|
bridgeHandlers["charts:add_to_queue"] = (p) => {
|
||||||
|
if (!p) return;
|
||||||
|
const tracks = Array.isArray(p.tracks) ? p.tracks : null;
|
||||||
|
const text = typeof p.text === "string" ? p.text : "";
|
||||||
|
const count = Number(p.count) || (tracks ? tracks.length : 0);
|
||||||
|
|
||||||
|
// Popola state.loaded come "tracks" (stesso pattern del "Carica lista")
|
||||||
|
if (tracks && tracks.length) {
|
||||||
|
state.loaded = {
|
||||||
|
ok: true,
|
||||||
|
kind: "tracks",
|
||||||
|
tracks: tracks,
|
||||||
|
count: tracks.length,
|
||||||
|
name: "charts",
|
||||||
|
};
|
||||||
|
// Aggiorna il campo URL e il badge sulla tab Scarica
|
||||||
|
const urlInput = $("#urlInput");
|
||||||
|
if (urlInput) {
|
||||||
|
urlInput.value = "[Charts] " + count + " brani";
|
||||||
|
}
|
||||||
|
const badge = $("#urlListBadge");
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = `· ${count} tracce`;
|
||||||
|
badge.hidden = false;
|
||||||
|
}
|
||||||
|
appendLog("download", `[INFO] Caricati ${count} brani dalla tab Charts`);
|
||||||
|
} else if (text) {
|
||||||
|
// Fallback: nessun tracks strutturati, usa il testo grezzo
|
||||||
|
const urlInput = $("#urlInput");
|
||||||
|
if (urlInput) {
|
||||||
|
urlInput.value = urlInput.value ? urlInput.value + "\n" + text : text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Naviga alla tab Scarica
|
||||||
|
showView("download");
|
||||||
|
toast(`${count} brani aggiunti alla coda Scarica`, "success");
|
||||||
|
};
|
||||||
|
|
||||||
|
_updateSelectedCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init };
|
||||||
|
})();
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Boot
|
// Boot
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in new issue
Block a user