diff --git a/webui/css/style.css b/webui/css/style.css index 86f0ebe..599c4ce 100644 --- a/webui/css/style.css +++ b/webui/css/style.css @@ -687,6 +687,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 +716,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 =============================== */ @@ -1970,3 +1975,131 @@ input[type="number"]::-webkit-inner-spin-button { 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); +} + diff --git a/webui/index.html b/webui/index.html index 30fd24b..83ea38b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -53,6 +53,10 @@ πŸ—‘ Dedup + + + + +
+ Nessuna cartella selezionata +
+
+ +
+ + +

Destinazione

+
+
+ + +
+
+ Uguale alla sorgente (in-place) +
+
+ β“˜ +
+ Se lasci vuota la destinazione i file vengono organizzati in-place nella cartella sorgente. + Le sottocartelle <Anno>/<Genere>/ vengono create automaticamente. + File senza match AcoustID finiscono in Unknown Year/Unknown Genre/. +
+
+
+
+ +

Progresso

+
+
+
+
+ 0% + In attesa +
+
+
+ +

Risultati

+ + + + + +

Log

+
+
+
+ + @@ -1397,6 +1501,29 @@ + + +
diff --git a/webui/js/app.js b/webui/js/app.js index 8be3ebd..46b81c8 100644 --- a/webui/js/app.js +++ b/webui/js/app.js @@ -409,6 +409,7 @@ const logEls = { convert: () => $("#convert-log"), traxsource: () => $("#traxsource-log"), dedup: () => $("#dedup-log"), + catalog: () => $("#catalog-log"), }; function classifyLog(msg) { @@ -530,6 +531,9 @@ async function init() { // Dedup tab (audio duplicati via Chromaprint) await DedupUI.init(); + + // Cataloga tab (AcoustID -> //) + await CatalogUI.init(); } async function refreshRecDevices() { @@ -3205,6 +3209,440 @@ const DedupUI = (() => { return { init }; })(); +// ============================================================ +// CatalogUI β€” AcoustID -> // +// ============================================================ +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 `errore`; + } + if (entry.matched && entry.year && entry.genre) { + return `match`; + } + if (entry.matched) { + // Match parziale (mancano anno o genere) + return `parziale`; + } + return `sconosciuto`; + } + + 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 = `${_escapeHtml(_basename(path))}`; + 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 { + res = await window.pywebview.api.catalog_move_files(selectedEntries, target); + } 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 }; +})(); + // ============================================================ // Boot // ============================================================