diff --git a/webui/css/style.css b/webui/css/style.css index 33579e7..ede070a 100644 --- a/webui/css/style.css +++ b/webui/css/style.css @@ -1826,3 +1826,139 @@ 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); +} + diff --git a/webui/js/app.js b/webui/js/app.js index 12ef45b..83c9c73 100644 --- a/webui/js/app.js +++ b/webui/js/app.js @@ -408,6 +408,7 @@ const logEls = { youtube: () => $("#youtube-log"), convert: () => $("#convert-log"), traxsource: () => $("#traxsource-log"), + dedup: () => $("#dedup-log"), }; function classifyLog(msg) { @@ -525,6 +526,9 @@ async function init() { // WAV -> MP3 converter tab await ConvertUI.init(); + + // Dedup tab (audio duplicati via Chromaprint) + await DedupUI.init(); } async function refreshRecDevices() { @@ -2857,6 +2861,298 @@ 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 = ` + Gruppo ${gi + 1} + · ${group.length} file + · totale ${_fmtBytes(totalSize)} + · fp ${_escapeHtml(fpPreview)}… + `; + 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 ? 'TIENI ' : ""; + info.innerHTML = ` +
${keepBadge}${_escapeHtml(_basename(entry.path))}
+
+ ${_escapeHtml(kbps)} · ${_escapeHtml(_fmtBytes(entry.size))} · ${_escapeHtml(_fmtDur(entry.duration))} +
+
${_escapeHtml(entry.path)}
+ `; + 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 = []; + 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"); + + let res; + try { + res = await window.pywebview.api.dedup_start_scan({ + directory: mstate.folder, + recursive: mstate.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.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-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; + } + + // 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; + } + }; + + bridgeHandlers["dedup:done"] = (p) => { + mstate.groups = (p && 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 }; +})(); + // ============================================================ // Boot // ============================================================