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:
1 parent
401bed967a
commit
cdbb1d85d5
7 files changed
+546
-9
No files matched your search
+4
-1
@@ -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
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user