feat: Dedup enhancements (streaming, retry, filename mode, scroll, auto-select)

Iterazioni sul feature Dedup (v1.10.0) da subito-post-scaffolding:
- compute_fingerprint espone il vero errore invece del generico None,
  worker propaga l'err_msg via progress_callback esteso
- Retry automatico: -length 30 su "invalid data / decoding frame",
  -length 60 + -algorithm 1 su "fingerprint vuoto"
- Nuovo metodo "filename" (Jaccard sui token nome file) con algoritmo
  incrementale O(N·K) — utile per file corrotti o per anteprima veloce
- Streaming groups: sia fingerprint che filename emettono `dedup:group`
  appena un gruppo raggiunge >=2 file; JS accumula in Map per update
  in-place. Progress bar avanza in tempo reale
- Fix shape bug che dava "NaN duplicati" nella summary line
- .dedup-groups-scroll: max-height 55vh + overflow-y auto per non
  perdere l'header/footer scrollando molti gruppi
- Bottone "Seleziona tutti i consigliati" nel footer: ripristina la
  selezione di default (tutti tranne il TIENI marcati per cancellazione)
- .gitignore: dedup_cache.db (SQLite locale per macchina utente)
This commit is contained in:
luciano committed 2026-08-02 13:59:39 +02:00
1 parent 00ff5a6220
commit e7d11d0235
8 files changed
+315 -47

No files matched your search

+4
View File
@@ -49,3 +49,7 @@ server/node_modules/
server/.wrangler/ server/.wrangler/
server/.dev.vars server/.dev.vars
server/dist/ server/dist/
# Dedup local cache (SQLite fingerprint cache, per macchina dell'utente)
dedup_cache.db
dedup_cache.db-journal
+25 -7
View File
@@ -1841,24 +1841,28 @@ class Api:
directory = (payload.get("directory") or "").strip() directory = (payload.get("directory") or "").strip()
recursive = bool(payload.get("recursive", True)) recursive = bool(payload.get("recursive", True))
method = (payload.get("method") or "fingerprint").strip()
if method not in ("fingerprint", "filename"):
method = "fingerprint"
if not directory: if not directory:
return {"ok": False, "error": "Cartella non impostata"} return {"ok": False, "error": "Cartella non impostata"}
if not os.path.isdir(directory): if not os.path.isdir(directory):
return {"ok": False, "error": "Cartella non trovata"} return {"ok": False, "error": "Cartella non trovata"}
# Persist last folder/recursive # Persist last folder/recursive/method
try: try:
cfg = load_config() cfg = load_config()
cfg["dedup_last_folder"] = directory cfg["dedup_last_folder"] = directory
cfg["dedup_recursive"] = recursive cfg["dedup_recursive"] = recursive
cfg["dedup_method"] = method
save_config(cfg) save_config(cfg)
except Exception: except Exception:
pass pass
self._dedup_thread = threading.Thread( self._dedup_thread = threading.Thread(
target=self._dedup_worker, target=self._dedup_worker,
args=(directory, recursive), args=(directory, recursive, method),
daemon=True, daemon=True,
) )
self._dedup_thread.start() self._dedup_thread.start()
@@ -1890,16 +1894,18 @@ class Api:
"failed": result.get("failed", []), "failed": result.get("failed", []),
"moved_count": moved_n, "failed_count": failed_n} "moved_count": moved_n, "failed_count": failed_n}
def _dedup_worker(self, directory: str, recursive: bool) -> None: def _dedup_worker(self, directory: str, recursive: bool,
method: str = "fingerprint") -> None:
"""Esegue la scansione in background e emette progress/done.""" """Esegue la scansione in background e emette progress/done."""
view = "dedup" view = "dedup"
dedup.reset_stop() dedup.reset_stop()
self._log(view, f"[INFO] Scansione: {directory} (recursive={recursive})") 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] _last = [0.0]
_THROTTLE = 0.05 _THROTTLE = 0.05
def progress_cb(idx: int, total: int, filename: str, status: str) -> None: def progress_cb(idx: int, total: int, filename: str, status: str, err_msg: str = "") -> None:
# Throttle solo eventi 'computing'/'cached' (che possono essere migliaia) # Throttle solo eventi 'computing'/'cached' (che possono essere migliaia)
if status in ("computing", "cached"): if status in ("computing", "cached"):
now = time.monotonic() now = time.monotonic()
@@ -1910,6 +1916,8 @@ class Api:
"idx": idx, "total": total, "idx": idx, "total": total,
"filename": filename, "status": status, "filename": filename, "status": status,
} }
if err_msg:
payload_evt["error_msg"] = err_msg
if total > 0: if total > 0:
payload_evt["overall"] = min(idx / total, 1.0) payload_evt["overall"] = min(idx / total, 1.0)
if status == "completed": if status == "completed":
@@ -1918,12 +1926,22 @@ class Api:
elif status == "stopped": elif status == "stopped":
self._log(view, "[INFO] Scansione interrotta.") self._log(view, "[INFO] Scansione interrotta.")
elif status == "error" and filename: elif status == "error" and filename:
self._log(view, f"[ERRORE] {filename}: errore lettura") detail = f": {err_msg}" if err_msg else ""
self._log(view, f"[ERRORE] {filename}{detail}")
self._emit("dedup:progress", payload_evt) 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: try:
groups = dedup.scan_folder(directory, recursive=recursive, groups = dedup.scan_folder(directory, recursive=recursive,
progress_callback=progress_cb) progress_callback=progress_cb,
method=method,
group_callback=group_cb)
except Exception as e: except Exception as e:
self._log(view, f"[ERRORE] {e}") self._log(view, f"[ERRORE] {e}")
self._emit("dedup:done", {"ok": False, "error": str(e), self._emit("dedup:done", {"ok": False, "error": str(e),
+1
View File
@@ -81,6 +81,7 @@ DEFAULTS = {
# ---- Dedup (audio duplicati via Chromaprint) ---- # ---- Dedup (audio duplicati via Chromaprint) ----
"dedup_last_folder": "", "dedup_last_folder": "",
"dedup_recursive": True, "dedup_recursive": True,
"dedup_method": "fingerprint", # "fingerprint" | "filename"
# ---- Licenza ---- # ---- Licenza ----
"license_key": "", # chiave fornita all'utente via email "license_key": "", # chiave fornita all'utente via email
"license_email": "", # email associata all'acquisto "license_email": "", # email associata all'acquisto
+203 -31
View File
@@ -136,35 +136,36 @@ def _cache_put(conn: sqlite3.Connection, path: str, size: int, mtime: float,
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# fpcalc # fpcalc
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def compute_fingerprint(fpcalc: str, path: str) -> Optional[dict]: def _run_fpcalc(fpcalc: str, path: str, length: Optional[int] = None) -> dict:
"""Chiama `fpcalc -json <file>` e ritorna {duration, fingerprint}. """Esegue fpcalc una volta. Ritorna {duration, fingerprint} su successo
o {_error: str} su fallimento."""
Ritorna None su qualsiasi errore (fpcalc mancante, file corrotto, cmd = [fpcalc, "-json"]
timeout, JSON malformato). if length is not None:
""" cmd += ["-length", str(length)]
if not fpcalc: cmd.append(str(path))
return None
try: try:
proc = subprocess.run( proc = subprocess.run(
[fpcalc, "-json", str(path)], cmd,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=_FPCALC_TIMEOUT_SEC, timeout=_FPCALC_TIMEOUT_SEC,
**subprocess_flags(), **subprocess_flags(),
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return None return {"_error": f"timeout {_FPCALC_TIMEOUT_SEC}s"}
except (OSError, ValueError): except (OSError, ValueError) as e:
return None return {"_error": f"subprocess: {e}"}
if proc.returncode != 0: if proc.returncode != 0:
return None err = (proc.stderr or proc.stdout or "").strip().splitlines()
msg = err[-1] if err else f"exit {proc.returncode}"
return {"_error": msg[:200]}
try: try:
data = json.loads(proc.stdout or "{}") data = json.loads(proc.stdout or "{}")
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError) as e:
return None return {"_error": f"JSON malformato: {e}"}
fp = data.get("fingerprint") fp = data.get("fingerprint")
if not fp: if not fp:
return None return {"_error": "fingerprint vuoto (audio troppo corto?)"}
try: try:
dur = float(data.get("duration") or 0) dur = float(data.get("duration") or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -172,6 +173,64 @@ def compute_fingerprint(fpcalc: str, path: str) -> Optional[dict]:
return {"duration": dur, "fingerprint": str(fp)} 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 # Scan
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -193,10 +252,97 @@ def _iter_audio_files(directory: str, recursive: bool) -> list[Path]:
return files 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( def scan_folder(
directory: str, directory: str,
recursive: bool = True, recursive: bool = True,
progress_callback: Optional[Callable] = None, progress_callback: Optional[Callable] = None,
method: str = "fingerprint",
group_callback: Optional[Callable] = None,
) -> list[list[dict]]: ) -> list[list[dict]]:
"""Ritorna la lista di gruppi di file duplicati (>= 2 file). """Ritorna la lista di gruppi di file duplicati (>= 2 file).
@@ -207,8 +353,11 @@ def scan_folder(
occupano piu' spazio vengono prima). All'interno di ogni gruppo: occupano piu' spazio vengono prima). All'interno di ogni gruppo:
bitrate DESC, poi size DESC (il primo e' quello "da tenere"). bitrate DESC, poi size DESC (il primo e' quello "da tenere").
Il fingerprint viene calcolato via `fpcalc -json` e messo in cache `method`:
SQLite. Al re-scan, se (size, mtime) invariati, non si rilancia fpcalc. - "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() reset_stop()
files = _iter_audio_files(directory, recursive) files = _iter_audio_files(directory, recursive)
@@ -218,6 +367,9 @@ def scan_folder(
progress_callback(0, 0, "", "completed") progress_callback(0, 0, "", "completed")
return [] return []
if method == "filename":
return _scan_by_filename(files, progress_callback, group_callback)
fpcalc = find_fpcalc() fpcalc = find_fpcalc()
if not fpcalc: if not fpcalc:
# Senza fpcalc non possiamo fare nulla. Segnaliamo errore su ogni # Senza fpcalc non possiamo fare nulla. Segnaliamo errore su ogni
@@ -231,18 +383,26 @@ def scan_folder(
# {fingerprint: [entry, ...]} # {fingerprint: [entry, ...]}
by_fp: dict[str, list[dict]] = {} 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): for i, fp_path in enumerate(files, start=1):
if is_stopped(): if is_stopped():
if progress_callback: _pc(i - 1, total, "", "stopped")
progress_callback(i - 1, total, "", "stopped")
return [] return []
try: try:
st = fp_path.stat() st = fp_path.stat()
size = st.st_size size = st.st_size
mtime = st.st_mtime mtime = st.st_mtime
except OSError: except OSError as e:
if progress_callback: _pc(i, total, fp_path.name, "error", f"stat: {e}")
progress_callback(i, total, fp_path.name, "error")
continue continue
path_str = str(fp_path) path_str = str(fp_path)
@@ -251,15 +411,13 @@ def scan_folder(
fp_hash = cached["fingerprint"] fp_hash = cached["fingerprint"]
duration = cached["duration"] duration = cached["duration"]
bitrate = cached["bitrate"] or get_bitrate(fp_path) bitrate = cached["bitrate"] or get_bitrate(fp_path)
if progress_callback: _pc(i, total, fp_path.name, "cached")
progress_callback(i, total, fp_path.name, "cached")
else: else:
if progress_callback: _pc(i, total, fp_path.name, "computing")
progress_callback(i, total, fp_path.name, "computing")
res = compute_fingerprint(fpcalc, path_str) res = compute_fingerprint(fpcalc, path_str)
if not res: if not res or not res.get("fingerprint"):
if progress_callback: err_msg = (res or {}).get("_error", "errore sconosciuto")
progress_callback(i, total, fp_path.name, "error") _pc(i, total, fp_path.name, "error", err_msg)
continue continue
fp_hash = res["fingerprint"] fp_hash = res["fingerprint"]
duration = res["duration"] duration = res["duration"]
@@ -276,7 +434,21 @@ def scan_folder(
"duration": float(duration or 0), "duration": float(duration or 0),
"fingerprint": fp_hash, "fingerprint": fp_hash,
} }
by_fp.setdefault(fp_hash, []).append(entry) 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: finally:
try: try:
conn.close() conn.close()
+10 -7
View File
@@ -160,12 +160,13 @@ class TestMoveToTrash:
# compute_fingerprint # compute_fingerprint
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class TestComputeFingerprint: class TestComputeFingerprint:
def test_timeout_returns_none(self): def test_timeout_returns_error(self):
"""Timeout di fpcalc -> None (non alza eccezione).""" """Timeout di fpcalc -> dict con _error, no fingerprint."""
with mock.patch("core.dedup.subprocess.run", with mock.patch("core.dedup.subprocess.run",
side_effect=subprocess.TimeoutExpired(cmd="fpcalc", timeout=30)): side_effect=subprocess.TimeoutExpired(cmd="fpcalc", timeout=30)):
result = dedup.compute_fingerprint("/fake/fpcalc", "/some/file.mp3") result = dedup.compute_fingerprint("/fake/fpcalc", "/some/file.mp3")
assert result is None assert result and "_error" in result and "timeout" in result["_error"]
assert "fingerprint" not in result
def test_success_returns_dict(self): def test_success_returns_dict(self):
"""Output JSON valido -> {duration, fingerprint}.""" """Output JSON valido -> {duration, fingerprint}."""
@@ -176,15 +177,17 @@ class TestComputeFingerprint:
result = dedup.compute_fingerprint("/fake/fpcalc", "/some/file.mp3") result = dedup.compute_fingerprint("/fake/fpcalc", "/some/file.mp3")
assert result == {"duration": 123.4, "fingerprint": "ABCDEF"} assert result == {"duration": 123.4, "fingerprint": "ABCDEF"}
def test_bad_json_returns_none(self): def test_bad_json_returns_error(self):
fake_proc = mock.Mock() fake_proc = mock.Mock()
fake_proc.returncode = 0 fake_proc.returncode = 0
fake_proc.stdout = "not json at all" fake_proc.stdout = "not json at all"
with mock.patch("core.dedup.subprocess.run", return_value=fake_proc): with mock.patch("core.dedup.subprocess.run", return_value=fake_proc):
result = dedup.compute_fingerprint("/fake/fpcalc", "/some/file.mp3") result = dedup.compute_fingerprint("/fake/fpcalc", "/some/file.mp3")
assert result is None assert result and "_error" in result and "JSON" in result["_error"]
assert "fingerprint" not in result
def test_missing_fpcalc_returns_none(self): def test_missing_fpcalc_returns_error(self):
# Nessuna chiamata subprocess se fpcalc e' vuoto # Nessuna chiamata subprocess se fpcalc e' vuoto
result = dedup.compute_fingerprint("", "/some/file.mp3") result = dedup.compute_fingerprint("", "/some/file.mp3")
assert result is None assert result and "_error" in result and "fpcalc" in result["_error"]
assert "fingerprint" not in result
+8
View File
@@ -1962,3 +1962,11 @@ input[type="number"]::-webkit-inner-spin-button {
border: 1px solid var(--border); 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 */
}
+13 -1
View File
@@ -1241,6 +1241,15 @@
<span>Ricerca ricorsiva (include sottocartelle)</span> <span>Ricerca ricorsiva (include sottocartelle)</span>
</label> </label>
</div> </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 id="dedup-status" class="beatport-status"></div>
</div> </div>
@@ -1259,10 +1268,13 @@
<div class="card" id="dedup-summary-card" hidden> <div class="card" id="dedup-summary-card" hidden>
<div class="dedup-summary" id="dedup-summary-line"></div> <div class="dedup-summary" id="dedup-summary-line"></div>
</div> </div>
<div id="dedup-groups-wrap"></div> <div id="dedup-groups-wrap" class="dedup-groups-scroll"></div>
<div class="dedup-footer-bar" id="dedup-footer-bar" hidden> <div class="dedup-footer-bar" id="dedup-footer-bar" hidden>
<span class="counter" id="dedup-selected-count">0 file da cancellare</span> <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> <button id="dedup-trash-btn" class="btn btn-danger pill" disabled>
<span class="ico">🗑</span> Sposta in cestino <span class="ico">🗑</span> Sposta in cestino
</button> </button>
+51 -1
View File
@@ -3011,6 +3011,7 @@ const DedupUI = (() => {
mstate.recursive = $("#dedup-recursive").checked; mstate.recursive = $("#dedup-recursive").checked;
mstate.scanning = true; mstate.scanning = true;
mstate.groups = []; mstate.groups = [];
if (mstate._groupIndex) mstate._groupIndex.clear();
mstate.toDelete.clear(); mstate.toDelete.clear();
renderGroups(); renderGroups();
$("#dedup-start-btn").disabled = true; $("#dedup-start-btn").disabled = true;
@@ -3022,11 +3023,13 @@ const DedupUI = (() => {
$("#dedupCounter").textContent = "Scansione in corso…"; $("#dedupCounter").textContent = "Scansione in corso…";
setStatus("Scansione in corso...", "loading"); setStatus("Scansione in corso...", "loading");
const method = $("#dedup-method")?.value || "fingerprint";
let res; let res;
try { try {
res = await window.pywebview.api.dedup_start_scan({ res = await window.pywebview.api.dedup_start_scan({
directory: mstate.folder, directory: mstate.folder,
recursive: mstate.recursive, recursive: mstate.recursive,
method,
}); });
} catch (e) { } catch (e) {
setStatus("Errore avvio: " + ((e && e.message) || e), "error"); setStatus("Errore avvio: " + ((e && e.message) || e), "error");
@@ -3099,6 +3102,22 @@ const DedupUI = (() => {
$("#dedup-start-btn").addEventListener("click", startScan); $("#dedup-start-btn").addEventListener("click", startScan);
$("#dedup-stop-btn").addEventListener("click", stopScan); $("#dedup-stop-btn").addEventListener("click", stopScan);
$("#dedup-trash-btn").addEventListener("click", moveSelectedToTrash); $("#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) => { $("#dedup-recursive").addEventListener("change", (e) => {
mstate.recursive = e.target.checked; mstate.recursive = e.target.checked;
}); });
@@ -3114,6 +3133,9 @@ const DedupUI = (() => {
$("#dedup-recursive").checked = cfg.dedup_recursive; $("#dedup-recursive").checked = cfg.dedup_recursive;
mstate.recursive = cfg.dedup_recursive; mstate.recursive = cfg.dedup_recursive;
} }
if (cfg.dedup_method && $("#dedup-method")) {
$("#dedup-method").value = cfg.dedup_method;
}
// Bridge handlers // Bridge handlers
bridgeHandlers["dedup:progress"] = (p) => { bridgeHandlers["dedup:progress"] = (p) => {
@@ -3136,8 +3158,36 @@ const DedupUI = (() => {
} }
}; };
// 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) => { bridgeHandlers["dedup:done"] = (p) => {
mstate.groups = (p && p.groups) || []; // 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(); renderGroups();
if (p && p.ok === false) { if (p && p.ok === false) {
setStatus(p.error || "Errore scansione", "error"); setStatus(p.error || "Errore scansione", "error");