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 = ` +