Files
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

241 lines
7.9 KiB
Python

"""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