From f8e2ee66bd12e9f974b699fc3a80850d393e6465 Mon Sep 17 00:00:00 2001 From: luciano Date: Sat, 1 Aug 2026 12:10:28 +0200 Subject: [PATCH] ui: TraxsourceUI (init, loadChart, render, download) --- webui/js/app.js | 491 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 473 insertions(+), 18 deletions(-) diff --git a/webui/js/app.js b/webui/js/app.js index b99df9a..2ba9a1c 100644 --- a/webui/js/app.js +++ b/webui/js/app.js @@ -400,6 +400,7 @@ const logEls = { spotify: () => $("#spotify-log"), youtube: () => $("#youtube-log"), convert: () => $("#convert-log"), + traxsource: () => $("#traxsource-log"), }; function classifyLog(msg) { @@ -512,6 +513,9 @@ async function init() { await SpotifyUI.init(); await YoutubeUI.init(); + // Traxsource charts tab + await TraxsourceUI.init(); + // WAV -> MP3 converter tab await ConvertUI.init(); } @@ -1183,6 +1187,61 @@ $("#quotaUpgradeBtn")?.addEventListener("click", async () => { try { await window.pywebview.api.open_purchase_page(); } catch (_e) {} }); +// ============================================================ +// Helper di ordinamento condivisi tra BeatportUI, SpotifyUI, YoutubeUI +// ============================================================ +function _sortPairs(tracks, existing, col, dir) { + const pairs = tracks.map((t, i) => ({ t, e: existing[i] || false, origIdx: i })); + const dirMul = dir === "desc" ? -1 : 1; + pairs.sort((a, b) => { + let av = a.t[col], bv = b.t[col]; + if (col === "position") { + // Per Beatport t.position esiste (rank ufficiale); per Spotify/YouTube + // torniamo l'ordine originale della risposta API. + av = (typeof av === "number") ? av : a.origIdx; + bv = (typeof bv === "number") ? bv : b.origIdx; + } + if (typeof av === "number" && typeof bv === "number") { + return (av - bv) * dirMul; + } + av = String(av == null ? "" : av).toLowerCase(); + bv = String(bv == null ? "" : bv).toLowerCase(); + // Empty strings sempre in fondo indipendentemente da direzione + if (av === "" && bv !== "") return 1; + if (bv === "" && av !== "") return -1; + if (av < bv) return -1 * dirMul; + if (av > bv) return 1 * dirMul; + return 0; + }); + return { + tracks: pairs.map(p => p.t), + existing: pairs.map(p => p.e), + origIndexes: pairs.map(p => p.origIdx), + }; +} + +function _bindSortableHeaders(tableSelector, onSort) { + document.querySelectorAll(`${tableSelector} th.sortable`).forEach(th => { + th.style.cursor = "pointer"; + th.style.userSelect = "none"; + th.addEventListener("click", () => onSort(th.dataset.sort)); + }); +} + +function _updateSortArrows(tableSelector, sortCol, sortDir) { + document.querySelectorAll(`${tableSelector} th.sortable`).forEach(th => { + const key = th.dataset.sort; + // Usa un per non toccare il testo principale + let arrow = th.querySelector(".sort-arrow"); + if (!arrow) { + arrow = document.createElement("span"); + arrow.className = "sort-arrow"; + th.appendChild(arrow); + } + arrow.textContent = key === sortCol ? (sortDir === "asc" ? " ▲" : " ▼") : ""; + }); +} + // ============================================================ // BEATPORT tab — carica Top 100 per genere, seleziona tracce, scarica // (il download riusa start_tracks_download, quindi passa dagli stessi @@ -1195,6 +1254,8 @@ const BeatportUI = (function () { let currentGenreName = ""; let currentGenreSlug = ""; let downloading = false; + let sortCol = null; + let sortDir = "asc"; function fmtDur(sec) { const n = Number(sec) || 0; @@ -1287,16 +1348,28 @@ const BeatportUI = (function () { const tbody = table.querySelector("tbody"); tbody.innerHTML = ""; - currentTracks.forEach((t, i) => { + let displayTracks = currentTracks; + let displayExisting = existing; + let origIndexes = currentTracks.map((_, i) => i); + if (sortCol) { + const s = _sortPairs(currentTracks, existing, sortCol, sortDir); + displayTracks = s.tracks; + displayExisting = s.existing; + origIndexes = s.origIndexes; + } + + displayTracks.forEach((t, i) => { + const origIdx = origIndexes[i]; + const alreadyDl = displayExisting[i]; const tr = document.createElement("tr"); - if (existing[i]) tr.classList.add("already-downloaded"); + if (alreadyDl) tr.classList.add("already-downloaded"); const tdCheck = document.createElement("td"); tdCheck.className = "col-check"; const cb = document.createElement("input"); cb.type = "checkbox"; - cb.dataset.idx = String(i); - cb.checked = !existing[i]; + cb.dataset.idx = String(origIdx); + cb.checked = !alreadyDl; cb.addEventListener("change", updateSelectionCount); tdCheck.appendChild(cb); tr.appendChild(tdCheck); @@ -1316,7 +1389,7 @@ const BeatportUI = (function () { const tdPos = document.createElement("td"); tdPos.className = "col-pos"; - tdPos.textContent = String(t.position || (i + 1)); + tdPos.textContent = String(t.position || (origIdx + 1)); tr.appendChild(tdPos); const tdArtist = document.createElement("td"); @@ -1332,20 +1405,36 @@ const BeatportUI = (function () { tdDur.textContent = fmtDur(t.duration_sec); tr.appendChild(tdDur); + const tdDate = document.createElement("td"); + tdDate.className = "col-date"; + tdDate.textContent = t.release_date || ""; + tr.appendChild(tdDate); + const tdState = document.createElement("td"); tdState.className = "col-state"; - if (existing[i]) tdState.textContent = "✓ già scaricato"; + if (alreadyDl) tdState.textContent = "✓ già scaricato"; tr.appendChild(tdState); tbody.appendChild(tr); }); + _updateSortArrows("#beatport-table", sortCol, sortDir); table.hidden = false; $("#beatport-toolbar").hidden = false; updateOutputInfo(); updateSelectionCount(); } + function handleSort(col) { + if (sortCol === col) { + sortDir = sortDir === "asc" ? "desc" : "asc"; + } else { + sortCol = col; + sortDir = "asc"; + } + renderTable(); + } + function updateSelectionCount() { const boxes = $$("#beatport-table tbody input[type=checkbox]"); const total = boxes.length; @@ -1462,6 +1551,7 @@ const BeatportUI = (function () { $("#beatport-download-btn").addEventListener("click", startDownload); $("#beatport-stop-btn").addEventListener("click", stopDownload); $("#beatport-select-all").addEventListener("change", (e) => toggleAll(e.target.checked)); + _bindSortableHeaders("#beatport-table", handleSort); // Aggancia agli event listener esistenti del canale "download" senza rompere // il comportamento della tab Scarica (wrapping additivo). @@ -1492,7 +1582,7 @@ const BeatportUI = (function () { const SpotifyUI = (() => { // NOTE: local module state — named `mstate` to NOT shadow the outer // module-level `state` (which holds `state.config`). - const mstate = { tracks: [], existing: [], downloading: false }; + const mstate = { tracks: [], existing: [], downloading: false, sortCol: null, sortDir: "asc" }; function _escape(s) { const d = document.createElement("div"); @@ -1530,31 +1620,55 @@ const SpotifyUI = (() => { const tbody = $("#spotify-tbody"); if (!tbody) return; tbody.innerHTML = ""; - mstate.tracks.forEach((t, i) => { - const already = !!mstate.existing[i]; + + let displayTracks = mstate.tracks; + let displayExisting = mstate.existing; + let origIndexes = mstate.tracks.map((_, i) => i); + if (mstate.sortCol) { + const s = _sortPairs(mstate.tracks, mstate.existing, mstate.sortCol, mstate.sortDir); + displayTracks = s.tracks; + displayExisting = s.existing; + origIndexes = s.origIndexes; + } + + displayTracks.forEach((t, i) => { + const origIdx = origIndexes[i]; + const already = !!displayExisting[i]; const tr = document.createElement("tr"); - tr.dataset.idx = String(i); + tr.dataset.idx = String(origIdx); if (already) tr.classList.add("already-downloaded"); const coverImg = t.image_url ? `` : ""; tr.innerHTML = ` - + ${coverImg} - ${i + 1} + ${origIdx + 1} ${_escape(t.artists)} ${_escape(t.name)} ${_escape(t.album)} ${_fmtDuration(t.duration_sec)} + ${_escape(t.release_date || "")} ${already ? "✓ già scaricato" : ""} `; const cb = tr.querySelector("input[type=checkbox]"); if (cb) cb.addEventListener("change", updateSelectionCount); tbody.appendChild(tr); }); + _updateSortArrows("#spotify-table", mstate.sortCol, mstate.sortDir); updateSelectionCount(); } + function handleSort(col) { + if (mstate.sortCol === col) { + mstate.sortDir = mstate.sortDir === "asc" ? "desc" : "asc"; + } else { + mstate.sortCol = col; + mstate.sortDir = "asc"; + } + renderTable(); + } + function setStatus(text, kind) { const el = $("#spotify-status"); if (!el) return; @@ -1708,6 +1822,7 @@ const SpotifyUI = (() => { }); $("#spotify-download-btn").addEventListener("click", startDownload); $("#spotify-stop-btn").addEventListener("click", stopDownload); + _bindSortableHeaders("#spotify-table", handleSort); // Wrap bridge handlers additivamente const origLog = bridgeHandlers["log"]; @@ -1731,7 +1846,7 @@ const SpotifyUI = (() => { // YouTube search // ===================================================================== const YoutubeUI = (() => { - const mstate = { tracks: [], existing: [], downloading: false }; + const mstate = { tracks: [], existing: [], downloading: false, sortCol: null, sortDir: "asc" }; function _escape(s) { const d = document.createElement("div"); @@ -1768,30 +1883,54 @@ const YoutubeUI = (() => { const tbody = $("#youtube-tbody"); if (!tbody) return; tbody.innerHTML = ""; - mstate.tracks.forEach((t, i) => { - const already = !!mstate.existing[i]; + + let displayTracks = mstate.tracks; + let displayExisting = mstate.existing; + let origIndexes = mstate.tracks.map((_, i) => i); + if (mstate.sortCol) { + const s = _sortPairs(mstate.tracks, mstate.existing, mstate.sortCol, mstate.sortDir); + displayTracks = s.tracks; + displayExisting = s.existing; + origIndexes = s.origIndexes; + } + + displayTracks.forEach((t, i) => { + const origIdx = origIndexes[i]; + const already = !!displayExisting[i]; const tr = document.createElement("tr"); - tr.dataset.idx = String(i); + tr.dataset.idx = String(origIdx); if (already) tr.classList.add("already-downloaded"); const coverImg = t.image_url ? `` : ""; tr.innerHTML = ` - + ${coverImg} - ${i + 1} + ${origIdx + 1} ${_escape(t.title)} ${_escape(t.channel)} ${_fmtDuration(t.duration_sec)} + ${_escape(t.release_date || "")} ${already ? "✓ già scaricato" : ""} `; const cb = tr.querySelector("input[type=checkbox]"); if (cb) cb.addEventListener("change", updateSelectionCount); tbody.appendChild(tr); }); + _updateSortArrows("#youtube-table", mstate.sortCol, mstate.sortDir); updateSelectionCount(); } + function handleSort(col) { + if (mstate.sortCol === col) { + mstate.sortDir = mstate.sortDir === "asc" ? "desc" : "asc"; + } else { + mstate.sortCol = col; + mstate.sortDir = "asc"; + } + renderTable(); + } + function setStatus(text, kind) { const el = $("#youtube-status"); if (!el) return; @@ -1934,6 +2073,7 @@ const YoutubeUI = (() => { }); $("#youtube-download-btn").addEventListener("click", startDownload); $("#youtube-stop-btn").addEventListener("click", stopDownload); + _bindSortableHeaders("#youtube-table", handleSort); const origLog = bridgeHandlers["log"]; bridgeHandlers["log"] = (payload) => { @@ -1952,6 +2092,321 @@ const YoutubeUI = (() => { return { init }; })(); +// ============================================================ +// TRAXSOURCE tab — carica Top 100 per genere, seleziona tracce, scarica +// Parallelo a BeatportUI (stessi canali "download:progress" / +// "download:done" / log view="download"). Colonne: cover, #, artista, +// titolo (mix), label, stato. No Durata / Data (non nel markup Traxsource). +// ============================================================ +const TraxsourceUI = (function () { + let genres = []; + let currentTracks = []; + let existing = []; + let currentGenreName = ""; + let currentGenreSlug = ""; + let downloading = false; + let sortCol = null; + let sortDir = "asc"; + + function safeGenreFolder(name) { + return String(name || "").replace(/[\/\\]/g, "_").trim(); + } + + function populateSelect() { + const sel = $("#traxsource-genre"); + if (!sel) return; + sel.innerHTML = ""; + for (const g of genres) { + const opt = document.createElement("option"); + opt.value = g.slug; + opt.textContent = g.name; + sel.appendChild(opt); + } + } + + function updateOutputInfo() { + const info = $("#traxsource-output-info"); + if (!info) return; + const root = (state.config && state.config.output_dir) || ""; + if (!root) { + info.textContent = "⚠ Imposta la cartella output in Impostazioni prima di scaricare"; + info.className = "beatport-output-info warn"; + return; + } + const folder = safeGenreFolder(currentGenreName); + info.textContent = folder + ? `Destinazione: ${root}/Traxsource_${folder}` + : `Destinazione: ${root}/Traxsource`; + info.className = "beatport-output-info"; + } + + function setStatus(text, kind) { + const el = $("#traxsource-status"); + if (!el) return; + el.textContent = text || ""; + el.className = "beatport-status" + (kind ? " " + kind : ""); + } + + async function loadChart(forceRefresh) { + const sel = $("#traxsource-genre"); + const slug = sel && sel.value; + if (!slug) return; + const genreName = (sel.selectedOptions[0] && sel.selectedOptions[0].textContent) || slug; + currentGenreSlug = slug; + currentGenreName = genreName; + + setStatus(forceRefresh ? "Ricarico Top 100 (bypass cache)…" : "Caricamento Top 100…", "loading"); + $("#traxsource-table").hidden = true; + $("#traxsource-toolbar").hidden = true; + $("#traxsource-table").querySelector("tbody").innerHTML = ""; + + let res; + try { + res = await window.pywebview.api.traxsource_fetch_chart(slug, !!forceRefresh); + } catch (e) { + setStatus("Errore: " + ((e && e.message) || e), "error"); + return; + } + if (!res || !res.ok) { + const code = res && res.error; + let msg = (res && res.message) || "Errore sconosciuto"; + if (code === "invalid_genre") msg = "Genere non valido"; + else if (code === "unreachable") msg = "Traxsource non raggiungibile — riprova più tardi"; + else if (code === "parse") msg = "Impossibile leggere i dati (schema pagina cambiato?)"; + setStatus(msg, "error"); + return; + } + + currentTracks = res.tracks || []; + try { + existing = await window.pywebview.api.traxsource_check_existing(currentTracks, currentGenreName); + } catch (_e) { + existing = currentTracks.map(() => false); + } + setStatus(`${currentTracks.length} brani caricati`, "ok"); + renderTable(); + } + + function renderTable() { + const table = $("#traxsource-table"); + const tbody = table.querySelector("tbody"); + tbody.innerHTML = ""; + + let displayTracks = currentTracks; + let displayExisting = existing; + let origIndexes = currentTracks.map((_, i) => i); + if (sortCol) { + const s = _sortPairs(currentTracks, existing, sortCol, sortDir); + displayTracks = s.tracks; + displayExisting = s.existing; + origIndexes = s.origIndexes; + } + + displayTracks.forEach((t, i) => { + const origIdx = origIndexes[i]; + const alreadyDl = displayExisting[i]; + const tr = document.createElement("tr"); + if (alreadyDl) tr.classList.add("already-downloaded"); + + const tdCheck = document.createElement("td"); + tdCheck.className = "col-check"; + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.dataset.idx = String(origIdx); + cb.checked = !alreadyDl; + cb.addEventListener("change", updateSelectionCount); + tdCheck.appendChild(cb); + tr.appendChild(tdCheck); + + const tdCover = document.createElement("td"); + tdCover.className = "col-cover"; + if (t.image_url) { + const img = document.createElement("img"); + img.src = t.image_url; + img.alt = ""; + img.loading = "lazy"; + img.referrerPolicy = "no-referrer"; + img.onerror = () => { img.style.visibility = "hidden"; }; + tdCover.appendChild(img); + } + tr.appendChild(tdCover); + + const tdPos = document.createElement("td"); + tdPos.className = "col-pos"; + tdPos.textContent = String(t.position || (origIdx + 1)); + tr.appendChild(tdPos); + + const tdArtist = document.createElement("td"); + tdArtist.textContent = t.artists || ""; + tr.appendChild(tdArtist); + + const tdTitle = document.createElement("td"); + tdTitle.textContent = t.mix ? `${t.title} (${t.mix})` : (t.title || ""); + tr.appendChild(tdTitle); + + const tdLabel = document.createElement("td"); + tdLabel.textContent = t.label || ""; + tr.appendChild(tdLabel); + + const tdState = document.createElement("td"); + tdState.className = "col-state"; + if (alreadyDl) tdState.textContent = "✓ già scaricato"; + tr.appendChild(tdState); + + tbody.appendChild(tr); + }); + + _updateSortArrows("#traxsource-table", sortCol, sortDir); + table.hidden = false; + $("#traxsource-toolbar").hidden = false; + updateOutputInfo(); + updateSelectionCount(); + } + + function handleSort(col) { + if (sortCol === col) { + sortDir = sortDir === "asc" ? "desc" : "asc"; + } else { + sortCol = col; + sortDir = "asc"; + } + renderTable(); + } + + function updateSelectionCount() { + const boxes = $$("#traxsource-table tbody input[type=checkbox]"); + const total = boxes.length; + const checked = boxes.filter((b) => b.checked).length; + $("#traxsource-selected-count").textContent = `${checked}/${total} selezionati`; + const dlBtn = $("#traxsource-download-btn"); + if (dlBtn) dlBtn.disabled = downloading || checked === 0; + + const master = $("#traxsource-select-all"); + if (master) { + if (total === 0) { master.checked = false; master.indeterminate = false; } + else if (checked === 0) { master.checked = false; master.indeterminate = false; } + else if (checked === total) { master.checked = true; master.indeterminate = false; } + else { master.checked = false; master.indeterminate = true; } + } + } + + function toggleAll(checked) { + $$("#traxsource-table tbody input[type=checkbox]").forEach((b) => { b.checked = checked; }); + updateSelectionCount(); + } + + function appendTraxsourceLog(msg) { + const el = $("#traxsource-log"); + if (!el) return; + const cls = classifyLog(msg); + const line = document.createElement("div"); + if (cls) line.className = cls; + line.textContent = msg; + el.appendChild(line); + el.scrollTop = el.scrollHeight; + } + + async function startDownload() { + const boxes = $$("#traxsource-table tbody input[type=checkbox]"); + const selected = []; + boxes.forEach((b) => { + if (b.checked) { + const idx = parseInt(b.dataset.idx, 10); + if (!isNaN(idx) && currentTracks[idx]) selected.push(currentTracks[idx]); + } + }); + if (!selected.length) { + toast("Seleziona almeno un brano", "error"); + return; + } + if (!state.config || !state.config.output_dir) { + toast("Imposta la cartella output in Impostazioni", "error"); + return; + } + + downloading = true; + $("#traxsource-download-btn").disabled = true; + $("#traxsource-stop-btn").hidden = false; + $("#traxsource-stop-btn").disabled = false; + $("#traxsource-log").innerHTML = ""; + appendTraxsourceLog(`[INFO] Avvio download di ${selected.length} brani da ${currentGenreName}…`); + + let res; + try { + res = await window.pywebview.api.traxsource_download_selected(selected, currentGenreName); + } catch (e) { + appendTraxsourceLog("[ERRORE] " + ((e && e.message) || e)); + finishDownload(); + return; + } + if (!res || !res.ok) { + const errMsg = (res && (res.error || res.message)) || "Impossibile avviare il download"; + appendTraxsourceLog("[ERRORE] " + errMsg); + if (!handleGateBlock(res || {})) toast(errMsg, "error"); + finishDownload(); + } + } + + async function stopDownload() { + try { + await window.pywebview.api.stop_download(); + } catch (e) { console.error(e); } + } + + function finishDownload() { + downloading = false; + $("#traxsource-stop-btn").hidden = true; + $("#traxsource-stop-btn").disabled = true; + updateSelectionCount(); + if (currentTracks.length) { + window.pywebview.api.traxsource_check_existing(currentTracks, currentGenreName) + .then((res) => { existing = res || existing; renderTable(); }) + .catch(() => {}); + } + } + + async function init() { + try { + genres = await window.pywebview.api.traxsource_genres(); + } catch (e) { + console.error("Traxsource: impossibile caricare i generi", e); + genres = []; + } + populateSelect(); + + const last = (state.config && state.config.traxsource_last_genre) || ""; + const sel = $("#traxsource-genre"); + if (sel && last && genres.some((g) => g.slug === last)) { + sel.value = last; + } + updateOutputInfo(); + + $("#traxsource-load-btn").addEventListener("click", (e) => loadChart(e.shiftKey)); + $("#traxsource-download-btn").addEventListener("click", startDownload); + $("#traxsource-stop-btn").addEventListener("click", stopDownload); + $("#traxsource-select-all").addEventListener("change", (e) => toggleAll(e.target.checked)); + _bindSortableHeaders("#traxsource-table", handleSort); + + // Wrapping additivo sui bridgeHandlers (canale "download" condiviso) + const origProgress = bridgeHandlers["download:progress"]; + bridgeHandlers["download:progress"] = (p) => { + if (origProgress) origProgress(p); + }; + const origDone = bridgeHandlers["download:done"]; + bridgeHandlers["download:done"] = (p) => { + if (origDone) origDone(p); + if (downloading) finishDownload(); + }; + const origLog = bridgeHandlers["log"]; + bridgeHandlers["log"] = ({ view, msg }) => { + if (origLog) origLog({ view, msg }); + if (downloading && view === "download") appendTraxsourceLog(msg); + }; + } + + return { init }; +})(); + // ===================================================================== // Converti (WAV -> MP3) // =====================================================================