Compare commits

..
4 Commits
Author SHA1 Message Date
luciano cdbb1d85d5 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)
2026-08-27 10:57:04 +02:00
luciano 401bed967a ui: tab Cataloga con streaming table + modal conferma move 2026-08-27 10:04:57 +02:00
luciano 9b711fd545 bridge: metodi Api per tab Cataloga + bump v1.11.0 2026-08-27 10:01:57 +02:00
luciano 9f9e4281b7 catalog: modulo AcoustID lookup + move to <year>/<genre>/ 2026-08-27 10:00:32 +02:00
11 changed files with 2351 additions and 1 deletions

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 local cache (SQLite fingerprint cache, per macchina dell'utente)
dedup_cache.db dedup_cache.db
dedup_cache.db-journal dedup_cache.db-journal
catalog_cache.db
catalog_cache.db-journal
+267
View File
@@ -47,6 +47,8 @@ from core.upgrader import (
count_files_info, count_files_info,
) )
from core import dedup from core import dedup
from core import catalog
from core import flatten
SPOTIFY_GUIDE_TEXT = """\ SPOTIFY_GUIDE_TEXT = """\
@@ -113,6 +115,8 @@ class Api:
self._upgrade_thread: Optional[threading.Thread] = None self._upgrade_thread: Optional[threading.Thread] = None
self._video_thread: Optional[threading.Thread] = None self._video_thread: Optional[threading.Thread] = None
self._dedup_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 # Coda usata dal resolve_callback per attendere la scelta utente
# sul modal "match locali multipli" della tab Upgrade. # sul modal "match locali multipli" della tab Upgrade.
self._upgrade_resolve_q: "queue.Queue[dict]" = queue.Queue(1) self._upgrade_resolve_q: "queue.Queue[dict]" = queue.Queue(1)
@@ -1963,3 +1967,266 @@ class Api:
"n_dupes": n_dupes, "n_dupes": n_dupes,
"reclaimable_bytes": total_bytes, "reclaimable_bytes": total_bytes,
}) })
# ================================================================
# CATALOGA — sposta file audio in <output>/<Anno>/<Genere>/
# via Chromaprint + AcoustID.
# ================================================================
def catalog_pick_source_folder(self) -> str:
"""Folder picker per la cartella sorgente da catalogare."""
return self.browse_directory()
def catalog_pick_target_folder(self) -> str:
"""Folder picker per la cartella target (dove finiscono i file
organizzati). Se l'utente lascia vuoto → si usa la source."""
return self.browse_directory()
def catalog_start_scan(self, payload: dict) -> dict:
"""Avvia worker di scansione + lookup AcoustID. Payload:
{source, target?, recursive}. Persiste last source/target/recursive.
"""
if self._catalog_thread and self._catalog_thread.is_alive():
return {"ok": False, "error": "Scansione catalogo gia in corso"}
source = (payload.get("source") or "").strip()
target = (payload.get("target") or "").strip()
recursive = bool(payload.get("recursive", True))
if not source:
return {"ok": False, "error": "Cartella input non impostata"}
if not os.path.isdir(source):
return {"ok": False, "error": "Cartella input non trovata"}
# target vuoto → sposta in-place nella source
if not target:
target = source
# Persist last source/target/recursive
try:
cfg = load_config()
cfg["catalog_last_source"] = source
cfg["catalog_last_target"] = target
cfg["catalog_recursive"] = recursive
save_config(cfg)
except Exception:
pass
self._catalog_thread = threading.Thread(
target=self._catalog_worker,
args=(source, recursive),
daemon=True,
)
self._catalog_thread.start()
return {"ok": True}
def catalog_stop_scan(self) -> dict:
catalog.request_stop()
self._log("catalog", "[INFO] Interruzione richiesta...")
return {"ok": True}
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,
non un download). Ritorna il summary con moved/failed/operations.
"""
if not isinstance(entries, list):
return {"ok": False, "error": "entries deve essere una lista"}
target = (target_root or "").strip()
if not target:
return {"ok": False, "error": "Cartella destinazione mancante"}
def log_cb(op: dict) -> None:
src = op.get("src", "")
dst = op.get("dst", "")
self._log("catalog", f"[MOVE] {src} → {dst}")
try:
self._emit("catalog:log", op)
except Exception:
pass
try:
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)}
moved_n = result.get("moved", 0)
failed = result.get("failed", []) or []
if moved_n:
self._log("catalog", f"[OK] {moved_n} file organizzati")
for f in failed:
self._log("catalog",
f"[ERRORE] {f.get('path')}: {f.get('error')}")
return {
"ok": True,
"moved": moved_n,
"failed_count": len(failed),
"failed": failed,
"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"
catalog.reset_stop()
self._log(view,
f"[INFO] Scansione: {directory} (recursive={recursive})")
_last = [0.0]
_THROTTLE = 0.05
def progress_cb(idx: int, total: int, filename: str,
status: str, err_msg: str = "") -> None:
# Throttle solo eventi "computing"/"lookup" (possono essere tanti)
if status in ("computing", "lookup"):
now = time.monotonic()
if now - _last[0] < _THROTTLE and idx != total:
return
_last[0] = now
payload_evt = {
"idx": idx, "total": total,
"filename": filename, "status": status,
}
if err_msg:
payload_evt["error_msg"] = err_msg
if total > 0:
payload_evt["overall"] = min(idx / total, 1.0)
if status == "completed":
payload_evt["overall"] = 1.0
self._log(view, f"[INFO] Scansione completata ({total} file).")
elif status == "stopped":
self._log(view, "[INFO] Scansione interrotta.")
elif status == "error" and filename:
detail = f": {err_msg}" if err_msg else ""
self._log(view, f"[ERRORE] {filename}{detail}")
self._emit("catalog:progress", payload_evt)
def entry_cb(entry: dict) -> None:
"""Streaming: appena un file è processato, emit alla UI."""
try:
self._emit("catalog:entry", entry)
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}")
self._emit("catalog:done", {"ok": False, "error": str(e),
"entries": []})
return
n_matched = sum(1 for e in entries if e.get("matched"))
n_unmatched = len(entries) - n_matched
self._log(view,
f"[INFO] Match AcoustID: {n_matched} · non identificati: "
f"{n_unmatched} (totale {len(entries)})")
self._emit("catalog:done", {
"ok": True,
"entries": entries,
"n_total": len(entries),
"n_matched": n_matched,
"n_unmatched": n_unmatched,
})
+240
View File
@@ -0,0 +1,240 @@
"""AcoustID + MusicBrainz lookup con cache SQLite.
Data un fingerprint Chromaprint (calcolato via `fpcalc`), interroga il
servizio pubblico AcoustID (https://acoustid.org) per recuperare
metadati sulla traccia: artista, titolo, anno di rilascio piu' antico
e (best-effort) un genere estratto dai tag dei release group MusicBrainz.
I risultati vengono cacheati su SQLite (chiave = fingerprint) per non
sprecare quota API su scan ripetute. La rate limit e' auto-imposta a
~3 req/s per rispettare i limiti pubblici AcoustID.
Nota: l'API AcoustID base NON restituisce sempre il genere — dipende
dai tag presenti nel release group MusicBrainz. Molte tracce dance
electronic hanno pochi tag; il campo `genre` puo' quindi essere vuoto
anche con un match valido. In quel caso il chiamante rimapa in
"Unknown Genre".
"""
from __future__ import annotations
import json
import sqlite3
import time
from pathlib import Path
from typing import Optional
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).
# 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
def _cache_db_path() -> Path:
"""Path del DB di cache dei lookup AcoustID.
Riusa `_get_config_dir` di core.config cosi' finisce nella stessa
cartella di config.json e dedup_cache.db.
"""
from core.config import _get_config_dir
return _get_config_dir() / "catalog_cache.db"
def _init_db(conn: sqlite3.Connection) -> None:
"""Crea (idempotente) lo schema della cache."""
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS lookups(
fingerprint TEXT PRIMARY KEY,
payload TEXT NOT NULL,
cached_at REAL NOT NULL
);
"""
)
conn.commit()
def _open_cache() -> sqlite3.Connection:
"""Apre (creando se serve) la connessione alla cache."""
p = _cache_db_path()
p.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(p))
_init_db(conn)
return conn
def _get_cached(conn: sqlite3.Connection, fingerprint: str) -> Optional[dict]:
row = conn.execute(
"SELECT payload FROM lookups WHERE fingerprint = ?", (fingerprint,)
).fetchone()
if not row:
return None
try:
return json.loads(row[0])
except Exception:
return None
def _put_cache(conn: sqlite3.Connection, fingerprint: str, data: dict) -> None:
conn.execute(
"INSERT OR REPLACE INTO lookups(fingerprint, payload, cached_at)"
" VALUES (?, ?, ?)",
(fingerprint, json.dumps(data, ensure_ascii=False), time.time()),
)
conn.commit()
# Rate-limit state (globale al processo — vale anche se lookup chiamato
# da thread diversi: non e' esattamente thread-safe ma il worst-case e'
# una richiesta leggermente troppo veloce, non un ban).
_last_request_at = [0.0]
def _throttle() -> None:
"""Attende quel tanto che basta per rispettare _RATE_LIMIT_SEC."""
now = time.monotonic()
elapsed = now - _last_request_at[0]
if elapsed < _RATE_LIMIT_SEC:
time.sleep(_RATE_LIMIT_SEC - elapsed)
_last_request_at[0] = time.monotonic()
def _extract_min_year(releases: list) -> Optional[int]:
"""Trova l'anno piu' antico tra i release. Ignora date invalide."""
year: Optional[int] = None
for rel in releases or []:
date = rel.get("date") if isinstance(rel, dict) else None
if not isinstance(date, dict):
continue
y = date.get("year")
if isinstance(y, int) and y > 0:
if year is None or y < year:
year = y
return year
def _extract_top_genre(releases: list) -> str:
"""Sceglie il tag piu' rilevante dai releasegroup (max `count`)."""
for rel in releases or []:
rg = rel.get("releasegroup") if isinstance(rel, dict) else None
if not isinstance(rg, dict):
continue
tags = rg.get("tags") or []
if not isinstance(tags, list) or not tags:
continue
try:
top = max(tags, key=lambda t: int(t.get("count", 0) or 0))
except (TypeError, ValueError):
top = tags[0]
name = (top.get("name") or "").strip() if isinstance(top, dict) else ""
if name:
return name
return ""
def lookup(fingerprint: str, duration: float,
app_key: str = _APP_KEY) -> dict:
"""Interroga AcoustID (o cache) per una tripla (year, genre, title).
Ritorna sempre un dict con almeno il campo `matched: bool`. In caso
di match valido, aggiunge `year: int|None`, `genre: str`,
`artist: str`, `title: str`. In caso di errore aggiunge `error: str`
ma NON alza eccezione: chiamanti di batch (Cataloga) devono poter
continuare anche se una chiamata singola fallisce.
Cache: risultati validi E "no match" vengono cacheati sul
fingerprint — cosi' scansioni ripetute non re-interrogano l'API.
Errori transitori (rete/HTTP) NON vengono cacheati.
"""
if not fingerprint:
return {"matched": False, "error": "fingerprint vuoto"}
if not app_key:
app_key = _APP_KEY
conn = _open_cache()
try:
cached = _get_cached(conn, fingerprint)
if cached is not None:
return cached
_throttle()
try:
resp = requests.get(
_API_URL,
params={
"client": app_key,
"meta": "recordings+releases+releasegroups",
"duration": int(duration or 0),
"fingerprint": fingerprint,
},
timeout=_REQUEST_TIMEOUT,
)
except requests.RequestException as e:
return {"matched": False, "error": f"network: {e}"}
except Exception as e:
return {"matched": False, "error": str(e)}
if resp.status_code != 200:
return {"matched": False, "error": f"HTTP {resp.status_code}"}
try:
data = resp.json()
except Exception as e:
return {"matched": False, "error": f"JSON malformato: {e}"}
if data.get("status") != "ok":
err = data.get("error", {}) or {}
msg = err.get("message") if isinstance(err, dict) else str(err)
return {"matched": False, "error": msg or "AcoustID status non-ok"}
results = data.get("results") or []
if not results:
result = {"matched": False}
_put_cache(conn, fingerprint, result)
return result
# Match col miglior score
best = max(results, key=lambda r: r.get("score", 0) or 0)
recordings = best.get("recordings") or []
if not recordings:
result = {"matched": False}
_put_cache(conn, fingerprint, result)
return result
rec = recordings[0] or {}
title = (rec.get("title") or "").strip()
artists = rec.get("artists") or []
artist_names = [
(a.get("name") or "").strip()
for a in artists
if isinstance(a, dict) and a.get("name")
]
artist = ", ".join(n for n in artist_names if n)
releases = rec.get("releases") or []
year = _extract_min_year(releases)
genre = _extract_top_genre(releases)
result = {
"matched": bool(year or genre or title),
"year": year,
"genre": genre,
"artist": artist,
"title": title,
}
_put_cache(conn, fingerprint, result)
return result
finally:
try:
conn.close()
except Exception:
pass
+390
View File
@@ -0,0 +1,390 @@
"""Cataloga file audio in sottocartelle <Anno>/<Genere>/ via AcoustID.
Pipeline:
1. Scansiona la cartella (opzionalmente ricorsivo) filtrando per
estensioni audio (AUDIO_EXTENSIONS di core.upgrader).
2. Per ogni file calcola il fingerprint Chromaprint (`fpcalc`,
riusa `core.dedup.compute_fingerprint`).
3. Lookup AcoustID (cache SQLite) per estrarre year + genre +
artist + title.
4. `move_files` sposta le entry selezionate in
`<target>/<Year>/<Genre>/<filename>`. Se manca year/genre usa
"Unknown Year" / "Unknown Genre".
Progress callback firma:
(processed, total, filename, status[, err_msg])
Status: 'computing' | 'lookup' | 'error' | 'stopped' | 'completed'.
`entry_callback(entry)` viene chiamato per ogni file processato,
permettendo alla UI di aggiornare la tabella in streaming.
"""
from __future__ import annotations
import re
import shutil
import threading
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
# ------------------------------------------------------------------
# Stop / interrupt
# ------------------------------------------------------------------
_stop_event = threading.Event()
def request_stop() -> None:
"""Segnala al worker di interrompere la scansione al prossimo file."""
_stop_event.set()
def reset_stop() -> None:
"""Azzera il flag di stop prima di iniziare una nuova scansione."""
_stop_event.clear()
def is_stopped() -> bool:
return _stop_event.is_set()
# ------------------------------------------------------------------
# Sanitize path
# ------------------------------------------------------------------
# Caratteri vietati o problematici in nomi cartella cross-platform.
# `/` e `\` sono trattati a parte perche' MusicBrainz usa spesso
# genre-name-style come "electronic/house": vogliamo rimpiazzarli con
# `_` (non con lo split, che creerebbe path nesting indesiderati).
_FORBIDDEN = re.compile(r'[<>:"|?*\\/]+')
_MULTI_SPACE = re.compile(r"\s+")
def _sanitize_folder(name: str) -> str:
"""Sanitize per path filesystem: rimpiazza chars non validi con `_`.
- Chars vietati Windows (<>:"|?*) + slash → `_`
- Spazi multipli collassati in uno solo
- Trim finale
- Troncamento a 100 char (limite pratico per path lunghi cumulati)
"""
if not name:
return ""
s = _FORBIDDEN.sub("_", name).strip()
s = _MULTI_SPACE.sub(" ", s)
if len(s) > 100:
s = s[:100].rstrip()
return s
# ------------------------------------------------------------------
# Scan
# ------------------------------------------------------------------
def scan_folder(
directory: str,
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.
Ogni entry:
{path, size, fingerprint, matched, year, genre, artist, title, error?}
- `progress_callback(idx, total, filename, status[, err])`:
chiamato con status 'computing' | 'lookup' | 'error' | 'stopped'
| 'completed'. Firma retrocompatibile (4 args) supportata.
- `entry_callback(entry)`: chiamato appena ogni file e' processato
(streaming alla UI).
"""
reset_stop()
base = Path(directory)
if not base.exists() or not base.is_dir():
if progress_callback:
_emit_progress(progress_callback, 0, 0, "", "completed", "")
return []
files: list = []
iterator = base.rglob("*") if recursive else base.iterdir()
for f in iterator:
try:
if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS:
files.append(f)
except OSError:
continue
files.sort()
total = len(files)
if total == 0:
if progress_callback:
_emit_progress(progress_callback, 0, 0, "", "completed", "")
return []
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", "")
return entries
try:
size = fp_path.stat().st_size
except OSError as e:
_emit_progress(progress_callback, i, total, fp_path.name,
"error", f"stat: {e}")
continue
# Fingerprint
_emit_progress(progress_callback, i, total, fp_path.name, "computing", "")
res = compute_fingerprint(fpcalc, str(fp_path)) if fpcalc else None
if not res or not res.get("fingerprint"):
err = (res or {}).get("_error") or "fpcalc non disponibile"
entry = {
"path": str(fp_path), "size": size, "fingerprint": "",
"matched": False, "error": err,
"year": None, "genre": "", "artist": "", "title": "",
}
entries.append(entry)
if entry_callback:
try:
entry_callback(entry)
except Exception:
pass
_emit_progress(progress_callback, i, total, fp_path.name,
"error", err)
continue
fp_hash = res["fingerprint"]
duration = float(res.get("duration") or 0)
# AcoustID lookup (cache SQLite dentro core.acoustid)
_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": 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"]
entries.append(entry)
if entry_callback:
try:
entry_callback(entry)
except Exception:
pass
_emit_progress(progress_callback, total, total, "", "completed", "")
return entries
def _emit_progress(cb: Optional[Callable], idx: int, total: int,
name: str, status: str, err: str = "") -> None:
"""Chiama progress_callback in modo retrocompatibile (4 o 5 args)."""
if not cb:
return
try:
cb(idx, total, name, status, err)
except TypeError:
try:
cb(idx, total, name, status)
except Exception:
pass
except Exception:
pass
# ------------------------------------------------------------------
# Move
# ------------------------------------------------------------------
def move_files(entries: list, target_root: str,
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"
- Se `genre` manca → cartella "Unknown Genre"
- Se il file destinazione esiste gia', aggiunge suffisso _1, _2...
(non sovrascrive mai).
Ritorna un dict:
{
"moved": int, # numero file spostati con successo
"skipped": int, # entries scartate (0 per ora)
"failed": [{path, error}], # errori per file
"operations": [{src, dst}], # log ops riuscite (utile per undo)
}
`log_callback(op)` viene chiamato per ogni operazione riuscita
(streaming alla UI).
"""
target_base = Path(target_root)
try:
target_base.mkdir(parents=True, exist_ok=True)
except OSError as e:
return {"moved": 0, "skipped": 0,
"failed": [{"path": target_root,
"error": f"impossibile creare target: {e}"}],
"operations": []}
result = {"moved": 0, "skipped": 0, "failed": [], "operations": []}
for entry in entries or []:
src_str = (entry or {}).get("path") or ""
src = Path(src_str)
if not src_str:
result["failed"].append({"path": "",
"error": "path mancante"})
continue
if not src.exists():
result["failed"].append({"path": src_str,
"error": "file non esiste"})
continue
year = entry.get("year")
genre = (entry.get("genre") or "").strip()
year_folder = str(year) if year else "Unknown Year"
genre_folder = _sanitize_folder(genre) or "Unknown Genre"
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:
result["failed"].append({"path": src_str,
"error": f"mkdir: {e}"})
continue
dst = dst_dir / src.name
# Anti-overwrite: se il target esiste, aggiungi _1, _2, ...
if dst.exists():
stem = dst.stem
suffix = dst.suffix
i = 1
while dst.exists():
dst = dst_dir / f"{stem}_{i}{suffix}"
i += 1
try:
shutil.move(str(src), str(dst))
except Exception as e:
result["failed"].append({"path": src_str, "error": str(e)})
continue
result["moved"] += 1
op = {"src": src_str, "dst": str(dst)}
result["operations"].append(op)
if log_callback:
try:
log_callback(op)
except Exception:
pass
return result
+6 -1
View File
@@ -5,7 +5,7 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
VERSION = "v1.10.1" VERSION = "v1.11.0"
APP_NAME = "MusicTools" APP_NAME = "MusicTools"
@@ -83,6 +83,11 @@ DEFAULTS = {
"dedup_last_folder": "", "dedup_last_folder": "",
"dedup_recursive": True, "dedup_recursive": True,
"dedup_method": "fingerprint", # "fingerprint" | "filename" "dedup_method": "fingerprint", # "fingerprint" | "filename"
# ---- Cataloga (AcoustID -> <Anno>/<Genere>/) ----
"catalog_last_source": "",
"catalog_last_target": "",
"catalog_recursive": True,
"acoustid_app_key": "", # opzionale: se vuoto usa default in core/acoustid.py
# ---- Licenza ---- # ---- Licenza ----
"license_key": "", # chiave fornita all'utente via email "license_key": "", # chiave fornita all'utente via email
"license_email": "", # email associata all'acquisto "license_email": "", # email associata all'acquisto
+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
+204
View File
@@ -0,0 +1,204 @@
"""Test per core.acoustid — lookup AcoustID + cache SQLite.
Mock su `requests.get` in modo che i test siano offline. Ogni test
isola la cache SQLite in tmp_path via monkeypatch di
`_cache_db_path`.
"""
from __future__ import annotations
from unittest import mock
import pytest
from core import acoustid
# ------------------------------------------------------------------
# Fixture: cache isolata + reset del rate-limit
# ------------------------------------------------------------------
@pytest.fixture
def patched_cache(tmp_path, monkeypatch):
db = tmp_path / "acoustid_cache_test.db"
monkeypatch.setattr(acoustid, "_cache_db_path", lambda: db)
# Rate-limit: azzera cosi' i test non aspettano throttle
monkeypatch.setattr(acoustid, "_RATE_LIMIT_SEC", 0.0)
monkeypatch.setattr(acoustid, "_last_request_at", [0.0])
return db
def _mock_resp(status_code: int = 200, json_data: dict = None):
"""Costruisce un mock di response `requests`."""
m = mock.Mock()
m.status_code = status_code
m.json.return_value = json_data or {}
return m
# ------------------------------------------------------------------
# lookup — cache
# ------------------------------------------------------------------
class TestLookupCache:
def test_lookup_uses_cache(self, patched_cache):
"""Seconda chiamata con lo stesso fingerprint riusa la cache."""
payload = {
"status": "ok",
"results": [{
"score": 0.99,
"recordings": [{
"title": "Some Song",
"artists": [{"name": "Artist X"}],
"releases": [
{"date": {"year": 2005},
"releasegroup": {"tags": [{"name": "House", "count": 5}]}},
],
}],
}],
}
with mock.patch.object(acoustid.requests, "get",
return_value=_mock_resp(200, payload)) as m:
r1 = acoustid.lookup("FP-1", 180.0)
r2 = acoustid.lookup("FP-1", 180.0)
assert m.call_count == 1, "la seconda chiamata deve venire dalla cache"
assert r1 == r2
assert r1["matched"] is True
assert r1["year"] == 2005
assert r1["genre"].lower() == "house"
assert r1["title"] == "Some Song"
assert r1["artist"] == "Artist X"
# ------------------------------------------------------------------
# lookup — no results / errori
# ------------------------------------------------------------------
class TestLookupNoResults:
def test_lookup_no_results_returns_unmatched(self, patched_cache):
payload = {"status": "ok", "results": []}
with mock.patch.object(acoustid.requests, "get",
return_value=_mock_resp(200, payload)):
r = acoustid.lookup("FP-NORESULT", 100.0)
assert r["matched"] is False
# deve essere cacheato
with mock.patch.object(acoustid.requests, "get") as m:
r2 = acoustid.lookup("FP-NORESULT", 100.0)
assert m.call_count == 0
assert r2 == r
def test_lookup_no_recordings_returns_unmatched(self, patched_cache):
"""results presenti ma senza recordings -> matched=False."""
payload = {
"status": "ok",
"results": [{"score": 0.5, "recordings": []}],
}
with mock.patch.object(acoustid.requests, "get",
return_value=_mock_resp(200, payload)):
r = acoustid.lookup("FP-EMPTYREC", 200)
assert r["matched"] is False
# ------------------------------------------------------------------
# lookup — anno minimo
# ------------------------------------------------------------------
class TestLookupYearExtraction:
def test_lookup_extracts_min_year(self, patched_cache):
"""3 releases (2003, 1998, 2010) -> year=1998."""
payload = {
"status": "ok",
"results": [{
"score": 0.95,
"recordings": [{
"title": "Classic",
"artists": [{"name": "Artist"}],
"releases": [
{"date": {"year": 2003}},
{"date": {"year": 1998}},
{"date": {"year": 2010}},
],
}],
}],
}
with mock.patch.object(acoustid.requests, "get",
return_value=_mock_resp(200, payload)):
r = acoustid.lookup("FP-YEAR", 180)
assert r["matched"] is True
assert r["year"] == 1998
def test_lookup_missing_year_becomes_none(self, patched_cache):
"""Nessuna release con year valido -> year=None (matched se ha titolo)."""
payload = {
"status": "ok",
"results": [{
"score": 0.9,
"recordings": [{
"title": "T",
"artists": [{"name": "A"}],
"releases": [{"date": {}}, {"other": 1}],
}],
}],
}
with mock.patch.object(acoustid.requests, "get",
return_value=_mock_resp(200, payload)):
r = acoustid.lookup("FP-NOY", 100)
# matched puo' essere True se ha almeno un titolo
assert r["year"] is None
assert r["title"] == "T"
# ------------------------------------------------------------------
# lookup — errori HTTP / rete
# ------------------------------------------------------------------
class TestLookupErrors:
def test_lookup_returns_error_on_http_failure(self, patched_cache):
with mock.patch.object(acoustid.requests, "get",
return_value=_mock_resp(500, {})):
r = acoustid.lookup("FP-HTTP", 100)
assert r["matched"] is False
assert "HTTP 500" in r.get("error", "")
def test_lookup_returns_error_on_network_failure(self, patched_cache):
with mock.patch.object(acoustid.requests, "get",
side_effect=acoustid.requests.ConnectionError("boom")):
r = acoustid.lookup("FP-NET", 100)
assert r["matched"] is False
assert "network" in r.get("error", "").lower() or "boom" in r.get("error", "")
def test_lookup_empty_fingerprint_returns_unmatched(self, patched_cache):
r = acoustid.lookup("", 100)
assert r["matched"] is False
assert "fingerprint" in r.get("error", "").lower()
def test_lookup_api_status_error(self, patched_cache):
payload = {"status": "error",
"error": {"message": "invalid fingerprint"}}
with mock.patch.object(acoustid.requests, "get",
return_value=_mock_resp(200, payload)):
r = acoustid.lookup("FP-BAD", 100)
assert r["matched"] is False
assert "invalid" in r.get("error", "").lower()
# ------------------------------------------------------------------
# helper: _extract_top_genre
# ------------------------------------------------------------------
class TestGenreExtraction:
def test_top_genre_picks_highest_count(self):
releases = [{
"releasegroup": {
"tags": [
{"name": "electronic", "count": 3},
{"name": "house", "count": 12},
{"name": "dance", "count": 7},
]
}
}]
assert acoustid._extract_top_genre(releases).lower() == "house"
def test_top_genre_no_tags_returns_empty(self):
releases = [{"releasegroup": {"tags": []}}]
assert acoustid._extract_top_genre(releases) == ""
def test_top_genre_no_releasegroup_returns_empty(self):
assert acoustid._extract_top_genre([{}]) == ""
+205
View File
@@ -0,0 +1,205 @@
"""Test per core.catalog — sanitize + move_files + scan_folder.
Focus principale sui casi in cui il piano ha promesso comportamento
esplicito: sanitize dei chars vietati, struttura year/genre, gestione
di anno/genere mancanti, conflitto di filename.
"""
from __future__ import annotations
from pathlib import Path
from unittest import mock
import pytest
from core import catalog
# ------------------------------------------------------------------
# Sanitize
# ------------------------------------------------------------------
class TestSanitizeFolder:
def test_sanitize_folder_removes_forbidden_chars(self):
# `/` (dai tag musicbrainz), `:` (Windows), `?`, `*`, `|`, ecc.
s = catalog._sanitize_folder("electronic/house")
assert "/" not in s
assert "electronic" in s and "house" in s
s2 = catalog._sanitize_folder("prog:rock?")
for ch in '<>:"|?*\\/':
assert ch not in s2
def test_sanitize_folder_collapses_spaces(self):
assert catalog._sanitize_folder(" tech house ") == "tech house"
def test_sanitize_folder_empty_returns_empty(self):
assert catalog._sanitize_folder("") == ""
assert catalog._sanitize_folder(None) == ""
def test_sanitize_folder_truncates_long(self):
s = catalog._sanitize_folder("a" * 300)
assert len(s) <= 100
# ------------------------------------------------------------------
# move_files
# ------------------------------------------------------------------
def _touch(path: Path, size: int = 8) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"\x00" * size)
return path
class TestMoveFiles:
def test_move_files_creates_year_genre_structure(self, tmp_path):
"""Un file matched → finisce in <target>/<year>/<genre>/<filename>."""
src = _touch(tmp_path / "source" / "song.mp3")
target = tmp_path / "cat"
entries = [{
"path": str(src), "size": src.stat().st_size,
"matched": True, "year": 2005, "genre": "House",
"artist": "X", "title": "T", "fingerprint": "FP",
}]
res = catalog.move_files(entries, str(target))
assert res["moved"] == 1
assert res["failed"] == []
dst = target / "2005" / "House" / "song.mp3"
assert dst.exists()
assert not src.exists()
# Log operations popolato
assert res["operations"] and res["operations"][0]["src"] == str(src)
assert res["operations"][0]["dst"] == str(dst)
def test_move_files_handles_missing_year_or_genre(self, tmp_path):
"""Year/genre mancanti → Unknown Year / Unknown Genre."""
no_year = _touch(tmp_path / "src" / "no_year.mp3")
no_genre = _touch(tmp_path / "src" / "no_genre.mp3")
no_both = _touch(tmp_path / "src" / "no_both.mp3")
target = tmp_path / "cat"
entries = [
{"path": str(no_year), "matched": True, "year": None,
"genre": "House"},
{"path": str(no_genre), "matched": True, "year": 2010,
"genre": ""},
{"path": str(no_both), "matched": False, "year": None,
"genre": ""},
]
res = catalog.move_files(entries, str(target))
assert res["moved"] == 3
assert (target / "Unknown Year" / "House" / "no_year.mp3").exists()
assert (target / "2010" / "Unknown Genre" / "no_genre.mp3").exists()
assert (target / "Unknown Year" / "Unknown Genre"
/ "no_both.mp3").exists()
def test_move_files_dedup_conflicting_filenames(self, tmp_path):
"""Se un file con lo stesso nome esiste gia' nel target, aggiungi
suffisso _1, _2, ... (mai overwrite)."""
src1 = _touch(tmp_path / "srcA" / "song.mp3", size=10)
src2 = _touch(tmp_path / "srcB" / "song.mp3", size=20)
target = tmp_path / "cat"
entries = [
{"path": str(src1), "matched": True, "year": 2000,
"genre": "Rock"},
{"path": str(src2), "matched": True, "year": 2000,
"genre": "Rock"},
]
res = catalog.move_files(entries, str(target))
assert res["moved"] == 2
d1 = target / "2000" / "Rock" / "song.mp3"
d2 = target / "2000" / "Rock" / "song_1.mp3"
assert d1.exists() and d2.exists()
# Contenuto preservato dal move (src1 = 10 byte, src2 = 20 byte).
# L'ordine di iterazione garantisce che song.mp3 = src1.
assert d1.stat().st_size == 10
assert d2.stat().st_size == 20
def test_move_files_sanitizes_forbidden_genre(self, tmp_path):
"""Genere con `/` (tipico di musicbrainz) va sanitizzato in `_`."""
src = _touch(tmp_path / "src" / "song.mp3")
target = tmp_path / "cat"
entries = [{
"path": str(src), "matched": True, "year": 2020,
"genre": "electronic/house",
}]
res = catalog.move_files(entries, str(target))
assert res["moved"] == 1
# NON deve creare "electronic" e dentro "house": e' un solo nome.
assert not (target / "2020" / "electronic").is_dir()
# La cartella deve contenere entrambe le parti sanitizzate
year_dir = target / "2020"
subdirs = [p.name for p in year_dir.iterdir() if p.is_dir()]
assert len(subdirs) == 1
assert "/" not in subdirs[0]
assert "electronic" in subdirs[0] and "house" in subdirs[0]
def test_move_files_missing_source_reports_failed(self, tmp_path):
"""File che non esiste piu' → finisce in failed, non alza."""
target = tmp_path / "cat"
entries = [{
"path": str(tmp_path / "does_not_exist.mp3"),
"matched": True, "year": 2000, "genre": "X",
}]
res = catalog.move_files(entries, str(target))
assert res["moved"] == 0
assert len(res["failed"]) == 1
assert "non esiste" in res["failed"][0]["error"].lower()
def test_move_files_empty_list_returns_zero(self, tmp_path):
res = catalog.move_files([], str(tmp_path / "cat"))
assert res["moved"] == 0
assert res["failed"] == []
assert res["operations"] == []
# ------------------------------------------------------------------
# scan_folder — smoke integration test (mock fpcalc + lookup)
# ------------------------------------------------------------------
class TestScanFolder:
def test_scan_folder_empty_directory(self, tmp_path):
# Cartella senza file audio → lista vuota, no crash
with mock.patch.object(catalog, "find_fpcalc",
return_value="/fake/fpcalc"):
entries = catalog.scan_folder(str(tmp_path), recursive=False)
assert entries == []
def test_scan_folder_processes_files(self, tmp_path):
# Un file audio fake + mock di fpcalc + lookup
(tmp_path / "a.mp3").write_bytes(b"\x00" * 100)
(tmp_path / "readme.txt").write_text("hi")
with mock.patch.object(catalog, "find_fpcalc",
return_value="/fake/fpcalc"), \
mock.patch.object(catalog, "compute_fingerprint",
return_value={"fingerprint": "FP", "duration": 100}), \
mock.patch.object(catalog, "lookup",
return_value={
"matched": True, "year": 2018,
"genre": "House", "artist": "A",
"title": "T",
}):
entries = catalog.scan_folder(str(tmp_path), recursive=False)
assert len(entries) == 1
e = entries[0]
assert e["matched"] is True
assert e["year"] == 2018
assert e["genre"] == "House"
assert e["fingerprint"] == "FP"
def test_scan_folder_records_fpcalc_error(self, tmp_path):
"""fpcalc fallito → entry con matched=False + error."""
(tmp_path / "broken.mp3").write_bytes(b"\x00" * 10)
with mock.patch.object(catalog, "find_fpcalc",
return_value="/fake/fpcalc"), \
mock.patch.object(catalog, "compute_fingerprint",
return_value={"_error": "fingerprint vuoto"}):
entries = catalog.scan_folder(str(tmp_path), recursive=False)
assert len(entries) == 1
assert entries[0]["matched"] is False
assert "vuoto" in entries[0]["error"]
+133
View File
@@ -687,6 +687,7 @@ input[type="number"]::-webkit-inner-spin-button {
.percent.green { color: var(--green-text); } .percent.green { color: var(--green-text); }
.percent.blue { color: var(--blue-text); } .percent.blue { color: var(--blue-text); }
.percent.pink { color: var(--pink-hover); } .percent.pink { color: var(--pink-hover); }
.percent.purple { color: var(--purple-text); }
.progress { .progress {
width: 100%; width: 100%;
@@ -715,6 +716,10 @@ input[type="number"]::-webkit-inner-spin-button {
background: linear-gradient(90deg, var(--pink), var(--pink-hover)); background: linear-gradient(90deg, var(--pink), var(--pink-hover));
} }
.progress-fill.purple {
background: linear-gradient(90deg, var(--purple), var(--purple-hover));
}
/* =============================== /* ===============================
LOG BOX LOG BOX
=============================== */ =============================== */
@@ -1970,3 +1975,131 @@ input[type="number"]::-webkit-inner-spin-button {
padding-right: 6px; /* spazio per la scrollbar sul bordo */ 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);
}
+198
View File
@@ -53,6 +53,14 @@
<span class="nav-icon">🗑</span> <span class="nav-icon">🗑</span>
<span>Dedup</span> <span>Dedup</span>
</button> </button>
<button class="nav-item" data-view="catalog" data-feature="audio">
<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"> <button class="nav-item" data-view="upgrade" data-feature="upgrade">
<span class="nav-icon">⚡</span> <span class="nav-icon">⚡</span>
<span>Upgrade</span> <span>Upgrade</span>
@@ -1304,6 +1312,173 @@
</div> </div>
</section> </section>
<!-- ===== VIEW: CATALOGA (AcoustID -> <Anno>/<Genere>/) ===== -->
<section class="view" id="view-catalog">
<header class="hero hero-purple">
<div class="hero-content">
<div class="hero-eyebrow purple">CATALOGA</div>
<h1 class="hero-title">Organizza per anno e genere</h1>
<p class="hero-subtitle">Identifica ogni brano via audio fingerprint (Chromaprint + AcoustID) e sposta i file in <code>&lt;Anno&gt;/&lt;Genere&gt;/</code>.</p>
</div>
<div class="hero-deco">📅</div>
</header>
<h2 class="section-label">Sorgente</h2>
<div class="card">
<div class="beatport-header">
<button id="catalog-pick-source" class="btn btn-primary pill">📁 Cartella input</button>
<button id="catalog-start-btn" class="btn btn-primary pill" disabled>
<span class="ico">▶</span> Scansiona
</button>
<button id="catalog-stop-btn" class="btn btn-danger pill" hidden>
<span class="ico">◼</span> Interrompi
</button>
</div>
<div class="beatport-header" style="margin-top:12px;gap:16px;">
<span id="catalog-source-display" class="beatport-output-info" style="margin:0;">Nessuna cartella selezionata</span>
</div>
<div class="beatport-header" style="margin-top:12px;gap:16px;">
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
<input type="checkbox" id="catalog-recursive" checked />
<span>Ricerca ricorsiva (include sottocartelle)</span>
</label>
</div>
</div>
<h2 class="section-label">Destinazione</h2>
<div class="card">
<div class="beatport-header">
<button id="catalog-pick-target" class="btn btn-ghost pill">📁 Cartella destinazione</button>
<button id="catalog-clear-target" class="btn btn-ghost pill btn-sm">Usa la stessa della sorgente</button>
</div>
<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 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>
</div>
<h2 class="section-label">Progresso</h2>
<div class="card">
<div class="progress">
<div class="progress-bar"><div id="catalogProgressFill" class="progress-fill purple"></div></div>
<div class="progress-meta">
<span id="catalogPercent" class="percent purple">0%</span>
<span id="catalogCounter">In attesa</span>
</div>
</div>
</div>
<h2 class="section-label">Risultati</h2>
<div class="card" id="catalog-summary-card" hidden>
<div class="catalog-summary" id="catalog-summary-line"></div>
</div>
<div id="catalog-table-wrap" class="catalog-scroll" hidden>
<table class="catalog-table">
<thead>
<tr>
<th class="catalog-col-check">
<input type="checkbox" id="catalog-select-all" title="Seleziona/deseleziona tutti"/>
</th>
<th class="catalog-col-file">File</th>
<th class="catalog-col-artist">Artista</th>
<th class="catalog-col-title">Titolo</th>
<th class="catalog-col-year">Anno</th>
<th class="catalog-col-genre">Genere</th>
<th class="catalog-col-status">Stato</th>
</tr>
</thead>
<tbody id="catalog-table-body"></tbody>
</table>
</div>
<div class="catalog-footer-bar" id="catalog-footer-bar" hidden>
<span class="counter" id="catalog-selected-count">0 file selezionati</span>
<button id="catalog-move-btn" class="btn btn-danger pill" disabled>
<span class="ico">📦</span> Sposta selezionati
</button>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="catalog-log"></div>
</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> </main>
</div> </div>
@@ -1397,6 +1572,29 @@
</div> </div>
</div> </div>
<!-- ============== MODAL: Cataloga — conferma spostamento ============== -->
<div class="modal-backdrop" id="catalogConfirmModal" hidden>
<div class="modal">
<div class="modal-head">
<h2>Conferma spostamento</h2>
<button class="modal-close" id="catalogConfirmCloseBtn">×</button>
</div>
<div class="modal-body">
<p id="catalogConfirmMsg">Stai per spostare N file. Procedi?</p>
<p class="modal-hint">
L'operazione riorganizza i file in sottocartelle
<code>&lt;Anno&gt;/&lt;Genere&gt;/</code>. E' reversibile
manualmente dal log ma puo' richiedere tempo su cartelle
grandi.
</p>
</div>
<div class="modal-foot">
<button class="btn btn-ghost pill" id="catalogConfirmCancelBtn">Annulla</button>
<button class="btn btn-danger pill" id="catalogConfirmProceedBtn">Sposta</button>
</div>
</div>
</div>
<!-- ============== TOAST ============== --> <!-- ============== TOAST ============== -->
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
+550
View File
@@ -409,6 +409,8 @@ const logEls = {
convert: () => $("#convert-log"), convert: () => $("#convert-log"),
traxsource: () => $("#traxsource-log"), traxsource: () => $("#traxsource-log"),
dedup: () => $("#dedup-log"), dedup: () => $("#dedup-log"),
catalog: () => $("#catalog-log"),
flatten: () => $("#flatten-log"),
}; };
function classifyLog(msg) { function classifyLog(msg) {
@@ -530,6 +532,10 @@ async function init() {
// Dedup tab (audio duplicati via Chromaprint) // Dedup tab (audio duplicati via Chromaprint)
await DedupUI.init(); await DedupUI.init();
// Cataloga tab (AcoustID -> <Anno>/<Genere>/)
await CatalogUI.init();
await FlattenUI.init();
} }
async function refreshRecDevices() { async function refreshRecDevices() {
@@ -3205,6 +3211,550 @@ const DedupUI = (() => {
return { init }; return { init };
})(); })();
// ============================================================
// CatalogUI — AcoustID -> <Anno>/<Genere>/
// ============================================================
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 `<span class="catalog-status-badge err" title="${_escapeHtml(entry.error)}">errore</span>`;
}
if (entry.matched && entry.year && entry.genre) {
return `<span class="catalog-status-badge ok">match</span>`;
}
if (entry.matched) {
// Match parziale (mancano anno o genere)
return `<span class="catalog-status-badge warn">parziale</span>`;
}
return `<span class="catalog-status-badge warn">sconosciuto</span>`;
}
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 = `<span class="catalog-file-cell" title="${_escapeHtml(path)}">${_escapeHtml(_basename(path))}</span>`;
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 {
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;
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 };
})();
// ============================================================
// 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 // Boot
// ============================================================ // ============================================================