From cdbb1d85d5caf40b07836c393fcb1a675b730d49 Mon Sep 17 00:00:00 2001 From: luciano Date: Thu, 27 Aug 2026 10:57:04 +0200 Subject: [PATCH] feat: fallback Spotify + layout selector + tab Appiattisci (v1.11.0) Cataloga: - Fallback Spotify quando AcoustID non trova (copertura ottima per dance/electronic mashup/bootleg/remix) - Dropdown "Struttura cartelle" per scegliere tra // e // - Chiave AcoustID default sostituita da placeholder invalido a quella pubblica di MusicBrainz Picard (valida) Nuova tab Appiattisci: - Sposta tutti i file audio delle sotto-cartelle nella cartella padre - Utile come undo della catalogazione o merge di archivi frammentati - Rimuove sotto-cartelle vuote dopo il move (opzionale) - Suffisso _1, _2 su nomi duplicati (no overwrite) --- .gitignore | 2 + api/bridge.py | 102 ++++++++++++++++++++++++++++++- core/acoustid.py | 5 +- core/catalog.py | 101 +++++++++++++++++++++++++++++- core/flatten.py | 156 +++++++++++++++++++++++++++++++++++++++++++++++ webui/index.html | 75 ++++++++++++++++++++++- webui/js/app.js | 114 +++++++++++++++++++++++++++++++++- 7 files changed, 546 insertions(+), 9 deletions(-) create mode 100644 core/flatten.py diff --git a/.gitignore b/.gitignore index afa4616..a357474 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,5 @@ server/dist/ # Dedup local cache (SQLite fingerprint cache, per macchina dell'utente) dedup_cache.db dedup_cache.db-journal +catalog_cache.db +catalog_cache.db-journal diff --git a/api/bridge.py b/api/bridge.py index 535e416..9cb1a0d 100644 --- a/api/bridge.py +++ b/api/bridge.py @@ -48,6 +48,7 @@ from core.upgrader import ( ) from core import dedup from core import catalog +from core import flatten SPOTIFY_GUIDE_TEXT = """\ @@ -115,6 +116,7 @@ class Api: self._video_thread: Optional[threading.Thread] = None self._dedup_thread: Optional[threading.Thread] = None self._catalog_thread: Optional[threading.Thread] = None + self._flatten_thread: Optional[threading.Thread] = None # Coda usata dal resolve_callback per attendere la scelta utente # sul modal "match locali multipli" della tab Upgrade. self._upgrade_resolve_q: "queue.Queue[dict]" = queue.Queue(1) @@ -2021,7 +2023,7 @@ class Api: self._log("catalog", "[INFO] Interruzione richiesta...") return {"ok": True} - def catalog_move_files(self, entries: list, target_root: str) -> dict: + def catalog_move_files(self, entries: list, target_root: str, layout: str = "year_genre") -> dict: """Sposta le entry selezionate in ///. Non consuma quota (e' una riorganizzazione di file locali, @@ -2043,7 +2045,8 @@ class Api: pass try: - result = catalog.move_files(entries, target, log_callback=log_cb) + safe_layout = layout if layout in ("year_genre", "genre_year") else "year_genre" + result = catalog.move_files(entries, target, log_callback=log_cb, layout=safe_layout) except Exception as e: self._log("catalog", f"[ERRORE] move: {e}") return {"ok": False, "error": str(e)} @@ -2064,6 +2067,88 @@ class Api: "operations": result.get("operations", []), } + # ------------------------------------------------------------------ + # FLATTEN — sposta tutti i file audio delle sotto-cartelle nella root + # ------------------------------------------------------------------ + def flatten_pick_folder(self) -> str: + if not self.window: + return "" + try: + r = self.window.create_file_dialog(self._folder_dialog_type()) + except Exception: + return "" + if not r: + return "" + return str(r[0]) if isinstance(r, (list, tuple)) else str(r) + + def flatten_start(self, payload: dict) -> dict: + """Payload: {directory, remove_empty}.""" + if self._flatten_thread and self._flatten_thread.is_alive(): + return {"ok": False, "error": "Operazione gia' in corso"} + directory = (payload.get("directory") or "").strip() + if not directory: + return {"ok": False, "error": "Cartella non impostata"} + if not os.path.isdir(directory): + return {"ok": False, "error": "Cartella non trovata"} + remove_empty = bool(payload.get("remove_empty", True)) + self._flatten_thread = threading.Thread( + target=self._flatten_worker, + args=(directory, remove_empty), + daemon=True, + ) + self._flatten_thread.start() + return {"ok": True} + + def flatten_stop(self) -> dict: + flatten.request_stop() + return {"ok": True} + + def _flatten_worker(self, directory: str, remove_empty: bool) -> None: + view = "flatten" + self._log(view, f"[INFO] Appiattisco: {directory} (remove_empty={remove_empty})") + + def progress_cb(idx, total, name, status, err=""): + payload = {"idx": idx, "total": total, "filename": name, "status": status} + if err: + payload["error_msg"] = err + if total > 0: + payload["overall"] = min(idx / total, 1.0) + if status == "completed": + payload["overall"] = 1.0 + self._log(view, f"[INFO] Completato: {total} file processati.") + elif status == "stopped": + self._log(view, "[INFO] Operazione interrotta.") + elif status == "error" and name: + self._log(view, f"[ERRORE] {name}: {err}") + self._emit("flatten:progress", payload) + + def log_cb(op): + self._log(view, f"[MOVE] {op.get('src','')} → {op.get('dst','')}") + + try: + result = flatten.flatten_folder( + directory, + remove_empty=remove_empty, + progress_callback=progress_cb, + log_callback=log_cb, + ) + except Exception as e: + self._log(view, f"[ERRORE] {e}") + self._emit("flatten:done", {"ok": False, "error": str(e)}) + return + + moved = result.get("moved", 0) + failed = result.get("failed", []) or [] + dirs_removed = result.get("dirs_removed", 0) + self._log(view, f"[OK] Spostati {moved} file. Cartelle rimosse: {dirs_removed}. Falliti: {len(failed)}.") + self._emit("flatten:done", { + "ok": True, + "moved": moved, + "failed_count": len(failed), + "failed": failed, + "dirs_removed": dirs_removed, + }) + def _catalog_worker(self, directory: str, recursive: bool) -> None: """Esegue scan + lookup AcoustID in background, emette streaming.""" view = "catalog" @@ -2107,12 +2192,25 @@ class Api: except Exception: pass + # Prova a ottenere un token Spotify per il fallback (facoltativo). + # Se non ci sono creds o l'auth fallisce, procediamo senza fallback. + spotify_token = None + try: + cfg = load_config() + cid = (cfg.get("client_id") or "").strip() + csecret = (cfg.get("client_secret") or "").strip() + if cid and csecret: + spotify_token = get_access_token(cid, csecret) + except Exception: + spotify_token = None + try: entries = catalog.scan_folder( directory, recursive=recursive, progress_callback=progress_cb, entry_callback=entry_cb, + spotify_token=spotify_token, ) except Exception as e: self._log(view, f"[ERRORE] {e}") diff --git a/core/acoustid.py b/core/acoustid.py index 46f4cdb..a9382ae 100644 --- a/core/acoustid.py +++ b/core/acoustid.py @@ -30,7 +30,10 @@ import requests # App key pubblica per MusicTools. Puo' essere sovrascritta passando # `app_key` esplicito o via config. Chiavi si ottengono gratis su # https://acoustid.org/api-key (max ~3 req/s). -_APP_KEY = "8XaBELgH" # placeholder demo — sostituibile via config +# Default: chiave pubblica di MusicBrainz Picard (open-source tagger ufficiale). +# Per uso intenso registra una app dedicata su https://acoustid.org/api-key +# e settala in Impostazioni → acoustid_app_key. +_APP_KEY = "v8pQ6oyB" _API_URL = "https://api.acoustid.org/v2/lookup" _REQUEST_TIMEOUT = 20 _RATE_LIMIT_SEC = 0.35 # ~3 req/s max diff --git a/core/catalog.py b/core/catalog.py index 430a79d..aa79873 100644 --- a/core/catalog.py +++ b/core/catalog.py @@ -28,6 +28,79 @@ from pathlib import Path from typing import Callable, Optional from core.acoustid import lookup + + +_NUM_PREFIX = re.compile(r"^\s*\d+\s*[.\-)]\s*") +_PAREN_TAIL = re.compile(r"\s*[\[(].*?[\])]\s*$") +_DASH_SPLIT = re.compile(r"\s+[-–—]\s+") + + +def _parse_filename(stem: str) -> tuple: + """Estrae (artist, title) da nome file tipo 'Artista - Titolo (Mix)'. + Ritorna ('', stem) se non c'è dash separator.""" + s = stem.strip() + s = _NUM_PREFIX.sub("", s) + # Rimuove parentesi finali (Extended Mix), [Original Mix] ecc. + for _ in range(3): + s = _PAREN_TAIL.sub("", s).strip() + parts = _DASH_SPLIT.split(s, maxsplit=1) + if len(parts) == 2: + artist, title = parts[0].strip(), parts[1].strip() + if artist and title: + return artist, title + return "", s + + +def _spotify_lookup_fallback(stem: str, token: str, genre_cache: dict) -> Optional[dict]: + """Fallback quando AcoustID non trova nulla: search Spotify col filename. + + Ritorna {matched, year, genre, artist, title} o None su errore. + Popola `genre_cache` (dict artist_id → primo genere) per non chiamare + l'API `/artists/` per ogni traccia dello stesso artista. + """ + from core.spotify_client import search_tracks, get_artist_genres + + artist_hint, title_hint = _parse_filename(stem) + query = f"{artist_hint} {title_hint}".strip() if artist_hint else title_hint + if not query: + return None + + try: + results = search_tracks(token, query, limit=1) + except Exception: + return None + if not results: + return None + + top = results[0] + release_date = (top.get("release_date") or "").strip() + year = None + if len(release_date) >= 4 and release_date[:4].isdigit(): + year = int(release_date[:4]) + + # Prendi il primo genere dell'artista (con cache per non abusare l'API) + genre = "" + artist_id = top.get("artist_id") or "" + if artist_id: + if artist_id in genre_cache: + genres = genre_cache[artist_id] + else: + try: + genres = get_artist_genres(token, artist_id) or [] + except Exception: + genres = [] + genre_cache[artist_id] = genres + if genres: + genre = genres[0] + + return { + "matched": bool(year or genre or top.get("name")), + "year": year, + "genre": genre, + "artist": top.get("artists", "") or "", + "title": top.get("name", "") or "", + "_source": "spotify", + } from core.dedup import compute_fingerprint from core.paths import find_fpcalc from core.upgrader import AUDIO_EXTENSIONS @@ -89,6 +162,7 @@ def scan_folder( recursive: bool = True, progress_callback: Optional[Callable] = None, entry_callback: Optional[Callable] = None, + spotify_token: Optional[str] = None, ) -> list: """Scansiona la cartella e ritorna la lista di entry con metadata. @@ -127,6 +201,8 @@ def scan_folder( fpcalc = find_fpcalc() entries: list = [] + # Cache locale per non re-fetchare i generi dello stesso artista Spotify + _spotify_genre_cache: dict = {} for i, fp_path in enumerate(files, start=1): if is_stopped(): _emit_progress(progress_callback, i - 1, total, "", "stopped", "") @@ -166,15 +242,30 @@ def scan_folder( _emit_progress(progress_callback, i, total, fp_path.name, "lookup", "") info = lookup(fp_hash, duration) + matched = bool(info.get("matched")) + source = "acoustid" + + # Fallback su Spotify se AcoustID non ha trovato niente e abbiamo il token. + # Molti file (mashup, bootleg, extended mix) non sono in MusicBrainz ma Spotify + # ha ottima copertura per dance/electronic. + if not matched and spotify_token: + _emit_progress(progress_callback, i, total, fp_path.name, "spotify_fallback", "") + fb = _spotify_lookup_fallback(fp_path.stem, spotify_token, _spotify_genre_cache) + if fb and fb.get("matched"): + info = fb + matched = True + source = "spotify" + entry = { "path": str(fp_path), "size": size, "fingerprint": fp_hash, - "matched": bool(info.get("matched")), + "matched": matched, "year": info.get("year"), "genre": (info.get("genre") or "").strip(), "artist": (info.get("artist") or "").strip(), "title": (info.get("title") or "").strip(), + "source": source, } if info.get("error"): entry["error"] = info["error"] @@ -210,7 +301,8 @@ def _emit_progress(cb: Optional[Callable], idx: int, total: int, # Move # ------------------------------------------------------------------ def move_files(entries: list, target_root: str, - log_callback: Optional[Callable] = None) -> dict: + log_callback: Optional[Callable] = None, + layout: str = "year_genre") -> dict: """Sposta i file elencati in `///`. - Se `year` manca → cartella "Unknown Year" @@ -258,7 +350,10 @@ def move_files(entries: list, target_root: str, year_folder = str(year) if year else "Unknown Year" genre_folder = _sanitize_folder(genre) or "Unknown Genre" - dst_dir = target_base / year_folder / genre_folder + if layout == "genre_year": + dst_dir = target_base / genre_folder / year_folder + else: # year_genre (default) + dst_dir = target_base / year_folder / genre_folder try: dst_dir.mkdir(parents=True, exist_ok=True) except OSError as e: diff --git a/core/flatten.py b/core/flatten.py new file mode 100644 index 0000000..603fb63 --- /dev/null +++ b/core/flatten.py @@ -0,0 +1,156 @@ +"""Appiattisce una cartella: sposta tutti i file audio delle sotto-cartelle +nella cartella padre. Utile come 'undo' di una catalogazione o per unire +archivi frammentati. + +Non tocca i file gia' nella root della cartella padre. +Conflitti filename: aggiunge suffisso _1, _2... (mai overwrite). +Opzionalmente rimuove le sotto-cartelle rimaste vuote dopo il move. +""" + +from __future__ import annotations + +import shutil +import threading +from pathlib import Path +from typing import Callable, Optional + +from core.upgrader import AUDIO_EXTENSIONS + + +_stop_event = threading.Event() + + +def request_stop() -> None: + _stop_event.set() + + +def reset_stop() -> None: + _stop_event.clear() + + +def is_stopped() -> bool: + return _stop_event.is_set() + + +def _iter_audio_in_subfolders(parent: Path) -> list: + """Enumera i file audio in TUTTE le sotto-cartelle (ricorsivo) escludendo + quelli gia' nella root del `parent`.""" + if not parent.is_dir(): + return [] + files: list = [] + for f in parent.rglob("*"): + if not f.is_file(): + continue + if f.suffix.lower() not in AUDIO_EXTENSIONS: + continue + if f.parent.resolve() == parent.resolve(): + continue # gia' nella root + files.append(f) + files.sort() + return files + + +def _unique_dst(dst: Path) -> Path: + """Restituisce un path non-esistente aggiungendo `_1`, `_2`... se serve.""" + if not dst.exists(): + return dst + stem = dst.stem + suffix = dst.suffix + i = 1 + while True: + candidate = dst.parent / f"{stem}_{i}{suffix}" + if not candidate.exists(): + return candidate + i += 1 + + +def _remove_empty_dirs(parent: Path) -> int: + """Rimuove ricorsivamente tutte le sotto-cartelle vuote di `parent`. + Non tocca `parent` stessa. Ritorna il numero di dir rimosse.""" + removed = 0 + for p in sorted(parent.rglob("*"), key=lambda x: len(str(x)), reverse=True): + if not p.is_dir(): + continue + if p.resolve() == parent.resolve(): + continue + try: + # rimuove .DS_Store residui che impediscono rmdir + for junk in p.glob(".DS_Store"): + try: junk.unlink() + except Exception: pass + p.rmdir() + removed += 1 + except OSError: + pass # non vuota o senza permessi + return removed + + +def flatten_folder( + parent_dir: str, + remove_empty: bool = True, + progress_callback: Optional[Callable] = None, + log_callback: Optional[Callable] = None, +) -> dict: + """Sposta tutti i file audio delle sotto-cartelle in `parent_dir`. + + Args: + parent_dir: cartella padre di destinazione + remove_empty: se True, elimina le sotto-cartelle rimaste vuote + progress_callback: (idx, total, filename, status[, err]) — status: + 'scanning' | 'moving' | 'skipped' | 'error' | 'stopped' | 'completed' + log_callback: chiamato con {src, dst} per ogni move riuscito + + Ritorna: + {moved, skipped, failed: [{path, error}], operations: [{src, dst}], + dirs_removed} + """ + reset_stop() + parent = Path(parent_dir) + result = { + "moved": 0, + "skipped": 0, + "failed": [], + "operations": [], + "dirs_removed": 0, + } + + if not parent.is_dir(): + return result + + files = _iter_audio_in_subfolders(parent) + total = len(files) + + def _pc(idx, name, status, err=""): + if not progress_callback: + return + try: + progress_callback(idx, total, name, status, err) + except TypeError: + progress_callback(idx, total, name, status) + + for i, src in enumerate(files, start=1): + if is_stopped(): + _pc(i - 1, "", "stopped") + break + + dst = _unique_dst(parent / src.name) + try: + shutil.move(str(src), str(dst)) + result["moved"] += 1 + op = {"src": str(src), "dst": str(dst)} + result["operations"].append(op) + if log_callback: + try: + log_callback(op) + except Exception: + pass + _pc(i, src.name, "moving") + except Exception as e: + result["failed"].append({"path": str(src), "error": str(e)}) + _pc(i, src.name, "error", str(e)) + + if remove_empty and not is_stopped(): + result["dirs_removed"] = _remove_empty_dirs(parent) + + _pc(total, "", "completed") + return result diff --git a/webui/index.html b/webui/index.html index 83ea38b..c5696b7 100644 --- a/webui/index.html +++ b/webui/index.html @@ -57,6 +57,10 @@ 📅 Cataloga + + + + +
+ Nessuna cartella selezionata +
+
+ +
+
+ ⓘ +
+ Ogni file con estensione audio (mp3, m4a, wav, flac...) nelle sotto-cartelle + verrà spostato nella cartella padre. In caso di nomi duplicati viene aggiunto + suffisso _1, _2... (nessun overwrite). +
+
+
+ + + +
+
+
+
+ 0% + In attesa +
+
+
+ + +
+
+
+ + diff --git a/webui/js/app.js b/webui/js/app.js index 46b81c8..b03fad8 100644 --- a/webui/js/app.js +++ b/webui/js/app.js @@ -410,6 +410,7 @@ const logEls = { traxsource: () => $("#traxsource-log"), dedup: () => $("#dedup-log"), catalog: () => $("#catalog-log"), + flatten: () => $("#flatten-log"), }; function classifyLog(msg) { @@ -534,6 +535,7 @@ async function init() { // Cataloga tab (AcoustID -> //) await CatalogUI.init(); + await FlattenUI.init(); } async function refreshRecDevices() { @@ -3512,7 +3514,8 @@ const CatalogUI = (() => { btn.disabled = true; let res; try { - res = await window.pywebview.api.catalog_move_files(selectedEntries, target); + const layout = $("#catalog-layout")?.value || "year_genre"; + res = await window.pywebview.api.catalog_move_files(selectedEntries, target, layout); } catch (e) { toast("Errore: " + ((e && e.message) || e), "error"); btn.disabled = false; @@ -3643,6 +3646,115 @@ const CatalogUI = (() => { return { init }; })(); +// ============================================================ +// FlattenUI — appiattisci sotto-cartelle nella root +// ============================================================ +const FlattenUI = (() => { + const mstate = { folder: "", removeEmpty: true, running: false }; + + function setStatus(text, kind) { + const el = $("#flatten-status"); + if (!el) return; + el.textContent = text || ""; + el.className = "beatport-status" + (kind ? " " + kind : ""); + } + + async function pickFolder() { + const p = await window.pywebview.api.flatten_pick_folder(); + if (p && typeof p === "string") { + mstate.folder = p; + $("#flatten-path-display").textContent = p; + $("#flatten-start-btn").disabled = false; + setStatus("", ""); + } + } + + async function startFlatten() { + if (!mstate.folder) return; + mstate.removeEmpty = $("#flatten-remove-empty").checked; + if (!window.confirm(`Vuoi spostare TUTTI i file audio delle sotto-cartelle nella cartella:\n\n${mstate.folder}\n\n${mstate.removeEmpty ? "Le sotto-cartelle vuote verranno eliminate." : "Le sotto-cartelle vuote verranno lasciate."}\n\nProseguire?`)) return; + + mstate.running = true; + $("#flatten-start-btn").disabled = true; + $("#flatten-pick-folder").disabled = true; + $("#flatten-stop-btn").hidden = false; + $("#flatten-log").innerHTML = ""; + $("#flattenProgressFill").style.width = "0%"; + $("#flattenPercent").textContent = "0%"; + $("#flattenCounter").textContent = "In corso…"; + setStatus("Operazione in corso...", "loading"); + + let res; + try { + res = await window.pywebview.api.flatten_start({ + directory: mstate.folder, + remove_empty: mstate.removeEmpty, + }); + } catch (e) { + setStatus("Errore: " + e, "error"); + finishFlatten(); + return; + } + if (!res || !res.ok) { + setStatus(res && res.error || "Errore avvio", "error"); + finishFlatten(); + } + } + + async function stopFlatten() { + try { await window.pywebview.api.flatten_stop(); } catch {} + } + + function finishFlatten() { + mstate.running = false; + $("#flatten-start-btn").disabled = !mstate.folder; + $("#flatten-pick-folder").disabled = false; + $("#flatten-stop-btn").hidden = true; + } + + async function init() { + $("#flatten-pick-folder").addEventListener("click", pickFolder); + $("#flatten-start-btn").addEventListener("click", startFlatten); + $("#flatten-stop-btn").addEventListener("click", stopFlatten); + + bridgeHandlers["flatten:progress"] = (p) => { + if (!p) return; + if (typeof p.overall === "number") { + const pct = Math.round(p.overall * 100); + $("#flattenProgressFill").style.width = pct + "%"; + $("#flattenPercent").textContent = pct + "%"; + } + if (typeof p.idx === "number" && typeof p.total === "number" && p.total > 0) { + const s = p.status || ""; + let label; + if (s === "moving") label = `${p.idx}/${p.total} · ${p.filename || ""}`; + else if (s === "completed") label = `Completato: ${p.total} file`; + else if (s === "stopped") label = "Interrotta"; + else if (s === "error") label = `${p.idx}/${p.total} · errore`; + else label = `${p.idx}/${p.total}`; + $("#flattenCounter").textContent = label; + } + }; + + bridgeHandlers["flatten:done"] = (p) => { + if (p && p.ok) { + const moved = p.moved || 0; + const dirs = p.dirs_removed || 0; + const failed = p.failed_count || 0; + const parts = [`${moved} file spostati`]; + if (dirs) parts.push(`${dirs} cartelle rimosse`); + if (failed) parts.push(`${failed} errori`); + setStatus(parts.join(" · "), failed ? "error" : "ok"); + } else if (p) { + setStatus(p.error || "Errore", "error"); + } + finishFlatten(); + }; + } + + return { init }; +})(); + // ============================================================ // Boot // ============================================================