Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c88e6d359c | ||
|
|
ed1059bf8c | ||
|
|
cdbb1d85d5 | ||
|
|
401bed967a | ||
|
|
9b711fd545 | ||
|
|
9f9e4281b7 | ||
|
|
39a117f08b | ||
|
|
e7d11d0235 | ||
|
|
00ff5a6220 | ||
|
|
e90e38edc8 | ||
|
|
f18dd375e8 | ||
|
|
fb13b0f70e |
No files matched your search
@@ -49,3 +49,12 @@ server/node_modules/
|
||||
server/.wrangler/
|
||||
server/.dev.vars
|
||||
server/dist/
|
||||
|
||||
# Dedup local cache (SQLite fingerprint cache, per macchina dell'utente)
|
||||
dedup_cache.db
|
||||
dedup_cache.db-journal
|
||||
catalog_cache.db
|
||||
catalog_cache.db-journal
|
||||
|
||||
# Log persistente (rotating file)
|
||||
logs/
|
||||
+443
@@ -46,6 +46,9 @@ from core.upgrader import (
|
||||
request_stop as request_upgrade_stop,
|
||||
count_files_info,
|
||||
)
|
||||
from core import dedup
|
||||
from core import catalog
|
||||
from core import flatten
|
||||
|
||||
|
||||
SPOTIFY_GUIDE_TEXT = """\
|
||||
@@ -111,6 +114,9 @@ class Api:
|
||||
self._download_thread: Optional[threading.Thread] = None
|
||||
self._upgrade_thread: Optional[threading.Thread] = None
|
||||
self._video_thread: Optional[threading.Thread] = None
|
||||
self._dedup_thread: Optional[threading.Thread] = None
|
||||
self._catalog_thread: Optional[threading.Thread] = None
|
||||
self._flatten_thread: Optional[threading.Thread] = None
|
||||
# Coda usata dal resolve_callback per attendere la scelta utente
|
||||
# sul modal "match locali multipli" della tab Upgrade.
|
||||
self._upgrade_resolve_q: "queue.Queue[dict]" = queue.Queue(1)
|
||||
@@ -130,7 +136,40 @@ class Api:
|
||||
pass
|
||||
|
||||
def _log(self, view: str, msg: str) -> None:
|
||||
# Emit alla UI + mirror sul file di log persistente
|
||||
self._emit("log", {"view": view, "msg": msg})
|
||||
try:
|
||||
import logging
|
||||
logging.getLogger(f"ui.{view}").info(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Log persistente
|
||||
# ------------------------------------------------------------------
|
||||
def get_log_file_path(self) -> str:
|
||||
"""Ritorna il path del file di log corrente (per apertura da UI)."""
|
||||
try:
|
||||
from core.applog import log_file_path
|
||||
return str(log_file_path())
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def open_log_folder(self) -> dict:
|
||||
"""Apre la cartella dei log nel file manager del sistema."""
|
||||
try:
|
||||
from core.applog import log_file_path
|
||||
import subprocess
|
||||
folder = log_file_path().parent
|
||||
if sys.platform == "darwin":
|
||||
subprocess.Popen(["open", str(folder)])
|
||||
elif sys.platform == "win32":
|
||||
os.startfile(str(folder)) # type: ignore[attr-defined]
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", str(folder)])
|
||||
return {"ok": True, "path": str(folder)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bootstrap
|
||||
@@ -168,6 +207,7 @@ class Api:
|
||||
"bitrate": payload.get("bitrate", "320K"),
|
||||
"hq_threshold": threshold,
|
||||
"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(),
|
||||
"theme": payload.get("theme", "dark"),
|
||||
})
|
||||
@@ -1820,3 +1860,406 @@ class Api:
|
||||
})
|
||||
|
||||
self._emit("convert:done", {"ok": True})
|
||||
|
||||
# ================================================================
|
||||
# DEDUP — audio duplicati via Chromaprint fingerprinting
|
||||
# ================================================================
|
||||
def dedup_pick_folder(self) -> str:
|
||||
"""Folder picker per la cartella da scansionare."""
|
||||
return self.browse_directory()
|
||||
|
||||
def dedup_start_scan(self, payload: dict) -> dict:
|
||||
"""Avvia worker di scansione. Payload: {directory, recursive}.
|
||||
|
||||
Salva `dedup_last_folder` e `dedup_recursive` in config per la
|
||||
prossima apertura del tab.
|
||||
"""
|
||||
if self._dedup_thread and self._dedup_thread.is_alive():
|
||||
return {"ok": False, "error": "Scansione dedup gia in corso"}
|
||||
|
||||
directory = (payload.get("directory") or "").strip()
|
||||
recursive = bool(payload.get("recursive", True))
|
||||
method = (payload.get("method") or "fingerprint").strip()
|
||||
if method not in ("fingerprint", "filename"):
|
||||
method = "fingerprint"
|
||||
|
||||
if not directory:
|
||||
return {"ok": False, "error": "Cartella non impostata"}
|
||||
if not os.path.isdir(directory):
|
||||
return {"ok": False, "error": "Cartella non trovata"}
|
||||
|
||||
# Persist last folder/recursive/method
|
||||
try:
|
||||
cfg = load_config()
|
||||
cfg["dedup_last_folder"] = directory
|
||||
cfg["dedup_recursive"] = recursive
|
||||
cfg["dedup_method"] = method
|
||||
save_config(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._dedup_thread = threading.Thread(
|
||||
target=self._dedup_worker,
|
||||
args=(directory, recursive, method),
|
||||
daemon=True,
|
||||
)
|
||||
self._dedup_thread.start()
|
||||
return {"ok": True}
|
||||
|
||||
def dedup_stop_scan(self) -> dict:
|
||||
dedup.request_stop()
|
||||
self._log("dedup", "[INFO] Interruzione richiesta...")
|
||||
return {"ok": True}
|
||||
|
||||
def dedup_move_to_trash(self, paths: list) -> dict:
|
||||
"""Sposta i file in cestino via send2trash. Non consuma quota."""
|
||||
if not isinstance(paths, list):
|
||||
return {"ok": False, "error": "paths deve essere una lista"}
|
||||
# Sanitize: solo str non vuote
|
||||
clean = [str(p).strip() for p in paths if p and str(p).strip()]
|
||||
if not clean:
|
||||
return {"ok": False, "error": "Nessun file da cancellare"}
|
||||
|
||||
result = dedup.move_to_trash(clean)
|
||||
moved_n = len(result.get("moved", []))
|
||||
failed_n = len(result.get("failed", []))
|
||||
if moved_n:
|
||||
self._log("dedup", f"[OK] {moved_n} file spostati nel cestino")
|
||||
for f in result.get("failed", []):
|
||||
self._log("dedup",
|
||||
f"[ERRORE] {f.get('path')}: {f.get('error')}")
|
||||
return {"ok": True, "moved": result.get("moved", []),
|
||||
"failed": result.get("failed", []),
|
||||
"moved_count": moved_n, "failed_count": failed_n}
|
||||
|
||||
def _dedup_worker(self, directory: str, recursive: bool,
|
||||
method: str = "fingerprint") -> None:
|
||||
"""Esegue la scansione in background e emette progress/done."""
|
||||
view = "dedup"
|
||||
dedup.reset_stop()
|
||||
method_label = "audio fingerprint" if method == "fingerprint" else "nome file"
|
||||
self._log(view, f"[INFO] Scansione: {directory} (metodo: {method_label}, 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'/'cached' (che possono essere migliaia)
|
||||
if status in ("computing", "cached"):
|
||||
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("dedup:progress", payload_evt)
|
||||
|
||||
def group_cb(group: dict) -> None:
|
||||
"""Emit streaming: appena un gruppo raggiunge/aggiorna >=2 file."""
|
||||
try:
|
||||
self._emit("dedup:group", group)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
groups = dedup.scan_folder(directory, recursive=recursive,
|
||||
progress_callback=progress_cb,
|
||||
method=method,
|
||||
group_callback=group_cb)
|
||||
except Exception as e:
|
||||
self._log(view, f"[ERRORE] {e}")
|
||||
self._emit("dedup:done", {"ok": False, "error": str(e),
|
||||
"groups": []})
|
||||
return
|
||||
|
||||
n_groups = len(groups)
|
||||
n_dupes = sum(max(0, len(g) - 1) for g in groups)
|
||||
total_bytes = sum(sum(int(e.get("size") or 0) for e in g[1:])
|
||||
for g in groups)
|
||||
self._log(view,
|
||||
f"[INFO] Gruppi: {n_groups} — duplicati: {n_dupes} — "
|
||||
f"spazio recuperabile: ~{total_bytes // 1024 // 1024} MB")
|
||||
self._emit("dedup:done", {
|
||||
"ok": True,
|
||||
"groups": groups,
|
||||
"n_groups": n_groups,
|
||||
"n_dupes": n_dupes,
|
||||
"reclaimable_bytes": total_bytes,
|
||||
})
|
||||
|
||||
# ================================================================
|
||||
# CATALOGA — sposta file audio in <output>/<Anno>/<Genere>/
|
||||
# via Chromaprint + AcoustID.
|
||||
# ================================================================
|
||||
def catalog_pick_source_folder(self) -> str:
|
||||
"""Folder picker per la cartella sorgente da catalogare."""
|
||||
return self.browse_directory()
|
||||
|
||||
def catalog_pick_target_folder(self) -> str:
|
||||
"""Folder picker per la cartella target (dove finiscono i file
|
||||
organizzati). Se l'utente lascia vuoto → si usa la source."""
|
||||
return self.browse_directory()
|
||||
|
||||
def catalog_start_scan(self, payload: dict) -> dict:
|
||||
"""Avvia worker di scansione + lookup AcoustID. Payload:
|
||||
{source, target?, recursive}. Persiste last source/target/recursive.
|
||||
"""
|
||||
if self._catalog_thread and self._catalog_thread.is_alive():
|
||||
return {"ok": False, "error": "Scansione catalogo gia in corso"}
|
||||
|
||||
source = (payload.get("source") or "").strip()
|
||||
target = (payload.get("target") or "").strip()
|
||||
recursive = bool(payload.get("recursive", True))
|
||||
|
||||
if not source:
|
||||
return {"ok": False, "error": "Cartella input non impostata"}
|
||||
if not os.path.isdir(source):
|
||||
return {"ok": False, "error": "Cartella input non trovata"}
|
||||
# target vuoto → sposta in-place nella source
|
||||
if not target:
|
||||
target = source
|
||||
|
||||
# Persist last source/target/recursive
|
||||
try:
|
||||
cfg = load_config()
|
||||
cfg["catalog_last_source"] = source
|
||||
cfg["catalog_last_target"] = target
|
||||
cfg["catalog_recursive"] = recursive
|
||||
save_config(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._catalog_thread = threading.Thread(
|
||||
target=self._catalog_worker,
|
||||
args=(source, recursive),
|
||||
daemon=True,
|
||||
)
|
||||
self._catalog_thread.start()
|
||||
return {"ok": True}
|
||||
|
||||
def catalog_stop_scan(self) -> dict:
|
||||
catalog.request_stop()
|
||||
self._log("catalog", "[INFO] Interruzione richiesta...")
|
||||
return {"ok": True}
|
||||
|
||||
def catalog_move_files(self, entries: list, target_root: str, layout: str = "year_genre") -> dict:
|
||||
"""Sposta le entry selezionate in <target_root>/<year>/<genre>/.
|
||||
|
||||
Non consuma quota (e' una riorganizzazione di file locali,
|
||||
non un download). Ritorna il summary con moved/failed/operations.
|
||||
"""
|
||||
if not isinstance(entries, list):
|
||||
return {"ok": False, "error": "entries deve essere una lista"}
|
||||
target = (target_root or "").strip()
|
||||
if not target:
|
||||
return {"ok": False, "error": "Cartella destinazione mancante"}
|
||||
|
||||
def log_cb(op: dict) -> None:
|
||||
src = op.get("src", "")
|
||||
dst = op.get("dst", "")
|
||||
self._log("catalog", f"[MOVE] {src} → {dst}")
|
||||
try:
|
||||
self._emit("catalog:log", op)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
safe_layout = layout if layout in ("year_genre", "genre_year") else "year_genre"
|
||||
result = catalog.move_files(entries, target, log_callback=log_cb, layout=safe_layout)
|
||||
except Exception as e:
|
||||
self._log("catalog", f"[ERRORE] move: {e}")
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
moved_n = result.get("moved", 0)
|
||||
failed = result.get("failed", []) or []
|
||||
if moved_n:
|
||||
self._log("catalog", f"[OK] {moved_n} file organizzati")
|
||||
for f in failed:
|
||||
self._log("catalog",
|
||||
f"[ERRORE] {f.get('path')}: {f.get('error')}")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"moved": moved_n,
|
||||
"failed_count": len(failed),
|
||||
"failed": failed,
|
||||
"operations": result.get("operations", []),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FLATTEN — sposta tutti i file audio delle sotto-cartelle nella root
|
||||
# ------------------------------------------------------------------
|
||||
def flatten_pick_folder(self) -> str:
|
||||
if not self.window:
|
||||
return ""
|
||||
try:
|
||||
r = self.window.create_file_dialog(self._folder_dialog_type())
|
||||
except Exception:
|
||||
return ""
|
||||
if not r:
|
||||
return ""
|
||||
return str(r[0]) if isinstance(r, (list, tuple)) else str(r)
|
||||
|
||||
def flatten_start(self, payload: dict) -> dict:
|
||||
"""Payload: {directory, remove_empty}."""
|
||||
if self._flatten_thread and self._flatten_thread.is_alive():
|
||||
return {"ok": False, "error": "Operazione gia' in corso"}
|
||||
directory = (payload.get("directory") or "").strip()
|
||||
if not directory:
|
||||
return {"ok": False, "error": "Cartella non impostata"}
|
||||
if not os.path.isdir(directory):
|
||||
return {"ok": False, "error": "Cartella non trovata"}
|
||||
remove_empty = bool(payload.get("remove_empty", True))
|
||||
self._flatten_thread = threading.Thread(
|
||||
target=self._flatten_worker,
|
||||
args=(directory, remove_empty),
|
||||
daemon=True,
|
||||
)
|
||||
self._flatten_thread.start()
|
||||
return {"ok": True}
|
||||
|
||||
def flatten_stop(self) -> dict:
|
||||
flatten.request_stop()
|
||||
return {"ok": True}
|
||||
|
||||
def _flatten_worker(self, directory: str, remove_empty: bool) -> None:
|
||||
view = "flatten"
|
||||
self._log(view, f"[INFO] Appiattisco: {directory} (remove_empty={remove_empty})")
|
||||
|
||||
def progress_cb(idx, total, name, status, err=""):
|
||||
payload = {"idx": idx, "total": total, "filename": name, "status": status}
|
||||
if err:
|
||||
payload["error_msg"] = err
|
||||
if total > 0:
|
||||
payload["overall"] = min(idx / total, 1.0)
|
||||
if status == "completed":
|
||||
payload["overall"] = 1.0
|
||||
self._log(view, f"[INFO] Completato: {total} file processati.")
|
||||
elif status == "stopped":
|
||||
self._log(view, "[INFO] Operazione interrotta.")
|
||||
elif status == "error" and name:
|
||||
self._log(view, f"[ERRORE] {name}: {err}")
|
||||
self._emit("flatten:progress", payload)
|
||||
|
||||
def log_cb(op):
|
||||
self._log(view, f"[MOVE] {op.get('src','')} → {op.get('dst','')}")
|
||||
|
||||
try:
|
||||
result = flatten.flatten_folder(
|
||||
directory,
|
||||
remove_empty=remove_empty,
|
||||
progress_callback=progress_cb,
|
||||
log_callback=log_cb,
|
||||
)
|
||||
except Exception as e:
|
||||
self._log(view, f"[ERRORE] {e}")
|
||||
self._emit("flatten:done", {"ok": False, "error": str(e)})
|
||||
return
|
||||
|
||||
moved = result.get("moved", 0)
|
||||
failed = result.get("failed", []) or []
|
||||
dirs_removed = result.get("dirs_removed", 0)
|
||||
self._log(view, f"[OK] Spostati {moved} file. Cartelle rimosse: {dirs_removed}. Falliti: {len(failed)}.")
|
||||
self._emit("flatten:done", {
|
||||
"ok": True,
|
||||
"moved": moved,
|
||||
"failed_count": len(failed),
|
||||
"failed": failed,
|
||||
"dirs_removed": dirs_removed,
|
||||
})
|
||||
|
||||
def _catalog_worker(self, directory: str, recursive: bool) -> None:
|
||||
"""Esegue scan + lookup AcoustID in background, emette streaming."""
|
||||
view = "catalog"
|
||||
catalog.reset_stop()
|
||||
self._log(view,
|
||||
f"[INFO] Scansione: {directory} (recursive={recursive})")
|
||||
|
||||
_last = [0.0]
|
||||
_THROTTLE = 0.05
|
||||
|
||||
def progress_cb(idx: int, total: int, filename: str,
|
||||
status: str, err_msg: str = "") -> None:
|
||||
# Throttle solo eventi "computing"/"lookup" (possono essere tanti)
|
||||
if status in ("computing", "lookup"):
|
||||
now = time.monotonic()
|
||||
if now - _last[0] < _THROTTLE and idx != total:
|
||||
return
|
||||
_last[0] = now
|
||||
payload_evt = {
|
||||
"idx": idx, "total": total,
|
||||
"filename": filename, "status": status,
|
||||
}
|
||||
if err_msg:
|
||||
payload_evt["error_msg"] = err_msg
|
||||
if total > 0:
|
||||
payload_evt["overall"] = min(idx / total, 1.0)
|
||||
if status == "completed":
|
||||
payload_evt["overall"] = 1.0
|
||||
self._log(view, f"[INFO] Scansione completata ({total} file).")
|
||||
elif status == "stopped":
|
||||
self._log(view, "[INFO] Scansione interrotta.")
|
||||
elif status == "error" and filename:
|
||||
detail = f": {err_msg}" if err_msg else ""
|
||||
self._log(view, f"[ERRORE] {filename}{detail}")
|
||||
self._emit("catalog:progress", payload_evt)
|
||||
|
||||
def entry_cb(entry: dict) -> None:
|
||||
"""Streaming: appena un file è processato, emit alla UI."""
|
||||
try:
|
||||
self._emit("catalog:entry", entry)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Prova a ottenere un token Spotify per il fallback (facoltativo).
|
||||
# Se non ci sono creds o l'auth fallisce, procediamo senza fallback.
|
||||
spotify_token = None
|
||||
try:
|
||||
cfg = load_config()
|
||||
cid = (cfg.get("client_id") or "").strip()
|
||||
csecret = (cfg.get("client_secret") or "").strip()
|
||||
if cid and csecret:
|
||||
spotify_token = get_access_token(cid, csecret)
|
||||
except Exception:
|
||||
spotify_token = None
|
||||
|
||||
try:
|
||||
entries = catalog.scan_folder(
|
||||
directory,
|
||||
recursive=recursive,
|
||||
progress_callback=progress_cb,
|
||||
entry_callback=entry_cb,
|
||||
spotify_token=spotify_token,
|
||||
)
|
||||
except Exception as e:
|
||||
self._log(view, f"[ERRORE] {e}")
|
||||
self._emit("catalog:done", {"ok": False, "error": str(e),
|
||||
"entries": []})
|
||||
return
|
||||
|
||||
n_matched = sum(1 for e in entries if e.get("matched"))
|
||||
n_unmatched = len(entries) - n_matched
|
||||
self._log(view,
|
||||
f"[INFO] Match AcoustID: {n_matched} · non identificati: "
|
||||
f"{n_unmatched} (totale {len(entries)})")
|
||||
self._emit("catalog:done", {
|
||||
"ok": True,
|
||||
"entries": entries,
|
||||
"n_total": len(entries),
|
||||
"n_matched": n_matched,
|
||||
"n_unmatched": n_unmatched,
|
||||
})
|
||||
+53
-8
@@ -39,21 +39,23 @@ def log(msg):
|
||||
# 1. Scarica yt-dlp standalone
|
||||
# =========================================================================
|
||||
def download_ytdlp():
|
||||
"""Scarica il binario standalone di yt-dlp per macOS."""
|
||||
"""Scarica il binario standalone di yt-dlp per macOS.
|
||||
|
||||
Usa il NIGHTLY channel invece dello stable perche' YouTube rompe le
|
||||
API ogni 1-2 settimane e la stable resta indietro di 3-4 settimane
|
||||
tipicamente. La nightly esce quasi ogni giorno con i fix.
|
||||
Sempre re-download: garantisce che ogni build imbarca yt-dlp fresco.
|
||||
"""
|
||||
arch = platform.machine() # arm64 o x86_64
|
||||
if arch == "arm64":
|
||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
|
||||
url = "https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download/yt-dlp_macos"
|
||||
else:
|
||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos_legacy"
|
||||
url = "https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download/yt-dlp_macos_legacy"
|
||||
|
||||
dest = BUNDLE_DIR / "yt-dlp"
|
||||
if dest.exists():
|
||||
log(f"yt-dlp gia presente: {dest}")
|
||||
return dest
|
||||
|
||||
log(f"Scarico yt-dlp da {url} ...")
|
||||
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
log(f"Scarico yt-dlp nightly da {url} ...")
|
||||
# Usa curl perche urllib di Python 3.8 ha problemi SSL su macOS
|
||||
subprocess.run(
|
||||
["curl", "-L", "-o", str(dest), url],
|
||||
@@ -64,6 +66,48 @@ def download_ytdlp():
|
||||
return dest
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 1b. Scarica fpcalc (Chromaprint) — usato dal tab Dedup
|
||||
# =========================================================================
|
||||
def download_fpcalc():
|
||||
"""Scarica il binario universal Chromaprint fpcalc per macOS."""
|
||||
dest = BUNDLE_DIR / "fpcalc"
|
||||
if dest.exists():
|
||||
log(f"fpcalc gia presente: {dest}")
|
||||
return dest
|
||||
|
||||
url = ("https://github.com/acoustid/chromaprint/releases/download/"
|
||||
"v1.5.1/chromaprint-fpcalc-1.5.1-macos-universal.tar.gz")
|
||||
log(f"Scarico fpcalc da {url} ...")
|
||||
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
tmp_tar = BUNDLE_DIR / "_fpcalc.tar.gz"
|
||||
subprocess.run(["curl", "-L", "-o", str(tmp_tar), url], check=True)
|
||||
# Estrae ovunque nella cartella, poi trova fpcalc e lo sposta al posto
|
||||
tmp_extract = BUNDLE_DIR / "_fpcalc_extract"
|
||||
if tmp_extract.exists():
|
||||
shutil.rmtree(tmp_extract)
|
||||
tmp_extract.mkdir()
|
||||
subprocess.run(["tar", "-xzf", str(tmp_tar), "-C", str(tmp_extract)],
|
||||
check=True)
|
||||
# Trova fpcalc nel folder estratto
|
||||
found = None
|
||||
for p in tmp_extract.rglob("fpcalc"):
|
||||
if p.is_file():
|
||||
found = p
|
||||
break
|
||||
if not found:
|
||||
shutil.rmtree(tmp_extract, ignore_errors=True)
|
||||
tmp_tar.unlink(missing_ok=True)
|
||||
raise RuntimeError("fpcalc non trovato nell'archivio")
|
||||
shutil.copy2(found, dest)
|
||||
dest.chmod(dest.stat().st_mode | stat.S_IEXEC)
|
||||
shutil.rmtree(tmp_extract, ignore_errors=True)
|
||||
tmp_tar.unlink(missing_ok=True)
|
||||
log(f"fpcalc scaricato: {dest}")
|
||||
return dest
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 2. Raccogli ffmpeg/ffprobe + dylib
|
||||
# =========================================================================
|
||||
@@ -511,6 +555,7 @@ def main():
|
||||
shutil.rmtree(BUNDLE_DIR)
|
||||
|
||||
download_ytdlp()
|
||||
download_fpcalc()
|
||||
bundle_ffmpeg()
|
||||
run_pyinstaller()
|
||||
|
||||
|
||||
+34
-6
@@ -29,8 +29,10 @@ BUILD_DIR = ROOT / "build"
|
||||
DIST_DIR = ROOT / "dist"
|
||||
BUNDLE_DIR = ROOT / "bundle_bin"
|
||||
|
||||
YTDLP_URL = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
|
||||
YTDLP_URL = "https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download/yt-dlp.exe"
|
||||
FFMPEG_URL = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
|
||||
FPCALC_URL = ("https://github.com/acoustid/chromaprint/releases/download/"
|
||||
"v1.5.1/chromaprint-fpcalc-1.5.1-windows-x86_64.zip")
|
||||
|
||||
|
||||
def log(msg):
|
||||
@@ -41,13 +43,11 @@ def log(msg):
|
||||
# 1. Scarica yt-dlp.exe
|
||||
# =========================================================================
|
||||
def download_ytdlp():
|
||||
"""Scarica sempre il nightly fresco: la stable resta indietro di
|
||||
3-4 settimane rispetto alle rotture API YouTube."""
|
||||
dest = BUNDLE_DIR / "yt-dlp.exe"
|
||||
if dest.exists():
|
||||
log(f"yt-dlp.exe gia presente: {dest}")
|
||||
return dest
|
||||
|
||||
log(f"Scarico yt-dlp.exe ...")
|
||||
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log(f"Scarico yt-dlp.exe nightly ...")
|
||||
urllib.request.urlretrieve(YTDLP_URL, dest)
|
||||
log(f"yt-dlp.exe scaricato ({dest.stat().st_size // 1024 // 1024} MB)")
|
||||
return dest
|
||||
@@ -87,6 +87,33 @@ def download_ffmpeg():
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 2b. Scarica fpcalc.exe (Chromaprint) — usato dal tab Dedup
|
||||
# =========================================================================
|
||||
def download_fpcalc():
|
||||
dest = BUNDLE_DIR / "fpcalc.exe"
|
||||
if dest.exists():
|
||||
log("fpcalc.exe gia presente")
|
||||
return
|
||||
|
||||
log(f"Scarico fpcalc.exe ...")
|
||||
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
response = urllib.request.urlopen(FPCALC_URL)
|
||||
zip_data = io.BytesIO(response.read())
|
||||
with zipfile.ZipFile(zip_data) as zf:
|
||||
for member in zf.namelist():
|
||||
basename = Path(member).name
|
||||
if basename == "fpcalc.exe":
|
||||
data = zf.read(member)
|
||||
dest.write_bytes(data)
|
||||
log(f" Estratto: fpcalc.exe ({len(data) // 1024} KB)")
|
||||
|
||||
if not dest.exists():
|
||||
print("ERRORE: fpcalc.exe non trovato nello zip!")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 3. PyInstaller
|
||||
# =========================================================================
|
||||
@@ -187,6 +214,7 @@ def main():
|
||||
|
||||
download_ytdlp()
|
||||
download_ffmpeg()
|
||||
download_fpcalc()
|
||||
run_pyinstaller()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
|
||||
@@ -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
|
||||
+11
-1
@@ -5,7 +5,7 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "v1.9.5"
|
||||
VERSION = "v1.11.2"
|
||||
|
||||
|
||||
APP_NAME = "MusicTools"
|
||||
@@ -68,6 +68,7 @@ DEFAULTS = {
|
||||
"bitrate": "320K",
|
||||
"hq_threshold": 310,
|
||||
"cookies_path": str(_project_dir / "cookies.txt"),
|
||||
"cookies_browser": "", # "" | chrome | safari | firefox | edge | brave
|
||||
"output_dir": str(_project_dir / "MUSICA"),
|
||||
"theme": "dark",
|
||||
# ---- Beatport ----
|
||||
@@ -78,6 +79,15 @@ DEFAULTS = {
|
||||
"spotify_search_last_query": "",
|
||||
"spotify_search_artist_mode": False,
|
||||
"youtube_search_last_query": "",
|
||||
# ---- Dedup (audio duplicati via Chromaprint) ----
|
||||
"dedup_last_folder": "",
|
||||
"dedup_recursive": True,
|
||||
"dedup_method": "fingerprint", # "fingerprint" | "filename"
|
||||
# ---- Cataloga (AcoustID -> <Anno>/<Genere>/) ----
|
||||
"catalog_last_source": "",
|
||||
"catalog_last_target": "",
|
||||
"catalog_recursive": True,
|
||||
"acoustid_app_key": "", # opzionale: se vuoto usa default in core/acoustid.py
|
||||
# ---- Licenza ----
|
||||
"license_key": "", # chiave fornita all'utente via email
|
||||
"license_email": "", # email associata all'acquisto
|
||||
|
||||
+518
@@ -0,0 +1,518 @@
|
||||
"""Deduplicator audio via Chromaprint fingerprinting + SQLite cache.
|
||||
|
||||
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 -json`).
|
||||
Il valore viene messo in cache SQLite: al re-scan, se
|
||||
(size, mtime) coincide col record, riusiamo il fingerprint senza
|
||||
rilanciare fpcalc.
|
||||
3. Raggruppa i file per fingerprint identico (>= 2 file). Per ogni
|
||||
gruppo, i file vengono ordinati per bitrate DESC (tie-break: size
|
||||
DESC): il primo e' quello "da tenere", gli altri i duplicati.
|
||||
4. `move_to_trash` invia i path selezionati al cestino di sistema
|
||||
tramite send2trash (reversibile via Finder/Explorer).
|
||||
|
||||
Progress callback firma:
|
||||
(processed: int, total: int, filename: str, status: str)
|
||||
Status validi: 'scanning' | 'computing' | 'cached' | 'error' | 'stopped'
|
||||
| 'completed'.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from core.paths import find_fpcalc, subprocess_flags
|
||||
from core.upgrader import AUDIO_EXTENSIONS, get_bitrate
|
||||
|
||||
|
||||
# Timeout massimo per una singola invocazione fpcalc.
|
||||
_FPCALC_TIMEOUT_SEC = 30
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stop / interrupt
|
||||
# ------------------------------------------------------------------
|
||||
_stop_event = threading.Event()
|
||||
|
||||
|
||||
def request_stop() -> None:
|
||||
"""Segnala al worker di interrompere la scansione al prossimo file."""
|
||||
_stop_event.set()
|
||||
|
||||
|
||||
def reset_stop() -> None:
|
||||
"""Azzera il flag di stop prima di iniziare una nuova scansione."""
|
||||
_stop_event.clear()
|
||||
|
||||
|
||||
def is_stopped() -> bool:
|
||||
return _stop_event.is_set()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cache SQLite
|
||||
# ------------------------------------------------------------------
|
||||
def _cache_db_path() -> Path:
|
||||
"""Path del DB di cache dei fingerprint.
|
||||
|
||||
Riusa `_get_config_dir` di core.config cosi' finisce nella stessa
|
||||
cartella di config.json (~/Library/Application Support/MusicTools/
|
||||
su macOS, %APPDATA%/MusicTools/ su Windows, project root in dev).
|
||||
"""
|
||||
from core.config import _get_config_dir
|
||||
return _get_config_dir() / "dedup_cache.db"
|
||||
|
||||
|
||||
def _init_db(conn: sqlite3.Connection) -> None:
|
||||
"""Crea (idempotente) lo schema della cache."""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
path TEXT PRIMARY KEY,
|
||||
size INTEGER NOT NULL,
|
||||
mtime REAL NOT NULL,
|
||||
duration REAL,
|
||||
fingerprint TEXT,
|
||||
bitrate INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _open_cache(db_path: Optional[Path] = None) -> sqlite3.Connection:
|
||||
"""Apre (creando se serve) la connessione alla cache."""
|
||||
p = db_path or _cache_db_path()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(p))
|
||||
_init_db(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def _cache_get(conn: sqlite3.Connection, path: str,
|
||||
size: int, mtime: float) -> Optional[dict]:
|
||||
"""Ritorna il record se (size, mtime) invariato, altrimenti None."""
|
||||
cur = conn.execute(
|
||||
"SELECT size, mtime, duration, fingerprint, bitrate FROM files WHERE path = ?",
|
||||
(path,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
csize, cmtime, dur, fp, br = row
|
||||
# Tolleranza minima sul mtime (float precision su alcuni FS)
|
||||
if csize != size or abs(float(cmtime) - float(mtime)) > 0.001:
|
||||
return None
|
||||
if not fp:
|
||||
return None
|
||||
return {
|
||||
"size": int(csize),
|
||||
"mtime": float(cmtime),
|
||||
"duration": float(dur) if dur is not None else 0.0,
|
||||
"fingerprint": str(fp),
|
||||
"bitrate": int(br) if br is not None else 0,
|
||||
}
|
||||
|
||||
|
||||
def _cache_put(conn: sqlite3.Connection, path: str, size: int, mtime: float,
|
||||
duration: float, fingerprint: str, bitrate: int) -> None:
|
||||
"""Upsert (SQLite ha ON CONFLICT REPLACE via INSERT OR REPLACE)."""
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO files (path, size, mtime, duration, fingerprint, bitrate)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(path, int(size), float(mtime), float(duration or 0),
|
||||
str(fingerprint or ""), int(bitrate or 0)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# fpcalc
|
||||
# ------------------------------------------------------------------
|
||||
def _run_fpcalc(fpcalc: str, path: str, length: Optional[int] = None) -> dict:
|
||||
"""Esegue fpcalc una volta. Ritorna {duration, fingerprint} su successo
|
||||
o {_error: str} su fallimento."""
|
||||
cmd = [fpcalc, "-json"]
|
||||
if length is not None:
|
||||
cmd += ["-length", str(length)]
|
||||
cmd.append(str(path))
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_FPCALC_TIMEOUT_SEC,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"_error": f"timeout {_FPCALC_TIMEOUT_SEC}s"}
|
||||
except (OSError, ValueError) as e:
|
||||
return {"_error": f"subprocess: {e}"}
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "").strip().splitlines()
|
||||
msg = err[-1] if err else f"exit {proc.returncode}"
|
||||
return {"_error": msg[:200]}
|
||||
try:
|
||||
data = json.loads(proc.stdout or "{}")
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
return {"_error": f"JSON malformato: {e}"}
|
||||
fp = data.get("fingerprint")
|
||||
if not fp:
|
||||
return {"_error": "fingerprint vuoto (audio troppo corto?)"}
|
||||
try:
|
||||
dur = float(data.get("duration") or 0)
|
||||
except (TypeError, ValueError):
|
||||
dur = 0.0
|
||||
return {"duration": dur, "fingerprint": str(fp)}
|
||||
|
||||
|
||||
def compute_fingerprint(fpcalc: str, path: str) -> Optional[dict]:
|
||||
"""Chiama fpcalc e ritorna {duration, fingerprint} o {_error}.
|
||||
|
||||
Se il primo tentativo (full length) fallisce con "Invalid data" o simili
|
||||
(frame audio corrotti che libav rifiuta), riprova con `-length 30`.
|
||||
Molti file danneggiati hanno i frame corrotti nella parte finale e
|
||||
limitando la scansione ai primi 30s si riesce a estrarre comunque
|
||||
un fingerprint affidabile (30s bastano per l'unicità Chromaprint).
|
||||
"""
|
||||
if not fpcalc:
|
||||
return {"_error": "fpcalc non trovato nel bundle"}
|
||||
|
||||
res = _run_fpcalc(fpcalc, path)
|
||||
if "fingerprint" in res:
|
||||
return res
|
||||
|
||||
err_msg = res.get("_error", "").lower()
|
||||
|
||||
# Retry 1: frame audio corrotti → riduci finestra a 30s
|
||||
corrupt_signals = ("invalid data", "decoding audio frame",
|
||||
"error while decoding", "invalid frame")
|
||||
if any(sig in err_msg for sig in corrupt_signals):
|
||||
res2 = _run_fpcalc(fpcalc, path, length=30)
|
||||
if "fingerprint" in res2:
|
||||
res2["_partial"] = True # 30s soltanto
|
||||
return res2
|
||||
|
||||
# Retry 2: fingerprint vuoto → prova con finestra piu' lunga (60s)
|
||||
# nel caso l'intro sia silenzio/muto (chromaprint richiede audio "reale")
|
||||
if "vuoto" in err_msg or "empty" in err_msg:
|
||||
res2 = _run_fpcalc(fpcalc, path, length=60)
|
||||
if "fingerprint" in res2:
|
||||
res2["_partial"] = True
|
||||
return res2
|
||||
# Ancora vuoto → prova algoritmo differente (chromaprint algo 1)
|
||||
# tramite subprocess diretto perche' _run_fpcalc non lo supporta
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[fpcalc, "-json", "-length", "60", "-algorithm", "1", str(path)],
|
||||
capture_output=True, text=True,
|
||||
timeout=_FPCALC_TIMEOUT_SEC,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
data = json.loads(proc.stdout or "{}")
|
||||
fp = data.get("fingerprint")
|
||||
if fp:
|
||||
return {
|
||||
"duration": float(data.get("duration") or 0),
|
||||
"fingerprint": str(fp),
|
||||
"_partial": True,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return res
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Scan
|
||||
# ------------------------------------------------------------------
|
||||
def _iter_audio_files(directory: str, recursive: bool) -> list[Path]:
|
||||
"""Elenca tutti i file audio (estensione case-insensitive)."""
|
||||
base = Path(directory)
|
||||
if not base.exists() or not base.is_dir():
|
||||
return []
|
||||
files: list[Path] = []
|
||||
if recursive:
|
||||
for f in base.rglob("*"):
|
||||
if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS:
|
||||
files.append(f)
|
||||
else:
|
||||
for f in base.iterdir():
|
||||
if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS:
|
||||
files.append(f)
|
||||
files.sort()
|
||||
return files
|
||||
|
||||
|
||||
def _scan_by_filename(files: list, progress_callback: Optional[Callable],
|
||||
group_callback: Optional[Callable] = None,
|
||||
similarity_threshold: float = 0.8) -> list[list[dict]]:
|
||||
"""Raggruppa file per similarità nome (Jaccard sui token normalizzati),
|
||||
algoritmo INCREMENTALE: per ogni nuovo file cerca match tra i gruppi già
|
||||
formati (lookup O(K) dove K = numero gruppi). Emette streaming via
|
||||
`group_callback` appena un gruppo raggiunge ≥ 2 file.
|
||||
"""
|
||||
from core.upgrader import _normalize_stem # riuso
|
||||
|
||||
def _pc(idx, total_n, name, status, err=""):
|
||||
if not progress_callback:
|
||||
return
|
||||
try:
|
||||
progress_callback(idx, total_n, name, status, err)
|
||||
except TypeError:
|
||||
progress_callback(idx, total_n, name, status)
|
||||
|
||||
def _gc(group_id: str, entries: list) -> None:
|
||||
if group_callback:
|
||||
try:
|
||||
group_callback({"id": group_id, "entries": list(entries)})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
total = len(files)
|
||||
# Ogni voce: {"id": str, "key_tokens": frozenset, "entries": [dict]}
|
||||
groups: list = []
|
||||
|
||||
for i, fp_path in enumerate(files, start=1):
|
||||
if is_stopped():
|
||||
_pc(i - 1, total, "", "stopped")
|
||||
break
|
||||
try:
|
||||
size = fp_path.stat().st_size
|
||||
except OSError as e:
|
||||
_pc(i, total, fp_path.name, "error", f"stat: {e}")
|
||||
continue
|
||||
tokens = frozenset(_normalize_stem(fp_path.stem))
|
||||
if not tokens:
|
||||
_pc(i, total, fp_path.name, "error", "nome senza token utili")
|
||||
continue
|
||||
try:
|
||||
bitrate = get_bitrate(fp_path)
|
||||
except Exception:
|
||||
bitrate = 0
|
||||
entry = {
|
||||
"path": str(fp_path), "size": size, "bitrate": bitrate,
|
||||
"duration": 0, "fingerprint": "",
|
||||
}
|
||||
|
||||
# Cerca match nei gruppi già formati (lineare sui gruppi, non sui file)
|
||||
matched = None
|
||||
for g in groups:
|
||||
common = len(tokens & g["key_tokens"])
|
||||
if common == 0:
|
||||
continue
|
||||
union = len(tokens | g["key_tokens"])
|
||||
if union > 0 and (common / union) >= similarity_threshold:
|
||||
matched = g
|
||||
break
|
||||
|
||||
if matched is not None:
|
||||
was_solo = len(matched["entries"]) == 1
|
||||
matched["entries"].append(entry)
|
||||
matched["entries"].sort(key=lambda e: (-e["bitrate"], -e["size"]))
|
||||
# Streaming: emit ogni volta che il gruppo diventa/rimane ≥ 2 file
|
||||
_gc(matched["id"], matched["entries"])
|
||||
else:
|
||||
gid = f"fn_{len(groups)}_{fp_path.stem[:20]}"
|
||||
groups.append({
|
||||
"id": gid,
|
||||
"key_tokens": set(tokens),
|
||||
"entries": [entry],
|
||||
})
|
||||
_pc(i, total, fp_path.name, "cached")
|
||||
|
||||
# Ritorna solo i gruppi con >= 2 file
|
||||
result = [sorted(g["entries"], key=lambda e: (-e["bitrate"], -e["size"]))
|
||||
for g in groups if len(g["entries"]) >= 2]
|
||||
result.sort(key=lambda g: -max(e["size"] for e in g))
|
||||
_pc(total, total, "", "completed")
|
||||
return result
|
||||
|
||||
|
||||
def scan_folder(
|
||||
directory: str,
|
||||
recursive: bool = True,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
method: str = "fingerprint",
|
||||
group_callback: Optional[Callable] = None,
|
||||
) -> list[list[dict]]:
|
||||
"""Ritorna la lista di gruppi di file duplicati (>= 2 file).
|
||||
|
||||
Ogni file nel gruppo e' un dict:
|
||||
{path, size, bitrate, duration, fingerprint}
|
||||
|
||||
Gruppi ordinati per size del file piu' grande DESC (i gruppi che
|
||||
occupano piu' spazio vengono prima). All'interno di ogni gruppo:
|
||||
bitrate DESC, poi size DESC (il primo e' quello "da tenere").
|
||||
|
||||
`method`:
|
||||
- "fingerprint" (default): Chromaprint via fpcalc, preciso ma lento.
|
||||
Cache SQLite persistente. Raggruppa per fingerprint identico.
|
||||
- "filename": similarità Jaccard sui nomi file. Veloce ma euristico.
|
||||
Non richiede fpcalc.
|
||||
"""
|
||||
reset_stop()
|
||||
files = _iter_audio_files(directory, recursive)
|
||||
total = len(files)
|
||||
if total == 0:
|
||||
if progress_callback:
|
||||
progress_callback(0, 0, "", "completed")
|
||||
return []
|
||||
|
||||
if method == "filename":
|
||||
return _scan_by_filename(files, progress_callback, group_callback)
|
||||
|
||||
fpcalc = find_fpcalc()
|
||||
if not fpcalc:
|
||||
# Senza fpcalc non possiamo fare nulla. Segnaliamo errore su ogni
|
||||
# file e ritorniamo lista vuota.
|
||||
if progress_callback:
|
||||
progress_callback(0, total, "", "error")
|
||||
return []
|
||||
|
||||
conn = _open_cache()
|
||||
try:
|
||||
# {fingerprint: [entry, ...]}
|
||||
by_fp: dict[str, list[dict]] = {}
|
||||
|
||||
def _pc(idx, total_n, name, status, err=""):
|
||||
"""Chiama progress_callback in modo retrocompatibile: la firma
|
||||
legacy è a 4 args, quella nuova a 5 con `error_msg` opzionale."""
|
||||
if not progress_callback:
|
||||
return
|
||||
try:
|
||||
progress_callback(idx, total_n, name, status, err)
|
||||
except TypeError:
|
||||
progress_callback(idx, total_n, name, status)
|
||||
|
||||
for i, fp_path in enumerate(files, start=1):
|
||||
if is_stopped():
|
||||
_pc(i - 1, total, "", "stopped")
|
||||
return []
|
||||
try:
|
||||
st = fp_path.stat()
|
||||
size = st.st_size
|
||||
mtime = st.st_mtime
|
||||
except OSError as e:
|
||||
_pc(i, total, fp_path.name, "error", f"stat: {e}")
|
||||
continue
|
||||
|
||||
path_str = str(fp_path)
|
||||
cached = _cache_get(conn, path_str, size, mtime)
|
||||
if cached:
|
||||
fp_hash = cached["fingerprint"]
|
||||
duration = cached["duration"]
|
||||
bitrate = cached["bitrate"] or get_bitrate(fp_path)
|
||||
_pc(i, total, fp_path.name, "cached")
|
||||
else:
|
||||
_pc(i, total, fp_path.name, "computing")
|
||||
res = compute_fingerprint(fpcalc, path_str)
|
||||
if not res or not res.get("fingerprint"):
|
||||
err_msg = (res or {}).get("_error", "errore sconosciuto")
|
||||
_pc(i, total, fp_path.name, "error", err_msg)
|
||||
continue
|
||||
fp_hash = res["fingerprint"]
|
||||
duration = res["duration"]
|
||||
try:
|
||||
bitrate = get_bitrate(fp_path)
|
||||
except Exception:
|
||||
bitrate = 0
|
||||
_cache_put(conn, path_str, size, mtime, duration, fp_hash, bitrate)
|
||||
|
||||
entry = {
|
||||
"path": path_str,
|
||||
"size": int(size),
|
||||
"bitrate": int(bitrate or 0),
|
||||
"duration": float(duration or 0),
|
||||
"fingerprint": fp_hash,
|
||||
}
|
||||
grp = by_fp.setdefault(fp_hash, [])
|
||||
grp.append(entry)
|
||||
# Streaming: appena il gruppo raggiunge (o supera) 2 elementi,
|
||||
# emetti update (JS accumula/aggiorna in tempo reale)
|
||||
if group_callback and len(grp) >= 2:
|
||||
# Ordinamento intra-gruppo prima di emit (best-to-keep primo)
|
||||
grp.sort(key=lambda e: (-int(e.get("bitrate") or 0),
|
||||
-int(e.get("size") or 0)))
|
||||
try:
|
||||
group_callback({
|
||||
"id": f"fp_{fp_hash[:24]}",
|
||||
"entries": list(grp),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Filtra: solo gruppi con >= 2 file
|
||||
groups = [g for g in by_fp.values() if len(g) >= 2]
|
||||
|
||||
# Sort dei file dentro il gruppo: bitrate DESC, size DESC.
|
||||
# Sort dei gruppi: size del file piu' grande DESC (usa max del gruppo).
|
||||
for g in groups:
|
||||
g.sort(key=lambda e: (-int(e.get("bitrate") or 0),
|
||||
-int(e.get("size") or 0)))
|
||||
groups.sort(key=lambda g: -max(int(e.get("size") or 0) for e in g))
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(total, total, "", "completed")
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Trash
|
||||
# ------------------------------------------------------------------
|
||||
def move_to_trash(paths: list[str]) -> dict:
|
||||
"""Sposta i file in cestino tramite send2trash.
|
||||
|
||||
Ritorna {moved: [...], failed: [{path, error}, ...]}. Non solleva
|
||||
mai eccezioni: gli errori per file singolo finiscono in `failed`.
|
||||
Aggiorna la cache SQLite rimuovendo i record dei file spostati (per
|
||||
quelli riusciti), cosi' un re-scan non li propone piu'.
|
||||
"""
|
||||
# Import interno per rendere il modulo importabile anche se
|
||||
# send2trash non e' installato (i test possono mockarlo).
|
||||
try:
|
||||
from send2trash import send2trash
|
||||
except Exception as e: # pragma: no cover — solo se pacchetto mancante
|
||||
return {
|
||||
"moved": [],
|
||||
"failed": [{"path": p, "error": f"send2trash non disponibile: {e}"}
|
||||
for p in (paths or [])],
|
||||
}
|
||||
|
||||
moved: list[str] = []
|
||||
failed: list[dict] = []
|
||||
for p in (paths or []):
|
||||
try:
|
||||
send2trash(p)
|
||||
moved.append(p)
|
||||
except Exception as e:
|
||||
failed.append({"path": p, "error": str(e)})
|
||||
|
||||
# Cache cleanup best-effort (non fatale se fallisce)
|
||||
if moved:
|
||||
try:
|
||||
conn = _open_cache()
|
||||
try:
|
||||
for p in moved:
|
||||
conn.execute("DELETE FROM files WHERE path = ?", (p,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"moved": moved, "failed": failed}
|
||||
+30
-14
@@ -12,6 +12,29 @@ from typing import Callable, Optional
|
||||
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
|
||||
_stop_event = threading.Event()
|
||||
_current_process: Optional[subprocess.Popen] = None
|
||||
@@ -99,8 +122,7 @@ def _search_youtube(query: str, cookies_path: Optional[str] = None) -> tuple[str
|
||||
"--no-warnings",
|
||||
"--flat-playlist",
|
||||
]
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
cmd.extend(_cookie_args(cookies_path))
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **subprocess_flags())
|
||||
if result.returncode != 0:
|
||||
@@ -216,8 +238,7 @@ def download_playlist(
|
||||
ffmpeg_dir = find_ffmpeg_dir()
|
||||
if ffmpeg_dir:
|
||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
cmd.extend(_cookie_args(cookies_path))
|
||||
|
||||
try:
|
||||
with _process_lock:
|
||||
@@ -300,8 +321,7 @@ def download_direct_url(
|
||||
"--no-warnings",
|
||||
url,
|
||||
]
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
probe_cmd.extend(["--cookies", cookies_path])
|
||||
probe_cmd.extend(_cookie_args(cookies_path))
|
||||
|
||||
try:
|
||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
|
||||
@@ -375,8 +395,7 @@ def download_direct_url(
|
||||
ffmpeg_dir = find_ffmpeg_dir()
|
||||
if ffmpeg_dir:
|
||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
cmd.extend(_cookie_args(cookies_path))
|
||||
|
||||
try:
|
||||
with _process_lock:
|
||||
@@ -511,8 +530,7 @@ def download_urls(
|
||||
ffmpeg_dir = find_ffmpeg_dir()
|
||||
if ffmpeg_dir:
|
||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
cmd.extend(_cookie_args(cookies_path))
|
||||
|
||||
try:
|
||||
with _process_lock:
|
||||
@@ -608,8 +626,7 @@ def download_video(
|
||||
probe_cmd = [
|
||||
ytdlp, "--dump-json", "--flat-playlist", "--no-download", "--no-warnings", url,
|
||||
]
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
probe_cmd.extend(["--cookies", cookies_path])
|
||||
probe_cmd.extend(_cookie_args(cookies_path))
|
||||
|
||||
try:
|
||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
|
||||
@@ -684,8 +701,7 @@ def download_video(
|
||||
ffmpeg_dir = find_ffmpeg_dir()
|
||||
if ffmpeg_dir:
|
||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
cmd.extend(_cookie_args(cookies_path))
|
||||
|
||||
try:
|
||||
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"
|
||||
if sub_macos.exists():
|
||||
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
|
||||
|
||||
|
||||
@@ -145,3 +152,28 @@ def find_ffmpeg() -> Optional[str]:
|
||||
def find_ffprobe() -> Optional[str]:
|
||||
"""Ritorna il path completo di ffprobe."""
|
||||
return _find_binary("ffprobe")
|
||||
|
||||
|
||||
def find_fpcalc() -> Optional[str]:
|
||||
"""Ritorna il path completo di fpcalc (Chromaprint), o None.
|
||||
|
||||
Cerca nelle stesse directory di find_ffmpeg / find_ytdlp: bundle
|
||||
PyInstaller prima, poi percorsi noti (Homebrew su macOS, LOCALAPPDATA
|
||||
su Windows), infine PATH generico. In dev mode aggiunge anche
|
||||
`<project_root>/bundle_bin/` così l'app funziona con `python main.py`
|
||||
dopo aver scaricato fpcalc via build script.
|
||||
|
||||
`fpcalc` viene usato dal modulo core.dedup per calcolare fingerprint
|
||||
audio (identificazione di duplicati).
|
||||
"""
|
||||
found = _find_binary("fpcalc")
|
||||
if found:
|
||||
return found
|
||||
# Fallback dev: bundle_bin del progetto
|
||||
if not _is_frozen():
|
||||
exe_name = _exe("fpcalc")
|
||||
project_bundle = Path(__file__).resolve().parent.parent / "bundle_bin"
|
||||
cand = project_bundle / exe_name
|
||||
if cand.exists():
|
||||
return str(cand)
|
||||
return None
|
||||
@@ -4,6 +4,17 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ============================================================
|
||||
# Logging persistente su file (rotazione giornaliera, 7 giorni).
|
||||
# Chiamato SUBITO cosi' ogni traceback o print da qui in poi
|
||||
# finisce nel file, anche in modalita' windowed.
|
||||
# ============================================================
|
||||
try:
|
||||
from core.applog import setup as _setup_log
|
||||
_setup_log()
|
||||
except Exception as _e:
|
||||
print(f"[bootstrap] log setup failed: {_e}")
|
||||
|
||||
# ============================================================
|
||||
# Fix SSL CA bundle: quando l'app e' frozen (PyInstaller) i moduli
|
||||
# ssl e requests non trovano nessun CA bundle di default -> tutte
|
||||
|
||||
@@ -11,6 +11,7 @@ pythonnet==3.0.5 ; sys_platform == "win32"
|
||||
clr-loader==0.2.7.post0 ; sys_platform == "win32"
|
||||
curl_cffi>=0.9.0
|
||||
beautifulsoup4>=4.12.0
|
||||
send2trash>=1.8.0
|
||||
|
||||
# --- dev only ---
|
||||
pytest>=8.0.0
|
||||
|
||||
@@ -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,193 @@
|
||||
"""Test per core.dedup — audio fingerprinting via fpcalc + cache SQLite.
|
||||
|
||||
Tutti gli unit test usano mock per fpcalc / send2trash: nessuna
|
||||
integrazione reale, nessun file audio necessario.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core import dedup
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fixture: dedup con cache DB isolato in tmp_path
|
||||
# ------------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def patched_cache(tmp_path, monkeypatch):
|
||||
"""Isola la cache SQLite in tmp_path per non toccare il config dir."""
|
||||
db = tmp_path / "dedup_cache_test.db"
|
||||
monkeypatch.setattr(dedup, "_cache_db_path", lambda: db)
|
||||
return db
|
||||
|
||||
|
||||
def _make_fake_audio(tmp_path: Path, name: str, size: int = 1024) -> Path:
|
||||
"""Crea un file 'audio' fake (byte casuali con estensione .mp3)."""
|
||||
f = tmp_path / name
|
||||
f.write_bytes(b"\x00" * size)
|
||||
return f
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# scan_folder
|
||||
# ------------------------------------------------------------------
|
||||
class TestScanFolder:
|
||||
def test_no_audio_files(self, tmp_path, patched_cache):
|
||||
"""Cartella senza audio -> gruppi vuoti, nessuna eccezione."""
|
||||
# Solo un file .txt (non audio)
|
||||
(tmp_path / "readme.txt").write_text("hello")
|
||||
|
||||
# Anche senza fpcalc disponibile, con 0 audio file ritorna [].
|
||||
with mock.patch.object(dedup, "find_fpcalc", return_value="/fake/fpcalc"):
|
||||
groups = dedup.scan_folder(str(tmp_path), recursive=False)
|
||||
|
||||
assert groups == []
|
||||
|
||||
def test_uses_cache_on_second_scan(self, tmp_path, patched_cache):
|
||||
"""Prima scan chiama fpcalc; seconda scan riusa la cache."""
|
||||
_make_fake_audio(tmp_path, "song.mp3", size=2048)
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_compute(fpcalc, path):
|
||||
calls.append(path)
|
||||
return {"duration": 180.5, "fingerprint": "FP-A"}
|
||||
|
||||
with mock.patch.object(dedup, "find_fpcalc", return_value="/fake/fpcalc"), \
|
||||
mock.patch.object(dedup, "compute_fingerprint", side_effect=fake_compute), \
|
||||
mock.patch.object(dedup, "get_bitrate", return_value=320):
|
||||
# Prima invocazione: fpcalc DEVE essere chiamato
|
||||
groups1 = dedup.scan_folder(str(tmp_path), recursive=False)
|
||||
first_calls = len(calls)
|
||||
|
||||
# Seconda invocazione (stesso file, stesso mtime/size):
|
||||
# cache HIT, fpcalc NON viene richiamato
|
||||
groups2 = dedup.scan_folder(str(tmp_path), recursive=False)
|
||||
second_calls = len(calls)
|
||||
|
||||
assert first_calls == 1, "prima scan deve chiamare fpcalc una volta"
|
||||
assert second_calls == 1, "seconda scan deve riusare la cache"
|
||||
# Con un solo file, nessun gruppo di duplicati
|
||||
assert groups1 == []
|
||||
assert groups2 == []
|
||||
|
||||
def test_groups_duplicates(self, tmp_path, patched_cache):
|
||||
"""3 file con lo stesso fingerprint -> 1 gruppo di 3, ordinato per bitrate DESC."""
|
||||
_make_fake_audio(tmp_path, "a.mp3", size=1000)
|
||||
_make_fake_audio(tmp_path, "b.mp3", size=3000) # size maggiore
|
||||
_make_fake_audio(tmp_path, "c.mp3", size=2000)
|
||||
|
||||
# Tutti stesso fingerprint. Bitrate differente per verificare
|
||||
# l'ordinamento: b=320 (top), a=192, c=128.
|
||||
bitrate_by_name = {"a.mp3": 192, "b.mp3": 320, "c.mp3": 128}
|
||||
|
||||
with mock.patch.object(dedup, "find_fpcalc", return_value="/fake/fpcalc"), \
|
||||
mock.patch.object(dedup, "compute_fingerprint",
|
||||
return_value={"duration": 200, "fingerprint": "SAME-FP"}), \
|
||||
mock.patch.object(dedup, "get_bitrate",
|
||||
side_effect=lambda p: bitrate_by_name[Path(p).name]):
|
||||
groups = dedup.scan_folder(str(tmp_path), recursive=False)
|
||||
|
||||
assert len(groups) == 1, "esattamente un gruppo di duplicati"
|
||||
g = groups[0]
|
||||
assert len(g) == 3, "tre file nel gruppo"
|
||||
# Ordine: bitrate DESC -> b (320), a (192), c (128)
|
||||
assert [Path(e["path"]).name for e in g] == ["b.mp3", "a.mp3", "c.mp3"]
|
||||
# Ogni entry ha i campi attesi
|
||||
for e in g:
|
||||
assert set(e.keys()) >= {"path", "size", "bitrate", "duration", "fingerprint"}
|
||||
assert e["fingerprint"] == "SAME-FP"
|
||||
|
||||
def test_ignores_singletons(self, tmp_path, patched_cache):
|
||||
"""File con fingerprint unico non compaiono nei gruppi."""
|
||||
_make_fake_audio(tmp_path, "dup1.mp3")
|
||||
_make_fake_audio(tmp_path, "dup2.mp3")
|
||||
_make_fake_audio(tmp_path, "unique.mp3")
|
||||
|
||||
fp_by_name = {"dup1.mp3": "FP-X", "dup2.mp3": "FP-X", "unique.mp3": "FP-Y"}
|
||||
|
||||
def fake_compute(fpcalc, path):
|
||||
return {"duration": 100, "fingerprint": fp_by_name[Path(path).name]}
|
||||
|
||||
with mock.patch.object(dedup, "find_fpcalc", return_value="/fake/fpcalc"), \
|
||||
mock.patch.object(dedup, "compute_fingerprint", side_effect=fake_compute), \
|
||||
mock.patch.object(dedup, "get_bitrate", return_value=256):
|
||||
groups = dedup.scan_folder(str(tmp_path), recursive=False)
|
||||
|
||||
# Solo il gruppo con dup1/dup2
|
||||
assert len(groups) == 1
|
||||
names = {Path(e["path"]).name for e in groups[0]}
|
||||
assert names == {"dup1.mp3", "dup2.mp3"}
|
||||
# unique.mp3 non appare in nessun gruppo
|
||||
for g in groups:
|
||||
for e in g:
|
||||
assert Path(e["path"]).name != "unique.mp3"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# move_to_trash
|
||||
# ------------------------------------------------------------------
|
||||
class TestMoveToTrash:
|
||||
def test_returns_moved_and_failed_summary(self, tmp_path, patched_cache):
|
||||
"""Verifica che il summary contenga moved/failed correttamente."""
|
||||
# 2 path OK + 1 path che alza eccezione
|
||||
ok1 = str(tmp_path / "ok1.mp3")
|
||||
ok2 = str(tmp_path / "ok2.mp3")
|
||||
bad = str(tmp_path / "bad.mp3")
|
||||
|
||||
def fake_send(p):
|
||||
if p == bad:
|
||||
raise OSError("simulated failure")
|
||||
# ok: no-op
|
||||
|
||||
# Il modulo importa send2trash *dentro* la funzione, quindi
|
||||
# dobbiamo patchare il modulo importato.
|
||||
with mock.patch("send2trash.send2trash", side_effect=fake_send):
|
||||
res = dedup.move_to_trash([ok1, bad, ok2])
|
||||
|
||||
assert set(res["moved"]) == {ok1, ok2}
|
||||
assert len(res["failed"]) == 1
|
||||
assert res["failed"][0]["path"] == bad
|
||||
assert "simulated failure" in res["failed"][0]["error"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# compute_fingerprint
|
||||
# ------------------------------------------------------------------
|
||||
class TestComputeFingerprint:
|
||||
def test_timeout_returns_error(self):
|
||||
"""Timeout di fpcalc -> dict con _error, no fingerprint."""
|
||||
with mock.patch("core.dedup.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="fpcalc", timeout=30)):
|
||||
result = dedup.compute_fingerprint("/fake/fpcalc", "/some/file.mp3")
|
||||
assert result and "_error" in result and "timeout" in result["_error"]
|
||||
assert "fingerprint" not in result
|
||||
|
||||
def test_success_returns_dict(self):
|
||||
"""Output JSON valido -> {duration, fingerprint}."""
|
||||
fake_proc = mock.Mock()
|
||||
fake_proc.returncode = 0
|
||||
fake_proc.stdout = '{"duration": 123.4, "fingerprint": "ABCDEF"}'
|
||||
with mock.patch("core.dedup.subprocess.run", return_value=fake_proc):
|
||||
result = dedup.compute_fingerprint("/fake/fpcalc", "/some/file.mp3")
|
||||
assert result == {"duration": 123.4, "fingerprint": "ABCDEF"}
|
||||
|
||||
def test_bad_json_returns_error(self):
|
||||
fake_proc = mock.Mock()
|
||||
fake_proc.returncode = 0
|
||||
fake_proc.stdout = "not json at all"
|
||||
with mock.patch("core.dedup.subprocess.run", return_value=fake_proc):
|
||||
result = dedup.compute_fingerprint("/fake/fpcalc", "/some/file.mp3")
|
||||
assert result and "_error" in result and "JSON" in result["_error"]
|
||||
assert "fingerprint" not in result
|
||||
|
||||
def test_missing_fpcalc_returns_error(self):
|
||||
# Nessuna chiamata subprocess se fpcalc e' vuoto
|
||||
result = dedup.compute_fingerprint("", "/some/file.mp3")
|
||||
assert result and "_error" in result and "fpcalc" in result["_error"]
|
||||
assert "fingerprint" not in result
|
||||
@@ -151,6 +151,9 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
flex-direction: column;
|
||||
padding: 24px 12px;
|
||||
border-right: 1px solid var(--border);
|
||||
height: 100vh;
|
||||
min-height: 0; /* consente ai flex children di rimpicciolirsi sotto il contenuto */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -192,7 +195,17 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-height: 0; /* per far funzionare overflow su flex child */
|
||||
overflow-y: auto; /* scrolla se ci sono troppi tab e la finestra è corta */
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.nav::-webkit-scrollbar { width: 6px; }
|
||||
.nav::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-input);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.nav::-webkit-scrollbar-thumb:hover { background: var(--bg-input-hover); }
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
@@ -687,6 +700,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
.percent.green { color: var(--green-text); }
|
||||
.percent.blue { color: var(--blue-text); }
|
||||
.percent.pink { color: var(--pink-hover); }
|
||||
.percent.purple { color: var(--purple-text); }
|
||||
|
||||
.progress {
|
||||
width: 100%;
|
||||
@@ -715,6 +729,10 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
background: linear-gradient(90deg, var(--pink), var(--pink-hover));
|
||||
}
|
||||
|
||||
.progress-fill.purple {
|
||||
background: linear-gradient(90deg, var(--purple), var(--purple-hover));
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
LOG BOX
|
||||
=============================== */
|
||||
@@ -1826,3 +1844,275 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
word-break: break-all;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
DEDUP tab (audio duplicati)
|
||||
=============================== */
|
||||
.dedup-summary {
|
||||
font-size: 14px;
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dedup-group-card {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.dedup-group-header {
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.dedup-group-header strong {
|
||||
color: var(--text-1);
|
||||
font-size: 14px;
|
||||
}
|
||||
.dedup-fp {
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dedup-file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dedup-file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-elev);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.dedup-file-row:hover {
|
||||
background: var(--bg-elev-hover, var(--bg-elev));
|
||||
}
|
||||
|
||||
.dedup-file-check {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--red);
|
||||
}
|
||||
.dedup-file-check:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.dedup-file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dedup-file-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dedup-file-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
margin-top: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dedup-file-path {
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
margin-top: 2px;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
word-break: break-all;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.dedup-file-play {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* File "da tenere" (bitrate massimo del gruppo) — verde */
|
||||
.dedup-keep {
|
||||
background: var(--green-dim);
|
||||
border-left: 3px solid var(--green);
|
||||
}
|
||||
.dedup-keep .dedup-file-title {
|
||||
color: var(--green-text);
|
||||
}
|
||||
|
||||
.dedup-keep-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--green);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.5px;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.dedup-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);
|
||||
}
|
||||
|
||||
/* Lista risultati Dedup con scroll interno: l'header (hero sticky) e il
|
||||
footer restano visibili anche se ci sono centinaia di gruppi. */
|
||||
.dedup-groups-scroll {
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,18 @@
|
||||
<span class="nav-icon">🔄</span>
|
||||
<span>Converti</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="dedup" data-feature="audio">
|
||||
<span class="nav-icon">🗑</span>
|
||||
<span>Dedup</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="catalog" data-feature="audio">
|
||||
<span class="nav-icon">📅</span>
|
||||
<span>Cataloga</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="flatten" data-feature="audio">
|
||||
<span class="nav-icon">📤</span>
|
||||
<span>Appiattisci</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
|
||||
<span class="nav-icon">⚡</span>
|
||||
<span>Upgrade</span>
|
||||
@@ -559,6 +571,24 @@
|
||||
</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">
|
||||
<label class="field-label">Cartella output predefinita</label>
|
||||
<div class="row">
|
||||
@@ -568,6 +598,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Log</h2>
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label class="field-label">Cartella dei log</label>
|
||||
<div class="row">
|
||||
<div class="path-display" id="logPathDisplay" style="flex:1;">—</div>
|
||||
<button class="btn btn-ghost pill btn-sm" id="openLogFolderBtn">📁 Apri cartella</button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
<span class="hint-ico">ⓘ</span>
|
||||
<div>
|
||||
L'app scrive un log giornaliero (<code>app.log</code>) con rotazione automatica (7 giorni).
|
||||
Utile per debug: incluide stdout, stderr, log delle tab e traceback.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Aspetto</h2>
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
@@ -1206,6 +1254,249 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: DEDUP (audio duplicati via Chromaprint) ===== -->
|
||||
<section class="view" id="view-dedup">
|
||||
<header class="hero hero-red">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow red">DEDUP AUDIO</div>
|
||||
<h1 class="hero-title">Trova e rimuovi duplicati audio</h1>
|
||||
<p class="hero-subtitle">Audio fingerprinting via Chromaprint: riconosce brani identici indipendentemente dal bitrate, dal formato o dai tag.</p>
|
||||
</div>
|
||||
<div class="hero-deco">🗑</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Cartella</h2>
|
||||
<div class="card">
|
||||
<div class="beatport-header">
|
||||
<button id="dedup-pick-folder" class="btn btn-primary pill">📁 Scegli cartella</button>
|
||||
<button id="dedup-start-btn" class="btn btn-primary pill" disabled>
|
||||
<span class="ico">▶</span> Scansiona
|
||||
</button>
|
||||
<button id="dedup-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="dedup-path-display" class="beatport-output-info" style="margin:0;">Nessuna cartella selezionata</span>
|
||||
</div>
|
||||
<div class="beatport-header" style="margin-top:12px;gap:16px;">
|
||||
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
|
||||
<input type="checkbox" id="dedup-recursive" checked />
|
||||
<span>Ricerca ricorsiva (include sottocartelle)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="beatport-header" style="margin-top:12px;gap:16px;">
|
||||
<label style="display:flex;gap:8px;align-items:center;color:var(--text-2);font-size:13px;">
|
||||
<span>Metodo:</span>
|
||||
<select id="dedup-method" class="input" style="padding:4px 8px;font-size:13px;">
|
||||
<option value="fingerprint">Audio fingerprint (preciso, lento)</option>
|
||||
<option value="filename">Nome file (veloce, meno preciso)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div id="dedup-status" class="beatport-status"></div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Progresso</h2>
|
||||
<div class="card">
|
||||
<div class="progress">
|
||||
<div class="progress-bar"><div id="dedupProgressFill" class="progress-fill pink"></div></div>
|
||||
<div class="progress-meta">
|
||||
<span id="dedupPercent" class="percent pink">0%</span>
|
||||
<span id="dedupCounter">In attesa</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Risultati</h2>
|
||||
<div class="card" id="dedup-summary-card" hidden>
|
||||
<div class="dedup-summary" id="dedup-summary-line"></div>
|
||||
</div>
|
||||
<div id="dedup-groups-wrap" class="dedup-groups-scroll"></div>
|
||||
|
||||
<div class="dedup-footer-bar" id="dedup-footer-bar" hidden>
|
||||
<span class="counter" id="dedup-selected-count">0 file da cancellare</span>
|
||||
<button id="dedup-select-suggested-btn" class="btn btn-ghost pill">
|
||||
✓ Seleziona tutti i consigliati
|
||||
</button>
|
||||
<button id="dedup-trash-btn" class="btn btn-danger pill" disabled>
|
||||
<span class="ico">🗑</span> Sposta in cestino
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Log</h2>
|
||||
<div class="card log-card">
|
||||
<div class="log" id="dedup-log"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: CATALOGA (AcoustID -> <Anno>/<Genere>/) ===== -->
|
||||
<section class="view" id="view-catalog">
|
||||
<header class="hero hero-purple">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow purple">CATALOGA</div>
|
||||
<h1 class="hero-title">Organizza per anno e genere</h1>
|
||||
<p class="hero-subtitle">Identifica ogni brano via audio fingerprint (Chromaprint + AcoustID) e sposta i file in <code><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>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1299,6 +1590,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============== MODAL: Cataloga — conferma spostamento ============== -->
|
||||
<div class="modal-backdrop" id="catalogConfirmModal" hidden>
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<h2>Conferma spostamento</h2>
|
||||
<button class="modal-close" id="catalogConfirmCloseBtn">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="catalogConfirmMsg">Stai per spostare N file. Procedi?</p>
|
||||
<p class="modal-hint">
|
||||
L'operazione riorganizza i file in sottocartelle
|
||||
<code><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 ============== -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
|
||||
+913
@@ -408,6 +408,9 @@ const logEls = {
|
||||
youtube: () => $("#youtube-log"),
|
||||
convert: () => $("#convert-log"),
|
||||
traxsource: () => $("#traxsource-log"),
|
||||
dedup: () => $("#dedup-log"),
|
||||
catalog: () => $("#catalog-log"),
|
||||
flatten: () => $("#flatten-log"),
|
||||
};
|
||||
|
||||
function classifyLog(msg) {
|
||||
@@ -484,8 +487,15 @@ async function init() {
|
||||
$("#bitrateSelect").value = state.config.bitrate || "320K";
|
||||
$("#hqThresholdInput").value = state.config.hq_threshold || 310;
|
||||
$("#cookiesInput").value = state.config.cookies_path || "";
|
||||
if ($("#cookiesBrowserSelect")) $("#cookiesBrowserSelect").value = state.config.cookies_browser || "";
|
||||
$("#outputInput").value = state.config.output_dir || "";
|
||||
$("#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);
|
||||
|
||||
// Download tab — usa output_dir come default
|
||||
@@ -525,6 +535,13 @@ async function init() {
|
||||
|
||||
// WAV -> MP3 converter tab
|
||||
await ConvertUI.init();
|
||||
|
||||
// Dedup tab (audio duplicati via Chromaprint)
|
||||
await DedupUI.init();
|
||||
|
||||
// Cataloga tab (AcoustID -> <Anno>/<Genere>/)
|
||||
await CatalogUI.init();
|
||||
await FlattenUI.init();
|
||||
}
|
||||
|
||||
async function refreshRecDevices() {
|
||||
@@ -1273,6 +1290,15 @@ $("#browseCookiesBtn").addEventListener("click", async () => {
|
||||
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 () => {
|
||||
const path = await window.pywebview.api.browse_directory();
|
||||
if (path) $("#outputInput").value = path;
|
||||
@@ -1289,6 +1315,7 @@ $("#saveBtn").addEventListener("click", async () => {
|
||||
bitrate: $("#bitrateSelect").value,
|
||||
hq_threshold: parseInt($("#hqThresholdInput").value, 10) || 310,
|
||||
cookies_path: $("#cookiesInput").value,
|
||||
cookies_browser: $("#cookiesBrowserSelect") ? $("#cookiesBrowserSelect").value : "",
|
||||
output_dir: $("#outputInput").value,
|
||||
theme: $("#themeSelect").value,
|
||||
};
|
||||
@@ -2857,6 +2884,892 @@ const ConvertUI = (() => {
|
||||
return { init };
|
||||
})();
|
||||
|
||||
// ============================================================
|
||||
// DedupUI — audio duplicati via Chromaprint fingerprinting
|
||||
// ============================================================
|
||||
const DedupUI = (() => {
|
||||
const mstate = {
|
||||
folder: "",
|
||||
recursive: true,
|
||||
scanning: false,
|
||||
groups: [],
|
||||
// Set di path da cancellare (chiave = string path)
|
||||
toDelete: new Set(),
|
||||
};
|
||||
|
||||
function _fmtDur(sec) {
|
||||
if (!sec || sec <= 0) return "—";
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function setStatus(text, kind) {
|
||||
const el = $("#dedup-status");
|
||||
if (!el) return;
|
||||
el.textContent = text || "";
|
||||
el.className = "beatport-status" + (kind ? " " + kind : "");
|
||||
}
|
||||
|
||||
function updateSelectedCount() {
|
||||
const n = mstate.toDelete.size;
|
||||
const label = n === 1 ? "1 file da cancellare" : `${n} file da cancellare`;
|
||||
const cnt = $("#dedup-selected-count");
|
||||
if (cnt) cnt.textContent = label;
|
||||
const btn = $("#dedup-trash-btn");
|
||||
if (btn) btn.disabled = n === 0 || mstate.scanning;
|
||||
const bar = $("#dedup-footer-bar");
|
||||
if (bar) bar.hidden = mstate.groups.length === 0;
|
||||
}
|
||||
|
||||
function renderGroups() {
|
||||
const wrap = $("#dedup-groups-wrap");
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML = "";
|
||||
mstate.toDelete.clear();
|
||||
|
||||
if (!mstate.groups.length) {
|
||||
$("#dedup-summary-card").hidden = true;
|
||||
$("#dedup-footer-bar").hidden = true;
|
||||
updateSelectedCount();
|
||||
return;
|
||||
}
|
||||
|
||||
// Summary line
|
||||
const nGroups = mstate.groups.length;
|
||||
let nDupes = 0;
|
||||
let reclaim = 0;
|
||||
mstate.groups.forEach((g) => {
|
||||
nDupes += Math.max(0, g.length - 1);
|
||||
for (let i = 1; i < g.length; i++) reclaim += (g[i].size || 0);
|
||||
});
|
||||
$("#dedup-summary-line").textContent =
|
||||
`${nGroups} gruppi · ${nDupes} duplicati · spazio recuperabile ~${_fmtBytes(reclaim)}`;
|
||||
$("#dedup-summary-card").hidden = false;
|
||||
|
||||
// Render each group card
|
||||
mstate.groups.forEach((group, gi) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "card dedup-group-card";
|
||||
|
||||
const totalSize = group.reduce((s, e) => s + (e.size || 0), 0);
|
||||
const fpPreview = (group[0].fingerprint || "").slice(0, 12);
|
||||
const header = document.createElement("div");
|
||||
header.className = "dedup-group-header";
|
||||
header.innerHTML = `
|
||||
<strong>Gruppo ${gi + 1}</strong>
|
||||
· <span>${group.length} file</span>
|
||||
· <span>totale ${_fmtBytes(totalSize)}</span>
|
||||
· <span class="dedup-fp">fp ${_escapeHtml(fpPreview)}…</span>
|
||||
`;
|
||||
card.appendChild(header);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "dedup-file-list";
|
||||
|
||||
group.forEach((entry, idx) => {
|
||||
const isKeep = idx === 0; // primo = bitrate piu' alto = da tenere
|
||||
const row = document.createElement("div");
|
||||
row.className = "dedup-file-row" + (isKeep ? " dedup-keep" : "");
|
||||
|
||||
// Pre-selezione: cancella tutti tranne il primo
|
||||
if (!isKeep) mstate.toDelete.add(entry.path);
|
||||
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.className = "dedup-file-check";
|
||||
checkbox.checked = !isKeep;
|
||||
checkbox.disabled = isKeep;
|
||||
checkbox.title = isKeep
|
||||
? "File da tenere (bitrate massimo)"
|
||||
: "Marca per la cancellazione";
|
||||
checkbox.addEventListener("change", () => {
|
||||
if (checkbox.checked) mstate.toDelete.add(entry.path);
|
||||
else mstate.toDelete.delete(entry.path);
|
||||
updateSelectedCount();
|
||||
});
|
||||
row.appendChild(checkbox);
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.className = "dedup-file-info";
|
||||
const kbps = entry.bitrate ? `${entry.bitrate}k` : "—";
|
||||
const keepBadge = isKeep ? '<span class="dedup-keep-badge">TIENI</span> ' : "";
|
||||
info.innerHTML = `
|
||||
<div class="dedup-file-title">${keepBadge}${_escapeHtml(_basename(entry.path))}</div>
|
||||
<div class="dedup-file-meta">
|
||||
${_escapeHtml(kbps)} · ${_escapeHtml(_fmtBytes(entry.size))} · ${_escapeHtml(_fmtDur(entry.duration))}
|
||||
</div>
|
||||
<div class="dedup-file-path">${_escapeHtml(entry.path)}</div>
|
||||
`;
|
||||
row.appendChild(info);
|
||||
|
||||
// Preview audio (riusa _makePreviewBtn della sezione Upgrade)
|
||||
const playSlot = document.createElement("div");
|
||||
playSlot.className = "dedup-file-play";
|
||||
playSlot.appendChild(_makePreviewBtn(entry.path));
|
||||
row.appendChild(playSlot);
|
||||
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
card.appendChild(list);
|
||||
wrap.appendChild(card);
|
||||
});
|
||||
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
async function pickFolder() {
|
||||
const p = await window.pywebview.api.dedup_pick_folder();
|
||||
if (p && typeof p === "string") {
|
||||
mstate.folder = p;
|
||||
$("#dedup-path-display").textContent = p;
|
||||
$("#dedup-start-btn").disabled = false;
|
||||
setStatus("", "");
|
||||
}
|
||||
}
|
||||
|
||||
async function startScan() {
|
||||
if (!mstate.folder) return;
|
||||
mstate.recursive = $("#dedup-recursive").checked;
|
||||
mstate.scanning = true;
|
||||
mstate.groups = [];
|
||||
if (mstate._groupIndex) mstate._groupIndex.clear();
|
||||
mstate.toDelete.clear();
|
||||
renderGroups();
|
||||
$("#dedup-start-btn").disabled = true;
|
||||
$("#dedup-pick-folder").disabled = true;
|
||||
$("#dedup-stop-btn").hidden = false;
|
||||
$("#dedup-log").innerHTML = "";
|
||||
$("#dedupProgressFill").style.width = "0%";
|
||||
$("#dedupPercent").textContent = "0%";
|
||||
$("#dedupCounter").textContent = "Scansione in corso…";
|
||||
setStatus("Scansione in corso...", "loading");
|
||||
|
||||
const method = $("#dedup-method")?.value || "fingerprint";
|
||||
let res;
|
||||
try {
|
||||
res = await window.pywebview.api.dedup_start_scan({
|
||||
directory: mstate.folder,
|
||||
recursive: mstate.recursive,
|
||||
method,
|
||||
});
|
||||
} 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.dedup_stop_scan(); } catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function finishScan() {
|
||||
mstate.scanning = false;
|
||||
$("#dedup-start-btn").disabled = !mstate.folder;
|
||||
$("#dedup-pick-folder").disabled = false;
|
||||
$("#dedup-stop-btn").hidden = true;
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
async function moveSelectedToTrash() {
|
||||
const paths = [...mstate.toDelete];
|
||||
if (!paths.length) return;
|
||||
const msg = paths.length === 1
|
||||
? "Spostare 1 file nel cestino?"
|
||||
: `Spostare ${paths.length} file nel cestino?`;
|
||||
if (!confirm(msg)) return;
|
||||
|
||||
const btn = $("#dedup-trash-btn");
|
||||
btn.disabled = true;
|
||||
let res;
|
||||
try {
|
||||
res = await window.pywebview.api.dedup_move_to_trash(paths);
|
||||
} 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;
|
||||
}
|
||||
|
||||
const moved = new Set(res.moved || []);
|
||||
// Rimuovi dai gruppi i file spostati; scarta gruppi che scendono sotto i 2
|
||||
mstate.groups = mstate.groups
|
||||
.map((g) => g.filter((e) => !moved.has(e.path)))
|
||||
.filter((g) => g.length >= 2);
|
||||
renderGroups();
|
||||
|
||||
const okN = res.moved_count || 0;
|
||||
const failN = res.failed_count || 0;
|
||||
if (failN > 0) {
|
||||
toast(`${okN} spostati, ${failN} falliti`, "error");
|
||||
} else {
|
||||
toast(`${okN} file spostati nel cestino`, "success");
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// Bind eventi
|
||||
$("#dedup-pick-folder").addEventListener("click", pickFolder);
|
||||
$("#dedup-start-btn").addEventListener("click", startScan);
|
||||
$("#dedup-stop-btn").addEventListener("click", stopScan);
|
||||
$("#dedup-trash-btn").addEventListener("click", moveSelectedToTrash);
|
||||
$("#dedup-select-suggested-btn").addEventListener("click", () => {
|
||||
// Reset selezione: per ogni gruppo, marca-per-cancellazione TUTTI
|
||||
// tranne il primo (il "TIENI" consigliato). Non tocca i primi.
|
||||
mstate.toDelete.clear();
|
||||
mstate.groups.forEach((group) => {
|
||||
for (let i = 1; i < group.length; i++) {
|
||||
if (group[i] && group[i].path) mstate.toDelete.add(group[i].path);
|
||||
}
|
||||
});
|
||||
// Sync checkbox nel DOM
|
||||
$$("#dedup-groups-wrap .dedup-file-check").forEach((cb) => {
|
||||
if (cb.disabled) return; // skip il "TIENI"
|
||||
cb.checked = true;
|
||||
});
|
||||
updateSelectedCount();
|
||||
});
|
||||
$("#dedup-recursive").addEventListener("change", (e) => {
|
||||
mstate.recursive = e.target.checked;
|
||||
});
|
||||
|
||||
// Ripristina ultima cartella + recursive da config
|
||||
const cfg = state.config || {};
|
||||
if (cfg.dedup_last_folder) {
|
||||
mstate.folder = cfg.dedup_last_folder;
|
||||
$("#dedup-path-display").textContent = cfg.dedup_last_folder;
|
||||
$("#dedup-start-btn").disabled = false;
|
||||
}
|
||||
if (typeof cfg.dedup_recursive === "boolean") {
|
||||
$("#dedup-recursive").checked = cfg.dedup_recursive;
|
||||
mstate.recursive = cfg.dedup_recursive;
|
||||
}
|
||||
if (cfg.dedup_method && $("#dedup-method")) {
|
||||
$("#dedup-method").value = cfg.dedup_method;
|
||||
}
|
||||
|
||||
// Bridge handlers
|
||||
bridgeHandlers["dedup:progress"] = (p) => {
|
||||
if (!p) return;
|
||||
if (typeof p.overall === "number") {
|
||||
const pct = Math.round(p.overall * 100);
|
||||
$("#dedupProgressFill").style.width = pct + "%";
|
||||
$("#dedupPercent").textContent = pct + "%";
|
||||
}
|
||||
if (typeof p.idx === "number" && typeof p.total === "number" && p.total > 0) {
|
||||
const status = p.status || "";
|
||||
let label;
|
||||
if (status === "cached") label = `File ${p.idx}/${p.total} · cache`;
|
||||
else if (status === "computing") label = `File ${p.idx}/${p.total} · calcolo`;
|
||||
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}`;
|
||||
$("#dedupCounter").textContent = label;
|
||||
}
|
||||
};
|
||||
|
||||
// Streaming: gruppi arrivano uno alla volta man mano che si formano.
|
||||
// mstate.groups è una lista di ARRAY di entries (shape richiesta da
|
||||
// renderGroups). Manteniamo un indice group_id → arrayRef per poter
|
||||
// aggiornare in-place quando il gruppo cresce.
|
||||
if (!mstate._groupIndex) mstate._groupIndex = new Map();
|
||||
bridgeHandlers["dedup:group"] = (p) => {
|
||||
if (!p || !p.id || !Array.isArray(p.entries)) return;
|
||||
const idx = mstate._groupIndex;
|
||||
const existing = idx.get(p.id);
|
||||
if (existing) {
|
||||
// Aggiorna elementi in-place (stesso riferimento array in mstate.groups)
|
||||
existing.length = 0;
|
||||
for (const e of p.entries) existing.push(e);
|
||||
} else {
|
||||
// Nuovo gruppo: array copia + tag `_id` non-enumerabile per il lookup
|
||||
const arr = p.entries.slice();
|
||||
Object.defineProperty(arr, "_id", { value: p.id, enumerable: false });
|
||||
idx.set(p.id, arr);
|
||||
mstate.groups.push(arr);
|
||||
}
|
||||
renderGroups();
|
||||
setStatus(`Trovati ${mstate.groups.length} gruppi finora…`, "loading");
|
||||
};
|
||||
|
||||
bridgeHandlers["dedup:done"] = (p) => {
|
||||
// Se sono arrivati group via streaming, ignoriamo p.groups (già in mstate).
|
||||
// Fallback: se lo streaming non ha popolato nulla, usiamo p.groups.
|
||||
if (mstate.groups.length === 0 && p && Array.isArray(p.groups)) {
|
||||
mstate.groups = p.groups;
|
||||
}
|
||||
renderGroups();
|
||||
if (p && p.ok === false) {
|
||||
setStatus(p.error || "Errore scansione", "error");
|
||||
} else if (!mstate.groups.length) {
|
||||
setStatus("Nessun duplicato trovato", "ok");
|
||||
} else {
|
||||
setStatus(`Trovati ${mstate.groups.length} gruppi di duplicati`, "ok");
|
||||
}
|
||||
finishScan();
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
})();
|
||||
|
||||
// ============================================================
|
||||
// Boot
|
||||
// ============================================================
|
||||
|
||||
Reference in new issue
Block a user