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)
157 lines
4.4 KiB
Python
157 lines
4.4 KiB
Python
"""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
|