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 <Anno>/<Genere>/ e
  <Genere>/<Anno>/
- 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)
This commit is contained in:
luciano committed 2026-08-27 10:57:04 +02:00
1 parent 401bed967a
commit cdbb1d85d5
7 files changed
+546 -9

No files matched your search

+2
View File
@@ -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
+100 -2
View File
@@ -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 <target_root>/<year>/<genre>/.
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}")
+4 -1
View File
@@ -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
+98 -3
View File
@@ -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/<id>` 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 `<target_root>/<year>/<genre>/<filename>`.
- 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:
+156
View File
@@ -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
+73 -2
View File
@@ -57,6 +57,10 @@
<span class="nav-icon">📅</span>
<span>Cataloga</span>
</button>
<button class="nav-item" data-view="flatten" data-feature="audio">
<span class="nav-icon">📤</span>
<span>Appiattisci</span>
</button>
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
<span class="nav-icon">⚡</span>
<span>Upgrade</span>
@@ -1350,12 +1354,21 @@
<div class="beatport-header" style="margin-top:12px;gap:16px;">
<span id="catalog-target-display" class="beatport-output-info" style="margin:0;">Uguale alla sorgente (in-place)</span>
</div>
<div class="beatport-header" style="margin-top:12px;gap:12px;">
<label style="display:flex;gap:8px;align-items:center;color:var(--text-2);font-size:13px;">
<span>Struttura cartelle:</span>
<select id="catalog-layout" class="input" style="padding:4px 8px;font-size:13px;">
<option value="year_genre">&lt;Anno&gt;/&lt;Genere&gt;/</option>
<option value="genre_year">&lt;Genere&gt;/&lt;Anno&gt;/</option>
</select>
</label>
</div>
<div class="hint" style="margin-top:10px;">
<span class="hint-ico">ⓘ</span>
<div>
Se lasci vuota la destinazione i file vengono organizzati <strong>in-place</strong> nella cartella sorgente.
Le sottocartelle <code>&lt;Anno&gt;/&lt;Genere&gt;/</code> vengono create automaticamente.
File senza match AcoustID finiscono in <code>Unknown Year/Unknown Genre/</code>.
Le sottocartelle vengono create automaticamente in base allo schema scelto.
File senza match finiscono in <code>Unknown Year</code> / <code>Unknown Genre</code>.
</div>
</div>
<div id="catalog-status" class="beatport-status"></div>
@@ -1408,6 +1421,64 @@
</div>
</section>
<!-- ===== VIEW: FLATTEN ===== -->
<section class="view" id="view-flatten">
<header class="hero hero-blue">
<div class="hero-content">
<div class="hero-eyebrow blue">APPIATTISCI</div>
<h1 class="hero-title">Sposta tutti i brani nella cartella padre</h1>
<p class="hero-subtitle">
Scansiona ricorsivamente ogni sotto-cartella e sposta i file audio nella root.
Utile come "undo" della catalogazione o per unire archivi frammentati.
</p>
</div>
<div class="hero-deco">📤</div>
</header>
<h2 class="section-label">Cartella</h2>
<div class="card">
<div class="beatport-header">
<button id="flatten-pick-folder" class="btn btn-primary pill">📁 Scegli cartella</button>
<button id="flatten-start-btn" class="btn btn-blue pill" disabled>▶ Sposta tutto nella root</button>
<button id="flatten-stop-btn" class="btn btn-danger pill" hidden>■ Interrompi</button>
</div>
<div class="beatport-header" style="margin-top:12px;">
<span id="flatten-path-display" class="beatport-output-info" style="margin:0;">Nessuna cartella selezionata</span>
</div>
<div class="beatport-header" style="margin-top:12px;">
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
<input type="checkbox" id="flatten-remove-empty" checked />
<span>Elimina le sotto-cartelle rimaste vuote dopo lo spostamento</span>
</label>
</div>
<div class="hint" style="margin-top:10px;">
<span class="hint-ico">ⓘ</span>
<div>
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 <code>_1</code>, <code>_2</code>... (nessun overwrite).
</div>
</div>
<div id="flatten-status" class="beatport-status"></div>
</div>
<h2 class="section-label">Progresso</h2>
<div class="card">
<div class="progress">
<div class="progress-bar"><div id="flattenProgressFill" class="progress-fill blue"></div></div>
<div class="progress-meta">
<span id="flattenPercent" class="percent blue">0%</span>
<span id="flattenCounter">In attesa</span>
</div>
</div>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="flatten-log"></div>
</div>
</section>
</main>
</div>
+113 -1
View File
@@ -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 -> <Anno>/<Genere>/)
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
// ============================================================