Files
musicdownload/core/paths.py
T
luciano fb13b0f70e dedup: modulo audio fingerprinting via fpcalc + cache SQLite
Nuovo modulo core.dedup per rilevare brani audio duplicati usando
Chromaprint (fpcalc): scan cartella (opzionalmente ricorsivo),
fingerprint acustico via fpcalc -json, cache SQLite in
_get_config_dir()/dedup_cache.db per non ricalcolare al re-scan,
raggruppamento per fingerprint identico + ordering per bitrate DESC.
move_to_trash() usa send2trash (reversibile via Finder/Explorer).

- core/paths.py: find_fpcalc() con fallback dev su project bundle_bin
- core/dedup.py: scan_folder, compute_fingerprint, move_to_trash
- tests/test_dedup.py: 9 unit test (mock fpcalc + send2trash)
- requirements.txt: send2trash>=1.8.0
- build_macos.py: download fpcalc universal binary da GitHub releases
- build_windows.py: download fpcalc.exe da GitHub releases
2026-08-02 13:22:24 +02:00

173 lines
5.3 KiB
Python

"""Risoluzione path per binari esterni (yt-dlp, ffmpeg).
Quando l'app gira come bundle PyInstaller, i binari sono inclusi
nella cartella del bundle. Altrimenti cerca nel sistema.
Cross-platform: macOS e Windows.
"""
from __future__ import annotations
import os
import shutil
import sys
from pathlib import Path
from typing import Optional
_IS_WINDOWS = sys.platform == "win32"
def _is_frozen() -> bool:
"""True se stiamo girando come bundle PyInstaller."""
return getattr(sys, "frozen", False)
def _bundle_dirs() -> list[Path]:
"""Directories dove cercare i binari bundled (PyInstaller)."""
dirs = []
if _is_frozen():
# _MEIPASS: directory dove PyInstaller estrae i file
meipass = Path(getattr(sys, "_MEIPASS", ""))
if meipass.exists():
dirs.append(meipass)
# Directory dell'eseguibile
exe_dir = Path(sys.executable).parent
dirs.append(exe_dir)
# macOS: Contents/Frameworks/ (PyInstaller onedir .app) + sub-bundle
# ffmpeg.app / ffprobe.app dove sono incapsulati per TCC.
if not _IS_WINDOWS:
frameworks = exe_dir.parent / "Frameworks"
if frameworks.exists():
dirs.append(frameworks)
# Mini-bundle .app per i subprocess (vedi build_macos.py
# wrap_subprocess_in_bundle).
for sub in ("ffmpeg.app", "ffprobe.app"):
sub_macos = frameworks / sub / "Contents" / "MacOS"
if sub_macos.exists():
dirs.append(sub_macos)
return dirs
def _exe(name: str) -> str:
"""Aggiunge .exe su Windows."""
if _IS_WINDOWS and not name.endswith(".exe"):
return name + ".exe"
return name
def subprocess_flags() -> dict:
"""Kwargs per subprocess.run/Popen che nascondono la console su Windows.
Senza CREATE_NO_WINDOW, ogni subprocess (yt-dlp, ffmpeg, ffprobe) lanciato
da un'app GUI PyInstaller apre una finestra cmd nera per la durata del
processo. Su macOS/Linux il flag non esiste, quindi ritorniamo dict vuoto.
"""
if _IS_WINDOWS:
# 0x08000000 == CREATE_NO_WINDOW
return {"creationflags": 0x08000000}
return {}
def find_ytdlp() -> str:
"""Trova il path di yt-dlp.
Ordine di ricerca:
1. Bundle PyInstaller
2. Homebrew (macOS) / PATH comune (Windows)
3. Qualsiasi posizione nel PATH
"""
name = _exe("yt-dlp")
# 1. Bundle
for d in _bundle_dirs():
candidate = d / name
if candidate.exists():
return str(candidate)
# 2. Percorsi noti per OS
if not _IS_WINDOWS:
brew = shutil.which("yt-dlp", path="/opt/homebrew/bin:/usr/local/bin")
if brew:
return brew
# 3. PATH generico
system = shutil.which("yt-dlp")
if system:
return system
msg = "yt-dlp non trovato. "
if _IS_WINDOWS:
msg += "Scaricalo da https://github.com/yt-dlp/yt-dlp/releases"
else:
msg += "Installalo con: brew install yt-dlp"
raise FileNotFoundError(msg)
def _find_binary(name: str) -> Optional[str]:
"""Trova un binario (ffmpeg o ffprobe) nelle dir del bundle o nel sistema."""
exe_name = _exe(name)
# 1. Bundle (incluso eventuali sub-bundle .app su macOS)
for d in _bundle_dirs():
candidate = d / exe_name
if candidate.exists():
return str(candidate)
# 2. Percorsi noti per OS
if _IS_WINDOWS:
for search in (
Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "ffmpeg" / "bin",
Path("C:/ffmpeg/bin"),
Path(os.environ.get("ProgramFiles", "")) / "ffmpeg" / "bin",
):
cand = search / exe_name
if cand.exists():
return str(cand)
else:
for search_path in ("/opt/homebrew/bin", "/usr/local/bin"):
cand = Path(search_path) / exe_name
if cand.exists():
return str(cand)
# 3. PATH generico
found = shutil.which(name)
return found if found else None
def find_ffmpeg_dir() -> Optional[str]:
"""Ritorna la directory contenente ffmpeg (utile per PATH env)."""
ff = _find_binary("ffmpeg")
return str(Path(ff).parent) if ff else None
def find_ffmpeg() -> Optional[str]:
"""Ritorna il path completo di ffmpeg."""
return _find_binary("ffmpeg")
def find_ffprobe() -> Optional[str]:
"""Ritorna il path completo di ffprobe."""
return _find_binary("ffprobe")
def find_fpcalc() -> Optional[str]:
"""Ritorna il path completo di fpcalc (Chromaprint), o None.
Cerca nelle stesse directory di find_ffmpeg / find_ytdlp: bundle
PyInstaller prima, poi percorsi noti (Homebrew su macOS, LOCALAPPDATA
su Windows), infine PATH generico. In dev mode aggiunge anche
`<project_root>/bundle_bin/` così l'app funziona con `python main.py`
dopo aver scaricato fpcalc via build script.
`fpcalc` viene usato dal modulo core.dedup per calcolare fingerprint
audio (identificazione di duplicati).
"""
found = _find_binary("fpcalc")
if found:
return found
# Fallback dev: bundle_bin del progetto
if not _is_frozen():
exe_name = _exe("fpcalc")
project_bundle = Path(__file__).resolve().parent.parent / "bundle_bin"
cand = project_bundle / exe_name
if cand.exists():
return str(cand)
return None