From e7fde842f2f4d6dc59a38a753e44e42174f63d3c Mon Sep 17 00:00:00 2001 From: luciano Date: Sun, 2 Aug 2026 12:56:19 +0200 Subject: [PATCH] feat: upgrade archivio con auto-pick, audio preview, cancel-all (v1.9.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab Upgrade: - Checkbox "Scelta automatica" per skippare il modal quando ci sono multi-match (usa bitrate DESC) - Anteprima audio nel picker: play button sul source + ogni candidato via bridge read_audio_data_url (WKWebView blocca file://) - Bottone "Annulla tutto" nel picker → interrompe l'intera coda - Fix mtime: shutil.copy (non copy2) così il file upgradato ha data odierna nel Finder - Fix stop audio → non triggera più toast di errore - Modal picker più largo (620-780px) con footer wrap Extra: campo release_date estratto anche in Beatport (publish_date) e YouTube (upload_date) — completato scaffold sort colonna Data --- api/bridge.py | 49 ++++++++++++++++++-- core/beatport.py | 2 + core/config.py | 2 +- core/upgrader.py | 7 ++- core/youtube_search.py | 9 ++++ webui/css/style.css | 51 ++++++++++++++++++++ webui/index.html | 9 +++- webui/js/app.js | 103 +++++++++++++++++++++++++++++++++++++++-- 8 files changed, 219 insertions(+), 13 deletions(-) diff --git a/api/bridge.py b/api/bridge.py index af97aa8..2188c70 100644 --- a/api/bridge.py +++ b/api/bridge.py @@ -887,6 +887,7 @@ class Api: directory = (payload.get("directory") or "").strip() archive_dir = (payload.get("archive_dir") or "").strip() or None + archive_auto_pick = bool(payload.get("archive_auto_pick", False)) recursive = bool(payload.get("recursive", False)) try: threshold = int(payload.get("threshold", 310)) @@ -912,7 +913,7 @@ class Api: self._upgrade_thread = threading.Thread( target=self._upgrade_worker, - args=(directory, threshold, cookies_path, recursive, archive_dir), + args=(directory, threshold, cookies_path, recursive, archive_dir, archive_auto_pick), daemon=True, ) self._upgrade_thread.start() @@ -929,6 +930,32 @@ class Api: self._log("upgrade", "[INFO] Interruzione richiesta...") return {"ok": True} + def read_audio_data_url(self, path: str) -> dict: + """Legge un file audio locale e lo ritorna come data URL base64 + per il preview HTML5 nel modal upgrade. WKWebView (macOS pywebview) + blocca `file://` da HTML servito via file://, quindi passiamo dal + bridge. Limite: 60MB per non esplodere la memoria JS.""" + import base64 as _b64 + MAX = 60 * 1024 * 1024 + try: + p = Path(path or "") + if not p.exists() or not p.is_file(): + return {"ok": False, "error": "File non trovato"} + size = p.stat().st_size + if size > MAX: + return {"ok": False, "error": f"File troppo grande ({size // 1024 // 1024}MB, max 60MB)"} + ext = p.suffix.lower().lstrip(".") + mime_map = { + "mp3": "audio/mpeg", "m4a": "audio/mp4", "aac": "audio/aac", + "wav": "audio/wav", "flac": "audio/flac", "ogg": "audio/ogg", + "opus": "audio/opus", "webm": "audio/webm", + } + mime = mime_map.get(ext, "audio/mpeg") + b64 = _b64.b64encode(p.read_bytes()).decode("ascii") + return {"ok": True, "data_url": f"data:{mime};base64,{b64}"} + except Exception as e: + return {"ok": False, "error": str(e)} + def upgrade_resolve_candidates(self, choice: dict) -> dict: """Riceve la scelta utente dal modal 'match locali multipli' e la deposita nella queue attesa dal resolve_callback del worker. @@ -949,7 +976,8 @@ class Api: return {"ok": False, "error": str(e)} def _upgrade_worker(self, directory, threshold, cookies_path, recursive, - archive_dir: Optional[str] = None): + archive_dir: Optional[str] = None, + archive_auto_pick: bool = False): view = "upgrade" def progress_cb(idx, total, filename, status, old_kbps, new_kbps): @@ -999,10 +1027,21 @@ class Api: self._emit("upgrade:progress", payload_evt) - def resolve_cb(filename: str, candidates: list) -> dict: - """Bloccante: emette evento e attende scelta utente via queue.""" + def resolve_cb(source_path: str, candidates: list) -> dict: + """Bloccante: emette evento e attende scelta utente via queue. + Se archive_auto_pick=True, salta il modal e sceglie il primo + candidato (già ordinato per bitrate DESC nel core). + `source_path` è il path assoluto del file sorgente (per il preview + audio nel modal).""" + src_name = Path(source_path).name if source_path else "" + if archive_auto_pick and candidates: + top = candidates[0] + path = top.get("path") if isinstance(top, dict) else str(top) + self._log(view, f"[AUTO] {src_name}: match multipli, uso {Path(path).name}") + return {"action": "use_local", "path": path} self._emit("upgrade:candidates_needed", { - "file": filename, + "file": src_name, + "source_path": source_path, "candidates": candidates, }) try: diff --git a/core/beatport.py b/core/beatport.py index 5ce9196..2468e64 100644 --- a/core/beatport.py +++ b/core/beatport.py @@ -24,6 +24,7 @@ class BeatportTrack: duration_sec: int beatport_id: int image_url: str = "" # URL cover art (default vuoto per retrocompatibilità test) + release_date: str = "" # ISO YYYY-MM-DD, vuoto se mancante @property def display(self) -> str: @@ -177,6 +178,7 @@ def _parse_tracks(data: dict) -> list: duration_sec=length_ms // 1000, beatport_id=int(item.get("id") or 0), image_url=_extract_image_url(item.get("image")), + release_date=str(item.get("publish_date") or item.get("new_release_date") or "").strip(), ) except (TypeError, ValueError) as e: raise BeatportParseError(f"track[{i}] shape inattesa: {e}") from e diff --git a/core/config.py b/core/config.py index 842c7ff..782c055 100644 --- a/core/config.py +++ b/core/config.py @@ -5,7 +5,7 @@ import os import sys from pathlib import Path -VERSION = "v1.9.3" +VERSION = "v1.9.4" APP_NAME = "MusicTools" diff --git a/core/upgrader.py b/core/upgrader.py index da8c4b4..45522ac 100644 --- a/core/upgrader.py +++ b/core/upgrader.py @@ -188,7 +188,10 @@ def _copy_or_convert_to_mp3( """ try: if src.suffix.lower() == ".mp3": - shutil.copy2(str(src), str(dst)) + # shutil.copy (non copy2): mtime del target = ora, non quello + # del sorgente. Così è chiaro nel Finder che il file è stato + # aggiornato dall'upgrade. + shutil.copy(str(src), str(dst)) return dst.exists() # Convert non-mp3 -> mp3 320k ffmpeg_bin = find_ffmpeg() or "ffmpeg" @@ -431,7 +434,7 @@ def upgrade_folder( progress_callback(processed, total, filepath.name, "resolve_wait", current_kbps, 0) try: - choice = resolve_callback(filepath.name, cand_payload) or {} + choice = resolve_callback(str(filepath), cand_payload) or {} except Exception: choice = {} action = (choice.get("action") or "").strip() diff --git a/core/youtube_search.py b/core/youtube_search.py index b5b7b40..885ecfa 100644 --- a/core/youtube_search.py +++ b/core/youtube_search.py @@ -63,6 +63,14 @@ def search_youtube(query: str, limit: int = 50) -> list: video_id = e.get("id") or "" url = e.get("url") or (f"https://www.youtube.com/watch?v={video_id}" if video_id else "") image_url = f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg" if video_id else "" + # yt-dlp `upload_date` è YYYYMMDD (stringa). Lo converto in ISO YYYY-MM-DD. + # Con --flat-playlist può essere assente; in tal caso resta stringa vuota. + raw_date = str(e.get("upload_date") or "").strip() + release_date = "" + if len(raw_date) == 8 and raw_date.isdigit(): + release_date = f"{raw_date[0:4]}-{raw_date[4:6]}-{raw_date[6:8]}" + elif raw_date: + release_date = raw_date # forma sconosciuta, passa così result.append({ "id": video_id, "url": url, @@ -70,5 +78,6 @@ def search_youtube(query: str, limit: int = 50) -> list: "channel": e.get("uploader") or e.get("channel") or "", "duration_sec": int(e.get("duration") or 0), "image_url": image_url, + "release_date": release_date, }) return result diff --git a/webui/css/style.css b/webui/css/style.css index f381aa4..33579e7 100644 --- a/webui/css/style.css +++ b/webui/css/style.css @@ -1680,8 +1680,21 @@ input[type="number"]::-webkit-inner-spin-button { } .beatport-table .col-pos { width: 40px; color: var(--text-3); font-variant-numeric: tabular-nums; } .beatport-table .col-dur { width: 70px; color: var(--text-2); font-variant-numeric: tabular-nums; text-align: right; } +.beatport-table .col-date { width: 100px; color: var(--text-2); font-variant-numeric: tabular-nums; font-size: 12px; white-space: nowrap; } .beatport-table .col-state { width: 130px; font-size: 12px; } +.beatport-table thead th.sortable { + cursor: pointer; + user-select: none; + transition: color 0.15s ease; +} +.beatport-table thead th.sortable:hover { color: var(--text); } +.beatport-table thead th .sort-arrow { + font-size: 10px; + margin-left: 4px; + color: var(--indigo-text); +} + .beatport-table input[type="checkbox"] { width: 16px; height: 16px; @@ -1720,6 +1733,17 @@ input[type="number"]::-webkit-inner-spin-button { white-space: normal; user-select: none; } + +/* Modal picker: più largo dei modal standard + footer con wrap + per far entrare i 4 bottoni senza troncare */ +#upgradePickerModal .modal { + min-width: 620px; + max-width: 780px; +} +#upgradePickerModal .modal-foot { + flex-wrap: wrap; + justify-content: flex-end; +} .modal-sub { font-size: 12px; color: var(--text-2); @@ -1757,6 +1781,33 @@ input[type="number"]::-webkit-inner-spin-button { flex: 1; min-width: 0; } +.upgrade-picker-play { + display: flex; + align-items: center; + flex-shrink: 0; +} +.upgrade-preview-btn { + width: 34px; + height: 34px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--bg-input, transparent); + color: var(--text); + font-size: 13px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + margin-left: 6px; + transition: background 0.15s ease, transform 0.1s ease; +} +.upgrade-preview-btn:hover { background: var(--bg-input-hover, rgba(255,255,255,0.05)); } +.upgrade-preview-btn.playing { + background: var(--green, #1DB954); + color: #000; + border-color: var(--green, #1DB954); +} .upgrade-picker-title { font-weight: 600; color: var(--text); diff --git a/webui/index.html b/webui/index.html index 06139e7..63c0526 100644 --- a/webui/index.html +++ b/webui/index.html @@ -280,9 +280,13 @@ +
ⓘ -
Se selezionata, l'app cerca versioni HQ del brano nella tua libreria locale prima di scaricare da YouTube. In caso di match multipli scegli tu quale usare.
+
Se selezionata, l'app cerca versioni HQ del brano nella tua libreria locale prima di scaricare da YouTube. In caso di match multipli scegli tu quale usare — oppure attiva "Scelta automatica" per non fermarti.
@@ -1289,7 +1293,8 @@ diff --git a/webui/js/app.js b/webui/js/app.js index ccc8b80..12ef45b 100644 --- a/webui/js/app.js +++ b/webui/js/app.js @@ -776,6 +776,7 @@ $("#upgradeBtn").addEventListener("click", async () => { recursive: $("#upRecursive").checked, threshold: parseInt($("#upThreshold").value, 10) || 310, archive_dir: state.upArchiveDir || "", + archive_auto_pick: $("#upArchiveAutoPick").checked, }); if (!res.ok) { if (!handleGateBlock(res)) toast(res.error || "Errore", "error"); @@ -809,14 +810,98 @@ function _escapeHtml(s) { }[c])); } +// --- Audio preview per il picker (single audio element condiviso) --- +let _upgradePreviewAudio = null; +let _upgradePreviewBtn = null; // bottone corrente in "playing" state + +function _fileUrl(absPath) { + if (!absPath) return ""; + // Split percorso e encode ogni segment (spazi → %20, unicode → utf-8 encoded) + const parts = String(absPath).split("/").map(encodeURIComponent); + return "file://" + parts.join("/"); +} + +function _stopUpgradePreview() { + if (_upgradePreviewAudio) { + // Rimuovi handler error PRIMA di pause/clear così stop manuale + // non fa scattare il toast di errore + _upgradePreviewAudio.onerror = null; + _upgradePreviewAudio.onended = null; + try { _upgradePreviewAudio.pause(); } catch {} + try { _upgradePreviewAudio.removeAttribute("src"); } catch {} + _upgradePreviewAudio = null; + } + if (_upgradePreviewBtn) { + _upgradePreviewBtn.textContent = "▶"; + _upgradePreviewBtn.classList.remove("playing"); + _upgradePreviewBtn = null; + } +} + +function _makePreviewBtn(absPath) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "upgrade-preview-btn"; + btn.textContent = "▶"; + btn.title = "Anteprima audio"; + btn.addEventListener("click", async (e) => { + e.preventDefault(); + e.stopPropagation(); + // Se già in play su questo bottone → stop + if (_upgradePreviewBtn === btn) { + _stopUpgradePreview(); + return; + } + _stopUpgradePreview(); + btn.textContent = "⏳"; + btn.disabled = true; + let res; + try { + res = await window.pywebview.api.read_audio_data_url(absPath); + } catch (err) { + btn.textContent = "▶"; + btn.disabled = false; + toast && toast("Errore lettura file: " + err, "error"); + return; + } + btn.disabled = false; + if (!res || !res.ok) { + btn.textContent = "▶"; + toast && toast(res && res.error || "Impossibile leggere il file", "error"); + return; + } + _upgradePreviewAudio = new Audio(res.data_url); + _upgradePreviewBtn = btn; + btn.textContent = "■"; + btn.classList.add("playing"); + _upgradePreviewAudio.onended = _stopUpgradePreview; + _upgradePreviewAudio.onerror = () => { + // Solo se il listener è ancora agganciato (stop manuale lo azzera prima) + const wasPlaying = !!_upgradePreviewAudio; + _stopUpgradePreview(); + if (wasPlaying) toast && toast("Errore riproduzione audio", "error"); + }; + _upgradePreviewAudio.play().catch(() => _stopUpgradePreview()); + }); + return btn; +} + function openUpgradePicker(payload) { const modal = $("#upgradePickerModal"); if (!modal) return; const file = (payload && payload.file) || ""; + const sourcePath = (payload && payload.source_path) || ""; const candidates = (payload && payload.candidates) || []; - state.upPicker = { file, candidates, selectedIdx: -1 }; + state.upPicker = { file, sourcePath, candidates, selectedIdx: -1 }; + + // Header: filename + play button per il source + const header = $("#upgradePickerFilename"); + header.innerHTML = ""; + const label = document.createElement("span"); + label.textContent = "Brano da upgradare: " + file + " "; + header.appendChild(label); + if (sourcePath) header.appendChild(_makePreviewBtn(sourcePath)); - $("#upgradePickerFilename").textContent = "Brano: " + file; const list = $("#upgradePickerList"); list.innerHTML = ""; candidates.forEach((c, i) => { @@ -832,8 +917,13 @@ function openUpgradePicker(payload) {
${_escapeHtml(kbps)} · ${_escapeHtml(_fmtBytes(c.size))} · similarity ${simPct}%
${_escapeHtml(c.path || "")}
+ `; - row.addEventListener("click", () => { + const playSlot = row.querySelector(".upgrade-picker-play"); + if (playSlot && c.path) playSlot.appendChild(_makePreviewBtn(c.path)); + row.addEventListener("click", (e) => { + // Se il click viene dal bottone play, non selezionare la riga + if (e.target.closest(".upgrade-preview-btn")) return; state.upPicker.selectedIdx = i; $("#upgradePickerUseBtn").disabled = false; $$("#upgradePickerList .upgrade-picker-row").forEach((r) => @@ -849,6 +939,7 @@ function openUpgradePicker(payload) { } function closeUpgradePicker() { + _stopUpgradePreview(); const modal = $("#upgradePickerModal"); if (modal) modal.hidden = true; } @@ -875,6 +966,12 @@ $("#upgradePickerSkipBtn")?.addEventListener("click", () => { _sendUpgradeChoice({ action: "skip" }); }); +$("#upgradePickerCancelAllBtn")?.addEventListener("click", async () => { + // Skip questo file per sbloccare il worker, poi ferma l'intera coda + _sendUpgradeChoice({ action: "skip" }); + try { await window.pywebview.api.stop_upgrade(); } catch {} +}); + $("#upgradePickerCloseBtn")?.addEventListener("click", () => { _sendUpgradeChoice({ action: "skip" }); });