Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9644ffec35 | ||
|
|
8432b2a592 | ||
|
|
39e843dc69 | ||
|
|
29c177ff69 | ||
|
|
75e3da5f82 | ||
|
|
29b9753aa3 | ||
|
|
c88e6d359c | ||
|
|
ed1059bf8c | ||
|
|
cdbb1d85d5 | ||
|
|
401bed967a | ||
|
|
9b711fd545 | ||
|
|
9f9e4281b7 | ||
|
|
39a117f08b | ||
|
|
e7d11d0235 | ||
|
|
00ff5a6220 | ||
|
|
e90e38edc8 | ||
|
|
f18dd375e8 | ||
|
|
fb13b0f70e | ||
|
|
e09ee1382b | ||
|
|
e7fde842f2 | ||
|
|
e10466d93b | ||
|
|
f0a29f7ca7 | ||
|
|
4e1af7b8f6 | ||
|
|
d8723f9408 | ||
|
|
eb20a034e6 | ||
|
|
cdd380d62f | ||
|
|
2689f686ec | ||
|
|
f8e2ee66bd | ||
|
|
a07f6c34ca | ||
|
|
6c473eaec9 | ||
|
|
16c8b8ee67 | ||
|
|
88e59ad37f | ||
|
|
784b56682f | ||
|
|
e1e629f8a2 | ||
|
|
a95912f78c | ||
|
|
7feda99720 | ||
|
|
191c245fc1 | ||
|
|
98235625ba | ||
|
|
72e700c85d | ||
|
|
a774906814 | ||
|
|
9f41f9d302 | ||
|
|
b0cd046375 | ||
|
|
5fc9f8ac2b | ||
|
|
c4e6d6e322 | ||
|
|
c7dfe95890 | ||
|
|
25b646d754 | ||
|
|
73b4480890 | ||
|
|
d53b8f20ed | ||
|
|
0f7cd24703 | ||
|
|
f1437e8e24 | ||
|
|
acbd9c0b1f | ||
|
|
f226c3fa8e |
No files matched your search
@@ -49,3 +49,12 @@ server/node_modules/
|
||||
server/.wrangler/
|
||||
server/.dev.vars
|
||||
server/dist/
|
||||
|
||||
# Dedup local cache (SQLite fingerprint cache, per macchina dell'utente)
|
||||
dedup_cache.db
|
||||
dedup_cache.db-journal
|
||||
catalog_cache.db
|
||||
catalog_cache.db-journal
|
||||
|
||||
# Log persistente (rotating file)
|
||||
logs/
|
||||
+1128
-14
File diff suppressed because it is too large.
Load diff
+55
-8
@@ -39,21 +39,23 @@ def log(msg):
|
||||
# 1. Scarica yt-dlp standalone
|
||||
# =========================================================================
|
||||
def download_ytdlp():
|
||||
"""Scarica il binario standalone di yt-dlp per macOS."""
|
||||
"""Scarica il binario standalone di yt-dlp per macOS.
|
||||
|
||||
Usa il NIGHTLY channel invece dello stable perche' YouTube rompe le
|
||||
API ogni 1-2 settimane e la stable resta indietro di 3-4 settimane
|
||||
tipicamente. La nightly esce quasi ogni giorno con i fix.
|
||||
Sempre re-download: garantisce che ogni build imbarca yt-dlp fresco.
|
||||
"""
|
||||
arch = platform.machine() # arm64 o x86_64
|
||||
if arch == "arm64":
|
||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
|
||||
url = "https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download/yt-dlp_macos"
|
||||
else:
|
||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos_legacy"
|
||||
url = "https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download/yt-dlp_macos_legacy"
|
||||
|
||||
dest = BUNDLE_DIR / "yt-dlp"
|
||||
if dest.exists():
|
||||
log(f"yt-dlp gia presente: {dest}")
|
||||
return dest
|
||||
|
||||
log(f"Scarico yt-dlp da {url} ...")
|
||||
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
log(f"Scarico yt-dlp nightly da {url} ...")
|
||||
# Usa curl perche urllib di Python 3.8 ha problemi SSL su macOS
|
||||
subprocess.run(
|
||||
["curl", "-L", "-o", str(dest), url],
|
||||
@@ -64,6 +66,48 @@ def download_ytdlp():
|
||||
return dest
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 1b. Scarica fpcalc (Chromaprint) — usato dal tab Dedup
|
||||
# =========================================================================
|
||||
def download_fpcalc():
|
||||
"""Scarica il binario universal Chromaprint fpcalc per macOS."""
|
||||
dest = BUNDLE_DIR / "fpcalc"
|
||||
if dest.exists():
|
||||
log(f"fpcalc gia presente: {dest}")
|
||||
return dest
|
||||
|
||||
url = ("https://github.com/acoustid/chromaprint/releases/download/"
|
||||
"v1.5.1/chromaprint-fpcalc-1.5.1-macos-universal.tar.gz")
|
||||
log(f"Scarico fpcalc da {url} ...")
|
||||
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
tmp_tar = BUNDLE_DIR / "_fpcalc.tar.gz"
|
||||
subprocess.run(["curl", "-L", "-o", str(tmp_tar), url], check=True)
|
||||
# Estrae ovunque nella cartella, poi trova fpcalc e lo sposta al posto
|
||||
tmp_extract = BUNDLE_DIR / "_fpcalc_extract"
|
||||
if tmp_extract.exists():
|
||||
shutil.rmtree(tmp_extract)
|
||||
tmp_extract.mkdir()
|
||||
subprocess.run(["tar", "-xzf", str(tmp_tar), "-C", str(tmp_extract)],
|
||||
check=True)
|
||||
# Trova fpcalc nel folder estratto
|
||||
found = None
|
||||
for p in tmp_extract.rglob("fpcalc"):
|
||||
if p.is_file():
|
||||
found = p
|
||||
break
|
||||
if not found:
|
||||
shutil.rmtree(tmp_extract, ignore_errors=True)
|
||||
tmp_tar.unlink(missing_ok=True)
|
||||
raise RuntimeError("fpcalc non trovato nell'archivio")
|
||||
shutil.copy2(found, dest)
|
||||
dest.chmod(dest.stat().st_mode | stat.S_IEXEC)
|
||||
shutil.rmtree(tmp_extract, ignore_errors=True)
|
||||
tmp_tar.unlink(missing_ok=True)
|
||||
log(f"fpcalc scaricato: {dest}")
|
||||
return dest
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 2. Raccogli ffmpeg/ffprobe + dylib
|
||||
# =========================================================================
|
||||
@@ -226,6 +270,8 @@ def run_pyinstaller():
|
||||
"--hidden-import", "webview.platforms.cocoa",
|
||||
"--hidden-import", "requests",
|
||||
"--collect-all", "mutagen",
|
||||
# certifi ha cacert.pem necessario per SSL/HTTPS
|
||||
"--collect-data", "certifi",
|
||||
# Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs)
|
||||
*add_binaries,
|
||||
# Entry point
|
||||
@@ -509,6 +555,7 @@ def main():
|
||||
shutil.rmtree(BUNDLE_DIR)
|
||||
|
||||
download_ytdlp()
|
||||
download_fpcalc()
|
||||
bundle_ffmpeg()
|
||||
run_pyinstaller()
|
||||
|
||||
|
||||
+36
-6
@@ -29,8 +29,10 @@ BUILD_DIR = ROOT / "build"
|
||||
DIST_DIR = ROOT / "dist"
|
||||
BUNDLE_DIR = ROOT / "bundle_bin"
|
||||
|
||||
YTDLP_URL = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
|
||||
YTDLP_URL = "https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download/yt-dlp.exe"
|
||||
FFMPEG_URL = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
|
||||
FPCALC_URL = ("https://github.com/acoustid/chromaprint/releases/download/"
|
||||
"v1.5.1/chromaprint-fpcalc-1.5.1-windows-x86_64.zip")
|
||||
|
||||
|
||||
def log(msg):
|
||||
@@ -41,13 +43,11 @@ def log(msg):
|
||||
# 1. Scarica yt-dlp.exe
|
||||
# =========================================================================
|
||||
def download_ytdlp():
|
||||
"""Scarica sempre il nightly fresco: la stable resta indietro di
|
||||
3-4 settimane rispetto alle rotture API YouTube."""
|
||||
dest = BUNDLE_DIR / "yt-dlp.exe"
|
||||
if dest.exists():
|
||||
log(f"yt-dlp.exe gia presente: {dest}")
|
||||
return dest
|
||||
|
||||
log(f"Scarico yt-dlp.exe ...")
|
||||
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log(f"Scarico yt-dlp.exe nightly ...")
|
||||
urllib.request.urlretrieve(YTDLP_URL, dest)
|
||||
log(f"yt-dlp.exe scaricato ({dest.stat().st_size // 1024 // 1024} MB)")
|
||||
return dest
|
||||
@@ -87,6 +87,33 @@ def download_ffmpeg():
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 2b. Scarica fpcalc.exe (Chromaprint) — usato dal tab Dedup
|
||||
# =========================================================================
|
||||
def download_fpcalc():
|
||||
dest = BUNDLE_DIR / "fpcalc.exe"
|
||||
if dest.exists():
|
||||
log("fpcalc.exe gia presente")
|
||||
return
|
||||
|
||||
log(f"Scarico fpcalc.exe ...")
|
||||
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
response = urllib.request.urlopen(FPCALC_URL)
|
||||
zip_data = io.BytesIO(response.read())
|
||||
with zipfile.ZipFile(zip_data) as zf:
|
||||
for member in zf.namelist():
|
||||
basename = Path(member).name
|
||||
if basename == "fpcalc.exe":
|
||||
data = zf.read(member)
|
||||
dest.write_bytes(data)
|
||||
log(f" Estratto: fpcalc.exe ({len(data) // 1024} KB)")
|
||||
|
||||
if not dest.exists():
|
||||
print("ERRORE: fpcalc.exe non trovato nello zip!")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 3. PyInstaller
|
||||
# =========================================================================
|
||||
@@ -133,6 +160,8 @@ def run_pyinstaller():
|
||||
"--collect-all", "pythonnet",
|
||||
"--collect-all", "clr_loader",
|
||||
"--collect-all", "mutagen",
|
||||
# certifi ha il file dati cacert.pem necessario per SSL/HTTPS
|
||||
"--collect-data", "certifi",
|
||||
# collect-data prende anche i file .json/.dll non-Python che
|
||||
# collect-all potrebbe saltare (Python.Runtime.runtimeconfig.json
|
||||
# in particolare).
|
||||
@@ -185,6 +214,7 @@ def main():
|
||||
|
||||
download_ytdlp()
|
||||
download_ffmpeg()
|
||||
download_fpcalc()
|
||||
run_pyinstaller()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
|
||||
@@ -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
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
"""Setup logging persistente su file rotante.
|
||||
|
||||
Chiamato in `main.py` prima di qualsiasi import pesante. Il file di log
|
||||
vive in:
|
||||
- macOS: ~/Library/Application Support/MusicTools/logs/app.log
|
||||
- Windows: %APPDATA%/MusicTools/logs/app.log
|
||||
- Dev: <project_root>/logs/app.log
|
||||
|
||||
Rotazione: 1 file al giorno, mantiene gli ultimi 7. Cattura anche stdout
|
||||
e stderr per non perdere `print()` o traceback che l'app windowed
|
||||
altrimenti scarterebbe.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_setup_done = False
|
||||
|
||||
|
||||
def _logs_dir() -> Path:
|
||||
"""Directory dei log — parallela a config.json."""
|
||||
if getattr(sys, "frozen", False):
|
||||
if sys.platform == "win32":
|
||||
base = Path(os.environ.get("APPDATA", Path.home()))
|
||||
else:
|
||||
base = Path.home() / "Library" / "Application Support"
|
||||
d = base / "MusicTools" / "logs"
|
||||
else:
|
||||
d = Path(__file__).resolve().parent.parent / "logs"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def log_file_path() -> Path:
|
||||
return _logs_dir() / "app.log"
|
||||
|
||||
|
||||
def _install_excepthook() -> None:
|
||||
"""Cattura ogni uncaught exception dentro il file di log.
|
||||
NON tocca sys.stdout/stderr (romperebbe WebKit)."""
|
||||
import traceback
|
||||
logger = logging.getLogger("uncaught")
|
||||
_orig = sys.excepthook
|
||||
|
||||
def _hook(exc_type, exc_value, tb):
|
||||
try:
|
||||
logger.error(
|
||||
"Uncaught: %s: %s\n%s",
|
||||
exc_type.__name__,
|
||||
exc_value,
|
||||
"".join(traceback.format_exception(exc_type, exc_value, tb)),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_orig(exc_type, exc_value, tb)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sys.excepthook = _hook
|
||||
|
||||
# Anche per exception in threading (Python 3.8+)
|
||||
try:
|
||||
_orig_thread = threading_excepthook = getattr(__import__("threading"), "excepthook", None)
|
||||
|
||||
def _thread_hook(args):
|
||||
try:
|
||||
logger.error(
|
||||
"Thread uncaught: %s: %s\n%s",
|
||||
args.exc_type.__name__,
|
||||
args.exc_value,
|
||||
"".join(traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback)),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if _orig_thread:
|
||||
try:
|
||||
_orig_thread(args)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if threading_excepthook is not None:
|
||||
__import__("threading").excepthook = _thread_hook
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def setup() -> Path:
|
||||
"""Configura il logger root — NON tocca stdout/stderr per non rompere
|
||||
WebKit (segfault). Cattura traceback via sys.excepthook + logger.
|
||||
"""
|
||||
global _setup_done
|
||||
path = log_file_path()
|
||||
if _setup_done:
|
||||
return path
|
||||
_setup_done = True
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.INFO)
|
||||
|
||||
fmt = logging.Formatter(
|
||||
fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
# File handler con rotazione giornaliera (max 7 file = 7 giorni)
|
||||
fh = logging.handlers.TimedRotatingFileHandler(
|
||||
str(path),
|
||||
when="midnight",
|
||||
backupCount=7,
|
||||
encoding="utf-8",
|
||||
utc=False,
|
||||
)
|
||||
fh.setFormatter(fmt)
|
||||
fh.setLevel(logging.INFO)
|
||||
root.addHandler(fh)
|
||||
|
||||
# Se in dev, mantieni anche console (utile durante lavoro)
|
||||
if not getattr(sys, "frozen", False):
|
||||
ch = logging.StreamHandler(sys.__stderr__)
|
||||
ch.setFormatter(fmt)
|
||||
ch.setLevel(logging.INFO)
|
||||
root.addHandler(ch)
|
||||
|
||||
_install_excepthook()
|
||||
|
||||
root.info(f"MusicTools log start — file: {path}")
|
||||
return path
|
||||
@@ -23,6 +23,8 @@ class BeatportTrack:
|
||||
artists: str # es. "A, B & C" già formattato
|
||||
duration_sec: int
|
||||
beatport_id: int
|
||||
image_url: str = "" # URL cover art (default vuoto per retrocompatibilità test)
|
||||
release_date: str = "" # ISO YYYY-MM-DD, vuoto se mancante
|
||||
|
||||
@property
|
||||
def display(self) -> str:
|
||||
@@ -150,6 +152,17 @@ def _format_artists(artists_field: object) -> str:
|
||||
return ", ".join(names[:-1]) + " & " + names[-1]
|
||||
|
||||
|
||||
def _extract_image_url(image_field: object, size: int = 95) -> str:
|
||||
"""Estrae URL cover art dall'oggetto `image` di Beatport.
|
||||
Preferisce `dynamic_uri` sostituendo {w}x{h}, fallback a `uri` fisso."""
|
||||
if not isinstance(image_field, dict):
|
||||
return ""
|
||||
dyn = image_field.get("dynamic_uri") or ""
|
||||
if isinstance(dyn, str) and "{w}" in dyn and "{h}" in dyn:
|
||||
return dyn.replace("{w}", str(size)).replace("{h}", str(size))
|
||||
return image_field.get("uri") or ""
|
||||
|
||||
|
||||
def _parse_tracks(data: dict) -> list:
|
||||
"""Trasforma i track dict di Beatport in BeatportTrack ordinati per posizione."""
|
||||
raw = _find_tracks_results(data)
|
||||
@@ -164,6 +177,8 @@ def _parse_tracks(data: dict) -> list:
|
||||
artists=_format_artists(item.get("artists")),
|
||||
duration_sec=length_ms // 1000,
|
||||
beatport_id=int(item.get("id") or 0),
|
||||
image_url=_extract_image_url(item.get("image")),
|
||||
release_date=str(item.get("publish_date") or item.get("new_release_date") or "").strip(),
|
||||
)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise BeatportParseError(f"track[{i}] shape inattesa: {e}") from e
|
||||
|
||||
+390
@@ -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
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
"""Fetch classifiche musicali da varie sorgenti + cache SQLite.
|
||||
|
||||
Sorgenti supportate:
|
||||
- **iTunes RSS** (`fetch_itunes`) — Top 100 "most-played" per paese (it/us/gb/ww).
|
||||
Endpoint pubblico, no auth. Response JSON.
|
||||
- **Last.fm** (`fetch_lastfm`) — Top tracks per tag (combinazione decade
|
||||
+ genere musicale, es. "90s dance"). Richiede API key: c'e' una demo
|
||||
hardcoded, l'utente puo' generarne una propria e sovrascriverla via
|
||||
config (`lastfm_api_key`). https://www.last.fm/api/account/create
|
||||
- **Spotify** (`fetch_spotify`) — Legge playlist chart editoriali di Spotify
|
||||
(Top 50 / Viral 50 per paese) via `playlists/<id>/tracks`. Riusa le
|
||||
credenziali Client Credentials gia' configurate (`client_id`/
|
||||
`client_secret`). Se mancano ritorna [].
|
||||
- **M2O Chart** (`fetch_m2o`) — Scraping HTML della chart dance italiana
|
||||
settimanale di M2O (https://www.m2o.it/classifiche/m2o-chart/).
|
||||
|
||||
Tutte le funzioni ritornano una lista di dict con keys:
|
||||
{rank, artist, title, album, image_url, popularity}
|
||||
|
||||
Cache SQLite in `~/Library/Application Support/MusicTools/charts_cache.db`
|
||||
(dev: nella dir del progetto). TTL 6h. `force=True` bypassa la cache.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
# ---- Last.fm ----
|
||||
# Demo key: sostituibile via config `lastfm_api_key`. Se questa non funziona
|
||||
# piu', registrane una nuova su https://www.last.fm/api/account/create.
|
||||
_LASTFM_KEY = "d060bf4a1907efe5b1e4b8b0f9a4a86e"
|
||||
_LASTFM_URL = "https://ws.audioscrobbler.com/2.0/"
|
||||
|
||||
# ---- iTunes RSS ----
|
||||
_ITUNES_URL_TPL = (
|
||||
"https://rss.applemarketingtools.com/api/v2/{country}/music/"
|
||||
"most-played/100/songs.json"
|
||||
)
|
||||
|
||||
# ---- Spotify chart playlists ----
|
||||
# Playlist ID editoriali di Spotify. Chiave interna -> (playlist_id, nome UI).
|
||||
_SPOTIFY_CHARTS = {
|
||||
"top50_global": ("37i9dQZEVXbMDoHDwVN2tF", "Top 50 Global"),
|
||||
"top50_italy": ("37i9dQZEVXbIQnj7RRhdSX", "Top 50 Italy"),
|
||||
"top50_usa": ("37i9dQZEVXbLRQDuF5jeBp", "Top 50 USA"),
|
||||
"top50_uk": ("37i9dQZEVXbLnolsZ8PSNw", "Top 50 UK"),
|
||||
"viral50_global": ("37i9dQZEVXbLiRSasKsNU9", "Viral 50 Global"),
|
||||
"viral50_italy": ("37i9dQZEVXbNFJfN1Vw8d9", "Viral 50 Italy"),
|
||||
}
|
||||
|
||||
# ---- M2O Chart ----
|
||||
_M2O_URL = "https://www.m2o.it/classifiche/m2o-chart/"
|
||||
|
||||
# ---- Cache ----
|
||||
_CACHE_TTL_SEC = 6 * 3600 # 6h
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cache SQLite
|
||||
# ------------------------------------------------------------------
|
||||
def _cache_db_path() -> Path:
|
||||
from core.config import _get_config_dir
|
||||
return _get_config_dir() / "charts_cache.db"
|
||||
|
||||
|
||||
def _init_db(conn: sqlite3.Connection) -> None:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS charts(
|
||||
key TEXT PRIMARY KEY,
|
||||
payload TEXT,
|
||||
cached_at REAL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _get_cached(conn: sqlite3.Connection, key: str) -> Optional[list]:
|
||||
row = conn.execute(
|
||||
"SELECT payload, cached_at FROM charts WHERE key=?", (key,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
payload, cached_at = row
|
||||
if time.time() - float(cached_at or 0) > _CACHE_TTL_SEC:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
return data if isinstance(data, list) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _put_cache(conn: sqlite3.Connection, key: str, data: list) -> None:
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO charts(key, payload, cached_at) VALUES (?, ?, ?)",
|
||||
(key, json.dumps(data, ensure_ascii=False), time.time()),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.Error:
|
||||
# Cache best-effort: se scrivere fallisce non blocchiamo il fetch
|
||||
pass
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Last.fm helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _get_lastfm_key() -> str:
|
||||
"""Ritorna la key Last.fm dalla config se settata, altrimenti la demo."""
|
||||
try:
|
||||
from core.config import load_config
|
||||
k = (load_config().get("lastfm_api_key") or "").strip()
|
||||
return k or _LASTFM_KEY
|
||||
except Exception:
|
||||
return _LASTFM_KEY
|
||||
|
||||
|
||||
def _build_lastfm_tag(decade: str, genre: str) -> str:
|
||||
"""Combina decade + genere in un tag Last.fm compatibile.
|
||||
|
||||
decade: '' | '70s' | '80s' | '90s' | '2000s' | '2010s'
|
||||
genre: '' | 'pop' | 'rock' | 'dance' | ...
|
||||
|
||||
Esempi:
|
||||
_build_lastfm_tag('90s', 'dance') -> '90s dance'
|
||||
_build_lastfm_tag('', 'italo disco') -> 'italo disco'
|
||||
_build_lastfm_tag('80s', '') -> '80s'
|
||||
_build_lastfm_tag('', '') -> 'pop' (fallback)
|
||||
"""
|
||||
d = (decade or "").strip().lower()
|
||||
g = (genre or "").strip().lower()
|
||||
parts: list = []
|
||||
if d:
|
||||
parts.append(d)
|
||||
if g:
|
||||
parts.append(g)
|
||||
return " ".join(parts) if parts else "pop"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# iTunes RSS
|
||||
# ------------------------------------------------------------------
|
||||
def fetch_itunes(country: str = "it", force: bool = False) -> list:
|
||||
"""Ritorna la top 100 iTunes "most-played" per il paese.
|
||||
|
||||
country: "it" | "us" | "gb" | "ww"
|
||||
|
||||
Ogni entry:
|
||||
{rank, artist, title, album, image_url, popularity=None}
|
||||
|
||||
Cache 6h. `force=True` bypassa. In caso di errore HTTP/JSON ritorna [].
|
||||
"""
|
||||
country = (country or "it").lower().strip() or "it"
|
||||
key = f"itunes:{country}"
|
||||
|
||||
conn = sqlite3.connect(_cache_db_path())
|
||||
try:
|
||||
_init_db(conn)
|
||||
if not force:
|
||||
cached = _get_cached(conn, key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
url = _ITUNES_URL_TPL.format(country=country)
|
||||
try:
|
||||
resp = requests.get(url, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
entries: list = []
|
||||
for i, item in enumerate(
|
||||
(data.get("feed", {}) or {}).get("results", []) or [], start=1
|
||||
):
|
||||
entries.append({
|
||||
"rank": i,
|
||||
"artist": (item.get("artistName") or "").strip(),
|
||||
"title": (item.get("name") or "").strip(),
|
||||
"album": (item.get("collectionName") or "").strip(),
|
||||
"image_url": (item.get("artworkUrl100") or "").strip(),
|
||||
"popularity": None,
|
||||
})
|
||||
_put_cache(conn, key, entries)
|
||||
return entries
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Last.fm
|
||||
# ------------------------------------------------------------------
|
||||
def fetch_lastfm(decade: str, genre: str, force: bool = False) -> list:
|
||||
"""Ritorna top tracks per il tag Last.fm costruito da decade+genere.
|
||||
|
||||
Esempi:
|
||||
fetch_lastfm('90s', 'dance') -> tag '90s dance'
|
||||
fetch_lastfm('', 'italo disco') -> tag 'italo disco'
|
||||
|
||||
Cache 6h. In caso di errore o `tracks.track` mancante ritorna [].
|
||||
"""
|
||||
tag = _build_lastfm_tag(decade, genre)
|
||||
key = f"lastfm:{tag}"
|
||||
|
||||
conn = sqlite3.connect(_cache_db_path())
|
||||
try:
|
||||
_init_db(conn)
|
||||
if not force:
|
||||
cached = _get_cached(conn, key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
_LASTFM_URL,
|
||||
params={
|
||||
"method": "tag.gettoptracks",
|
||||
"tag": tag,
|
||||
"api_key": _get_lastfm_key(),
|
||||
"format": "json",
|
||||
"limit": 50,
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
entries: list = []
|
||||
tracks = (data.get("tracks", {}) or {}).get("track", []) or []
|
||||
for i, item in enumerate(tracks, start=1):
|
||||
artist_obj = item.get("artist") or {}
|
||||
if isinstance(artist_obj, dict):
|
||||
artist_name = (artist_obj.get("name") or "").strip()
|
||||
else:
|
||||
artist_name = str(artist_obj).strip()
|
||||
# Ultima immagine (di solito la piu' grande) se presente
|
||||
image_url = ""
|
||||
imgs = item.get("image") or []
|
||||
if isinstance(imgs, list) and imgs:
|
||||
last = imgs[-1]
|
||||
if isinstance(last, dict):
|
||||
image_url = (last.get("#text") or "").strip()
|
||||
entries.append({
|
||||
"rank": i,
|
||||
"artist": artist_name,
|
||||
"title": (item.get("name") or "").strip(),
|
||||
"album": "",
|
||||
"image_url": image_url,
|
||||
"popularity": item.get("listeners"),
|
||||
})
|
||||
_put_cache(conn, key, entries)
|
||||
return entries
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Spotify chart playlists
|
||||
# ------------------------------------------------------------------
|
||||
def _spotify_credentials() -> tuple:
|
||||
"""Ritorna (client_id, client_secret) dalla config, o ('','') se assenti."""
|
||||
try:
|
||||
from core.config import load_config
|
||||
cfg = load_config()
|
||||
return (
|
||||
(cfg.get("client_id") or "").strip(),
|
||||
(cfg.get("client_secret") or "").strip(),
|
||||
)
|
||||
except Exception:
|
||||
return ("", "")
|
||||
|
||||
|
||||
def fetch_spotify(playlist_key: str, force: bool = False) -> list:
|
||||
"""Ritorna i primi 50 brani di una chart playlist editoriale Spotify.
|
||||
|
||||
`playlist_key` deve essere una chiave di `_SPOTIFY_CHARTS`.
|
||||
Richiede `client_id`/`client_secret` gia' configurate (le stesse
|
||||
usate dalle altre tab). Se mancano o l'auth fallisce ritorna [].
|
||||
"""
|
||||
key = (playlist_key or "").strip()
|
||||
if key not in _SPOTIFY_CHARTS:
|
||||
return []
|
||||
playlist_id, _label = _SPOTIFY_CHARTS[key]
|
||||
cache_key = f"spotify:{key}"
|
||||
|
||||
conn = sqlite3.connect(_cache_db_path())
|
||||
try:
|
||||
_init_db(conn)
|
||||
if not force:
|
||||
cached = _get_cached(conn, cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
client_id, client_secret = _spotify_credentials()
|
||||
if not client_id or not client_secret:
|
||||
return []
|
||||
|
||||
# Import qui per evitare import ciclici con core.config a livello modulo.
|
||||
from core.spotify_client import get_access_token
|
||||
try:
|
||||
token = get_access_token(client_id, client_secret)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"https://api.spotify.com/v1/playlists/{playlist_id}/tracks",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
params={"limit": 50},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
entries: list = []
|
||||
items = data.get("items") or []
|
||||
for i, item in enumerate(items, start=1):
|
||||
track = (item or {}).get("track") or {}
|
||||
if not track:
|
||||
continue
|
||||
artists = track.get("artists") or []
|
||||
artist_names = [
|
||||
(a.get("name") or "").strip()
|
||||
for a in artists
|
||||
if isinstance(a, dict) and a.get("name")
|
||||
]
|
||||
artist = ", ".join(artist_names)
|
||||
album = ((track.get("album") or {}).get("name") or "").strip()
|
||||
image_url = ""
|
||||
imgs = ((track.get("album") or {}).get("images") or [])
|
||||
if imgs and isinstance(imgs[0], dict):
|
||||
image_url = (imgs[0].get("url") or "").strip()
|
||||
entries.append({
|
||||
"rank": i,
|
||||
"artist": artist,
|
||||
"title": (track.get("name") or "").strip(),
|
||||
"album": album,
|
||||
"image_url": image_url,
|
||||
"popularity": track.get("popularity"),
|
||||
})
|
||||
_put_cache(conn, cache_key, entries)
|
||||
return entries
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# M2O Chart
|
||||
# ------------------------------------------------------------------
|
||||
def fetch_m2o(force: bool = False) -> list:
|
||||
"""Scraping della chart dance italiana settimanale di M2O.
|
||||
|
||||
La pagina non ha API: parsiamo l'HTML con BeautifulSoup. Estraiamo
|
||||
da ogni <article>:
|
||||
- rank <- .position (int, altrimenti skip)
|
||||
- title <- h3.title.song
|
||||
- artist <- h4.title.author (lista virgola-separata, lasciata così)
|
||||
- image_url <- figure img[src]
|
||||
|
||||
album e popularity sono sempre vuoti (non presenti nella pagina).
|
||||
Cache 6h. Errori HTTP/parse -> [].
|
||||
"""
|
||||
cache_key = "m2o:chart"
|
||||
|
||||
conn = sqlite3.connect(_cache_db_path())
|
||||
try:
|
||||
_init_db(conn)
|
||||
if not force:
|
||||
cached = _get_cached(conn, cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
_M2O_URL,
|
||||
headers={"User-Agent": "Mozilla/5.0 (MusicTools charts)"},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
html = resp.text
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
entries: list = []
|
||||
for art in soup.find_all("article"):
|
||||
pos_el = art.select_one(".position")
|
||||
if pos_el is None:
|
||||
continue
|
||||
try:
|
||||
rank = int((pos_el.get_text() or "").strip())
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
title_el = art.select_one("h3.title.song") or art.select_one("h3.song")
|
||||
artist_el = art.select_one("h4.title.author") or art.select_one("h4.author")
|
||||
title = title_el.get_text(strip=True) if title_el else ""
|
||||
artist = artist_el.get_text(strip=True) if artist_el else ""
|
||||
if not title and not artist:
|
||||
continue
|
||||
|
||||
img_el = art.select_one("figure img")
|
||||
image_url = ""
|
||||
if img_el is not None:
|
||||
image_url = (img_el.get("src") or img_el.get("data-src") or "").strip()
|
||||
|
||||
entries.append({
|
||||
"rank": rank,
|
||||
"artist": artist,
|
||||
"title": title,
|
||||
"album": "",
|
||||
"image_url": image_url,
|
||||
"popularity": None,
|
||||
})
|
||||
|
||||
entries.sort(key=lambda e: e.get("rank", 9999))
|
||||
_put_cache(conn, cache_key, entries)
|
||||
return entries
|
||||
finally:
|
||||
conn.close()
|
||||
+20
-1
@@ -5,7 +5,7 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "v1.8.2"
|
||||
VERSION = "v1.13.0"
|
||||
|
||||
|
||||
APP_NAME = "MusicTools"
|
||||
@@ -68,14 +68,33 @@ DEFAULTS = {
|
||||
"bitrate": "320K",
|
||||
"hq_threshold": 310,
|
||||
"cookies_path": str(_project_dir / "cookies.txt"),
|
||||
"cookies_browser": "", # "" | chrome | safari | firefox | edge | brave
|
||||
"output_dir": str(_project_dir / "MUSICA"),
|
||||
"theme": "dark",
|
||||
# ---- Beatport ----
|
||||
"beatport_last_genre": "melodic-house-techno", # ultimo genere Top 100 caricato
|
||||
# ---- Traxsource ----
|
||||
"traxsource_last_genre": "tech-house",
|
||||
# ---- Music Search (Spotify + YouTube) ----
|
||||
"spotify_search_last_query": "",
|
||||
"spotify_search_artist_mode": False,
|
||||
"youtube_search_last_query": "",
|
||||
# ---- Dedup (audio duplicati via Chromaprint) ----
|
||||
"dedup_last_folder": "",
|
||||
"dedup_recursive": True,
|
||||
"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
|
||||
# ---- Charts (iTunes RSS + Last.fm + Spotify + M2O) ----
|
||||
"charts_last_source": "itunes", # "itunes" | "spotify" | "m2o" | "lastfm"
|
||||
"charts_itunes_country": "it", # "it" | "us" | "gb" | "ww"
|
||||
"charts_lastfm_decade": "90s", # "" | "70s" | "80s" | "90s" | "2000s" | "2010s"
|
||||
"charts_lastfm_genre": "dance", # "" | pop, rock, dance, ...
|
||||
"charts_spotify_playlist": "top50_global", # chiave in charts._SPOTIFY_CHARTS
|
||||
"lastfm_api_key": "", # opzionale: override della chiave demo
|
||||
# ---- Licenza ----
|
||||
"license_key": "", # chiave fornita all'utente via email
|
||||
"license_email": "", # email associata all'acquisto
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Conversione WAV -> MP3 via ffmpeg subprocess."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from core.paths import find_ffmpeg_dir, subprocess_flags
|
||||
|
||||
|
||||
_stop_event = threading.Event()
|
||||
_current_process: Optional[subprocess.Popen] = None
|
||||
_process_lock = threading.Lock()
|
||||
|
||||
|
||||
def request_stop() -> None:
|
||||
_stop_event.set()
|
||||
with _process_lock:
|
||||
if _current_process and _current_process.poll() is None:
|
||||
_current_process.terminate()
|
||||
|
||||
|
||||
def reset_stop() -> None:
|
||||
_stop_event.clear()
|
||||
|
||||
|
||||
def is_stopped() -> bool:
|
||||
return _stop_event.is_set()
|
||||
|
||||
|
||||
# VBR quality mapping da bitrate label a -q:a (libmp3lame). Piu basso = migliore qualita.
|
||||
# Vedi https://trac.ffmpeg.org/wiki/Encode/MP3
|
||||
_VBR_QUALITY = {
|
||||
128: 5,
|
||||
192: 2,
|
||||
256: 0,
|
||||
320: 0, # V0 e' ~245k medio; per >V0 c'e' solo CBR 320
|
||||
}
|
||||
|
||||
|
||||
def _find_ffmpeg() -> str:
|
||||
"""Trova il binario ffmpeg dentro bundle_bin o PATH.
|
||||
|
||||
Solleva RuntimeError se non trovato.
|
||||
"""
|
||||
ffmpeg_dir = find_ffmpeg_dir()
|
||||
if ffmpeg_dir:
|
||||
for name in ("ffmpeg", "ffmpeg.exe"):
|
||||
p = Path(ffmpeg_dir) / name
|
||||
if p.exists():
|
||||
return str(p)
|
||||
# Fallback PATH
|
||||
import shutil
|
||||
ff = shutil.which("ffmpeg")
|
||||
if ff:
|
||||
return ff
|
||||
raise RuntimeError("ffmpeg non trovato nel bundle o nel PATH")
|
||||
|
||||
|
||||
def convert_wav_to_mp3(
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
bitrate: int = 320,
|
||||
vbr: bool = False,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""Converte un file WAV in MP3.
|
||||
|
||||
Args:
|
||||
input_path: file .wav sorgente
|
||||
output_path: file .mp3 destinazione
|
||||
bitrate: 128 / 192 / 256 / 320
|
||||
vbr: True per Variable Bit Rate (-q:a), False per Constant (-b:a)
|
||||
progress_callback: opzionale, chiamato con (percent 0..100)
|
||||
|
||||
Raises:
|
||||
RuntimeError: se ffmpeg non trovato o exit non-zero.
|
||||
FileNotFoundError: se input_path non esiste.
|
||||
"""
|
||||
src = Path(input_path)
|
||||
if not src.exists():
|
||||
raise FileNotFoundError(f"File sorgente non trovato: {input_path}")
|
||||
|
||||
dst = Path(output_path)
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ffmpeg = _find_ffmpeg()
|
||||
|
||||
cmd = [ffmpeg, "-y", "-i", str(src)]
|
||||
if vbr:
|
||||
quality = _VBR_QUALITY.get(bitrate, 2)
|
||||
cmd.extend(["-c:a", "libmp3lame", "-q:a", str(quality)])
|
||||
else:
|
||||
cmd.extend(["-c:a", "libmp3lame", "-b:a", f"{bitrate}k"])
|
||||
cmd.extend([
|
||||
"-map_metadata", "0", # copia metadata WAV se presenti
|
||||
"-id3v2_version", "3",
|
||||
"-progress", "pipe:2", # progress su stderr in formato key=value
|
||||
str(dst),
|
||||
])
|
||||
|
||||
# Prova a ottenere la durata totale per calcolare % — leggendo lo stream INFO
|
||||
duration_us = _probe_duration_us(ffmpeg, str(src))
|
||||
|
||||
global _current_process
|
||||
with _process_lock:
|
||||
_current_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
proc = _current_process
|
||||
|
||||
try:
|
||||
for line in proc.stderr:
|
||||
if is_stopped():
|
||||
proc.terminate()
|
||||
if progress_callback:
|
||||
progress_callback(-1) # segnale di interruzione
|
||||
return
|
||||
if not progress_callback or not duration_us:
|
||||
continue
|
||||
# ffmpeg -progress emette "out_time_us=<microsecondi>"
|
||||
m = re.match(r"out_time_us=(\d+)", line.strip())
|
||||
if m:
|
||||
elapsed = int(m.group(1))
|
||||
pct = min(100, int(elapsed * 100 / duration_us))
|
||||
progress_callback(pct)
|
||||
|
||||
rc = proc.wait()
|
||||
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"ffmpeg exit {rc}")
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(100)
|
||||
finally:
|
||||
with _process_lock:
|
||||
_current_process = None
|
||||
|
||||
|
||||
def _probe_duration_us(ffmpeg: str, input_path: str) -> int:
|
||||
"""Estrae la durata in microsecondi via ffmpeg -f null. Ritorna 0 se non riesce."""
|
||||
try:
|
||||
# ffprobe potrebbe non essere sempre bundlato; usa ffmpeg
|
||||
result = subprocess.run(
|
||||
[ffmpeg, "-i", input_path, "-f", "null", "-"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
# Cerca "Duration: HH:MM:SS.ms"
|
||||
m = re.search(r"Duration:\s+(\d+):(\d+):(\d+)\.(\d+)", result.stderr)
|
||||
if m:
|
||||
h, mi, s, ms = map(int, m.groups())
|
||||
total_sec = h * 3600 + mi * 60 + s + ms / 100
|
||||
return int(total_sec * 1_000_000)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def list_wav_files(directory: str, recursive: bool = True) -> list:
|
||||
"""Scan cartella per file .wav. Ritorna list di path stringa ordinati."""
|
||||
root = Path(directory)
|
||||
if not root.is_dir():
|
||||
return []
|
||||
pattern = "**/*.wav" if recursive else "*.wav"
|
||||
return sorted(str(p) for p in root.glob(pattern) if p.is_file())
|
||||
+518
@@ -0,0 +1,518 @@
|
||||
"""Deduplicator audio via Chromaprint fingerprinting + SQLite cache.
|
||||
|
||||
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 -json`).
|
||||
Il valore viene messo in cache SQLite: al re-scan, se
|
||||
(size, mtime) coincide col record, riusiamo il fingerprint senza
|
||||
rilanciare fpcalc.
|
||||
3. Raggruppa i file per fingerprint identico (>= 2 file). Per ogni
|
||||
gruppo, i file vengono ordinati per bitrate DESC (tie-break: size
|
||||
DESC): il primo e' quello "da tenere", gli altri i duplicati.
|
||||
4. `move_to_trash` invia i path selezionati al cestino di sistema
|
||||
tramite send2trash (reversibile via Finder/Explorer).
|
||||
|
||||
Progress callback firma:
|
||||
(processed: int, total: int, filename: str, status: str)
|
||||
Status validi: 'scanning' | 'computing' | 'cached' | 'error' | 'stopped'
|
||||
| 'completed'.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from core.paths import find_fpcalc, subprocess_flags
|
||||
from core.upgrader import AUDIO_EXTENSIONS, get_bitrate
|
||||
|
||||
|
||||
# Timeout massimo per una singola invocazione fpcalc.
|
||||
_FPCALC_TIMEOUT_SEC = 30
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cache SQLite
|
||||
# ------------------------------------------------------------------
|
||||
def _cache_db_path() -> Path:
|
||||
"""Path del DB di cache dei fingerprint.
|
||||
|
||||
Riusa `_get_config_dir` di core.config cosi' finisce nella stessa
|
||||
cartella di config.json (~/Library/Application Support/MusicTools/
|
||||
su macOS, %APPDATA%/MusicTools/ su Windows, project root in dev).
|
||||
"""
|
||||
from core.config import _get_config_dir
|
||||
return _get_config_dir() / "dedup_cache.db"
|
||||
|
||||
|
||||
def _init_db(conn: sqlite3.Connection) -> None:
|
||||
"""Crea (idempotente) lo schema della cache."""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
path TEXT PRIMARY KEY,
|
||||
size INTEGER NOT NULL,
|
||||
mtime REAL NOT NULL,
|
||||
duration REAL,
|
||||
fingerprint TEXT,
|
||||
bitrate INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _open_cache(db_path: Optional[Path] = None) -> sqlite3.Connection:
|
||||
"""Apre (creando se serve) la connessione alla cache."""
|
||||
p = db_path or _cache_db_path()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(p))
|
||||
_init_db(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def _cache_get(conn: sqlite3.Connection, path: str,
|
||||
size: int, mtime: float) -> Optional[dict]:
|
||||
"""Ritorna il record se (size, mtime) invariato, altrimenti None."""
|
||||
cur = conn.execute(
|
||||
"SELECT size, mtime, duration, fingerprint, bitrate FROM files WHERE path = ?",
|
||||
(path,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
csize, cmtime, dur, fp, br = row
|
||||
# Tolleranza minima sul mtime (float precision su alcuni FS)
|
||||
if csize != size or abs(float(cmtime) - float(mtime)) > 0.001:
|
||||
return None
|
||||
if not fp:
|
||||
return None
|
||||
return {
|
||||
"size": int(csize),
|
||||
"mtime": float(cmtime),
|
||||
"duration": float(dur) if dur is not None else 0.0,
|
||||
"fingerprint": str(fp),
|
||||
"bitrate": int(br) if br is not None else 0,
|
||||
}
|
||||
|
||||
|
||||
def _cache_put(conn: sqlite3.Connection, path: str, size: int, mtime: float,
|
||||
duration: float, fingerprint: str, bitrate: int) -> None:
|
||||
"""Upsert (SQLite ha ON CONFLICT REPLACE via INSERT OR REPLACE)."""
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO files (path, size, mtime, duration, fingerprint, bitrate)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(path, int(size), float(mtime), float(duration or 0),
|
||||
str(fingerprint or ""), int(bitrate or 0)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# fpcalc
|
||||
# ------------------------------------------------------------------
|
||||
def _run_fpcalc(fpcalc: str, path: str, length: Optional[int] = None) -> dict:
|
||||
"""Esegue fpcalc una volta. Ritorna {duration, fingerprint} su successo
|
||||
o {_error: str} su fallimento."""
|
||||
cmd = [fpcalc, "-json"]
|
||||
if length is not None:
|
||||
cmd += ["-length", str(length)]
|
||||
cmd.append(str(path))
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_FPCALC_TIMEOUT_SEC,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"_error": f"timeout {_FPCALC_TIMEOUT_SEC}s"}
|
||||
except (OSError, ValueError) as e:
|
||||
return {"_error": f"subprocess: {e}"}
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "").strip().splitlines()
|
||||
msg = err[-1] if err else f"exit {proc.returncode}"
|
||||
return {"_error": msg[:200]}
|
||||
try:
|
||||
data = json.loads(proc.stdout or "{}")
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
return {"_error": f"JSON malformato: {e}"}
|
||||
fp = data.get("fingerprint")
|
||||
if not fp:
|
||||
return {"_error": "fingerprint vuoto (audio troppo corto?)"}
|
||||
try:
|
||||
dur = float(data.get("duration") or 0)
|
||||
except (TypeError, ValueError):
|
||||
dur = 0.0
|
||||
return {"duration": dur, "fingerprint": str(fp)}
|
||||
|
||||
|
||||
def compute_fingerprint(fpcalc: str, path: str) -> Optional[dict]:
|
||||
"""Chiama fpcalc e ritorna {duration, fingerprint} o {_error}.
|
||||
|
||||
Se il primo tentativo (full length) fallisce con "Invalid data" o simili
|
||||
(frame audio corrotti che libav rifiuta), riprova con `-length 30`.
|
||||
Molti file danneggiati hanno i frame corrotti nella parte finale e
|
||||
limitando la scansione ai primi 30s si riesce a estrarre comunque
|
||||
un fingerprint affidabile (30s bastano per l'unicità Chromaprint).
|
||||
"""
|
||||
if not fpcalc:
|
||||
return {"_error": "fpcalc non trovato nel bundle"}
|
||||
|
||||
res = _run_fpcalc(fpcalc, path)
|
||||
if "fingerprint" in res:
|
||||
return res
|
||||
|
||||
err_msg = res.get("_error", "").lower()
|
||||
|
||||
# Retry 1: frame audio corrotti → riduci finestra a 30s
|
||||
corrupt_signals = ("invalid data", "decoding audio frame",
|
||||
"error while decoding", "invalid frame")
|
||||
if any(sig in err_msg for sig in corrupt_signals):
|
||||
res2 = _run_fpcalc(fpcalc, path, length=30)
|
||||
if "fingerprint" in res2:
|
||||
res2["_partial"] = True # 30s soltanto
|
||||
return res2
|
||||
|
||||
# Retry 2: fingerprint vuoto → prova con finestra piu' lunga (60s)
|
||||
# nel caso l'intro sia silenzio/muto (chromaprint richiede audio "reale")
|
||||
if "vuoto" in err_msg or "empty" in err_msg:
|
||||
res2 = _run_fpcalc(fpcalc, path, length=60)
|
||||
if "fingerprint" in res2:
|
||||
res2["_partial"] = True
|
||||
return res2
|
||||
# Ancora vuoto → prova algoritmo differente (chromaprint algo 1)
|
||||
# tramite subprocess diretto perche' _run_fpcalc non lo supporta
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[fpcalc, "-json", "-length", "60", "-algorithm", "1", str(path)],
|
||||
capture_output=True, text=True,
|
||||
timeout=_FPCALC_TIMEOUT_SEC,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
data = json.loads(proc.stdout or "{}")
|
||||
fp = data.get("fingerprint")
|
||||
if fp:
|
||||
return {
|
||||
"duration": float(data.get("duration") or 0),
|
||||
"fingerprint": str(fp),
|
||||
"_partial": True,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return res
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Scan
|
||||
# ------------------------------------------------------------------
|
||||
def _iter_audio_files(directory: str, recursive: bool) -> list[Path]:
|
||||
"""Elenca tutti i file audio (estensione case-insensitive)."""
|
||||
base = Path(directory)
|
||||
if not base.exists() or not base.is_dir():
|
||||
return []
|
||||
files: list[Path] = []
|
||||
if recursive:
|
||||
for f in base.rglob("*"):
|
||||
if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS:
|
||||
files.append(f)
|
||||
else:
|
||||
for f in base.iterdir():
|
||||
if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS:
|
||||
files.append(f)
|
||||
files.sort()
|
||||
return files
|
||||
|
||||
|
||||
def _scan_by_filename(files: list, progress_callback: Optional[Callable],
|
||||
group_callback: Optional[Callable] = None,
|
||||
similarity_threshold: float = 0.8) -> list[list[dict]]:
|
||||
"""Raggruppa file per similarità nome (Jaccard sui token normalizzati),
|
||||
algoritmo INCREMENTALE: per ogni nuovo file cerca match tra i gruppi già
|
||||
formati (lookup O(K) dove K = numero gruppi). Emette streaming via
|
||||
`group_callback` appena un gruppo raggiunge ≥ 2 file.
|
||||
"""
|
||||
from core.upgrader import _normalize_stem # riuso
|
||||
|
||||
def _pc(idx, total_n, name, status, err=""):
|
||||
if not progress_callback:
|
||||
return
|
||||
try:
|
||||
progress_callback(idx, total_n, name, status, err)
|
||||
except TypeError:
|
||||
progress_callback(idx, total_n, name, status)
|
||||
|
||||
def _gc(group_id: str, entries: list) -> None:
|
||||
if group_callback:
|
||||
try:
|
||||
group_callback({"id": group_id, "entries": list(entries)})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
total = len(files)
|
||||
# Ogni voce: {"id": str, "key_tokens": frozenset, "entries": [dict]}
|
||||
groups: list = []
|
||||
|
||||
for i, fp_path in enumerate(files, start=1):
|
||||
if is_stopped():
|
||||
_pc(i - 1, total, "", "stopped")
|
||||
break
|
||||
try:
|
||||
size = fp_path.stat().st_size
|
||||
except OSError as e:
|
||||
_pc(i, total, fp_path.name, "error", f"stat: {e}")
|
||||
continue
|
||||
tokens = frozenset(_normalize_stem(fp_path.stem))
|
||||
if not tokens:
|
||||
_pc(i, total, fp_path.name, "error", "nome senza token utili")
|
||||
continue
|
||||
try:
|
||||
bitrate = get_bitrate(fp_path)
|
||||
except Exception:
|
||||
bitrate = 0
|
||||
entry = {
|
||||
"path": str(fp_path), "size": size, "bitrate": bitrate,
|
||||
"duration": 0, "fingerprint": "",
|
||||
}
|
||||
|
||||
# Cerca match nei gruppi già formati (lineare sui gruppi, non sui file)
|
||||
matched = None
|
||||
for g in groups:
|
||||
common = len(tokens & g["key_tokens"])
|
||||
if common == 0:
|
||||
continue
|
||||
union = len(tokens | g["key_tokens"])
|
||||
if union > 0 and (common / union) >= similarity_threshold:
|
||||
matched = g
|
||||
break
|
||||
|
||||
if matched is not None:
|
||||
was_solo = len(matched["entries"]) == 1
|
||||
matched["entries"].append(entry)
|
||||
matched["entries"].sort(key=lambda e: (-e["bitrate"], -e["size"]))
|
||||
# Streaming: emit ogni volta che il gruppo diventa/rimane ≥ 2 file
|
||||
_gc(matched["id"], matched["entries"])
|
||||
else:
|
||||
gid = f"fn_{len(groups)}_{fp_path.stem[:20]}"
|
||||
groups.append({
|
||||
"id": gid,
|
||||
"key_tokens": set(tokens),
|
||||
"entries": [entry],
|
||||
})
|
||||
_pc(i, total, fp_path.name, "cached")
|
||||
|
||||
# Ritorna solo i gruppi con >= 2 file
|
||||
result = [sorted(g["entries"], key=lambda e: (-e["bitrate"], -e["size"]))
|
||||
for g in groups if len(g["entries"]) >= 2]
|
||||
result.sort(key=lambda g: -max(e["size"] for e in g))
|
||||
_pc(total, total, "", "completed")
|
||||
return result
|
||||
|
||||
|
||||
def scan_folder(
|
||||
directory: str,
|
||||
recursive: bool = True,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
method: str = "fingerprint",
|
||||
group_callback: Optional[Callable] = None,
|
||||
) -> list[list[dict]]:
|
||||
"""Ritorna la lista di gruppi di file duplicati (>= 2 file).
|
||||
|
||||
Ogni file nel gruppo e' un dict:
|
||||
{path, size, bitrate, duration, fingerprint}
|
||||
|
||||
Gruppi ordinati per size del file piu' grande DESC (i gruppi che
|
||||
occupano piu' spazio vengono prima). All'interno di ogni gruppo:
|
||||
bitrate DESC, poi size DESC (il primo e' quello "da tenere").
|
||||
|
||||
`method`:
|
||||
- "fingerprint" (default): Chromaprint via fpcalc, preciso ma lento.
|
||||
Cache SQLite persistente. Raggruppa per fingerprint identico.
|
||||
- "filename": similarità Jaccard sui nomi file. Veloce ma euristico.
|
||||
Non richiede fpcalc.
|
||||
"""
|
||||
reset_stop()
|
||||
files = _iter_audio_files(directory, recursive)
|
||||
total = len(files)
|
||||
if total == 0:
|
||||
if progress_callback:
|
||||
progress_callback(0, 0, "", "completed")
|
||||
return []
|
||||
|
||||
if method == "filename":
|
||||
return _scan_by_filename(files, progress_callback, group_callback)
|
||||
|
||||
fpcalc = find_fpcalc()
|
||||
if not fpcalc:
|
||||
# Senza fpcalc non possiamo fare nulla. Segnaliamo errore su ogni
|
||||
# file e ritorniamo lista vuota.
|
||||
if progress_callback:
|
||||
progress_callback(0, total, "", "error")
|
||||
return []
|
||||
|
||||
conn = _open_cache()
|
||||
try:
|
||||
# {fingerprint: [entry, ...]}
|
||||
by_fp: dict[str, list[dict]] = {}
|
||||
|
||||
def _pc(idx, total_n, name, status, err=""):
|
||||
"""Chiama progress_callback in modo retrocompatibile: la firma
|
||||
legacy è a 4 args, quella nuova a 5 con `error_msg` opzionale."""
|
||||
if not progress_callback:
|
||||
return
|
||||
try:
|
||||
progress_callback(idx, total_n, name, status, err)
|
||||
except TypeError:
|
||||
progress_callback(idx, total_n, name, status)
|
||||
|
||||
for i, fp_path in enumerate(files, start=1):
|
||||
if is_stopped():
|
||||
_pc(i - 1, total, "", "stopped")
|
||||
return []
|
||||
try:
|
||||
st = fp_path.stat()
|
||||
size = st.st_size
|
||||
mtime = st.st_mtime
|
||||
except OSError as e:
|
||||
_pc(i, total, fp_path.name, "error", f"stat: {e}")
|
||||
continue
|
||||
|
||||
path_str = str(fp_path)
|
||||
cached = _cache_get(conn, path_str, size, mtime)
|
||||
if cached:
|
||||
fp_hash = cached["fingerprint"]
|
||||
duration = cached["duration"]
|
||||
bitrate = cached["bitrate"] or get_bitrate(fp_path)
|
||||
_pc(i, total, fp_path.name, "cached")
|
||||
else:
|
||||
_pc(i, total, fp_path.name, "computing")
|
||||
res = compute_fingerprint(fpcalc, path_str)
|
||||
if not res or not res.get("fingerprint"):
|
||||
err_msg = (res or {}).get("_error", "errore sconosciuto")
|
||||
_pc(i, total, fp_path.name, "error", err_msg)
|
||||
continue
|
||||
fp_hash = res["fingerprint"]
|
||||
duration = res["duration"]
|
||||
try:
|
||||
bitrate = get_bitrate(fp_path)
|
||||
except Exception:
|
||||
bitrate = 0
|
||||
_cache_put(conn, path_str, size, mtime, duration, fp_hash, bitrate)
|
||||
|
||||
entry = {
|
||||
"path": path_str,
|
||||
"size": int(size),
|
||||
"bitrate": int(bitrate or 0),
|
||||
"duration": float(duration or 0),
|
||||
"fingerprint": fp_hash,
|
||||
}
|
||||
grp = by_fp.setdefault(fp_hash, [])
|
||||
grp.append(entry)
|
||||
# Streaming: appena il gruppo raggiunge (o supera) 2 elementi,
|
||||
# emetti update (JS accumula/aggiorna in tempo reale)
|
||||
if group_callback and len(grp) >= 2:
|
||||
# Ordinamento intra-gruppo prima di emit (best-to-keep primo)
|
||||
grp.sort(key=lambda e: (-int(e.get("bitrate") or 0),
|
||||
-int(e.get("size") or 0)))
|
||||
try:
|
||||
group_callback({
|
||||
"id": f"fp_{fp_hash[:24]}",
|
||||
"entries": list(grp),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Filtra: solo gruppi con >= 2 file
|
||||
groups = [g for g in by_fp.values() if len(g) >= 2]
|
||||
|
||||
# Sort dei file dentro il gruppo: bitrate DESC, size DESC.
|
||||
# Sort dei gruppi: size del file piu' grande DESC (usa max del gruppo).
|
||||
for g in groups:
|
||||
g.sort(key=lambda e: (-int(e.get("bitrate") or 0),
|
||||
-int(e.get("size") or 0)))
|
||||
groups.sort(key=lambda g: -max(int(e.get("size") or 0) for e in g))
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(total, total, "", "completed")
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Trash
|
||||
# ------------------------------------------------------------------
|
||||
def move_to_trash(paths: list[str]) -> dict:
|
||||
"""Sposta i file in cestino tramite send2trash.
|
||||
|
||||
Ritorna {moved: [...], failed: [{path, error}, ...]}. Non solleva
|
||||
mai eccezioni: gli errori per file singolo finiscono in `failed`.
|
||||
Aggiorna la cache SQLite rimuovendo i record dei file spostati (per
|
||||
quelli riusciti), cosi' un re-scan non li propone piu'.
|
||||
"""
|
||||
# Import interno per rendere il modulo importabile anche se
|
||||
# send2trash non e' installato (i test possono mockarlo).
|
||||
try:
|
||||
from send2trash import send2trash
|
||||
except Exception as e: # pragma: no cover — solo se pacchetto mancante
|
||||
return {
|
||||
"moved": [],
|
||||
"failed": [{"path": p, "error": f"send2trash non disponibile: {e}"}
|
||||
for p in (paths or [])],
|
||||
}
|
||||
|
||||
moved: list[str] = []
|
||||
failed: list[dict] = []
|
||||
for p in (paths or []):
|
||||
try:
|
||||
send2trash(p)
|
||||
moved.append(p)
|
||||
except Exception as e:
|
||||
failed.append({"path": p, "error": str(e)})
|
||||
|
||||
# Cache cleanup best-effort (non fatale se fallisce)
|
||||
if moved:
|
||||
try:
|
||||
conn = _open_cache()
|
||||
try:
|
||||
for p in moved:
|
||||
conn.execute("DELETE FROM files WHERE path = ?", (p,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"moved": moved, "failed": failed}
|
||||
+96
-17
@@ -12,6 +12,29 @@ from typing import Callable, Optional
|
||||
from core.paths import find_ytdlp, find_ffmpeg_dir, subprocess_flags
|
||||
|
||||
|
||||
_ALLOWED_BROWSERS = {"chrome", "safari", "firefox", "edge", "brave", "chromium", "opera", "vivaldi"}
|
||||
|
||||
|
||||
def _cookie_args(cookies_path: Optional[str]) -> list:
|
||||
"""Ritorna gli argomenti yt-dlp per i cookies.
|
||||
|
||||
Priorità: file cookies_path se esiste → altrimenti --cookies-from-browser
|
||||
<name> se `cookies_browser` è settato in config → altrimenti niente.
|
||||
"""
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
return ["--cookies", cookies_path]
|
||||
# Fallback: legge il browser dalla config al volo (evita di cambiare
|
||||
# firma di tutte le funzioni download_*)
|
||||
try:
|
||||
from core.config import load_config
|
||||
browser = (load_config().get("cookies_browser") or "").strip().lower()
|
||||
except Exception:
|
||||
browser = ""
|
||||
if browser in _ALLOWED_BROWSERS:
|
||||
return ["--cookies-from-browser", browser]
|
||||
return []
|
||||
|
||||
|
||||
# Flag globale per interruzione
|
||||
_stop_event = threading.Event()
|
||||
_current_process: Optional[subprocess.Popen] = None
|
||||
@@ -99,8 +122,7 @@ def _search_youtube(query: str, cookies_path: Optional[str] = None) -> tuple[str
|
||||
"--no-warnings",
|
||||
"--flat-playlist",
|
||||
]
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
cmd.extend(_cookie_args(cookies_path))
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **subprocess_flags())
|
||||
if result.returncode != 0:
|
||||
@@ -121,6 +143,8 @@ def download_playlist(
|
||||
bitrate: str = "320K",
|
||||
cookies_path: Optional[str] = None,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
output_filenames: Optional[list] = None,
|
||||
post_download_callback: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""Scarica tutti i brani dalla lista di tracce.
|
||||
|
||||
@@ -130,6 +154,12 @@ def download_playlist(
|
||||
bitrate: qualita audio (es. "320K")
|
||||
cookies_path: percorso file cookies (opzionale)
|
||||
progress_callback: callback(track_index, total, track_name, status, percent)
|
||||
output_filenames: opzionale, parallelo a `tracks`. Se presente e non
|
||||
vuoto per l'indice i, yt-dlp scrive `<output_filenames[i]>.mp3`
|
||||
invece di usare il titolo YouTube. Passa gia' sanitizzato.
|
||||
post_download_callback: opzionale, chiamato come `(i, filepath)`
|
||||
dopo yt-dlp exit 0 con il path effettivo del file .mp3 salvato.
|
||||
Usato per tagging ID3 post-download.
|
||||
"""
|
||||
reset_stop()
|
||||
total = len(tracks)
|
||||
@@ -183,6 +213,15 @@ def download_playlist(
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, "downloading", 0)
|
||||
|
||||
# Nome file custom (per tagging: "Artista - Titolo") oppure titolo YouTube grezzo
|
||||
custom_stem = ""
|
||||
if output_filenames and i < len(output_filenames):
|
||||
custom_stem = (output_filenames[i] or "").strip()
|
||||
if custom_stem:
|
||||
out_template = str(Path(output_dir) / f"{custom_stem}.%(ext)s")
|
||||
else:
|
||||
out_template = str(Path(output_dir) / "%(title)s.%(ext)s")
|
||||
|
||||
# Scarica con yt-dlp subprocess
|
||||
cmd = [
|
||||
ytdlp,
|
||||
@@ -193,14 +232,13 @@ def download_playlist(
|
||||
"--add-metadata",
|
||||
"--no-check-certificates",
|
||||
"--newline",
|
||||
"--output", str(Path(output_dir) / "%(title)s.%(ext)s"),
|
||||
"--output", out_template,
|
||||
video_url,
|
||||
]
|
||||
ffmpeg_dir = find_ffmpeg_dir()
|
||||
if ffmpeg_dir:
|
||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
cmd.extend(_cookie_args(cookies_path))
|
||||
|
||||
try:
|
||||
with _process_lock:
|
||||
@@ -209,6 +247,7 @@ def download_playlist(
|
||||
**subprocess_flags(),
|
||||
)
|
||||
|
||||
last_lines: list = [] # buffer per capire l'errore se exit != 0
|
||||
for line in _current_process.stdout:
|
||||
if is_stopped():
|
||||
_current_process.terminate()
|
||||
@@ -216,6 +255,13 @@ def download_playlist(
|
||||
progress_callback(i, total, query, "stopped", 0)
|
||||
return
|
||||
|
||||
# Tieni le ultime 10 righe non-progress per il report d'errore
|
||||
stripped = line.strip()
|
||||
if stripped and not re.match(r"^\[download\]\s+\d", stripped):
|
||||
last_lines.append(stripped)
|
||||
if len(last_lines) > 10:
|
||||
last_lines.pop(0)
|
||||
|
||||
# Parse progress da output yt-dlp
|
||||
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
|
||||
if pct_match and progress_callback:
|
||||
@@ -231,11 +277,26 @@ def download_playlist(
|
||||
_mark_done(done_file, query)
|
||||
done_set.add(query)
|
||||
existing_files = _scan_existing_files(out_path)
|
||||
if post_download_callback and custom_stem:
|
||||
mp3_path = Path(output_dir) / f"{custom_stem}.mp3"
|
||||
try:
|
||||
post_download_callback(i, str(mp3_path))
|
||||
except Exception:
|
||||
pass
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, "done", 100)
|
||||
else:
|
||||
# Estrai la vera causa da last_lines: cerca la riga ERROR di yt-dlp
|
||||
err_hint = ""
|
||||
for l in reversed(last_lines):
|
||||
if "ERROR" in l or "error" in l:
|
||||
err_hint = l[:220]
|
||||
break
|
||||
if not err_hint and last_lines:
|
||||
err_hint = last_lines[-1][:220]
|
||||
detail = f" — {err_hint}" if err_hint else ""
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, f"error: yt-dlp exit {return_code}", 0)
|
||||
progress_callback(i, total, query, f"error: yt-dlp exit {return_code}{detail}", 0)
|
||||
|
||||
except Exception as e:
|
||||
with _process_lock:
|
||||
@@ -277,8 +338,7 @@ def download_direct_url(
|
||||
"--no-warnings",
|
||||
url,
|
||||
]
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
probe_cmd.extend(["--cookies", cookies_path])
|
||||
probe_cmd.extend(_cookie_args(cookies_path))
|
||||
|
||||
try:
|
||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
|
||||
@@ -352,8 +412,7 @@ def download_direct_url(
|
||||
ffmpeg_dir = find_ffmpeg_dir()
|
||||
if ffmpeg_dir:
|
||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
cmd.extend(_cookie_args(cookies_path))
|
||||
|
||||
try:
|
||||
with _process_lock:
|
||||
@@ -406,6 +465,8 @@ def download_urls(
|
||||
bitrate: str = "320K",
|
||||
cookies_path: Optional[str] = None,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
output_filenames: Optional[list] = None,
|
||||
post_download_callback: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""Scarica direttamente da URL YouTube (bypassa search).
|
||||
|
||||
@@ -420,6 +481,12 @@ def download_urls(
|
||||
bitrate: qualita audio (default 320K)
|
||||
cookies_path: file cookies opzionale
|
||||
progress_callback: callback(idx, total, title, status, percent)
|
||||
output_filenames: opzionale, parallelo a `urls`. Se presente e non
|
||||
vuoto per l'indice i, yt-dlp scrive `<output_filenames[i]>.mp3`
|
||||
invece di usare il titolo YouTube. Passa gia' sanitizzato.
|
||||
post_download_callback: opzionale, chiamato come `(i, filepath)`
|
||||
dopo yt-dlp exit 0 con il path effettivo del file .mp3 salvato.
|
||||
Usato per tagging ID3 post-download.
|
||||
"""
|
||||
global _current_process
|
||||
reset_stop()
|
||||
@@ -456,6 +523,15 @@ def download_urls(
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, "downloading", 0)
|
||||
|
||||
# Nome file custom (per tagging: "Artista - Titolo") oppure titolo YouTube grezzo
|
||||
custom_stem = ""
|
||||
if output_filenames and i < len(output_filenames):
|
||||
custom_stem = (output_filenames[i] or "").strip()
|
||||
if custom_stem:
|
||||
out_template = str(Path(output_dir) / f"{custom_stem}.%(ext)s")
|
||||
else:
|
||||
out_template = str(Path(output_dir) / "%(title)s.%(ext)s")
|
||||
|
||||
cmd = [
|
||||
ytdlp,
|
||||
"--extract-audio",
|
||||
@@ -465,14 +541,13 @@ def download_urls(
|
||||
"--add-metadata",
|
||||
"--no-check-certificates",
|
||||
"--newline",
|
||||
"--output", str(Path(output_dir) / "%(title)s.%(ext)s"),
|
||||
"--output", out_template,
|
||||
url,
|
||||
]
|
||||
ffmpeg_dir = find_ffmpeg_dir()
|
||||
if ffmpeg_dir:
|
||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
cmd.extend(_cookie_args(cookies_path))
|
||||
|
||||
try:
|
||||
with _process_lock:
|
||||
@@ -502,6 +577,12 @@ def download_urls(
|
||||
_mark_done(done_file, key)
|
||||
done_set.add(key)
|
||||
existing_files = _scan_existing_files(out_path)
|
||||
if post_download_callback and custom_stem:
|
||||
mp3_path = Path(output_dir) / f"{custom_stem}.mp3"
|
||||
try:
|
||||
post_download_callback(i, str(mp3_path))
|
||||
except Exception:
|
||||
pass
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, "done", 100)
|
||||
else:
|
||||
@@ -562,8 +643,7 @@ def download_video(
|
||||
probe_cmd = [
|
||||
ytdlp, "--dump-json", "--flat-playlist", "--no-download", "--no-warnings", url,
|
||||
]
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
probe_cmd.extend(["--cookies", cookies_path])
|
||||
probe_cmd.extend(_cookie_args(cookies_path))
|
||||
|
||||
try:
|
||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
|
||||
@@ -638,8 +718,7 @@ def download_video(
|
||||
ffmpeg_dir = find_ffmpeg_dir()
|
||||
if ffmpeg_dir:
|
||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
cmd.extend(_cookie_args(cookies_path))
|
||||
|
||||
try:
|
||||
with _process_lock:
|
||||
|
||||
+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
|
||||
@@ -14,6 +14,7 @@ from mutagen import File as MutagenFile
|
||||
from mutagen.id3 import (
|
||||
ID3, ID3NoHeaderError,
|
||||
APIC, TIT2, TPE1, TPE2, TALB, TDRC, TRCK, TCON, COMM, TBPM, TKEY,
|
||||
USLT, WOAS,
|
||||
)
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
from mutagen.flac import FLAC, Picture
|
||||
@@ -84,6 +85,69 @@ def _text(value) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
_AI_MARKERS = {
|
||||
"suno": ["suno.com", "made with suno", "sunoai"],
|
||||
"udio": ["udio.com", "made with udio"],
|
||||
"riffusion": ["riffusion.com", "made with riffusion"],
|
||||
"mubert": ["mubert.com", "made with mubert"],
|
||||
"aiva": ["aiva.ai"],
|
||||
"boomy": ["boomy.com", "made with boomy"],
|
||||
"soundraw": ["soundraw.io"],
|
||||
"loudly": ["loudly.com"],
|
||||
}
|
||||
|
||||
|
||||
def _detect_ai(comment: str, source_url: str, extra: str = "") -> tuple:
|
||||
"""Ritorna (is_ai: bool, source_name: str). Case-insensitive.
|
||||
`extra` = altre stringhe da controllare (es. presenza C2PA manifest)."""
|
||||
blob = f"{comment} {source_url} {extra}".lower()
|
||||
for source, markers in _AI_MARKERS.items():
|
||||
for m in markers:
|
||||
if m in blob:
|
||||
return True, source
|
||||
if "c2pa" in blob:
|
||||
return True, ""
|
||||
return False, ""
|
||||
|
||||
|
||||
def _dump_id3_frames(tags) -> list:
|
||||
"""Lista compatta di tutti i frame ID3 (esclusa APIC binaria).
|
||||
Ogni voce: {key, preview, size, kind}. Utile per audit AI-generated."""
|
||||
out = []
|
||||
for key in tags.keys():
|
||||
try:
|
||||
frame = tags[key]
|
||||
except Exception:
|
||||
continue
|
||||
kind = "text"
|
||||
preview = ""
|
||||
size = 0
|
||||
if key.startswith("APIC"):
|
||||
kind = "binary"
|
||||
preview = f"{frame.mime or 'image'} ({len(frame.data)} bytes)"
|
||||
size = len(frame.data)
|
||||
elif key.startswith("GEOB"):
|
||||
kind = "binary"
|
||||
data = getattr(frame, "data", b"") or b""
|
||||
desc = getattr(frame, "desc", "") or ""
|
||||
mime = getattr(frame, "mime", "") or ""
|
||||
preview = f"{desc or mime or 'blob'} ({len(data)} bytes)"
|
||||
size = len(data)
|
||||
elif key.startswith("USLT"):
|
||||
txt = getattr(frame, "text", "") or ""
|
||||
preview = txt[:180] + ("…" if len(txt) > 180 else "")
|
||||
size = len(txt)
|
||||
elif key.startswith("WOAS") or key.startswith("WXXX") or key.startswith("WOAR"):
|
||||
preview = getattr(frame, "url", "") or str(frame)
|
||||
size = len(preview)
|
||||
else:
|
||||
txt = str(frame)
|
||||
preview = txt[:180] + ("…" if len(txt) > 180 else "")
|
||||
size = len(txt)
|
||||
out.append({"key": key, "preview": preview, "size": size, "kind": kind})
|
||||
return out
|
||||
|
||||
|
||||
def _read_mp3(path: str, result: dict) -> dict:
|
||||
result["format"] = "MP3"
|
||||
try:
|
||||
@@ -91,6 +155,8 @@ def _read_mp3(path: str, result: dict) -> dict:
|
||||
except ID3NoHeaderError:
|
||||
return result
|
||||
|
||||
result["raw_frames"] = _dump_id3_frames(tags)
|
||||
|
||||
result["title"] = _text(tags.get("TIT2"))
|
||||
result["artist"] = _text(tags.get("TPE1"))
|
||||
result["album_artist"] = _text(tags.get("TPE2"))
|
||||
@@ -100,12 +166,19 @@ def _read_mp3(path: str, result: dict) -> dict:
|
||||
result["genre"] = _text(tags.get("TCON"))
|
||||
result["bpm"] = _text(tags.get("TBPM"))
|
||||
result["key"] = _text(tags.get("TKEY"))
|
||||
result["source_url"] = _text(tags.get("WOAS"))
|
||||
|
||||
for k in tags.keys():
|
||||
if k.startswith("COMM"):
|
||||
result["comment"] = _text(tags[k])
|
||||
break
|
||||
|
||||
for k in tags.keys():
|
||||
if k.startswith("USLT"):
|
||||
frame = tags[k]
|
||||
result["lyrics"] = getattr(frame, "text", "") or ""
|
||||
break
|
||||
|
||||
for k in tags.keys():
|
||||
if k.startswith("APIC"):
|
||||
apic = tags[k]
|
||||
@@ -113,13 +186,44 @@ def _read_mp3(path: str, result: dict) -> dict:
|
||||
result["cover_mime"] = apic.mime or "image/jpeg"
|
||||
break
|
||||
|
||||
# AI detection: C2PA manifest (GEOB frame) o markers testuali
|
||||
has_c2pa = any("c2pa" in k.lower() for k in tags.keys())
|
||||
result["ai_generated"], result["ai_source"] = _detect_ai(
|
||||
result["comment"], result["source_url"], "c2pa" if has_c2pa else "",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _dump_mp4_atoms(tags) -> list:
|
||||
out = []
|
||||
for key, val in tags.items():
|
||||
preview = ""
|
||||
kind = "text"
|
||||
size = 0
|
||||
if key == "covr":
|
||||
kind = "binary"
|
||||
data = bytes(val[0]) if val else b""
|
||||
preview = f"cover ({len(data)} bytes)"
|
||||
size = len(data)
|
||||
else:
|
||||
try:
|
||||
preview = str(val[0] if isinstance(val, list) and val else val)
|
||||
except Exception:
|
||||
preview = "<binary>"
|
||||
if isinstance(preview, str):
|
||||
size = len(preview)
|
||||
if len(preview) > 180:
|
||||
preview = preview[:180] + "…"
|
||||
out.append({"key": key, "preview": preview, "size": size, "kind": kind})
|
||||
return out
|
||||
|
||||
|
||||
def _read_mp4(path: str, result: dict) -> dict:
|
||||
result["format"] = "M4A/MP4"
|
||||
m = MP4(path)
|
||||
tags = m.tags or {}
|
||||
result["raw_frames"] = _dump_mp4_atoms(tags)
|
||||
result["title"] = _text(tags.get("\xa9nam"))
|
||||
result["artist"] = _text(tags.get("\xa9ART"))
|
||||
result["album_artist"] = _text(tags.get("aART"))
|
||||
@@ -132,12 +236,24 @@ def _read_mp4(path: str, result: dict) -> dict:
|
||||
result["genre"] = _text(tags.get("\xa9gen"))
|
||||
result["comment"] = _text(tags.get("\xa9cmt"))
|
||||
result["bpm"] = _text(tags.get("tmpo"))
|
||||
result["lyrics"] = _text(tags.get("\xa9lyr"))
|
||||
# M4A non ha WOAS: cerchiamo in freeform ----:com.apple.iTunes:URL
|
||||
for k in tags.keys():
|
||||
if isinstance(k, str) and k.startswith("----") and "URL" in k.upper():
|
||||
v = tags[k]
|
||||
if v:
|
||||
result["source_url"] = v[0].decode("utf-8", errors="ignore") if isinstance(v[0], bytes) else str(v[0])
|
||||
break
|
||||
|
||||
covers = tags.get("covr")
|
||||
if covers:
|
||||
c = covers[0]
|
||||
result["cover_base64"] = base64.b64encode(bytes(c)).decode("ascii")
|
||||
result["cover_mime"] = "image/jpeg" if c.imageformat == MP4Cover.FORMAT_JPEG else "image/png"
|
||||
|
||||
result["ai_generated"], result["ai_source"] = _detect_ai(
|
||||
result["comment"], result["source_url"],
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -292,8 +408,13 @@ def read_metadata(path: str) -> dict:
|
||||
"bpm": "", "key": "",
|
||||
"duration": 0, "bitrate": 0,
|
||||
"cover_base64": "", "cover_mime": "",
|
||||
"lyrics": "", # USLT (MP3), \xa9lyr (M4A), UNSYNCEDLYRICS (FLAC)
|
||||
"source_url": "", # WOAS (MP3), custom (altri) — es. URL Suno
|
||||
"ai_generated": False, # True se rilevato marker AI (C2PA, "made with suno"...)
|
||||
"ai_source": "", # es. "suno", "udio", "riffusion" — sorgente dedotta
|
||||
"where_from": _read_where_from(str(p)),
|
||||
"is_macos": sys.platform == "darwin",
|
||||
"raw_frames": [], # list of {key, preview, size, kind} — tutti i frame ID3/atomi
|
||||
}
|
||||
|
||||
audio = MutagenFile(str(p))
|
||||
@@ -346,6 +467,22 @@ def _write_mp3(path: str, data: dict, cover_path: Optional[str], remove_cover: b
|
||||
if comment:
|
||||
tags.add(COMM(encoding=3, lang="ita", desc="", text=comment))
|
||||
|
||||
# Lyrics (USLT). Rimuovi tutti gli USLT esistenti, poi (ri)crea se non vuoto
|
||||
for k in list(tags.keys()):
|
||||
if k.startswith("USLT"):
|
||||
del tags[k]
|
||||
lyrics = data.get("lyrics", "")
|
||||
if lyrics:
|
||||
tags.add(USLT(encoding=3, lang="eng", desc="", text=lyrics))
|
||||
|
||||
# Source URL (WOAS)
|
||||
if "source_url" in data:
|
||||
src = data.get("source_url", "")
|
||||
if "WOAS" in tags:
|
||||
del tags["WOAS"]
|
||||
if src:
|
||||
tags.add(WOAS(url=src))
|
||||
|
||||
# Cover
|
||||
if remove_cover or cover_path:
|
||||
for k in list(tags.keys()):
|
||||
@@ -379,6 +516,7 @@ def _write_mp4(path: str, data: dict, cover_path: Optional[str], remove_cover: b
|
||||
set_or_del("\xa9day", data.get("year", ""))
|
||||
set_or_del("\xa9gen", data.get("genre", ""))
|
||||
set_or_del("\xa9cmt", data.get("comment", ""))
|
||||
set_or_del("\xa9lyr", data.get("lyrics", ""))
|
||||
|
||||
track = data.get("track", "")
|
||||
if track:
|
||||
@@ -518,3 +656,38 @@ def write_metadata(path: str, data: dict, cover_path: Optional[str] = None,
|
||||
# macOS extended attribute: kMDItemWhereFroms (lista URL/origini)
|
||||
if "where_from" in data:
|
||||
_write_where_from(path, data.get("where_from") or [])
|
||||
|
||||
|
||||
def remove_frames(path: str, frame_keys: list) -> int:
|
||||
"""Rimuove specifici frame ID3 (MP3) o atomi (MP4) dal file.
|
||||
Ritorna il numero di frame rimossi. `frame_keys` sono le chiavi
|
||||
esatte così come tornano da `raw_frames` (es. "TXXX:comment",
|
||||
"GEOB:c2pa manifest store", "\xa9lyr")."""
|
||||
if not frame_keys:
|
||||
return 0
|
||||
ext = Path(path).suffix.lower()
|
||||
removed = 0
|
||||
if ext == ".mp3":
|
||||
try:
|
||||
tags = ID3(path)
|
||||
except ID3NoHeaderError:
|
||||
return 0
|
||||
for key in frame_keys:
|
||||
if key in tags:
|
||||
del tags[key]
|
||||
removed += 1
|
||||
if removed:
|
||||
tags.save(path)
|
||||
elif ext in (".m4a", ".mp4", ".aac"):
|
||||
m = MP4(path)
|
||||
if m.tags is None:
|
||||
return 0
|
||||
for key in frame_keys:
|
||||
if key in m.tags:
|
||||
del m.tags[key]
|
||||
removed += 1
|
||||
if removed:
|
||||
m.save()
|
||||
else:
|
||||
raise ValueError(f"Rimozione frame non supportata per: {ext}")
|
||||
return removed
|
||||
@@ -45,6 +45,13 @@ def _bundle_dirs() -> list[Path]:
|
||||
sub_macos = frameworks / sub / "Contents" / "MacOS"
|
||||
if sub_macos.exists():
|
||||
dirs.append(sub_macos)
|
||||
else:
|
||||
# Dev: cerca in <project_root>/bundle_bin/ così `python main.py`
|
||||
# usa gli stessi binari del bundle (aggiornati via build_*.py)
|
||||
# invece di Homebrew/PATH che possono essere obsoleti.
|
||||
project_bundle = Path(__file__).resolve().parent.parent / "bundle_bin"
|
||||
if project_bundle.exists():
|
||||
dirs.append(project_bundle)
|
||||
return dirs
|
||||
|
||||
|
||||
@@ -145,3 +152,28 @@ def find_ffmpeg() -> Optional[str]:
|
||||
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
|
||||
+69
-5
@@ -308,16 +308,74 @@ def search_tracks(token: str, query: str, limit: int = 50) -> list:
|
||||
|
||||
def _track_to_dict(t: dict) -> dict:
|
||||
"""Mappa il track object Spotify sul nostro schema uniforme."""
|
||||
album = t.get("album", {}) or {}
|
||||
images = album.get("images", []) or []
|
||||
# Spotify torna 3 taglie ordinate large->small. Prendo la più piccola (~64px)
|
||||
# per la thumbnail UI, la più grande (~640px) per il tagging cover.
|
||||
image_url = "" # ~64px per UI thumbnail
|
||||
cover_url_large = "" # ~640px per tagging ID3
|
||||
if images:
|
||||
image_url = images[-1].get("url", "") or images[0].get("url", "")
|
||||
cover_url_large = images[0].get("url", "") or image_url
|
||||
# release_date può essere YYYY, YYYY-MM o YYYY-MM-DD in base a release_date_precision
|
||||
release_date = str(album.get("release_date") or "").strip()
|
||||
# track_number (opzionale: presente su brani da album, assente su top-tracks flat)
|
||||
tracknumber = t.get("track_number")
|
||||
# artist_id (primo artista) per fetchare i generi ID3
|
||||
artists_arr = t.get("artists", []) or []
|
||||
artist_id = artists_arr[0].get("id", "") if artists_arr else ""
|
||||
return {
|
||||
"id": t.get("id", ""),
|
||||
"url": t.get("external_urls", {}).get("spotify", ""),
|
||||
"name": t.get("name", ""),
|
||||
"artists": ", ".join(a.get("name", "") for a in t.get("artists", [])),
|
||||
"album": t.get("album", {}).get("name", ""),
|
||||
"artists": ", ".join(a.get("name", "") for a in artists_arr),
|
||||
"album": album.get("name", ""),
|
||||
"duration_sec": int(t.get("duration_ms", 0)) // 1000,
|
||||
"image_url": image_url,
|
||||
"cover_url_large": cover_url_large,
|
||||
"release_date": release_date,
|
||||
"track_number": int(tracknumber) if tracknumber else 0,
|
||||
"artist_id": artist_id,
|
||||
}
|
||||
|
||||
|
||||
def get_artist_genres(token: str, artist_id: str) -> list:
|
||||
"""Ritorna la lista di generi dell'artista (può essere vuota).
|
||||
|
||||
Silenzia gli errori HTTP: usato per arricchimento best-effort dei tag
|
||||
ID3, non deve mai bloccare il download.
|
||||
"""
|
||||
if not artist_id:
|
||||
return []
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"https://api.spotify.com/v1/artists/{artist_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
return list(resp.json().get("genres") or [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def enrich_from_youtube_title(token: str, video_title: str) -> dict:
|
||||
"""Cerca su Spotify il primo match per il titolo di un video YouTube.
|
||||
|
||||
Ritorna un dict con la stessa shape di _track_to_dict, oppure {} se
|
||||
nessun match / errore. Best-effort per il tagging dei download YouTube.
|
||||
"""
|
||||
query = (video_title or "").strip()
|
||||
if not query:
|
||||
return {}
|
||||
try:
|
||||
results = search_tracks(token, query, limit=1)
|
||||
except Exception:
|
||||
return {}
|
||||
return results[0] if results else {}
|
||||
|
||||
|
||||
def search_artist_discography(token: str, artist_name: str) -> list:
|
||||
"""Trova l'artista esatto (o il piu' popolare tra i match) e ritorna
|
||||
tutti i suoi brani: top tracks + tracce di ogni album/single.
|
||||
@@ -372,10 +430,13 @@ def search_artist_discography(token: str, artist_name: str) -> list:
|
||||
r_alb.raise_for_status()
|
||||
albums = r_alb.json().get("items", [])
|
||||
|
||||
# 4. Per ogni album, tracce (album/track object non ha "album" sub-field, iniettiamola)
|
||||
# 4. Per ogni album, tracce (album/track object non ha "album" sub-field, iniettiamola
|
||||
# includendo anche le images dell'album così _track_to_dict riesce a estrarre la cover)
|
||||
for alb in albums:
|
||||
alb_id = alb.get("id")
|
||||
alb_name = alb.get("name", "")
|
||||
alb_images = alb.get("images", []) or []
|
||||
alb_release_date = alb.get("release_date", "")
|
||||
if not alb_id:
|
||||
continue
|
||||
time.sleep(0.1) # rate limit interno
|
||||
@@ -388,8 +449,11 @@ def search_artist_discography(token: str, artist_name: str) -> list:
|
||||
r_at.raise_for_status()
|
||||
for t in r_at.json().get("items", []):
|
||||
t = dict(t)
|
||||
# Album tracks non hanno "album" nested; iniettiamo il nome
|
||||
t.setdefault("album", {"name": alb_name})
|
||||
t.setdefault("album", {
|
||||
"name": alb_name,
|
||||
"images": alb_images,
|
||||
"release_date": alb_release_date,
|
||||
})
|
||||
collected.append(_track_to_dict(t))
|
||||
|
||||
# 5. Dedupe
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
"""Scrittura tag ID3 su file MP3 via mutagen, con cover art da URL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from mutagen.easyid3 import EasyID3
|
||||
from mutagen.id3 import ID3, APIC
|
||||
from mutagen.mp3 import MP3
|
||||
|
||||
|
||||
SAFE_FILENAME_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||
|
||||
|
||||
def sanitize_filename_stem(stem: str, max_len: int = 180) -> str:
|
||||
"""Rimuove caratteri non validi per un filename e limita lunghezza."""
|
||||
s = SAFE_FILENAME_RE.sub("_", stem or "").strip()
|
||||
s = re.sub(r"\s+", " ", s)
|
||||
if len(s) > max_len:
|
||||
s = s[:max_len].rstrip()
|
||||
return s or "untitled"
|
||||
|
||||
|
||||
def build_filename_stem(artist: str, title: str) -> str:
|
||||
"""Compone 'Artista - Titolo' sanitizzato."""
|
||||
artist = (artist or "").strip()
|
||||
title = (title or "").strip()
|
||||
if artist and title:
|
||||
return sanitize_filename_stem(f"{artist} - {title}")
|
||||
return sanitize_filename_stem(title or artist or "untitled")
|
||||
|
||||
|
||||
def _download_cover(url: str) -> Optional[bytes]:
|
||||
"""Scarica bytes cover, ritorna None su qualsiasi errore."""
|
||||
try:
|
||||
resp = requests.get(url, timeout=15)
|
||||
if resp.status_code == 200:
|
||||
return resp.content
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _cover_mime(url: str, content: bytes) -> str:
|
||||
"""Deduci MIME dell'immagine da URL o magic bytes."""
|
||||
u = url.lower()
|
||||
if u.endswith(".png"):
|
||||
return "image/png"
|
||||
if u.endswith((".jpg", ".jpeg")):
|
||||
return "image/jpeg"
|
||||
# magic bytes
|
||||
if content[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
return "image/png"
|
||||
if content[:3] == b"\xff\xd8\xff":
|
||||
return "image/jpeg"
|
||||
return "image/jpeg" # default
|
||||
|
||||
|
||||
def write_tags(filepath: str, metadata: dict) -> None:
|
||||
"""Scrive ID3 tag su file MP3 esistente.
|
||||
|
||||
metadata: dict con chiavi opzionali:
|
||||
title, artist, album, date (YYYY o YYYY-MM-DD), genre,
|
||||
tracknumber, cover_url
|
||||
|
||||
Errori non fatali (file mancante, tag non scrivibili): silenziosi
|
||||
per non bloccare il download principale.
|
||||
"""
|
||||
p = Path(filepath)
|
||||
if not p.exists():
|
||||
return
|
||||
|
||||
# 1. Tag testuali via EasyID3
|
||||
try:
|
||||
try:
|
||||
audio = EasyID3(str(p))
|
||||
except Exception:
|
||||
# Se il file non ha header ID3, aggiungine uno vuoto
|
||||
mp3 = MP3(str(p))
|
||||
if mp3.tags is None:
|
||||
mp3.add_tags()
|
||||
mp3.save()
|
||||
audio = EasyID3(str(p))
|
||||
for key in ("title", "artist", "album", "date", "genre", "tracknumber"):
|
||||
val = metadata.get(key)
|
||||
if val:
|
||||
audio[key] = str(val)
|
||||
audio.save(str(p))
|
||||
except Exception:
|
||||
return # ID3 broken, non provo la cover
|
||||
|
||||
# 2. Cover art via ID3 APIC
|
||||
cover_url = (metadata.get("cover_url") or "").strip()
|
||||
if not cover_url:
|
||||
return
|
||||
cover_bytes = _download_cover(cover_url)
|
||||
if not cover_bytes:
|
||||
return
|
||||
try:
|
||||
mime = _cover_mime(cover_url, cover_bytes)
|
||||
tags = ID3(str(p))
|
||||
tags.delall("APIC")
|
||||
tags.add(APIC(encoding=3, mime=mime, type=3, desc="Cover", data=cover_bytes))
|
||||
tags.save(str(p))
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Fetch Top 100 Traxsource per genere.
|
||||
|
||||
Approccio: session curl_cffi (bypass CF via cookie) + BeautifulSoup HTML scraping.
|
||||
Spec: docs/superpowers/specs/2026-08-01-traxsource-charts-design.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
# Generi musicali Traxsource (slug -> (id, display_name)).
|
||||
# Aggiornato via scripts/refresh_traxsource_genres.py. Esclusi "sounds/samples/loops",
|
||||
# "acapella", "beats", "efx-dj-tools", "stems" (non generi ma tipi di prodotto).
|
||||
# NOTA (ago 2026): Traxsource ha rimosso la "Top 100 <genre>" per 10 generi minori
|
||||
# (broken-beat-nu-jazz, classic-house, drum-and-bass, electro-house, electronica,
|
||||
# leftfield, lounge-chill-out, pop-dance, r-and-b-hip-hop, world). Rimossi dal
|
||||
# dropdown finché non ripristinano la playlist canonica.
|
||||
GENRES: dict = {
|
||||
"afro-house": (27, "Afro House"),
|
||||
"afro-latin-brazilian": (23, "Afro / Latin / Brazilian"),
|
||||
"deep-house": (13, "Deep House"),
|
||||
"garage": (29, "Garage"),
|
||||
"house": (4, "House"),
|
||||
"jackin-house": (15, "Jackin House"),
|
||||
"melodic-progressive-house": (19, "Melodic / Progressive House"),
|
||||
"minimal-deep-tech": (16, "Minimal / Deep Tech"),
|
||||
"nu-disco-indie-dance": (17, "Nu Disco / Indie Dance"),
|
||||
"soul-funk-disco": (3, "Soul / Funk / Disco"),
|
||||
"soulful-house": (24, "Soulful House"),
|
||||
"tech-house": (18, "Tech House"),
|
||||
"techno": (20, "Techno"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TraxsourceTrack:
|
||||
position: int
|
||||
title: str
|
||||
mix: str
|
||||
artists: str
|
||||
label: str
|
||||
traxsource_id: int
|
||||
slug: str
|
||||
image_url: str = ""
|
||||
cover_url_large: str = ""
|
||||
|
||||
|
||||
def list_genres() -> list:
|
||||
result = [
|
||||
{"slug": slug, "id": gid, "name": name}
|
||||
for slug, (gid, name) in GENRES.items()
|
||||
]
|
||||
result.sort(key=lambda g: g["name"].casefold())
|
||||
return result
|
||||
|
||||
|
||||
class TraxsourceError(Exception):
|
||||
"""Base per errori Traxsource."""
|
||||
|
||||
|
||||
class TraxsourceUnreachableError(TraxsourceError):
|
||||
"""Rete / 5xx dopo retry."""
|
||||
|
||||
|
||||
class TraxsourceParseError(TraxsourceError):
|
||||
"""HTML ricevuto ma non conforme allo schema atteso."""
|
||||
|
||||
|
||||
_MIX_PAREN_RE = re.compile(r"^(.*)\s*\(([^()]+)\)\s*$")
|
||||
_SIZE_RE = re.compile(r"/\d+x\d+/")
|
||||
|
||||
|
||||
def _split_title_mix(full_title: str) -> tuple:
|
||||
"""Estrae mix dalle parentesi finali. 'Foo (Extended Mix)' -> ('Foo', 'Extended Mix').
|
||||
Se non ci sono parentesi finali, mix = ''."""
|
||||
if not full_title:
|
||||
return ("", "")
|
||||
m = _MIX_PAREN_RE.match(full_title.strip())
|
||||
if m:
|
||||
return (m.group(1).strip(), m.group(2).strip())
|
||||
return (full_title.strip(), "")
|
||||
|
||||
|
||||
def _format_artists(names: list) -> str:
|
||||
"""['A', 'B', 'C'] -> 'A, B & C'. Strips whitespace."""
|
||||
clean = [n.strip() for n in names if n and n.strip()]
|
||||
if not clean:
|
||||
return ""
|
||||
if len(clean) == 1:
|
||||
return clean[0]
|
||||
return ", ".join(clean[:-1]) + " & " + clean[-1]
|
||||
|
||||
|
||||
def _large_cover(url: str) -> str:
|
||||
"""Sostituisce /NxN/ nel path con /500x500/. Se pattern assente, ritorna invariato."""
|
||||
if not url:
|
||||
return ""
|
||||
return _SIZE_RE.sub("/500x500/", url)
|
||||
|
||||
|
||||
_TOP100_LINK_RE = re.compile(r'href="(/title/\d+/top-100-[a-z0-9-]+)"')
|
||||
|
||||
|
||||
def _discover_top100_url(genre_html: str) -> str:
|
||||
"""Estrae il path relativo della playlist Top 100 corrente dalla pagina di un genere."""
|
||||
m = _TOP100_LINK_RE.search(genre_html)
|
||||
if not m:
|
||||
raise TraxsourceParseError("link Top 100 non trovato nella pagina genere")
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _parse_tracks(html: str) -> list:
|
||||
"""Parsa la pagina Top 100 (title playlist) e ritorna list[TraxsourceTrack].
|
||||
|
||||
Selettori (verificati su fixture tech-house 2026-07):
|
||||
row = div.trk-row.play-trk (data-trid=<int>)
|
||||
position = div.tnum (inside div.tnum-pos)
|
||||
title <a> = div.trk-cell.title a[href^="/track/"]
|
||||
version = span.version (contiene child span.duration da rimuovere)
|
||||
artists = a.com-artists (uno o piu)
|
||||
label <a> = div.trk-cell.label a
|
||||
cover img = div.trk-cell.thumb img (src /scripts/image.php/52x52/...)
|
||||
"""
|
||||
# Lazy import — bs4 non e' hard-dep del modulo (importato solo quando serve).
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
rows = soup.select("div.trk-row.play-trk")
|
||||
if not rows:
|
||||
raise TraxsourceParseError("nessuna track (div.trk-row.play-trk) trovata")
|
||||
|
||||
out: list = []
|
||||
for i, row in enumerate(rows, 1):
|
||||
try:
|
||||
trid = int(row.get("data-trid") or 0)
|
||||
|
||||
pos_el = row.select_one("div.tnum")
|
||||
position = i # fallback su enumerate se pos manca / non e' un numero
|
||||
if pos_el:
|
||||
pos_txt = pos_el.get_text(strip=True)
|
||||
if pos_txt.isdigit():
|
||||
position = int(pos_txt)
|
||||
|
||||
title_a = row.select_one('div.trk-cell.title a[href^="/track/"]')
|
||||
if not title_a:
|
||||
continue
|
||||
title = title_a.get_text(strip=True)
|
||||
href = title_a.get("href") or ""
|
||||
slug = href.rsplit("/", 1)[-1]
|
||||
|
||||
# Mix version: contenuto di span.version, escluso span.duration
|
||||
mix = ""
|
||||
version_el = row.select_one("span.version")
|
||||
if version_el:
|
||||
dur_el = version_el.select_one("span.duration")
|
||||
if dur_el:
|
||||
dur_el.extract()
|
||||
mix = version_el.get_text(strip=True)
|
||||
|
||||
artist_names = [a.get_text(strip=True) for a in row.select("a.com-artists")]
|
||||
artists = _format_artists(artist_names)
|
||||
|
||||
label_a = row.select_one("div.trk-cell.label a")
|
||||
label = label_a.get_text(strip=True) if label_a else ""
|
||||
|
||||
img = row.select_one('div.trk-cell.thumb img[src*="/scripts/image.php/"]')
|
||||
image_url = (img.get("src") or "") if img else ""
|
||||
cover_large = _large_cover(image_url)
|
||||
|
||||
out.append(TraxsourceTrack(
|
||||
position=position,
|
||||
title=title,
|
||||
mix=mix,
|
||||
artists=artists,
|
||||
label=label,
|
||||
traxsource_id=trid,
|
||||
slug=slug,
|
||||
image_url=image_url,
|
||||
cover_url_large=cover_large,
|
||||
))
|
||||
except Exception as e:
|
||||
raise TraxsourceParseError(f"errore parse track[{i}]: {e}") from e
|
||||
return out
|
||||
|
||||
|
||||
# --- fetch_top100 con session curl_cffi + retry + cache ------------------
|
||||
|
||||
_IMPERSONATE = "chrome131"
|
||||
_REQUEST_TIMEOUT = 15
|
||||
_MAX_ATTEMPTS = 3
|
||||
_BACKOFF_SEC = [1, 3]
|
||||
_CACHE_TTL_SEC = 15 * 60
|
||||
|
||||
_cache: dict = {}
|
||||
_session_singleton = None
|
||||
|
||||
|
||||
def _session():
|
||||
"""Ritorna la Session curl_cffi singleton, preriscaldata con GET a /."""
|
||||
global _session_singleton
|
||||
if _session_singleton is None:
|
||||
from curl_cffi import requests as _cffi
|
||||
_session_singleton = _cffi.Session(impersonate=_IMPERSONATE)
|
||||
try:
|
||||
_session_singleton.get("https://www.traxsource.com/", timeout=_REQUEST_TIMEOUT)
|
||||
except Exception:
|
||||
pass # cookie CF possono arrivare comunque
|
||||
return _session_singleton
|
||||
|
||||
|
||||
def _do_get(session, url: str) -> str:
|
||||
"""GET con retry + backoff. Include Referer per pagine interne."""
|
||||
last_exc = None
|
||||
for attempt in range(_MAX_ATTEMPTS):
|
||||
try:
|
||||
resp = session.get(
|
||||
url,
|
||||
timeout=_REQUEST_TIMEOUT,
|
||||
headers={"Referer": "https://www.traxsource.com/"},
|
||||
)
|
||||
if resp.status_code >= 500 or resp.status_code == 403:
|
||||
raise Exception(f"HTTP {resp.status_code}")
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
if attempt < _MAX_ATTEMPTS - 1:
|
||||
time.sleep(_BACKOFF_SEC[attempt])
|
||||
raise TraxsourceUnreachableError(
|
||||
f"Traxsource irraggiungibile dopo {_MAX_ATTEMPTS} tentativi: {last_exc}"
|
||||
)
|
||||
|
||||
|
||||
def fetch_top100(slug: str, force_refresh: bool = False) -> list:
|
||||
"""Fetches Top 100 corrente per il genere.
|
||||
|
||||
1. Verifica slug in GENRES
|
||||
2. Fetch pagina genre -> _discover_top100_url
|
||||
3. Fetch pagina Top 100 -> _parse_tracks
|
||||
4. Cache 15 min
|
||||
|
||||
Raises:
|
||||
ValueError: slug non in GENRES
|
||||
TraxsourceUnreachableError: rete/5xx dopo retry
|
||||
TraxsourceParseError: HTML non conforme
|
||||
"""
|
||||
if slug not in GENRES:
|
||||
raise ValueError(f"slug genere non valido: {slug!r}")
|
||||
|
||||
now = time.time()
|
||||
if not force_refresh:
|
||||
cached = _cache.get(slug)
|
||||
if cached and (now - cached[0]) < _CACHE_TTL_SEC:
|
||||
return cached[1]
|
||||
|
||||
gid, _name = GENRES[slug]
|
||||
sess = _session()
|
||||
|
||||
genre_url = f"https://www.traxsource.com/genre/{gid}/{slug}"
|
||||
genre_html = _do_get(sess, genre_url)
|
||||
top100_path = _discover_top100_url(genre_html)
|
||||
|
||||
top100_url = "https://www.traxsource.com" + top100_path
|
||||
top100_html = _do_get(sess, top100_url)
|
||||
tracks = _parse_tracks(top100_html)
|
||||
|
||||
_cache[slug] = (now, tracks)
|
||||
return tracks
|
||||
+330
-7
@@ -3,16 +3,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffprobe, subprocess_flags
|
||||
from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffmpeg, find_ffprobe, subprocess_flags
|
||||
|
||||
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".wav", ".flac"}
|
||||
|
||||
# Soglia minima Jaccard per considerare un file dell'archivio come candidato.
|
||||
_ARCHIVE_MIN_SIMILARITY = 0.5
|
||||
|
||||
# Token di lunghezza inferiore a questa vengono scartati (troppo generici).
|
||||
_MIN_TOKEN_LEN = 3
|
||||
|
||||
# Timeout massimo per singolo download yt-dlp (secondi). Watchdog kill.
|
||||
# Serve a evitare hang su video geo-restricted, YouTube throttle o rete lenta.
|
||||
_DOWNLOAD_TIMEOUT_SEC = 300
|
||||
|
||||
# Flag globale per interruzione
|
||||
_stop_event = threading.Event()
|
||||
_current_process: Optional[subprocess.Popen] = None
|
||||
@@ -70,6 +82,143 @@ def get_bitrate(filepath: str | Path) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _normalize_stem(stem: str) -> set:
|
||||
"""Ritorna il set di token normalizzati per un nome file (senza extension).
|
||||
|
||||
Pipeline:
|
||||
1. Lowercase
|
||||
2. Sostituisce caratteri non alfanumerici con spazi (mantiene split naturale)
|
||||
3. Split su whitespace
|
||||
4. Filtra token di lunghezza < _MIN_TOKEN_LEN (troppo generici)
|
||||
"""
|
||||
if not stem:
|
||||
return set()
|
||||
lowered = stem.lower()
|
||||
# Manteniamo spazi ma sostituiamo tutto il resto (che non è alfanumerico) con spazi
|
||||
cleaned = re.sub(r"[^a-z0-9\s]+", " ", lowered)
|
||||
tokens = cleaned.split()
|
||||
return {t for t in tokens if len(t) >= _MIN_TOKEN_LEN}
|
||||
|
||||
|
||||
def _tokens_to_key(tokens: set) -> str:
|
||||
"""Chiave stabile per un set di token (usata come chiave del dict d'indice)."""
|
||||
return "|".join(sorted(tokens))
|
||||
|
||||
|
||||
def _key_to_tokens(key: str) -> set:
|
||||
"""Inverso di _tokens_to_key."""
|
||||
if not key:
|
||||
return set()
|
||||
return set(key.split("|"))
|
||||
|
||||
|
||||
def _scan_archive(archive_dir: str) -> dict:
|
||||
"""Indicizza recursivamente un archivio di file audio.
|
||||
|
||||
Ritorna dict {token_key: [Path, ...]}. La scansione è case-insensitive
|
||||
sulle estensioni: `.MP3`, `.Mp3`, `.mp3` sono tutti riconosciuti.
|
||||
"""
|
||||
base = Path(archive_dir)
|
||||
index: dict = {}
|
||||
if not base.exists() or not base.is_dir():
|
||||
return index
|
||||
for f in base.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
if f.suffix.lower() not in AUDIO_EXTENSIONS:
|
||||
continue
|
||||
tokens = _normalize_stem(f.stem)
|
||||
if not tokens:
|
||||
continue
|
||||
key = _tokens_to_key(tokens)
|
||||
index.setdefault(key, []).append(f)
|
||||
return index
|
||||
|
||||
|
||||
def _find_candidates(
|
||||
target_stem: str,
|
||||
index: dict,
|
||||
min_similarity: float = _ARCHIVE_MIN_SIMILARITY,
|
||||
) -> list:
|
||||
"""Cerca candidati nell'indice tramite Jaccard sui token del nome.
|
||||
|
||||
Ritorna lista di tuple `(path, similarity, bitrate)` ordinata per
|
||||
bitrate DESC, poi similarity DESC.
|
||||
"""
|
||||
target_tokens = _normalize_stem(target_stem)
|
||||
if not target_tokens:
|
||||
return []
|
||||
|
||||
results: list = []
|
||||
for key, paths in index.items():
|
||||
entry_tokens = _key_to_tokens(key)
|
||||
if not entry_tokens:
|
||||
continue
|
||||
common = len(target_tokens & entry_tokens)
|
||||
if common == 0:
|
||||
continue
|
||||
total = len(target_tokens | entry_tokens)
|
||||
if total == 0:
|
||||
continue
|
||||
sim = common / total
|
||||
if sim < min_similarity:
|
||||
continue
|
||||
for p in paths:
|
||||
try:
|
||||
br = get_bitrate(p)
|
||||
except Exception:
|
||||
br = 0
|
||||
results.append((p, sim, br))
|
||||
|
||||
# Ordina: bitrate DESC, poi similarity DESC (stabile)
|
||||
results.sort(key=lambda t: (-t[2], -t[1]))
|
||||
return results
|
||||
|
||||
|
||||
def _copy_or_convert_to_mp3(
|
||||
src: Path,
|
||||
dst: Path,
|
||||
temp_dir: Path,
|
||||
) -> bool:
|
||||
"""Copia (o converte se necessario) `src` in `dst` come MP3.
|
||||
|
||||
- Se src è già .mp3 → copia diretta con shutil.copy2
|
||||
- Altrimenti → converti con ffmpeg a 320k CBR
|
||||
Ritorna True in caso di successo.
|
||||
"""
|
||||
try:
|
||||
if src.suffix.lower() == ".mp3":
|
||||
# shutil.copy (non copy2): mtime del target = ora, non quello
|
||||
# del sorgente. Così è chiaro nel Finder che il file è stato
|
||||
# aggiornato dall'upgrade.
|
||||
shutil.copy(str(src), str(dst))
|
||||
return dst.exists()
|
||||
# Convert non-mp3 -> mp3 320k
|
||||
ffmpeg_bin = find_ffmpeg() or "ffmpeg"
|
||||
temp_out = temp_dir / f"_archive_convert_{dst.stem}.mp3"
|
||||
# -y per sovrascrivere se residuo di run precedente
|
||||
subprocess.run(
|
||||
[
|
||||
ffmpeg_bin, "-y",
|
||||
"-i", str(src),
|
||||
"-vn", # scarta eventuali stream video/cover art (le riproviamo dopo)
|
||||
"-c:a", "libmp3lame",
|
||||
"-b:a", "320k",
|
||||
"-id3v2_version", "3",
|
||||
str(temp_out),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
if not temp_out.exists():
|
||||
return False
|
||||
temp_out.replace(dst)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _load_done_set(done_file: Path) -> set[str]:
|
||||
if done_file.exists():
|
||||
return set(done_file.read_text(encoding="utf-8").splitlines())
|
||||
@@ -123,7 +272,7 @@ def update_cover_only(
|
||||
"--output", str(temp_dir / "cover"),
|
||||
video_url,
|
||||
]
|
||||
ffmpeg_dir = _find_ffmpeg_dir()
|
||||
ffmpeg_dir = find_ffmpeg_dir()
|
||||
if ffmpeg_dir:
|
||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
@@ -180,8 +329,21 @@ def upgrade_folder(
|
||||
cookies_path: Optional[str] = None,
|
||||
recursive: bool = False,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
archive_dir: Optional[str] = None,
|
||||
resolve_callback: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""Logica principale di upgrade qualita."""
|
||||
"""Logica principale di upgrade qualita.
|
||||
|
||||
Args:
|
||||
archive_dir: se presente, prima di scaricare da YouTube l'app cerca
|
||||
una versione HQ del brano in questa cartella (recursive). Se ne
|
||||
trova una la copia (o converte in mp3 320k) mantenendo il nome
|
||||
originale.
|
||||
resolve_callback: chiamato in caso di match multiplo nell'archivio.
|
||||
Riceve una lista di dict {path, bitrate, size, similarity} e deve
|
||||
ritornare bloccante un dict {'action': 'use_local'|'use_youtube'|
|
||||
'skip', 'path': Optional[str]}.
|
||||
"""
|
||||
reset_stop()
|
||||
ytdlp = find_ytdlp()
|
||||
|
||||
@@ -195,7 +357,7 @@ def upgrade_folder(
|
||||
else:
|
||||
folders = [Path(directory)]
|
||||
|
||||
all_items: list[tuple[Path, Path]] = []
|
||||
all_items: list = []
|
||||
for folder in folders:
|
||||
for ext in AUDIO_EXTENSIONS:
|
||||
for f in sorted(folder.glob(f"*{ext}")):
|
||||
@@ -207,6 +369,16 @@ def upgrade_folder(
|
||||
progress_callback(0, 0, "", "no_files", 0, 0)
|
||||
return
|
||||
|
||||
# Pre-scan dell'archivio (una volta sola). Se archive_dir è None si salta.
|
||||
archive_index: dict = {}
|
||||
if archive_dir:
|
||||
try:
|
||||
archive_index = _scan_archive(archive_dir)
|
||||
except Exception:
|
||||
archive_index = {}
|
||||
if progress_callback:
|
||||
progress_callback(0, total, "", "scan_archive", 0, len(archive_index))
|
||||
|
||||
processed = 0
|
||||
|
||||
for filepath, folder in all_items:
|
||||
@@ -230,6 +402,95 @@ def upgrade_folder(
|
||||
|
||||
current_kbps = get_bitrate(filepath)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1) ARCHIVE LOOKUP (se archive_dir presente)
|
||||
# ------------------------------------------------------------------
|
||||
if archive_index:
|
||||
try:
|
||||
candidates = _find_candidates(filename, archive_index)
|
||||
except Exception:
|
||||
candidates = []
|
||||
|
||||
selected_path: Optional[Path] = None
|
||||
user_chose_youtube = False
|
||||
user_chose_skip = False
|
||||
|
||||
if len(candidates) == 1:
|
||||
selected_path = candidates[0][0]
|
||||
elif len(candidates) >= 2 and resolve_callback is not None:
|
||||
cand_payload = []
|
||||
for cp, csim, cbr in candidates:
|
||||
try:
|
||||
csize = cp.stat().st_size
|
||||
except Exception:
|
||||
csize = 0
|
||||
cand_payload.append({
|
||||
"path": str(cp),
|
||||
"bitrate": cbr,
|
||||
"size": csize,
|
||||
"similarity": csim,
|
||||
})
|
||||
if progress_callback:
|
||||
progress_callback(processed, total, filepath.name,
|
||||
"resolve_wait", current_kbps, 0)
|
||||
try:
|
||||
choice = resolve_callback(str(filepath), cand_payload) or {}
|
||||
except Exception:
|
||||
choice = {}
|
||||
action = (choice.get("action") or "").strip()
|
||||
if action == "use_local":
|
||||
chosen = (choice.get("path") or "").strip()
|
||||
if chosen:
|
||||
cp = Path(chosen)
|
||||
if cp.exists():
|
||||
selected_path = cp
|
||||
elif action == "use_youtube":
|
||||
user_chose_youtube = True
|
||||
elif action == "skip":
|
||||
user_chose_skip = True
|
||||
else:
|
||||
# Risposta invalida: fallback su YouTube per non bloccare
|
||||
user_chose_youtube = True
|
||||
# len(candidates) >= 2 senza callback: fallback YouTube
|
||||
# len(candidates) == 0: fallback YouTube
|
||||
|
||||
if user_chose_skip:
|
||||
_mark_done(done_file, filename)
|
||||
processed += 1
|
||||
if progress_callback:
|
||||
progress_callback(processed, total, filepath.name,
|
||||
"skipped_by_user", current_kbps, 0)
|
||||
continue
|
||||
|
||||
if selected_path is not None and not user_chose_youtube:
|
||||
# Copia/converte il candidato locale come .mp3 con nome originale
|
||||
dst = filepath.parent / f"{filename}.mp3"
|
||||
if progress_callback:
|
||||
progress_callback(processed, total, filepath.name,
|
||||
"local_copy", current_kbps, 0)
|
||||
# Rimuovi originale solo se ha estensione diversa (altrimenti
|
||||
# verrà sovrascritto dalla copia)
|
||||
try:
|
||||
if filepath.exists() and filepath.resolve() != dst.resolve():
|
||||
filepath.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
ok = _copy_or_convert_to_mp3(selected_path, dst, temp_dir)
|
||||
if ok:
|
||||
new_kbps = get_bitrate(dst)
|
||||
_mark_done(done_file, filename)
|
||||
_cleanup_temp(temp_dir)
|
||||
processed += 1
|
||||
if progress_callback:
|
||||
progress_callback(processed, total, filepath.name,
|
||||
"local_upgraded", current_kbps, new_kbps)
|
||||
continue
|
||||
# Copia fallita: fallback YouTube (non marchiamo done)
|
||||
_cleanup_temp(temp_dir)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2) YOUTUBE FALLBACK (comportamento originale)
|
||||
# ------------------------------------------------------------------
|
||||
if progress_callback:
|
||||
progress_callback(processed, total, filepath.name, "searching", current_kbps, 0)
|
||||
|
||||
@@ -288,17 +549,79 @@ def upgrade_folder(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
proc = _current_process
|
||||
|
||||
for line in _current_process.stdout:
|
||||
# Watchdog: kill del subprocess se supera _DOWNLOAD_TIMEOUT_SEC.
|
||||
# Previene hang indefinito su video problematici (geo-block,
|
||||
# YouTube throttle, rete lenta).
|
||||
def _watchdog_kill():
|
||||
try:
|
||||
if proc.poll() is None:
|
||||
proc.kill() # SIGKILL — SIGTERM può lasciare ffmpeg orfano che tiene aperto il pipe
|
||||
except Exception:
|
||||
pass
|
||||
watchdog = threading.Timer(_DOWNLOAD_TIMEOUT_SEC, _watchdog_kill)
|
||||
watchdog.daemon = True
|
||||
watchdog.start()
|
||||
|
||||
# Lettore stdout in thread separato + queue: il main loop polla
|
||||
# con timeout invece di bloccare su `for line in proc.stdout`.
|
||||
# Cosi is_stopped() e proc.poll() vengono controllati periodicamente
|
||||
# -> il bottone Stop risponde in <1s anche se il subprocess ha
|
||||
# figli orfani (ffmpeg) che tengono aperto il pipe.
|
||||
output_q: queue.Queue = queue.Queue()
|
||||
|
||||
def _reader():
|
||||
try:
|
||||
for line in proc.stdout:
|
||||
output_q.put(line)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
output_q.put(None) # sentinel: pipe closed
|
||||
|
||||
reader = threading.Thread(target=_reader, daemon=True)
|
||||
reader.start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
if is_stopped():
|
||||
_current_process.terminate()
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
try:
|
||||
line = output_q.get(timeout=0.5)
|
||||
except queue.Empty:
|
||||
# Se il subprocess e' morto e il pipe non produce piu' output
|
||||
# (child orfano), esci comunque.
|
||||
if proc.poll() is not None and output_q.empty():
|
||||
# Aspetta ancora un attimo per drenare
|
||||
try:
|
||||
line = output_q.get(timeout=1.0)
|
||||
except queue.Empty:
|
||||
break
|
||||
else:
|
||||
continue
|
||||
if line is None:
|
||||
break
|
||||
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
|
||||
if pct_match and progress_callback:
|
||||
pct = int(float(pct_match.group(1)))
|
||||
progress_callback(processed, total, filepath.name, "downloading", current_kbps, pct)
|
||||
|
||||
_current_process.wait()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
watchdog.cancel()
|
||||
|
||||
with _process_lock:
|
||||
_current_process = None
|
||||
|
||||
|
||||
@@ -62,11 +62,22 @@ def search_youtube(query: str, limit: int = 50) -> list:
|
||||
continue
|
||||
video_id = e.get("id") or ""
|
||||
url = e.get("url") or (f"https://www.youtube.com/watch?v={video_id}" if video_id else "")
|
||||
image_url = f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg" if video_id else ""
|
||||
# yt-dlp `upload_date` è YYYYMMDD (stringa). Lo converto in ISO YYYY-MM-DD.
|
||||
# Con --flat-playlist può essere assente; in tal caso resta stringa vuota.
|
||||
raw_date = str(e.get("upload_date") or "").strip()
|
||||
release_date = ""
|
||||
if len(raw_date) == 8 and raw_date.isdigit():
|
||||
release_date = f"{raw_date[0:4]}-{raw_date[4:6]}-{raw_date[6:8]}"
|
||||
elif raw_date:
|
||||
release_date = raw_date # forma sconosciuta, passa così
|
||||
result.append({
|
||||
"id": video_id,
|
||||
"url": url,
|
||||
"title": e.get("title") or "",
|
||||
"channel": e.get("uploader") or e.get("channel") or "",
|
||||
"duration_sec": int(e.get("duration") or 0),
|
||||
"image_url": image_url,
|
||||
"release_date": release_date,
|
||||
})
|
||||
return result
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,256 @@
|
||||
# Traxsource Charts — Design Spec
|
||||
|
||||
- **Data:** 2026-08-01
|
||||
- **Autore:** LuZa + Claude
|
||||
- **Stato:** Approvato, pronto per implementation plan
|
||||
- **Target release:** MusicTools v1.9.0
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Nuova tab "▲ Traxsource" nell'app che permette di:
|
||||
|
||||
1. Scegliere un genere musicale da un elenco di ~15 generi Traxsource
|
||||
2. Caricare la **Top 100** ufficiale del mese corrente per quel genere
|
||||
3. Anteprima checkbox + download riusando il pipeline esistente (Spotify search → yt-dlp → tag ID3 + cover)
|
||||
|
||||
Sostituisce l'attività manuale di copiare/incollare tracklist Traxsource.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Chart diverse dalla Top 100 (Must Have, Just Added, ecc.)
|
||||
- Ricerca artista/label specifica su Traxsource
|
||||
- Preview audio (Traxsource ha DRM)
|
||||
- Duration tracks (non è nell'HTML della lista; sarebbe una richiesta extra per track)
|
||||
- Login/account Traxsource
|
||||
|
||||
## Ricognizione tecnica (fatta 2026-08-01)
|
||||
|
||||
- **Cloudflare Managed Challenge attivo** su tutte le pagine tranne home e /top. Bypass: session-based con `curl_cffi.Session(impersonate='chrome131')`, prima GET a `/`, poi le pagine sensibili con `Referer: https://www.traxsource.com/`.
|
||||
- **Non è Next.js** → no `__NEXT_DATA__`. HTML server-rendered.
|
||||
- **Struttura URL:**
|
||||
- Genre index: `/genre/<id>/<slug>` — es. `/genre/18/tech-house`. Contiene solo 10 top track più altri widget
|
||||
- Top 100 corrente: `/title/<title_id>/top-100-<genre>-of-<month>-<year>` — es. `/title/2847986/top-100-tech-house-of-july-2026`. Contiene 100 track completi in un unico documento (~394KB)
|
||||
- **Il title_id cambia ogni mese.** Non è hardcodabile. Va scoperto dinamicamente
|
||||
- **Discovery Top 100 URL:** nella pagina `/genre/<id>/<slug>` c'è (almeno un) `<a href="/title/<id>/top-100-<slug>-of-<month>-<year>">`. Estrai col regex `href="(/title/\d+/top-100-[a-z0-9-]+)"`
|
||||
- **Track markup (stabile):**
|
||||
```html
|
||||
<div data-trid="14842066" class="top-item play-trk ptk-14842066">
|
||||
<div class="ttib position">1</div>
|
||||
<div class="image"><img src="https://.../52x52/HASH.jpg" /></div>
|
||||
<div class="ttib info">
|
||||
<a href="/track/14842066/badman-sound-extended-mix" class="com-title">Badman Sound (Extended Mix)</a>
|
||||
<a href="/artist/92248/hannah-wants" class="com-artists">Hannah Wants</a>,
|
||||
<a href="/artist/321517/trace" class="com-artists">Trace</a>
|
||||
<a href="/label/325/nervous" class="com-label">Nervous</a>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
Class names sono stabili (semantici, non generati da bundler).
|
||||
- **Cover art:** URL contiene la size (`/52x52/`). Sostituendo con `/500x500/` si ottiene la versione grande per tagging ID3.
|
||||
|
||||
## Approccio scelto
|
||||
|
||||
- Session `curl_cffi` con `impersonate='chrome131'`. Cookie CF conservati automaticamente. Preriscaldamento con GET a `/` al primo utilizzo.
|
||||
- Parser: `BeautifulSoup4` con selettori CSS puliti — `div.top-item.play-trk` per righe, `a.com-title` per titolo, `a.com-artists` per artisti, `a.com-label` per label, `div.ttib.position` per rank, `img` per cover URL.
|
||||
- Regex sul titolo per estrarre "mix name" tra parentesi: `Song Title (Extended Mix)` → title="Song Title", mix="Extended Mix".
|
||||
- Cache in-memory 15 min per la lista, come Beatport.
|
||||
- Genere → (numeric_id, slug, display_name) map hardcoded (estratti dalla home /top).
|
||||
|
||||
**Alternative scartate:**
|
||||
- `requests` puro: bloccato da CF, verificato
|
||||
- API interna Traxsource: non esistono API pubbliche gratuite documentate; reverse engineering rischio ban
|
||||
- Playwright: overkill (~100MB dep) quando session + BeautifulSoup basta
|
||||
|
||||
## Architettura
|
||||
|
||||
### Nuovo modulo `core/traxsource.py`
|
||||
|
||||
```python
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# Mappa slug → (numeric_id, display_name)
|
||||
# Verificati 2026-08-01 dalla home /top di Traxsource
|
||||
GENRES: dict = {
|
||||
"afro-house": (33, "Afro House"),
|
||||
"afro-latin-brazilian": (23, "Afro / Latin / Brazilian"),
|
||||
"amapiano": (37, "Amapiano"),
|
||||
"bass-club": (25, "Bass / Club"),
|
||||
"breaks-uk-bass": (14, "Breaks / UK Bass"),
|
||||
"classic-house": (12, "Classic House"),
|
||||
"deep-house": (13, "Deep House"),
|
||||
"dj-tools": (16, "DJ Tools"),
|
||||
"downtempo-nu-disco-indie-dance": (22, "Downtempo / Nu Disco / Indie Dance"),
|
||||
"electronica": (5, "Electronica"),
|
||||
"electro-house": (11, "Electro House"),
|
||||
"funky-jackin-groovy": (15, "Funky / Jackin / Groovy"),
|
||||
"garage": (29, "Garage"),
|
||||
"house": (1, "House"),
|
||||
"lounge-chill-out": (10, "Lounge / Chill Out"),
|
||||
"melodic-house-techno-progressive-house": (34, "Melodic House / Techno / Progressive House"),
|
||||
"minimal-deep-tech": (27, "Minimal / Deep Tech"),
|
||||
"organic-house-downtempo": (36, "Organic House / Downtempo"),
|
||||
"progressive-house": (28, "Progressive House"),
|
||||
"r-and-b-hip-hop": (6, "R&B / Hip Hop"),
|
||||
"soulful-house": (24, "Soulful House"),
|
||||
"tech-house": (18, "Tech House"),
|
||||
"techno-peak-time-driving": (26, "Techno (Peak Time / Driving)"),
|
||||
"techno-raw-deep-hypnotic": (32, "Techno (Raw / Deep / Hypnotic)"),
|
||||
"trance": (30, "Trance"),
|
||||
"world-reggae": (21, "World / Reggae"),
|
||||
}
|
||||
# NOTA: la mappa è enumerata in fase di implementazione fetchando /top con curl_cffi
|
||||
# e cercando href="/genre/<id>/<slug>". Vedi Task 1 del plan.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TraxsourceTrack:
|
||||
position: int
|
||||
title: str
|
||||
mix: str
|
||||
artists: str # "A, B & C" formattato
|
||||
label: str
|
||||
traxsource_id: int
|
||||
slug: str
|
||||
image_url: str = "" # URL thumbnail (per UI)
|
||||
cover_url_large: str = "" # URL cover 500x500 (per tagging)
|
||||
|
||||
|
||||
class TraxsourceError(Exception): pass
|
||||
class TraxsourceUnreachableError(TraxsourceError): pass
|
||||
class TraxsourceParseError(TraxsourceError): pass
|
||||
|
||||
|
||||
def list_genres() -> list:
|
||||
"""Ritorna [{slug, id, name}, ...] ordinato alfabeticamente per name."""
|
||||
|
||||
|
||||
def fetch_top100(slug: str, force_refresh: bool = False) -> list[TraxsourceTrack]:
|
||||
"""Fetches Top 100 corrente per il genere.
|
||||
|
||||
1. Scopre l'URL della playlist Top 100 del mese via GET /genre/<id>/<slug>
|
||||
2. Fetch della playlist /title/<id>/top-100-...
|
||||
3. Parse HTML con BeautifulSoup
|
||||
4. Cache in-memory 15 min
|
||||
|
||||
Raises:
|
||||
ValueError se slug non in GENRES
|
||||
TraxsourceUnreachableError su rete/5xx dopo retry
|
||||
TraxsourceParseError su HTML non conforme (title link non trovato, meno di 50 track, ecc.)
|
||||
"""
|
||||
```
|
||||
|
||||
**Internals:**
|
||||
- `_session()` singleton — inizializza `curl_cffi.Session(impersonate='chrome131')` e fa GET a `/` una volta per riscaldare CF cookies. Riusata per tutte le fetch successive.
|
||||
- `_discover_top100_url(session, slug) → str` — fetcha `/genre/<id>/<slug>`, regex per il link Top 100
|
||||
- `_parse_tracks(html) → list[TraxsourceTrack]` — BeautifulSoup, itera `div.top-item.play-trk`, estrae campi
|
||||
- `_split_title_mix(full_title) → (title, mix)` — parsa `"Foo (Extended Mix)"` → `("Foo", "Extended Mix")`. Se no parentesi, mix = "".
|
||||
- `_format_artists(anchors) → str` — join `[a.text for a in anchors]` con formattazione "A, B & C"
|
||||
- `_large_cover(url) → str` — sostituisce `/52x52/` con `/500x500/` nel path
|
||||
|
||||
**Retry + timeout:** riusa gli stessi pattern di `core/beatport.py`: 3 tentativi con backoff [1, 3]s, timeout 15s.
|
||||
|
||||
### Nuovi metodi in `api/bridge.py`
|
||||
|
||||
Paralleli ai metodi Beatport (stesso schema):
|
||||
|
||||
- `traxsource_genres() → list[dict]`
|
||||
- `traxsource_fetch_chart(slug, force_refresh) → {ok, tracks} | {ok:false, error, message}` — salva anche `traxsource_last_genre` in config
|
||||
- `traxsource_check_existing(tracks, genre_name) → list[bool]` — check in `<output_dir>/Traxsource_<genre>/`
|
||||
- `traxsource_download_selected(tracks, genre_name) → dict` — costruisce metadata (title/artist/album=label/date=YYYY-MM/genre=display_name/cover_url) e chiama `start_tracks_download` riusando il pipeline con tagging ID3
|
||||
|
||||
**Metadata per il tagger:**
|
||||
- title, artist: dai campi track
|
||||
- album: label Traxsource (best proxy — non c'è "album" per singole tracce)
|
||||
- date: mese/anno corrente (dato che la Top 100 è mensile)
|
||||
- genre: display_name del genere
|
||||
- cover_url: `cover_url_large` (500x500)
|
||||
|
||||
### Frontend
|
||||
|
||||
Nuova tab in sidebar dopo YouTube (o Beatport):
|
||||
```html
|
||||
<button class="nav-item" data-view="traxsource" data-feature="audio">
|
||||
<span class="nav-icon">▲</span><span>Traxsource</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
Section `#view-traxsource` con struttura identica a Beatport (dropdown genere + Carica Top 100 + tabella + toolbar + log). Riusa tutte le classi `.beatport-*` per lo stile.
|
||||
|
||||
Modulo `TraxsourceUI` in `webui/js/app.js` — copia diretta di `BeatportUI` con selettori cambiati (`traxsource-*` invece di `beatport-*`) e chiamate API alle nuove funzioni.
|
||||
|
||||
**Ordinamento colonne** (sortable) e **cover art** già supportati riusando la stessa struttura tabella + modulo helper `_sortPairs`/`_bindSortableHeaders`/`_updateSortArrows` esistenti.
|
||||
|
||||
### Persistenza
|
||||
|
||||
Nuovo campo in `core/config.py::DEFAULTS`:
|
||||
```python
|
||||
"traxsource_last_genre": "tech-house",
|
||||
```
|
||||
|
||||
### Dipendenze
|
||||
|
||||
- `beautifulsoup4` — nuova dep runtime. Aggiungere in `requirements.txt`. Serve `--collect-data bs4` in build_windows.py + build_macos.py? Verificare — bs4 di solito no.
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit tests `tests/test_traxsource.py` (senza rete, con fixture HTML):**
|
||||
|
||||
Serve una fixture HTML reale della pagina Top 100 (~400KB). Task 1 la genera con `curl_cffi` come fatto per Beatport (`beatport_melodic_top100.html`).
|
||||
|
||||
| Test | Verifica |
|
||||
|---|---|
|
||||
| `test_list_genres_shape` | Lista dict con {slug, id, name}, ordinati |
|
||||
| `test_parse_top100_extracts_100_tracks` | Fixture HTML → 100 track |
|
||||
| `test_parse_positions_sequential` | 1..100 senza buchi |
|
||||
| `test_parse_track_shape` | Ogni track ha title, artists, label, cover URLs non vuoti |
|
||||
| `test_split_title_mix_with_parens` | `"Foo (Extended Mix)"` → `("Foo", "Extended Mix")` |
|
||||
| `test_split_title_mix_no_parens` | `"Foo"` → `("Foo", "")` |
|
||||
| `test_format_artists_multi` | `["A", "B", "C"]` → `"A, B & C"` |
|
||||
| `test_large_cover_url_substitution` | `.../52x52/x.jpg` → `.../500x500/x.jpg` |
|
||||
| `test_discover_top100_url_returns_playlist_link` | HTML genre stub → estrae `/title/N/top-100-...` |
|
||||
| `test_fetch_top100_invalid_slug_raises_value_error` | slug non in GENRES → ValueError early |
|
||||
|
||||
Mock HTTP con `unittest.mock.patch("core.traxsource._session")` (patch della session singleton).
|
||||
|
||||
**Test manuale end-to-end:**
|
||||
1. Avvia app → tab Traxsource
|
||||
2. Selezione "Tech House" → Carica Top 100 → tabella 100 righe in <5s
|
||||
3. Verifica cover art (thumbnail nella tabella)
|
||||
4. Deseleziona 90, tieni 10 → Scarica selezionati
|
||||
5. File in `MUSICA/Traxsource_Tech House/` con tag ID3 completi (title, artist, album=label, genre=Tech House, cover 500x500)
|
||||
6. Ricarica stesso genere → cache hit
|
||||
7. Riavvio → ultimo genere ricordato
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Branch `feat/traxsource-charts`
|
||||
2. Bump `core/config.py::VERSION` → `v1.9.0` (minor: nuova tab visibile)
|
||||
3. Note release `/tmp/notes-v1.9.0.md`
|
||||
4. Merge → tag → CI → server DB update (stesso flusso di sempre)
|
||||
|
||||
## Struttura file impattati
|
||||
|
||||
**Nuovi:**
|
||||
- `core/traxsource.py` (~250 LOC)
|
||||
- `tests/test_traxsource.py` (~200 LOC)
|
||||
- `tests/fixtures/traxsource_tech_house_top100.html` (~400KB)
|
||||
- `scripts/refresh_traxsource_genres.py` (~50 LOC, opzionale)
|
||||
|
||||
**Modificati:**
|
||||
- `requirements.txt` — `beautifulsoup4>=4.12.0`
|
||||
- `build_windows.py` + `build_macos.py` — verificare se serve `--collect-data bs4`
|
||||
- `core/config.py` — VERSION bump + `traxsource_last_genre`
|
||||
- `api/bridge.py` — 4 metodi Api `traxsource_*` (~150 LOC)
|
||||
- `webui/index.html` — nav-item + section (~90 LOC)
|
||||
- `webui/js/app.js` — modulo `TraxsourceUI` (~250 LOC, copia BeatportUI adattata)
|
||||
|
||||
## Considerazioni operative
|
||||
|
||||
- **Fragility scraping:** i class name di Traxsource (`com-title`, `com-artists`, `top-item`, ecc.) sono usati anche nel JS del sito → stabili nel tempo. Rischio: se Traxsource migra a un framework nuovo (React/Next), parser va rifatto.
|
||||
- **Rate limiting:** un solo GET per la genre + un GET per la playlist Top 100 = 2 richieste per caricare la chart. Cache 15 min. Zero rischio ban in uso normale.
|
||||
- **Legal:** stesso perimetro di Beatport — lettura di pagine pubbliche, no ToS violation esplicito.
|
||||
- **Comparabilità con Beatport:** un utente power potrebbe usare entrambe le tab per triangolare le release del mese. UX consistente = curva apprendimento zero.
|
||||
@@ -4,6 +4,32 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ============================================================
|
||||
# Logging persistente su file (rotazione giornaliera, 7 giorni).
|
||||
# Chiamato SUBITO cosi' ogni traceback o print da qui in poi
|
||||
# finisce nel file, anche in modalita' windowed.
|
||||
# ============================================================
|
||||
try:
|
||||
from core.applog import setup as _setup_log
|
||||
_setup_log()
|
||||
except Exception as _e:
|
||||
print(f"[bootstrap] log setup failed: {_e}")
|
||||
|
||||
# ============================================================
|
||||
# Fix SSL CA bundle: quando l'app e' frozen (PyInstaller) i moduli
|
||||
# ssl e requests non trovano nessun CA bundle di default -> tutte
|
||||
# le chiamate HTTPS falliscono con CERTIFICATE_VERIFY_FAILED.
|
||||
# Puntiamo esplicitamente al bundle di certifi (che il build script
|
||||
# include via `--collect-data certifi`).
|
||||
# ============================================================
|
||||
try:
|
||||
import certifi as _certifi
|
||||
_ca_bundle = _certifi.where()
|
||||
os.environ.setdefault("SSL_CERT_FILE", _ca_bundle)
|
||||
os.environ.setdefault("REQUESTS_CA_BUNDLE", _ca_bundle)
|
||||
except Exception as _e:
|
||||
print(f"[bootstrap] certifi setup failed: {_e}")
|
||||
|
||||
# Windows: forza il caricamento del runtime pythonnet PRIMA di importare
|
||||
# webview. Con PyInstaller il lazy-loader fallisce con
|
||||
# "Failed to resolve Python.Runtime.Loader.Initialize" perche'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pywebview>=5.0
|
||||
requests>=2.31.0
|
||||
certifi>=2024.0.0
|
||||
yt-dlp>=2024.0.0
|
||||
mutagen>=1.47.0
|
||||
pyobjc-core>=10.0 ; sys_platform == "darwin"
|
||||
@@ -9,6 +10,8 @@ pyobjc-framework-AVFoundation>=10.0 ; sys_platform == "darwin"
|
||||
pythonnet==3.0.5 ; sys_platform == "win32"
|
||||
clr-loader==0.2.7.post0 ; sys_platform == "win32"
|
||||
curl_cffi>=0.9.0
|
||||
beautifulsoup4>=4.12.0
|
||||
send2trash>=1.8.0
|
||||
|
||||
# --- dev only ---
|
||||
pytest>=8.0.0
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Estrae la lista dei generi Traxsource dalla pagina /top.
|
||||
Uso: python3 scripts/refresh_traxsource_genres.py > /tmp/tx_genres.txt
|
||||
Poi copia manualmente in core/traxsource.py::GENRES."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from curl_cffi import requests
|
||||
|
||||
|
||||
def main() -> int:
|
||||
s = requests.Session(impersonate="chrome131")
|
||||
s.get("https://www.traxsource.com/", timeout=15)
|
||||
r = s.get("https://www.traxsource.com/top", timeout=15,
|
||||
headers={"Referer": "https://www.traxsource.com/"})
|
||||
if r.status_code != 200:
|
||||
print(f"HTTP {r.status_code}", file=sys.stderr)
|
||||
return 1
|
||||
# href="/genre/<id>/<slug>"
|
||||
hits = re.findall(r'href="/genre/(\d+)/([a-z0-9-]+)"', r.text)
|
||||
seen = set()
|
||||
for gid, slug in hits:
|
||||
if slug in seen:
|
||||
continue
|
||||
seen.add(slug)
|
||||
# Display name = slug con capitalizzazione euristica (l'utente lo raffina a mano)
|
||||
name = " ".join(w.capitalize() for w in slug.replace("-", " ").split())
|
||||
print(f' "{slug}": ({gid}, "{name}"),')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+2882
File diff suppressed because it is too large.
Load diff
+6332
File diff suppressed because it is too large.
Load diff
@@ -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([{}]) == ""
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,434 @@
|
||||
"""Test per core.charts — fetch iTunes/Last.fm/Spotify/M2O + cache SQLite.
|
||||
|
||||
Ogni test isola il DB cache in tmp_path via monkeypatch di `_cache_db_path`
|
||||
e usa `responses`/`mock` per non toccare la rete.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from freezegun import freeze_time
|
||||
|
||||
from core import charts
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fixture: cache isolata per test
|
||||
# ------------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def patched_cache(tmp_path, monkeypatch):
|
||||
db = tmp_path / "charts_cache_test.db"
|
||||
monkeypatch.setattr(charts, "_cache_db_path", lambda: db)
|
||||
return db
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# iTunes RSS
|
||||
# ==================================================================
|
||||
class TestFetchItunes:
|
||||
_SAMPLE = {
|
||||
"feed": {
|
||||
"results": [
|
||||
{
|
||||
"name": "Song A",
|
||||
"artistName": "Artist A",
|
||||
"collectionName": "Album A",
|
||||
"artworkUrl100": "https://cdn/a.jpg",
|
||||
"url": "https://itunes/1",
|
||||
},
|
||||
{
|
||||
"name": "Song B",
|
||||
"artistName": "Artist B",
|
||||
"collectionName": "Album B",
|
||||
"artworkUrl100": "https://cdn/b.jpg",
|
||||
"url": "https://itunes/2",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_itunes_parses_response(self, patched_cache):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
charts._ITUNES_URL_TPL.format(country="it"),
|
||||
json=self._SAMPLE,
|
||||
status=200,
|
||||
)
|
||||
out = charts.fetch_itunes("it", force=True)
|
||||
assert len(out) == 2
|
||||
assert out[0] == {
|
||||
"rank": 1,
|
||||
"artist": "Artist A",
|
||||
"title": "Song A",
|
||||
"album": "Album A",
|
||||
"image_url": "https://cdn/a.jpg",
|
||||
"popularity": None,
|
||||
}
|
||||
assert out[1]["rank"] == 2
|
||||
assert out[1]["artist"] == "Artist B"
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_itunes_uses_cache(self, patched_cache):
|
||||
"""Seconda chiamata NON deve colpire la rete: il mock registra 1 call."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
charts._ITUNES_URL_TPL.format(country="us"),
|
||||
json=self._SAMPLE,
|
||||
status=200,
|
||||
)
|
||||
first = charts.fetch_itunes("us", force=False)
|
||||
second = charts.fetch_itunes("us", force=False)
|
||||
assert first == second
|
||||
# `responses` fallirebbe se ci fossero call non registrate;
|
||||
# verifica esplicitamente che la seconda non ha creato una call in piu'
|
||||
assert len(responses.calls) == 1
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_itunes_force_bypasses_cache(self, patched_cache):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
charts._ITUNES_URL_TPL.format(country="gb"),
|
||||
json=self._SAMPLE,
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
charts._ITUNES_URL_TPL.format(country="gb"),
|
||||
json=self._SAMPLE,
|
||||
status=200,
|
||||
)
|
||||
charts.fetch_itunes("gb", force=False)
|
||||
charts.fetch_itunes("gb", force=True)
|
||||
assert len(responses.calls) == 2
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_itunes_returns_empty_on_error(self, patched_cache):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
charts._ITUNES_URL_TPL.format(country="ww"),
|
||||
status=500,
|
||||
)
|
||||
assert charts.fetch_itunes("ww", force=True) == []
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_itunes_defaults_to_it(self, patched_cache):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
charts._ITUNES_URL_TPL.format(country="it"),
|
||||
json={"feed": {"results": []}},
|
||||
status=200,
|
||||
)
|
||||
assert charts.fetch_itunes("", force=True) == []
|
||||
assert responses.calls[0].request.url.startswith(
|
||||
"https://rss.applemarketingtools.com/api/v2/it/"
|
||||
)
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# Last.fm tag builder
|
||||
# ==================================================================
|
||||
class TestBuildLastfmTag:
|
||||
def test_only_decade(self):
|
||||
assert charts._build_lastfm_tag("80s", "") == "80s"
|
||||
|
||||
def test_only_genre(self):
|
||||
assert charts._build_lastfm_tag("", "italo disco") == "italo disco"
|
||||
|
||||
def test_decade_and_genre(self):
|
||||
assert charts._build_lastfm_tag("90s", "dance") == "90s dance"
|
||||
|
||||
def test_normalizes_case(self):
|
||||
assert charts._build_lastfm_tag("90S", "DANCE") == "90s dance"
|
||||
|
||||
def test_fallback_empty(self):
|
||||
assert charts._build_lastfm_tag("", "") == "pop"
|
||||
assert charts._build_lastfm_tag(None, None) == "pop"
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# Last.fm fetch
|
||||
# ==================================================================
|
||||
class TestFetchLastfm:
|
||||
_SAMPLE = {
|
||||
"tracks": {
|
||||
"track": [
|
||||
{
|
||||
"name": "Blue (Da Ba Dee)",
|
||||
"artist": {"name": "Eiffel 65"},
|
||||
"listeners": "12345",
|
||||
"image": [
|
||||
{"#text": "small.jpg", "size": "small"},
|
||||
{"#text": "large.jpg", "size": "large"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Barbie Girl",
|
||||
"artist": {"name": "Aqua"},
|
||||
"listeners": "9999",
|
||||
"image": [],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_lastfm_parses_response(self, patched_cache):
|
||||
responses.add(responses.GET, charts._LASTFM_URL, json=self._SAMPLE, status=200)
|
||||
out = charts.fetch_lastfm("90s", "dance", force=True)
|
||||
assert len(out) == 2
|
||||
assert out[0]["artist"] == "Eiffel 65"
|
||||
assert out[0]["title"] == "Blue (Da Ba Dee)"
|
||||
# Ultima immagine della lista
|
||||
assert out[0]["image_url"] == "large.jpg"
|
||||
assert out[0]["popularity"] == "12345"
|
||||
# Traccia senza immagini: image_url vuoto
|
||||
assert out[1]["image_url"] == ""
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_lastfm_handles_empty_tracks(self, patched_cache):
|
||||
responses.add(
|
||||
responses.GET, charts._LASTFM_URL,
|
||||
json={"tracks": {}}, status=200,
|
||||
)
|
||||
assert charts.fetch_lastfm("70s", "rock", force=True) == []
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_lastfm_returns_empty_on_error(self, patched_cache):
|
||||
responses.add(responses.GET, charts._LASTFM_URL, status=500)
|
||||
assert charts.fetch_lastfm("80s", "pop", force=True) == []
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_lastfm_uses_cache(self, patched_cache):
|
||||
responses.add(responses.GET, charts._LASTFM_URL, json=self._SAMPLE, status=200)
|
||||
charts.fetch_lastfm("2000s", "house", force=False)
|
||||
charts.fetch_lastfm("2000s", "house", force=False)
|
||||
assert len(responses.calls) == 1
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_lastfm_sends_configured_tag(self, patched_cache):
|
||||
responses.add(responses.GET, charts._LASTFM_URL, json=self._SAMPLE, status=200)
|
||||
charts.fetch_lastfm("90s", "dance", force=True)
|
||||
assert "tag=90s+dance" in responses.calls[0].request.url
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# Cache TTL
|
||||
# ==================================================================
|
||||
class TestCacheTtl:
|
||||
@responses.activate
|
||||
def test_cache_ttl_expires(self, patched_cache):
|
||||
sample = {"feed": {"results": []}}
|
||||
responses.add(
|
||||
responses.GET,
|
||||
charts._ITUNES_URL_TPL.format(country="it"),
|
||||
json=sample, status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
charts._ITUNES_URL_TPL.format(country="it"),
|
||||
json=sample, status=200,
|
||||
)
|
||||
|
||||
with freeze_time("2026-01-01 12:00:00"):
|
||||
charts.fetch_itunes("it", force=False)
|
||||
assert len(responses.calls) == 1
|
||||
|
||||
# +7h -> cache scaduta (TTL 6h)
|
||||
with freeze_time("2026-01-01 19:00:00"):
|
||||
charts.fetch_itunes("it", force=False)
|
||||
assert len(responses.calls) == 2
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# Spotify chart playlists
|
||||
# ==================================================================
|
||||
class TestSpotifyChartsRegistry:
|
||||
def test_spotify_charts_dict_has_expected_keys(self):
|
||||
expected = {
|
||||
"top50_global", "top50_italy", "top50_usa", "top50_uk",
|
||||
"viral50_global", "viral50_italy",
|
||||
}
|
||||
assert expected.issubset(charts._SPOTIFY_CHARTS.keys())
|
||||
# Ognuno mappato a (playlist_id, label)
|
||||
for k, v in charts._SPOTIFY_CHARTS.items():
|
||||
assert isinstance(v, tuple) and len(v) == 2
|
||||
assert v[0] and v[1]
|
||||
|
||||
|
||||
class TestFetchSpotify:
|
||||
_TRACKS = {
|
||||
"items": [
|
||||
{
|
||||
"track": {
|
||||
"name": "Track One",
|
||||
"popularity": 88,
|
||||
"artists": [{"name": "Artist X"}, {"name": "Artist Y"}],
|
||||
"album": {
|
||||
"name": "Album Z",
|
||||
"images": [{"url": "https://spo/img.jpg"}],
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
"track": {
|
||||
"name": "Track Two",
|
||||
"popularity": 72,
|
||||
"artists": [{"name": "Solo"}],
|
||||
"album": {"name": "Alb2", "images": []},
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_spotify_parses_response(self, patched_cache, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
charts, "_spotify_credentials", lambda: ("cid", "csecret"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.spotify_client.get_access_token",
|
||||
lambda cid, csecret: "tok",
|
||||
)
|
||||
pid, _ = charts._SPOTIFY_CHARTS["top50_global"]
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f"https://api.spotify.com/v1/playlists/{pid}/tracks",
|
||||
json=self._TRACKS,
|
||||
status=200,
|
||||
)
|
||||
out = charts.fetch_spotify("top50_global", force=True)
|
||||
assert len(out) == 2
|
||||
assert out[0]["rank"] == 1
|
||||
assert out[0]["artist"] == "Artist X, Artist Y"
|
||||
assert out[0]["title"] == "Track One"
|
||||
assert out[0]["album"] == "Album Z"
|
||||
assert out[0]["image_url"] == "https://spo/img.jpg"
|
||||
assert out[0]["popularity"] == 88
|
||||
assert out[1]["image_url"] == ""
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_spotify_uses_cache(self, patched_cache, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
charts, "_spotify_credentials", lambda: ("cid", "csecret"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.spotify_client.get_access_token",
|
||||
lambda cid, csecret: "tok",
|
||||
)
|
||||
pid, _ = charts._SPOTIFY_CHARTS["top50_italy"]
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f"https://api.spotify.com/v1/playlists/{pid}/tracks",
|
||||
json=self._TRACKS, status=200,
|
||||
)
|
||||
charts.fetch_spotify("top50_italy", force=False)
|
||||
charts.fetch_spotify("top50_italy", force=False)
|
||||
assert len(responses.calls) == 1
|
||||
|
||||
def test_fetch_spotify_returns_empty_if_no_creds(self, patched_cache, monkeypatch):
|
||||
monkeypatch.setattr(charts, "_spotify_credentials", lambda: ("", ""))
|
||||
assert charts.fetch_spotify("top50_global", force=True) == []
|
||||
|
||||
def test_fetch_spotify_returns_empty_if_invalid_key(self, patched_cache):
|
||||
assert charts.fetch_spotify("not_a_real_key", force=True) == []
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_spotify_returns_empty_on_404(self, patched_cache, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
charts, "_spotify_credentials", lambda: ("cid", "csecret"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.spotify_client.get_access_token",
|
||||
lambda cid, csecret: "tok",
|
||||
)
|
||||
pid, _ = charts._SPOTIFY_CHARTS["viral50_global"]
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f"https://api.spotify.com/v1/playlists/{pid}/tracks",
|
||||
status=404,
|
||||
)
|
||||
assert charts.fetch_spotify("viral50_global", force=True) == []
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# M2O Chart
|
||||
# ==================================================================
|
||||
class TestFetchM2O:
|
||||
_HTML = """
|
||||
<html><body>
|
||||
<main>
|
||||
<article>
|
||||
<div class="chart-left">
|
||||
<span class="position equal">1</span>
|
||||
<div class="text-list">
|
||||
<h3 class="title small song">MOVIN' TO THE SUN</h3>
|
||||
<h4 class="title xsmall author">HUGEL, IMAEL ANGEL, ULTRA NATE</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-right">
|
||||
<figure><img src="https://m2o/img1.jpg"></figure>
|
||||
</div>
|
||||
</article>
|
||||
<article>
|
||||
<div class="chart-left">
|
||||
<span class="position">2</span>
|
||||
<h3 class="title small song">SECOND SONG</h3>
|
||||
<h4 class="title xsmall author">DJ TEST</h4>
|
||||
</div>
|
||||
<div class="chart-right">
|
||||
<figure><img src="https://m2o/img2.jpg"></figure>
|
||||
</div>
|
||||
</article>
|
||||
<article>
|
||||
<div class="chart-left">
|
||||
<span class="position">N/A</span>
|
||||
<h3 class="title small song">INVALID</h3>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_m2o_parses_html(self, patched_cache):
|
||||
responses.add(responses.GET, charts._M2O_URL, body=self._HTML, status=200)
|
||||
out = charts.fetch_m2o(force=True)
|
||||
# Il 3o article ha "N/A" come position -> skippato
|
||||
assert len(out) == 2
|
||||
assert out[0]["rank"] == 1
|
||||
assert out[0]["title"] == "MOVIN' TO THE SUN"
|
||||
assert out[0]["artist"] == "HUGEL, IMAEL ANGEL, ULTRA NATE"
|
||||
assert out[0]["image_url"] == "https://m2o/img1.jpg"
|
||||
assert out[0]["album"] == ""
|
||||
assert out[0]["popularity"] is None
|
||||
assert out[1]["rank"] == 2
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_m2o_skips_invalid_position(self, patched_cache):
|
||||
# Solo 1 article valido nella pagina
|
||||
responses.add(
|
||||
responses.GET, charts._M2O_URL,
|
||||
body="""<article>
|
||||
<span class="position">not_a_number</span>
|
||||
<h3 class="title small song">X</h3>
|
||||
</article>""",
|
||||
status=200,
|
||||
)
|
||||
assert charts.fetch_m2o(force=True) == []
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_m2o_uses_cache(self, patched_cache):
|
||||
responses.add(responses.GET, charts._M2O_URL, body=self._HTML, status=200)
|
||||
charts.fetch_m2o(force=False)
|
||||
charts.fetch_m2o(force=False)
|
||||
assert len(responses.calls) == 1
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_m2o_empty_on_error(self, patched_cache):
|
||||
responses.add(responses.GET, charts._M2O_URL, status=500)
|
||||
assert charts.fetch_m2o(force=True) == []
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Test per core.converter — file scanning + stop event.
|
||||
|
||||
I test evitano di invocare ffmpeg reale (che potrebbe non essere in PATH
|
||||
in CI). La funzione di conversione vera e propria e' coperta manualmente.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from core import converter
|
||||
|
||||
|
||||
class TestListWavFiles:
|
||||
def test_scans_recursively(self, tmp_path: Path):
|
||||
# Struttura:
|
||||
# root/a.wav
|
||||
# root/b.WAV (case insensitive: glob su POSIX distingue,
|
||||
# quindi controllo esplicito che almeno i .wav
|
||||
# minuscoli siano restituiti)
|
||||
# root/c.mp3
|
||||
# root/sub/d.wav
|
||||
(tmp_path / "a.wav").write_bytes(b"RIFF")
|
||||
(tmp_path / "c.mp3").write_bytes(b"ID3")
|
||||
sub = tmp_path / "sub"
|
||||
sub.mkdir()
|
||||
(sub / "d.wav").write_bytes(b"RIFF")
|
||||
|
||||
got = converter.list_wav_files(str(tmp_path), recursive=True)
|
||||
|
||||
assert len(got) == 2
|
||||
names = {Path(p).name for p in got}
|
||||
assert names == {"a.wav", "d.wav"}
|
||||
# Ordinamento stabile
|
||||
assert got == sorted(got)
|
||||
|
||||
def test_non_recursive_ignores_subfolders(self, tmp_path: Path):
|
||||
(tmp_path / "a.wav").write_bytes(b"RIFF")
|
||||
sub = tmp_path / "sub"
|
||||
sub.mkdir()
|
||||
(sub / "b.wav").write_bytes(b"RIFF")
|
||||
|
||||
got = converter.list_wav_files(str(tmp_path), recursive=False)
|
||||
|
||||
assert len(got) == 1
|
||||
assert Path(got[0]).name == "a.wav"
|
||||
|
||||
def test_empty_dir_returns_empty(self, tmp_path: Path):
|
||||
assert converter.list_wav_files(str(tmp_path)) == []
|
||||
|
||||
def test_missing_dir_returns_empty(self, tmp_path: Path):
|
||||
missing = tmp_path / "does-not-exist"
|
||||
assert converter.list_wav_files(str(missing)) == []
|
||||
|
||||
def test_ignores_non_wav_files(self, tmp_path: Path):
|
||||
(tmp_path / "song.mp3").write_bytes(b"ID3")
|
||||
(tmp_path / "song.flac").write_bytes(b"fLaC")
|
||||
(tmp_path / "notes.txt").write_text("hello")
|
||||
|
||||
assert converter.list_wav_files(str(tmp_path)) == []
|
||||
|
||||
|
||||
class TestStopEvent:
|
||||
def setup_method(self):
|
||||
# Ogni test parte con lo stop event pulito.
|
||||
converter.reset_stop()
|
||||
|
||||
def teardown_method(self):
|
||||
converter.reset_stop()
|
||||
|
||||
def test_reset_initially_not_stopped(self):
|
||||
converter.reset_stop()
|
||||
assert converter.is_stopped() is False
|
||||
|
||||
def test_request_stop_sets_flag(self):
|
||||
converter.request_stop()
|
||||
assert converter.is_stopped() is True
|
||||
|
||||
def test_reset_after_stop_clears_flag(self):
|
||||
converter.request_stop()
|
||||
assert converter.is_stopped() is True
|
||||
converter.reset_stop()
|
||||
assert converter.is_stopped() is False
|
||||
|
||||
|
||||
class TestConvertWavToMp3Errors:
|
||||
def test_missing_input_raises(self, tmp_path: Path):
|
||||
missing = tmp_path / "nope.wav"
|
||||
out = tmp_path / "out.mp3"
|
||||
with pytest.raises(FileNotFoundError):
|
||||
converter.convert_wav_to_mp3(str(missing), str(out))
|
||||
|
||||
|
||||
class TestVbrQualityMap:
|
||||
def test_all_bitrates_have_mapping(self):
|
||||
for br in (128, 192, 256, 320):
|
||||
assert br in converter._VBR_QUALITY
|
||||
|
||||
def test_higher_bitrate_lower_or_equal_quality_number(self):
|
||||
# -q:a: piu' basso = migliore. Coerenza monotona sulle chiavi.
|
||||
vals = [converter._VBR_QUALITY[br] for br in (128, 192, 256, 320)]
|
||||
assert vals == sorted(vals, reverse=True)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Test per core.dedup — audio fingerprinting via fpcalc + cache SQLite.
|
||||
|
||||
Tutti gli unit test usano mock per fpcalc / send2trash: nessuna
|
||||
integrazione reale, nessun file audio necessario.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core import dedup
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fixture: dedup con cache DB isolato in tmp_path
|
||||
# ------------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def patched_cache(tmp_path, monkeypatch):
|
||||
"""Isola la cache SQLite in tmp_path per non toccare il config dir."""
|
||||
db = tmp_path / "dedup_cache_test.db"
|
||||
monkeypatch.setattr(dedup, "_cache_db_path", lambda: db)
|
||||
return db
|
||||
|
||||
|
||||
def _make_fake_audio(tmp_path: Path, name: str, size: int = 1024) -> Path:
|
||||
"""Crea un file 'audio' fake (byte casuali con estensione .mp3)."""
|
||||
f = tmp_path / name
|
||||
f.write_bytes(b"\x00" * size)
|
||||
return f
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# scan_folder
|
||||
# ------------------------------------------------------------------
|
||||
class TestScanFolder:
|
||||
def test_no_audio_files(self, tmp_path, patched_cache):
|
||||
"""Cartella senza audio -> gruppi vuoti, nessuna eccezione."""
|
||||
# Solo un file .txt (non audio)
|
||||
(tmp_path / "readme.txt").write_text("hello")
|
||||
|
||||
# Anche senza fpcalc disponibile, con 0 audio file ritorna [].
|
||||
with mock.patch.object(dedup, "find_fpcalc", return_value="/fake/fpcalc"):
|
||||
groups = dedup.scan_folder(str(tmp_path), recursive=False)
|
||||
|
||||
assert groups == []
|
||||
|
||||
def test_uses_cache_on_second_scan(self, tmp_path, patched_cache):
|
||||
"""Prima scan chiama fpcalc; seconda scan riusa la cache."""
|
||||
_make_fake_audio(tmp_path, "song.mp3", size=2048)
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_compute(fpcalc, path):
|
||||
calls.append(path)
|
||||
return {"duration": 180.5, "fingerprint": "FP-A"}
|
||||
|
||||
with mock.patch.object(dedup, "find_fpcalc", return_value="/fake/fpcalc"), \
|
||||
mock.patch.object(dedup, "compute_fingerprint", side_effect=fake_compute), \
|
||||
mock.patch.object(dedup, "get_bitrate", return_value=320):
|
||||
# Prima invocazione: fpcalc DEVE essere chiamato
|
||||
groups1 = dedup.scan_folder(str(tmp_path), recursive=False)
|
||||
first_calls = len(calls)
|
||||
|
||||
# Seconda invocazione (stesso file, stesso mtime/size):
|
||||
# cache HIT, fpcalc NON viene richiamato
|
||||
groups2 = dedup.scan_folder(str(tmp_path), recursive=False)
|
||||
second_calls = len(calls)
|
||||
|
||||
assert first_calls == 1, "prima scan deve chiamare fpcalc una volta"
|
||||
assert second_calls == 1, "seconda scan deve riusare la cache"
|
||||
# Con un solo file, nessun gruppo di duplicati
|
||||
assert groups1 == []
|
||||
assert groups2 == []
|
||||
|
||||
def test_groups_duplicates(self, tmp_path, patched_cache):
|
||||
"""3 file con lo stesso fingerprint -> 1 gruppo di 3, ordinato per bitrate DESC."""
|
||||
_make_fake_audio(tmp_path, "a.mp3", size=1000)
|
||||
_make_fake_audio(tmp_path, "b.mp3", size=3000) # size maggiore
|
||||
_make_fake_audio(tmp_path, "c.mp3", size=2000)
|
||||
|
||||
# Tutti stesso fingerprint. Bitrate differente per verificare
|
||||
# l'ordinamento: b=320 (top), a=192, c=128.
|
||||
bitrate_by_name = {"a.mp3": 192, "b.mp3": 320, "c.mp3": 128}
|
||||
|
||||
with mock.patch.object(dedup, "find_fpcalc", return_value="/fake/fpcalc"), \
|
||||
mock.patch.object(dedup, "compute_fingerprint",
|
||||
return_value={"duration": 200, "fingerprint": "SAME-FP"}), \
|
||||
mock.patch.object(dedup, "get_bitrate",
|
||||
side_effect=lambda p: bitrate_by_name[Path(p).name]):
|
||||
groups = dedup.scan_folder(str(tmp_path), recursive=False)
|
||||
|
||||
assert len(groups) == 1, "esattamente un gruppo di duplicati"
|
||||
g = groups[0]
|
||||
assert len(g) == 3, "tre file nel gruppo"
|
||||
# Ordine: bitrate DESC -> b (320), a (192), c (128)
|
||||
assert [Path(e["path"]).name for e in g] == ["b.mp3", "a.mp3", "c.mp3"]
|
||||
# Ogni entry ha i campi attesi
|
||||
for e in g:
|
||||
assert set(e.keys()) >= {"path", "size", "bitrate", "duration", "fingerprint"}
|
||||
assert e["fingerprint"] == "SAME-FP"
|
||||
|
||||
def test_ignores_singletons(self, tmp_path, patched_cache):
|
||||
"""File con fingerprint unico non compaiono nei gruppi."""
|
||||
_make_fake_audio(tmp_path, "dup1.mp3")
|
||||
_make_fake_audio(tmp_path, "dup2.mp3")
|
||||
_make_fake_audio(tmp_path, "unique.mp3")
|
||||
|
||||
fp_by_name = {"dup1.mp3": "FP-X", "dup2.mp3": "FP-X", "unique.mp3": "FP-Y"}
|
||||
|
||||
def fake_compute(fpcalc, path):
|
||||
return {"duration": 100, "fingerprint": fp_by_name[Path(path).name]}
|
||||
|
||||
with mock.patch.object(dedup, "find_fpcalc", return_value="/fake/fpcalc"), \
|
||||
mock.patch.object(dedup, "compute_fingerprint", side_effect=fake_compute), \
|
||||
mock.patch.object(dedup, "get_bitrate", return_value=256):
|
||||
groups = dedup.scan_folder(str(tmp_path), recursive=False)
|
||||
|
||||
# Solo il gruppo con dup1/dup2
|
||||
assert len(groups) == 1
|
||||
names = {Path(e["path"]).name for e in groups[0]}
|
||||
assert names == {"dup1.mp3", "dup2.mp3"}
|
||||
# unique.mp3 non appare in nessun gruppo
|
||||
for g in groups:
|
||||
for e in g:
|
||||
assert Path(e["path"]).name != "unique.mp3"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# move_to_trash
|
||||
# ------------------------------------------------------------------
|
||||
class TestMoveToTrash:
|
||||
def test_returns_moved_and_failed_summary(self, tmp_path, patched_cache):
|
||||
"""Verifica che il summary contenga moved/failed correttamente."""
|
||||
# 2 path OK + 1 path che alza eccezione
|
||||
ok1 = str(tmp_path / "ok1.mp3")
|
||||
ok2 = str(tmp_path / "ok2.mp3")
|
||||
bad = str(tmp_path / "bad.mp3")
|
||||
|
||||
def fake_send(p):
|
||||
if p == bad:
|
||||
raise OSError("simulated failure")
|
||||
# ok: no-op
|
||||
|
||||
# Il modulo importa send2trash *dentro* la funzione, quindi
|
||||
# dobbiamo patchare il modulo importato.
|
||||
with mock.patch("send2trash.send2trash", side_effect=fake_send):
|
||||
res = dedup.move_to_trash([ok1, bad, ok2])
|
||||
|
||||
assert set(res["moved"]) == {ok1, ok2}
|
||||
assert len(res["failed"]) == 1
|
||||
assert res["failed"][0]["path"] == bad
|
||||
assert "simulated failure" in res["failed"][0]["error"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# compute_fingerprint
|
||||
# ------------------------------------------------------------------
|
||||
class TestComputeFingerprint:
|
||||
def test_timeout_returns_error(self):
|
||||
"""Timeout di fpcalc -> dict con _error, no fingerprint."""
|
||||
with mock.patch("core.dedup.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="fpcalc", timeout=30)):
|
||||
result = dedup.compute_fingerprint("/fake/fpcalc", "/some/file.mp3")
|
||||
assert result and "_error" in result and "timeout" in result["_error"]
|
||||
assert "fingerprint" not in result
|
||||
|
||||
def test_success_returns_dict(self):
|
||||
"""Output JSON valido -> {duration, fingerprint}."""
|
||||
fake_proc = mock.Mock()
|
||||
fake_proc.returncode = 0
|
||||
fake_proc.stdout = '{"duration": 123.4, "fingerprint": "ABCDEF"}'
|
||||
with mock.patch("core.dedup.subprocess.run", return_value=fake_proc):
|
||||
result = dedup.compute_fingerprint("/fake/fpcalc", "/some/file.mp3")
|
||||
assert result == {"duration": 123.4, "fingerprint": "ABCDEF"}
|
||||
|
||||
def test_bad_json_returns_error(self):
|
||||
fake_proc = mock.Mock()
|
||||
fake_proc.returncode = 0
|
||||
fake_proc.stdout = "not json at all"
|
||||
with mock.patch("core.dedup.subprocess.run", return_value=fake_proc):
|
||||
result = dedup.compute_fingerprint("/fake/fpcalc", "/some/file.mp3")
|
||||
assert result and "_error" in result and "JSON" in result["_error"]
|
||||
assert "fingerprint" not in result
|
||||
|
||||
def test_missing_fpcalc_returns_error(self):
|
||||
# Nessuna chiamata subprocess se fpcalc e' vuoto
|
||||
result = dedup.compute_fingerprint("", "/some/file.mp3")
|
||||
assert result and "_error" in result and "fpcalc" in result["_error"]
|
||||
assert "fingerprint" not in result
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Test per core.tagger — filename sanitization + write_tags safety net."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core import tagger
|
||||
|
||||
|
||||
class TestSanitizeFilenameStem:
|
||||
def test_removes_bad_chars(self):
|
||||
# Tutti i char vietati su Windows/macOS: < > : " / \ | ? *
|
||||
got = tagger.sanitize_filename_stem('bad<name>:"/\\|?*test')
|
||||
# Nessuno dei char proibiti sopravvive
|
||||
for ch in '<>:"/\\|?*':
|
||||
assert ch not in got
|
||||
# E ho ottenuto qualcosa di non vuoto
|
||||
assert got
|
||||
|
||||
def test_collapses_whitespace(self):
|
||||
assert tagger.sanitize_filename_stem("a b c") == "a b c"
|
||||
|
||||
def test_truncates_long_stem(self):
|
||||
s = "x" * 500
|
||||
got = tagger.sanitize_filename_stem(s, max_len=180)
|
||||
assert len(got) <= 180
|
||||
|
||||
def test_empty_returns_untitled(self):
|
||||
assert tagger.sanitize_filename_stem("") == "untitled"
|
||||
assert tagger.sanitize_filename_stem(" ") == "untitled"
|
||||
|
||||
|
||||
class TestBuildFilenameStem:
|
||||
def test_format_artist_dash_title(self):
|
||||
assert tagger.build_filename_stem("Kapuchon", "Hot Sauce") == "Kapuchon - Hot Sauce"
|
||||
|
||||
def test_multiple_artists_ok(self):
|
||||
got = tagger.build_filename_stem("Kapuchon, Miss Monique & GLZ", "Hot Sauce (Extended)")
|
||||
assert got == "Kapuchon, Miss Monique & GLZ - Hot Sauce (Extended)"
|
||||
|
||||
def test_missing_artist_uses_title_only(self):
|
||||
assert tagger.build_filename_stem("", "Only Title") == "Only Title"
|
||||
|
||||
def test_missing_title_uses_artist_only(self):
|
||||
assert tagger.build_filename_stem("Only Artist", "") == "Only Artist"
|
||||
|
||||
def test_sanitizes_bad_chars(self):
|
||||
got = tagger.build_filename_stem("A/B", "C:D")
|
||||
# Nessuno slash o colon residuo
|
||||
assert "/" not in got
|
||||
assert ":" not in got
|
||||
|
||||
|
||||
class TestWriteTagsMissingFile:
|
||||
def test_missing_file_is_noop(self):
|
||||
# Non deve sollevare alcuna eccezione se il file non esiste
|
||||
tagger.write_tags("/nonexistent/path/to/file.mp3", {
|
||||
"title": "x", "artist": "y", "cover_url": "http://example.com/x.jpg",
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Test per core.traxsource."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core import traxsource
|
||||
|
||||
|
||||
class TestListGenres:
|
||||
def test_returns_list_of_dicts(self):
|
||||
result = traxsource.list_genres()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) >= 10
|
||||
for g in result:
|
||||
assert set(g.keys()) == {"slug", "id", "name"}
|
||||
assert isinstance(g["slug"], str) and g["slug"]
|
||||
assert isinstance(g["id"], int) and g["id"] > 0
|
||||
assert isinstance(g["name"], str) and g["name"]
|
||||
|
||||
def test_sorted_alphabetically_by_name(self):
|
||||
result = traxsource.list_genres()
|
||||
names = [g["name"] for g in result]
|
||||
assert names == sorted(names, key=str.casefold)
|
||||
|
||||
def test_tech_house_present(self):
|
||||
result = traxsource.list_genres()
|
||||
slugs = [g["slug"] for g in result]
|
||||
assert "tech-house" in slugs
|
||||
|
||||
|
||||
class TestTraxsourceTrack:
|
||||
def test_frozen(self):
|
||||
t = traxsource.TraxsourceTrack(
|
||||
position=1, title="X", mix="Y", artists="A", label="L",
|
||||
traxsource_id=1, slug="x",
|
||||
)
|
||||
with pytest.raises(Exception):
|
||||
t.title = "Z"
|
||||
|
||||
def test_defaults(self):
|
||||
t = traxsource.TraxsourceTrack(
|
||||
position=1, title="X", mix="", artists="A", label="",
|
||||
traxsource_id=1, slug="x",
|
||||
)
|
||||
assert t.image_url == ""
|
||||
assert t.cover_url_large == ""
|
||||
|
||||
|
||||
class TestSplitTitleMix:
|
||||
def test_with_parens(self):
|
||||
assert traxsource._split_title_mix("Foo (Extended Mix)") == ("Foo", "Extended Mix")
|
||||
|
||||
def test_without_parens(self):
|
||||
assert traxsource._split_title_mix("Foo") == ("Foo", "")
|
||||
|
||||
def test_multiple_parens_takes_last(self):
|
||||
# es. "Foo (feat. Bar) (Original Mix)" -> mix = "Original Mix"
|
||||
assert traxsource._split_title_mix("Foo (feat. Bar) (Original Mix)") == ("Foo (feat. Bar)", "Original Mix")
|
||||
|
||||
def test_empty(self):
|
||||
assert traxsource._split_title_mix("") == ("", "")
|
||||
|
||||
|
||||
class TestFormatArtists:
|
||||
def test_single(self):
|
||||
assert traxsource._format_artists(["Kapuchon"]) == "Kapuchon"
|
||||
|
||||
def test_two(self):
|
||||
assert traxsource._format_artists(["A", "B"]) == "A & B"
|
||||
|
||||
def test_three(self):
|
||||
assert traxsource._format_artists(["A", "B", "C"]) == "A, B & C"
|
||||
|
||||
def test_empty(self):
|
||||
assert traxsource._format_artists([]) == ""
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert traxsource._format_artists([" A ", " B "]) == "A & B"
|
||||
|
||||
|
||||
class TestLargeCover:
|
||||
def test_substitutes_52x52_with_500x500(self):
|
||||
url = "https://www.traxsource.com/scripts/image.php/52x52/abc.jpg"
|
||||
assert traxsource._large_cover(url) == "https://www.traxsource.com/scripts/image.php/500x500/abc.jpg"
|
||||
|
||||
def test_no_change_when_no_pattern(self):
|
||||
assert traxsource._large_cover("https://example.com/x.jpg") == "https://example.com/x.jpg"
|
||||
|
||||
def test_empty(self):
|
||||
assert traxsource._large_cover("") == ""
|
||||
|
||||
|
||||
class TestExceptions:
|
||||
def test_exceptions_are_subclasses(self):
|
||||
assert issubclass(traxsource.TraxsourceUnreachableError, traxsource.TraxsourceError)
|
||||
assert issubclass(traxsource.TraxsourceParseError, traxsource.TraxsourceError)
|
||||
|
||||
|
||||
class TestDiscoverTop100Url:
|
||||
def test_extracts_link_from_genre_page(self, fixtures_dir):
|
||||
html = (fixtures_dir / "traxsource_tech_house_genre.html").read_text()
|
||||
url = traxsource._discover_top100_url(html)
|
||||
assert url.startswith("/title/")
|
||||
assert "top-100" in url
|
||||
|
||||
def test_raises_when_no_link(self):
|
||||
with pytest.raises(traxsource.TraxsourceParseError, match="Top 100"):
|
||||
traxsource._discover_top100_url("<html>nulla</html>")
|
||||
|
||||
|
||||
class TestParseTracks:
|
||||
def test_parses_100_tracks(self, fixtures_dir):
|
||||
html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
|
||||
tracks = traxsource._parse_tracks(html)
|
||||
assert len(tracks) == 100
|
||||
|
||||
def test_positions_sequential_1_to_100(self, fixtures_dir):
|
||||
html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
|
||||
tracks = traxsource._parse_tracks(html)
|
||||
positions = [t.position for t in tracks]
|
||||
assert positions == list(range(1, 101))
|
||||
|
||||
def test_track_shape(self, fixtures_dir):
|
||||
html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
|
||||
tracks = traxsource._parse_tracks(html)
|
||||
first = tracks[0]
|
||||
assert first.title
|
||||
assert first.artists
|
||||
assert first.traxsource_id > 0
|
||||
assert first.slug
|
||||
assert first.image_url.startswith("https://")
|
||||
assert first.cover_url_large.startswith("https://")
|
||||
assert "500x500" in first.cover_url_large
|
||||
|
||||
def test_raises_when_no_tracks(self):
|
||||
with pytest.raises(traxsource.TraxsourceParseError, match="track"):
|
||||
traxsource._parse_tracks("<html>vuoto</html>")
|
||||
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
def _mock_response(text: str, status_code: int = 200) -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.text = text
|
||||
resp.status_code = status_code
|
||||
|
||||
def _raise():
|
||||
if status_code >= 400:
|
||||
raise Exception(f"HTTP {status_code}")
|
||||
resp.raise_for_status = _raise
|
||||
return resp
|
||||
|
||||
|
||||
class TestFetchTop100:
|
||||
@pytest.fixture
|
||||
def genre_html(self, fixtures_dir):
|
||||
return (fixtures_dir / "traxsource_tech_house_genre.html").read_text()
|
||||
|
||||
@pytest.fixture
|
||||
def top100_html(self, fixtures_dir):
|
||||
return (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
|
||||
|
||||
def test_success_returns_100_tracks(self, genre_html, top100_html):
|
||||
traxsource._cache.clear()
|
||||
mock_sess = MagicMock()
|
||||
mock_sess.get.side_effect = [
|
||||
_mock_response(genre_html, 200),
|
||||
_mock_response(top100_html, 200),
|
||||
]
|
||||
with patch("core.traxsource._session", return_value=mock_sess):
|
||||
tracks = traxsource.fetch_top100("tech-house")
|
||||
assert len(tracks) == 100
|
||||
|
||||
def test_invalid_slug_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="slug"):
|
||||
traxsource.fetch_top100("not-a-genre")
|
||||
|
||||
def test_5xx_retries_and_raises_unreachable(self):
|
||||
traxsource._cache.clear()
|
||||
mock_sess = MagicMock()
|
||||
mock_sess.get.return_value = _mock_response("", 503)
|
||||
with patch("core.traxsource._session", return_value=mock_sess), \
|
||||
patch("core.traxsource.time.sleep"):
|
||||
with pytest.raises(traxsource.TraxsourceUnreachableError):
|
||||
traxsource.fetch_top100("tech-house")
|
||||
# 3 tentativi
|
||||
assert mock_sess.get.call_count == 3
|
||||
|
||||
def test_cache_hit_within_ttl(self, genre_html, top100_html):
|
||||
traxsource._cache.clear()
|
||||
mock_sess = MagicMock()
|
||||
mock_sess.get.side_effect = [
|
||||
_mock_response(genre_html, 200),
|
||||
_mock_response(top100_html, 200),
|
||||
]
|
||||
with patch("core.traxsource._session", return_value=mock_sess):
|
||||
traxsource.fetch_top100("tech-house")
|
||||
traxsource.fetch_top100("tech-house")
|
||||
# 2 chiamate al primo fetch (genre + top100), 0 al secondo
|
||||
assert mock_sess.get.call_count == 2
|
||||
|
||||
def test_force_refresh_bypasses_cache(self, genre_html, top100_html):
|
||||
traxsource._cache.clear()
|
||||
mock_sess = MagicMock()
|
||||
# 4 risposte (2 fetch x 2 richieste ciascuno)
|
||||
mock_sess.get.side_effect = [
|
||||
_mock_response(genre_html, 200),
|
||||
_mock_response(top100_html, 200),
|
||||
_mock_response(genre_html, 200),
|
||||
_mock_response(top100_html, 200),
|
||||
]
|
||||
with patch("core.traxsource._session", return_value=mock_sess):
|
||||
traxsource.fetch_top100("tech-house")
|
||||
traxsource.fetch_top100("tech-house", force_refresh=True)
|
||||
assert mock_sess.get.call_count == 4
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Test unitari per le funzioni di archive-lookup in core.upgrader.
|
||||
|
||||
Focalizzati su _normalize_stem, _scan_archive, _find_candidates.
|
||||
Non testiamo il flow end-to-end di upgrade_folder (richiederebbe mock
|
||||
di subprocess/yt-dlp/ffmpeg — out of scope, coperto da smoke test manuale).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core import upgrader
|
||||
|
||||
|
||||
# ============================================================
|
||||
# _normalize_stem
|
||||
# ============================================================
|
||||
class TestNormalizeStem:
|
||||
def test_lowercase_and_dedup(self):
|
||||
# "Hot" e "hot" → un solo token; token duplicati collassano nel set
|
||||
got = upgrader._normalize_stem("Hot Sauce hot sauce")
|
||||
assert got == {"hot", "sauce"}
|
||||
|
||||
def test_filters_short_tokens(self):
|
||||
# Token di lunghezza < 3 vengono scartati (di, il, a, b, cd...)
|
||||
got = upgrader._normalize_stem("A B cd Boombox")
|
||||
assert "a" not in got
|
||||
assert "b" not in got
|
||||
assert "cd" not in got
|
||||
assert "boombox" in got
|
||||
|
||||
def test_removes_punctuation(self):
|
||||
# Trattini, virgole, parentesi, apostrofi → tutti sostituiti con spazi
|
||||
got = upgrader._normalize_stem("Artist - Title (Extended Mix)")
|
||||
assert got == {"artist", "title", "extended", "mix"}
|
||||
|
||||
def test_empty_input(self):
|
||||
assert upgrader._normalize_stem("") == set()
|
||||
assert upgrader._normalize_stem(" ") == set()
|
||||
|
||||
def test_only_short_tokens_returns_empty(self):
|
||||
assert upgrader._normalize_stem("a b c d") == set()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# _scan_archive
|
||||
# ============================================================
|
||||
class TestScanArchive:
|
||||
def test_recursive_scan(self, tmp_path: Path):
|
||||
# Crea albero: top-level + sub/ + sub/sub2/
|
||||
(tmp_path / "Artist - Song.mp3").touch()
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "sub" / "Second Track.mp3").touch()
|
||||
(tmp_path / "sub" / "sub2").mkdir()
|
||||
(tmp_path / "sub" / "sub2" / "Deep One.m4a").touch()
|
||||
# File non-audio devono essere ignorati
|
||||
(tmp_path / "readme.txt").touch()
|
||||
|
||||
index = upgrader._scan_archive(str(tmp_path))
|
||||
# 3 entries totali (una per ogni file audio, ognuna con token distinti)
|
||||
assert len(index) == 3
|
||||
# Verifica che le path siano riferite ai file corretti
|
||||
all_paths = [p for paths in index.values() for p in paths]
|
||||
names = sorted(p.name for p in all_paths)
|
||||
assert names == ["Artist - Song.mp3", "Deep One.m4a", "Second Track.mp3"]
|
||||
|
||||
def test_case_insensitive_extensions(self, tmp_path: Path):
|
||||
(tmp_path / "one.MP3").touch()
|
||||
(tmp_path / "two.Mp3").touch()
|
||||
(tmp_path / "three.WAV").touch()
|
||||
(tmp_path / "four.FLAC").touch()
|
||||
(tmp_path / "five.txt").touch() # non-audio: ignorato
|
||||
index = upgrader._scan_archive(str(tmp_path))
|
||||
all_paths = [p for paths in index.values() for p in paths]
|
||||
assert len(all_paths) == 4 # 4 file audio, 1 skippato
|
||||
|
||||
def test_duplicates_appended(self, tmp_path: Path):
|
||||
# Due file con stesso token set → stessa key, due path
|
||||
(tmp_path / "sub1").mkdir()
|
||||
(tmp_path / "sub2").mkdir()
|
||||
(tmp_path / "sub1" / "Hot Sauce.mp3").touch()
|
||||
(tmp_path / "sub2" / "Hot Sauce.mp3").touch()
|
||||
index = upgrader._scan_archive(str(tmp_path))
|
||||
assert len(index) == 1
|
||||
# Una sola chiave, con 2 path
|
||||
paths = list(index.values())[0]
|
||||
assert len(paths) == 2
|
||||
|
||||
def test_nonexistent_dir_returns_empty(self, tmp_path: Path):
|
||||
assert upgrader._scan_archive(str(tmp_path / "nope")) == {}
|
||||
|
||||
def test_empty_stems_skipped(self, tmp_path: Path):
|
||||
# File il cui stem produce zero token utili (solo caratteri corti) → skip
|
||||
(tmp_path / "a.mp3").touch()
|
||||
(tmp_path / "Real Track Name.mp3").touch()
|
||||
index = upgrader._scan_archive(str(tmp_path))
|
||||
# Solo "Real Track Name.mp3" ha token >=3 chars
|
||||
all_paths = [p for paths in index.values() for p in paths]
|
||||
assert len(all_paths) == 1
|
||||
assert all_paths[0].name == "Real Track Name.mp3"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# _find_candidates
|
||||
# ============================================================
|
||||
class TestFindCandidates:
|
||||
def _fake_index(self, tmp_path: Path, entries: list) -> dict:
|
||||
"""Helper: crea file (touch) e ritorna un index manuale."""
|
||||
index: dict = {}
|
||||
for name in entries:
|
||||
p = tmp_path / name
|
||||
p.touch()
|
||||
tokens = upgrader._normalize_stem(p.stem)
|
||||
key = upgrader._tokens_to_key(tokens)
|
||||
index.setdefault(key, []).append(p)
|
||||
return index
|
||||
|
||||
def test_ranking_bitrate_desc_then_similarity_desc(self, tmp_path: Path):
|
||||
# 3 candidati: variamo bitrate + similarity per verificare l'ordine
|
||||
# a: sim alta (0.75), bitrate basso (128)
|
||||
# b: sim media (0.5), bitrate alto (320)
|
||||
# c: sim alta (0.75), bitrate medio (192)
|
||||
# Ordine atteso: b(320) > c(192, sim 0.75) > a(128)
|
||||
idx = self._fake_index(tmp_path, [
|
||||
"Artist - Hot Sauce.mp3", # a: 3 token comuni su 4 = 0.75
|
||||
"Different Song Boombox.mp3", # b: 1 su 5 = 0.2 (troppo bassa, filtrato)
|
||||
"Artist Hot Sauce Extended.mp3", # c: 3 su 4 = 0.75
|
||||
])
|
||||
# Target: "Artist Hot Sauce" → tokens = {artist, hot, sauce}
|
||||
target = "Artist Hot Sauce"
|
||||
|
||||
# Mock bitrate: mappa nome → kbps
|
||||
def fake_bitrate(p):
|
||||
n = Path(p).name
|
||||
if n == "Artist - Hot Sauce.mp3": return 128
|
||||
if n == "Different Song Boombox.mp3": return 320
|
||||
if n == "Artist Hot Sauce Extended.mp3": return 192
|
||||
return 0
|
||||
|
||||
with patch.object(upgrader, "get_bitrate", side_effect=fake_bitrate):
|
||||
results = upgrader._find_candidates(target, idx, min_similarity=0.3)
|
||||
|
||||
# "b" viene filtrato (sim 1/5 = 0.2 < 0.3 min); restano a e c
|
||||
assert len(results) == 2
|
||||
# c (bitrate 192) prima di a (bitrate 128)
|
||||
assert results[0][0].name == "Artist Hot Sauce Extended.mp3"
|
||||
assert results[1][0].name == "Artist - Hot Sauce.mp3"
|
||||
|
||||
def test_below_threshold_filtered(self, tmp_path: Path):
|
||||
# Un solo candidato con similarity ~ 1/5 = 0.2 → sotto default 0.5 → escluso
|
||||
idx = self._fake_index(tmp_path, [
|
||||
"Foo Bar Baz Qux Extra.mp3",
|
||||
])
|
||||
with patch.object(upgrader, "get_bitrate", return_value=320):
|
||||
results = upgrader._find_candidates("Hot Sauce", idx) # solo "hot"/"sauce"
|
||||
assert results == []
|
||||
|
||||
def test_exact_match_returned(self, tmp_path: Path):
|
||||
idx = self._fake_index(tmp_path, [
|
||||
"Hot Sauce Extended.mp3",
|
||||
])
|
||||
with patch.object(upgrader, "get_bitrate", return_value=320):
|
||||
results = upgrader._find_candidates("Hot Sauce Extended", idx)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == 1.0 # perfect Jaccard
|
||||
assert results[0][2] == 320
|
||||
+649
-1
@@ -151,6 +151,9 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
flex-direction: column;
|
||||
padding: 24px 12px;
|
||||
border-right: 1px solid var(--border);
|
||||
height: 100vh;
|
||||
min-height: 0; /* consente ai flex children di rimpicciolirsi sotto il contenuto */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -192,7 +195,28 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-height: 0; /* per far funzionare overflow su flex child */
|
||||
overflow-y: auto; /* scrolla se ci sono troppi tab e la finestra è corta */
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.nav::-webkit-scrollbar { width: 6px; }
|
||||
.nav::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-input);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.nav::-webkit-scrollbar-thumb:hover { background: var(--bg-input-hover); }
|
||||
|
||||
.nav-section-label {
|
||||
padding: 12px 14px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1.2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
user-select: none;
|
||||
}
|
||||
.nav-section-label:first-child { padding-top: 4px; }
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
@@ -333,7 +357,9 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
HERO
|
||||
=============================== */
|
||||
.hero {
|
||||
position: relative;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
border-radius: var(--r-lg);
|
||||
padding: 28px 32px;
|
||||
margin-bottom: 24px;
|
||||
@@ -552,6 +578,87 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
.path-display:empty,
|
||||
.path-display.empty { color: var(--text-3); }
|
||||
|
||||
.ai-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
background: linear-gradient(135deg, var(--purple), var(--pink));
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgba(168, 85, 247, 0.35);
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
.ai-badge[hidden] { display: none !important; }
|
||||
.ai-badge::before { content: "🤖"; }
|
||||
|
||||
.raw-frames-details {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background: var(--bg-card-2);
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.raw-frames-details summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
padding: 2px 0;
|
||||
user-select: none;
|
||||
}
|
||||
.raw-frames-details summary:hover { color: var(--text); }
|
||||
.raw-frames-details[open] summary { margin-bottom: 10px; color: var(--text); }
|
||||
.raw-frames-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.raw-frames-table th,
|
||||
.raw-frames-table td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.raw-frames-table th {
|
||||
color: var(--text-3);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.raw-frames-table td.raw-key {
|
||||
font-family: ui-monospace, SF Mono, Menlo, monospace;
|
||||
color: var(--indigo-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.raw-frames-table td.raw-preview {
|
||||
word-break: break-word;
|
||||
color: var(--text);
|
||||
}
|
||||
.raw-frames-table td.raw-size {
|
||||
text-align: right;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.raw-frames-table .kind-binary {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: var(--amber-dim);
|
||||
color: var(--amber-text);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
BUTTONS
|
||||
=============================== */
|
||||
@@ -685,6 +792,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
.percent.green { color: var(--green-text); }
|
||||
.percent.blue { color: var(--blue-text); }
|
||||
.percent.pink { color: var(--pink-hover); }
|
||||
.percent.purple { color: var(--purple-text); }
|
||||
|
||||
.progress {
|
||||
width: 100%;
|
||||
@@ -713,6 +821,10 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
background: linear-gradient(90deg, var(--pink), var(--pink-hover));
|
||||
}
|
||||
|
||||
.progress-fill.purple {
|
||||
background: linear-gradient(90deg, var(--purple), var(--purple-hover));
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
LOG BOX
|
||||
=============================== */
|
||||
@@ -1667,10 +1779,32 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
}
|
||||
|
||||
.beatport-table .col-check { width: 36px; }
|
||||
.beatport-table .col-cover { width: 52px; padding: 4px 8px; }
|
||||
.beatport-table .col-cover img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
.beatport-table .col-pos { width: 40px; color: var(--text-3); font-variant-numeric: tabular-nums; }
|
||||
.beatport-table .col-dur { width: 70px; color: var(--text-2); font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.beatport-table .col-date { width: 100px; color: var(--text-2); font-variant-numeric: tabular-nums; font-size: 12px; white-space: nowrap; }
|
||||
.beatport-table .col-state { width: 130px; font-size: 12px; }
|
||||
|
||||
.beatport-table thead th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.beatport-table thead th.sortable:hover { color: var(--text); }
|
||||
.beatport-table thead th .sort-arrow {
|
||||
font-size: 10px;
|
||||
margin-left: 4px;
|
||||
color: var(--indigo-text);
|
||||
}
|
||||
|
||||
.beatport-table input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -1697,3 +1831,517 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
.hint-inline { margin-left: 0; }
|
||||
.beatport-table .col-state { width: 110px; }
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
UPGRADE — picker modal (match locale)
|
||||
=============================== */
|
||||
.modal-body.upgrade-picker-body {
|
||||
/* Override del default .modal-body (mono + pre-wrap): qui è UI, non log */
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
white-space: normal;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Modal picker: più largo dei modal standard + footer con wrap
|
||||
per far entrare i 4 bottoni senza troncare */
|
||||
#upgradePickerModal .modal {
|
||||
min-width: 620px;
|
||||
max-width: 780px;
|
||||
}
|
||||
#upgradePickerModal .modal-foot {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.modal-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.upgrade-picker-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.upgrade-picker-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md, 10px);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
background: var(--bg-input, transparent);
|
||||
}
|
||||
.upgrade-picker-row:hover { border-color: var(--text-2); }
|
||||
.upgrade-picker-row.selected {
|
||||
border-color: var(--blue, #3b82f6);
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
.upgrade-picker-row input[type="radio"] {
|
||||
margin-top: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.upgrade-picker-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.upgrade-picker-play {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.upgrade-preview-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-input, transparent);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin-left: 6px;
|
||||
transition: background 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
.upgrade-preview-btn:hover { background: var(--bg-input-hover, rgba(255,255,255,0.05)); }
|
||||
.upgrade-preview-btn.playing {
|
||||
background: var(--green, #1DB954);
|
||||
color: #000;
|
||||
border-color: var(--green, #1DB954);
|
||||
}
|
||||
.upgrade-picker-title {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
word-break: break-word;
|
||||
}
|
||||
.upgrade-picker-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
margin-top: 3px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.upgrade-picker-path {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
margin-top: 2px;
|
||||
word-break: break-all;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
DEDUP tab (audio duplicati)
|
||||
=============================== */
|
||||
.dedup-summary {
|
||||
font-size: 14px;
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dedup-group-card {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.dedup-group-header {
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.dedup-group-header strong {
|
||||
color: var(--text-1);
|
||||
font-size: 14px;
|
||||
}
|
||||
.dedup-fp {
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dedup-file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dedup-file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-elev);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.dedup-file-row:hover {
|
||||
background: var(--bg-elev-hover, var(--bg-elev));
|
||||
}
|
||||
|
||||
.dedup-file-check {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--red);
|
||||
}
|
||||
.dedup-file-check:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.dedup-file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dedup-file-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dedup-file-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
margin-top: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dedup-file-path {
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
margin-top: 2px;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
word-break: break-all;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.dedup-file-play {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* File "da tenere" (bitrate massimo del gruppo) — verde */
|
||||
.dedup-keep {
|
||||
background: var(--green-dim);
|
||||
border-left: 3px solid var(--green);
|
||||
}
|
||||
.dedup-keep .dedup-file-title {
|
||||
color: var(--green-text);
|
||||
}
|
||||
|
||||
.dedup-keep-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--green);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.5px;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.dedup-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);
|
||||
}
|
||||
|
||||
/* Lista risultati Dedup con scroll interno: l'header (hero sticky) e il
|
||||
footer restano visibili anche se ci sono centinaia di gruppi. */
|
||||
.dedup-groups-scroll {
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
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);
|
||||
}
|
||||
|
||||
/* ==================================================================
|
||||
* Charts tab (iTunes / Spotify / M2O / Last.fm)
|
||||
* Riusa in gran parte le classi catalog-* per stile e comportamento.
|
||||
* ================================================================== */
|
||||
.charts-source-row {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.charts-radio {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-1);
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.charts-radio:hover {
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
|
||||
.charts-radio input[type="radio"] {
|
||||
accent-color: var(--indigo-hover, #6366F1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.charts-radio input[type="radio"]:checked ~ span {
|
||||
color: var(--text-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.charts-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.charts-filters[hidden] { display: none !important; }
|
||||
|
||||
.charts-actions-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.charts-actions-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.charts-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);
|
||||
}
|
||||
|
||||
.charts-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.charts-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;
|
||||
}
|
||||
|
||||
.charts-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;
|
||||
}
|
||||
|
||||
.charts-table tbody tr:hover {
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
|
||||
.charts-col-check { width: 30px; text-align: center; }
|
||||
.charts-col-rank { width: 42px; text-align: center; font-variant-numeric: tabular-nums; color: var(--text-3); font-weight: 700; }
|
||||
.charts-col-pop { width: 110px; text-align: right; font-variant-numeric: tabular-nums; color: var(--text-3); }
|
||||
|
||||
.charts-check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--indigo-hover, #6366F1);
|
||||
}
|
||||
|
||||
.charts-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);
|
||||
}
|
||||
|
||||
+737
-20
@@ -21,6 +21,7 @@
|
||||
<div class="divider"></div>
|
||||
|
||||
<nav class="nav">
|
||||
<div class="nav-section-label">Scarica</div>
|
||||
<button class="nav-item active" data-view="download" data-feature="audio">
|
||||
<span class="nav-icon">⬇</span>
|
||||
<span>Scarica</span>
|
||||
@@ -29,10 +30,6 @@
|
||||
<span class="nav-icon">🎬</span>
|
||||
<span>Video</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="beatport" data-feature="audio">
|
||||
<span class="nav-icon">🎧</span>
|
||||
<span>Beatport</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="spotify" data-feature="audio">
|
||||
<span class="nav-icon">🟢</span>
|
||||
<span>Spotify</span>
|
||||
@@ -41,18 +38,52 @@
|
||||
<span class="nav-icon">▶</span>
|
||||
<span>YouTube</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
|
||||
<span class="nav-icon">⚡</span>
|
||||
<span>Upgrade</span>
|
||||
<button class="nav-item" data-view="beatport" data-feature="audio">
|
||||
<span class="nav-icon">🎧</span>
|
||||
<span>Beatport</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="traxsource" data-feature="audio">
|
||||
<span class="nav-icon">▲</span>
|
||||
<span>Traxsource</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="charts" data-feature="audio">
|
||||
<span class="nav-icon">📊</span>
|
||||
<span>Charts</span>
|
||||
</button>
|
||||
|
||||
<div class="nav-section-label">Organizza</div>
|
||||
<button class="nav-item" data-view="dedup" data-feature="audio">
|
||||
<span class="nav-icon">🗑</span>
|
||||
<span>Dedup</span>
|
||||
</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="convert" data-feature="audio">
|
||||
<span class="nav-icon">🔄</span>
|
||||
<span>Converti</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="meta" data-feature="metadata">
|
||||
<span class="nav-icon">🏷</span>
|
||||
<span>Metadati</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
|
||||
<span class="nav-icon">⚡</span>
|
||||
<span>Upgrade</span>
|
||||
</button>
|
||||
|
||||
<div class="nav-section-label">Altro</div>
|
||||
<button class="nav-item" data-view="record" data-feature="record">
|
||||
<span class="nav-icon">●</span>
|
||||
<span>Registra</span>
|
||||
</button>
|
||||
|
||||
<div class="nav-section-label">Sistema</div>
|
||||
<button class="nav-item" data-view="settings">
|
||||
<span class="nav-icon">⚙</span>
|
||||
<span>Impostazioni</span>
|
||||
@@ -264,6 +295,23 @@
|
||||
<label class="field-label">Soglia HQ (kbps)</label>
|
||||
<input type="number" id="upThreshold" class="input pill input-sm" value="310" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">Cartella archivio (opzionale)</label>
|
||||
<div class="row">
|
||||
<div class="path-display empty" id="upArchivePathDisplay">Nessuna cartella</div>
|
||||
<button class="btn btn-ghost pill btn-sm" id="upArchiveBrowseBtn">📂 Sfoglia</button>
|
||||
<button class="btn btn-ghost pill btn-sm" id="upArchiveClearBtn" hidden>Rimuovi</button>
|
||||
</div>
|
||||
<label style="display:flex;gap:6px;align-items:center;margin-top:8px;font-size:13px;color:var(--text-2);">
|
||||
<input type="checkbox" id="upArchiveAutoPick"/>
|
||||
<span>Scelta automatica se ci sono più match (usa quello a bitrate più alto)</span>
|
||||
</label>
|
||||
<div class="hint">
|
||||
<span class="hint-ico">ⓘ</span>
|
||||
<div>Se selezionata, l'app cerca versioni HQ del brano nella tua libreria locale prima di scaricare da YouTube. In caso di match multipli scegli tu quale usare — oppure attiva "Scelta automatica" per non fermarti.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card actions">
|
||||
@@ -396,6 +444,7 @@
|
||||
<div class="row">
|
||||
<button class="btn btn-amber pill" id="metaPickBtn">📂 Apri file audio</button>
|
||||
<div class="path-display" id="metaPathDisplay">Nessun file selezionato</div>
|
||||
<span id="metaAiBadge" class="ai-badge" hidden title="Brano generato con AI"></span>
|
||||
</div>
|
||||
<div class="meta-info" id="metaInfo" hidden></div>
|
||||
</div>
|
||||
@@ -444,6 +493,42 @@
|
||||
<label class="field-label">Commento</label>
|
||||
<textarea id="metaComment" class="input textarea" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label class="field-label">URL sorgente (WOAS)</label>
|
||||
<input type="text" id="metaSourceUrl" class="input pill" placeholder="https://...">
|
||||
<div class="cover-hint">Link alla pagina d'origine del brano (es. Suno, YouTube, SoundCloud). Sopravvive alle copie del file.</div>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label class="field-label">Testo / Prompt (USLT)</label>
|
||||
<textarea id="metaLyrics" class="input textarea" rows="8" placeholder="Lyrics del brano o prompt AI utilizzato..."></textarea>
|
||||
<div class="cover-hint">Testo o prompt strutturato. Suno lo compila automaticamente al momento della generazione.</div>
|
||||
</div>
|
||||
<details class="field full raw-frames-details">
|
||||
<summary>Metadati avanzati (tutti i frame ID3 grezzi)</summary>
|
||||
<table class="raw-frames-table" id="metaRawFrames">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:32px;"><input type="checkbox" id="metaRawSelectAll" title="Seleziona tutti"></th>
|
||||
<th>Frame</th>
|
||||
<th>Tipo</th>
|
||||
<th>Contenuto</th>
|
||||
<th style="text-align:right;">Bytes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="row" style="margin-top:10px;justify-content:space-between;align-items:center;">
|
||||
<span id="metaRawSelectedCount" style="font-size:12px;color:var(--text-3);">0 selezionati</span>
|
||||
<button id="metaRawRemoveBtn" class="btn btn-danger pill btn-sm" disabled>
|
||||
🗑 Rimuovi selezionati
|
||||
</button>
|
||||
</div>
|
||||
<div class="cover-hint" style="margin-top:8px;">
|
||||
I frame standard (TIT2 titolo, TPE1 artista, TALB album, APIC cover…) si gestiscono con i campi sopra.
|
||||
Qui rimuovi frame custom come TXXX, GEOB (C2PA), USLT (lyrics) o COMM extra.
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="field full" id="metaWhereFromField" hidden>
|
||||
<label class="field-label">
|
||||
Origine (macOS · kMDItemWhereFroms)
|
||||
@@ -534,6 +619,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">Cookies da browser (bypass 403 YouTube)</label>
|
||||
<div class="row">
|
||||
<select id="cookiesBrowserSelect" class="input pill" style="max-width:220px;">
|
||||
<option value="">Nessuno</option>
|
||||
<option value="chrome">Chrome</option>
|
||||
<option value="safari">Safari</option>
|
||||
<option value="firefox">Firefox</option>
|
||||
<option value="edge">Edge</option>
|
||||
<option value="brave">Brave</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="hint">
|
||||
<span class="hint-ico">ⓘ</span>
|
||||
<div>Alcuni video YouTube (musica protetta, region-lock, età) danno HTTP 403 senza cookies autenticati. Selezionando un browser, l'app legge i cookies dal tuo profilo — richiede che tu sia loggato su YouTube in quel browser. Se hai anche cookies.txt sopra, ha priorità il file.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">Cartella output predefinita</label>
|
||||
<div class="row">
|
||||
@@ -543,6 +646,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Log</h2>
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label class="field-label">Cartella dei log</label>
|
||||
<div class="row">
|
||||
<div class="path-display" id="logPathDisplay" style="flex:1;">—</div>
|
||||
<button class="btn btn-ghost pill btn-sm" id="openLogFolderBtn">📁 Apri cartella</button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
<span class="hint-ico">ⓘ</span>
|
||||
<div>
|
||||
L'app scrive un log giornaliero (<code>app.log</code>) con rotazione automatica (7 giorni).
|
||||
Utile per debug: incluide stdout, stderr, log delle tab e traceback.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Aspetto</h2>
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
@@ -891,10 +1012,12 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" id="beatport-select-all" /></th>
|
||||
<th class="col-pos">#</th>
|
||||
<th>Artista</th>
|
||||
<th>Titolo (Mix)</th>
|
||||
<th class="col-dur">Durata</th>
|
||||
<th class="col-cover"></th>
|
||||
<th class="col-pos sortable" data-sort="position">#</th>
|
||||
<th class="sortable" data-sort="artists">Artista</th>
|
||||
<th class="sortable" data-sort="title">Titolo (Mix)</th>
|
||||
<th class="col-dur sortable" data-sort="duration_sec">Durata</th>
|
||||
<th class="col-date sortable" data-sort="release_date">Data</th>
|
||||
<th class="col-state">Stato</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -918,6 +1041,64 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: TRAXSOURCE ===== -->
|
||||
<section class="view" id="view-traxsource">
|
||||
<header class="hero hero-purple">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow purple">TRAXSOURCE CHARTS</div>
|
||||
<h1 class="hero-title">Top 100 Traxsource per genere</h1>
|
||||
<p class="hero-subtitle">La classifica mensile ufficiale. Scarica in un click con tag ID3 completi (label, cover art, genere).</p>
|
||||
</div>
|
||||
<div class="hero-deco">▲</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Genere</h2>
|
||||
<div class="card">
|
||||
<div class="beatport-header">
|
||||
<select id="traxsource-genre" class="input pill"></select>
|
||||
<button class="btn btn-primary pill" id="traxsource-load-btn">
|
||||
<span class="ico">↓</span> Carica Top 100
|
||||
</button>
|
||||
<span class="hint-inline">Shift+click per bypassare la cache</span>
|
||||
</div>
|
||||
<div id="traxsource-status" class="beatport-status"></div>
|
||||
<div id="traxsource-output-info" class="beatport-output-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="card beatport-table-card">
|
||||
<div class="beatport-table-wrap">
|
||||
<table id="traxsource-table" class="beatport-table" hidden>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" id="traxsource-select-all" /></th>
|
||||
<th class="col-cover"></th>
|
||||
<th class="col-pos sortable" data-sort="position">#</th>
|
||||
<th class="sortable" data-sort="artists">Artista</th>
|
||||
<th class="sortable" data-sort="title">Titolo (Mix)</th>
|
||||
<th class="sortable" data-sort="label">Label</th>
|
||||
<th class="col-state">Stato</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="beatport-toolbar" id="traxsource-toolbar" hidden>
|
||||
<span class="counter" id="traxsource-selected-count">0/0 selezionati</span>
|
||||
<button class="btn btn-primary pill" id="traxsource-download-btn" disabled>
|
||||
<span class="ico">▶</span> Scarica selezionati
|
||||
</button>
|
||||
<button class="btn btn-danger pill" id="traxsource-stop-btn" hidden>
|
||||
<span class="ico">◼</span> Interrompi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Log</h2>
|
||||
<div class="card log-card">
|
||||
<div class="log" id="traxsource-log"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: SPOTIFY SEARCH ===== -->
|
||||
<section class="view" id="view-spotify">
|
||||
<header class="hero hero-indigo">
|
||||
@@ -951,11 +1132,13 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" id="spotify-select-all" checked /></th>
|
||||
<th class="col-pos">#</th>
|
||||
<th>Artista</th>
|
||||
<th>Titolo</th>
|
||||
<th>Album</th>
|
||||
<th class="col-dur">Durata</th>
|
||||
<th class="col-cover"></th>
|
||||
<th class="col-pos sortable" data-sort="position">#</th>
|
||||
<th class="sortable" data-sort="artists">Artista</th>
|
||||
<th class="sortable" data-sort="name">Titolo</th>
|
||||
<th class="sortable" data-sort="album">Album</th>
|
||||
<th class="col-dur sortable" data-sort="duration_sec">Durata</th>
|
||||
<th class="col-date sortable" data-sort="release_date">Data</th>
|
||||
<th class="col-state"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1008,10 +1191,12 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" id="youtube-select-all" checked /></th>
|
||||
<th class="col-pos">#</th>
|
||||
<th>Titolo video</th>
|
||||
<th>Canale</th>
|
||||
<th class="col-dur">Durata</th>
|
||||
<th class="col-cover"></th>
|
||||
<th class="col-pos sortable" data-sort="position">#</th>
|
||||
<th class="sortable" data-sort="title">Titolo video</th>
|
||||
<th class="sortable" data-sort="channel">Canale</th>
|
||||
<th class="col-dur sortable" data-sort="duration_sec">Durata</th>
|
||||
<th class="col-date sortable" data-sort="release_date">Data</th>
|
||||
<th class="col-state"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1035,6 +1220,495 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: CONVERTI (WAV -> MP3) ===== -->
|
||||
<section class="view" id="view-convert">
|
||||
<header class="hero hero-purple">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow purple">WAV → MP3</div>
|
||||
<h1 class="hero-title">Converti file WAV in MP3</h1>
|
||||
<p class="hero-subtitle">Bitrate configurabile, CBR o VBR, output accanto al file o in cartella custom.</p>
|
||||
</div>
|
||||
<div class="hero-deco">🔄</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Sorgente</h2>
|
||||
<div class="card">
|
||||
<div class="beatport-header">
|
||||
<button id="convert-pick-files" class="btn btn-primary pill">📄 Scegli file .wav</button>
|
||||
<button id="convert-pick-folder" class="btn btn-ghost pill">📁 Scegli cartella</button>
|
||||
<button id="convert-clear" class="btn btn-ghost pill">Svuota</button>
|
||||
</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;">
|
||||
Bitrate:
|
||||
<select id="convert-bitrate" class="input pill input-sm">
|
||||
<option value="128">128 kbps</option>
|
||||
<option value="192">192 kbps</option>
|
||||
<option value="256">256 kbps</option>
|
||||
<option value="320" selected>320 kbps</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
|
||||
<input type="checkbox" id="convert-vbr" />
|
||||
<span>VBR (qualità variabile)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="beatport-header" style="margin-top:12px;gap:12px;">
|
||||
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
|
||||
<input type="radio" name="convert-out" id="convert-out-same" checked />
|
||||
<span>Accanto al file originale</span>
|
||||
</label>
|
||||
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
|
||||
<input type="radio" name="convert-out" id="convert-out-custom" />
|
||||
<span>Cartella custom:</span>
|
||||
</label>
|
||||
<button id="convert-out-browse" class="btn btn-ghost pill btn-sm" disabled>Sfoglia…</button>
|
||||
<span id="convert-out-path" class="beatport-output-info" style="margin:0;"></span>
|
||||
</div>
|
||||
|
||||
<div id="convert-status" class="beatport-status"></div>
|
||||
</div>
|
||||
|
||||
<div class="card beatport-table-card" id="convert-table-wrap" hidden>
|
||||
<div class="beatport-table-wrap">
|
||||
<table class="beatport-table" id="convert-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" id="convert-select-all" checked /></th>
|
||||
<th class="col-pos">#</th>
|
||||
<th>File</th>
|
||||
<th class="col-state">Stato</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="convert-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="beatport-toolbar" id="convert-toolbar">
|
||||
<span class="counter" id="convert-selected-count">0/0 selezionati</span>
|
||||
<button id="convert-run-btn" class="btn btn-primary pill" disabled>
|
||||
<span class="ico">▶</span> Converti selezionati
|
||||
</button>
|
||||
<button id="convert-stop-btn" class="btn btn-danger pill" hidden>
|
||||
<span class="ico">◼</span> Interrompi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Log</h2>
|
||||
<div class="card log-card">
|
||||
<div class="log" id="convert-log"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: DEDUP (audio duplicati via Chromaprint) ===== -->
|
||||
<section class="view" id="view-dedup">
|
||||
<header class="hero hero-red">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow red">DEDUP AUDIO</div>
|
||||
<h1 class="hero-title">Trova e rimuovi duplicati audio</h1>
|
||||
<p class="hero-subtitle">Audio fingerprinting via Chromaprint: riconosce brani identici indipendentemente dal bitrate, dal formato o dai tag.</p>
|
||||
</div>
|
||||
<div class="hero-deco">🗑</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Cartella</h2>
|
||||
<div class="card">
|
||||
<div class="beatport-header">
|
||||
<button id="dedup-pick-folder" class="btn btn-primary pill">📁 Scegli cartella</button>
|
||||
<button id="dedup-start-btn" class="btn btn-primary pill" disabled>
|
||||
<span class="ico">▶</span> Scansiona
|
||||
</button>
|
||||
<button id="dedup-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="dedup-path-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="dedup-recursive" checked />
|
||||
<span>Ricerca ricorsiva (include sottocartelle)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="beatport-header" style="margin-top:12px;gap:16px;">
|
||||
<label style="display:flex;gap:8px;align-items:center;color:var(--text-2);font-size:13px;">
|
||||
<span>Metodo:</span>
|
||||
<select id="dedup-method" class="input" style="padding:4px 8px;font-size:13px;">
|
||||
<option value="fingerprint">Audio fingerprint (preciso, lento)</option>
|
||||
<option value="filename">Nome file (veloce, meno preciso)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div id="dedup-status" class="beatport-status"></div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Progresso</h2>
|
||||
<div class="card">
|
||||
<div class="progress">
|
||||
<div class="progress-bar"><div id="dedupProgressFill" class="progress-fill pink"></div></div>
|
||||
<div class="progress-meta">
|
||||
<span id="dedupPercent" class="percent pink">0%</span>
|
||||
<span id="dedupCounter">In attesa</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Risultati</h2>
|
||||
<div class="card" id="dedup-summary-card" hidden>
|
||||
<div class="dedup-summary" id="dedup-summary-line"></div>
|
||||
</div>
|
||||
<div id="dedup-groups-wrap" class="dedup-groups-scroll"></div>
|
||||
|
||||
<div class="dedup-footer-bar" id="dedup-footer-bar" hidden>
|
||||
<span class="counter" id="dedup-selected-count">0 file da cancellare</span>
|
||||
<button id="dedup-select-suggested-btn" class="btn btn-ghost pill">
|
||||
✓ Seleziona tutti i consigliati
|
||||
</button>
|
||||
<button id="dedup-trash-btn" class="btn btn-danger pill" disabled>
|
||||
<span class="ico">🗑</span> Sposta in cestino
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Log</h2>
|
||||
<div class="card log-card">
|
||||
<div class="log" id="dedup-log"></div>
|
||||
</div>
|
||||
</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><Anno>/<Genere>/</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"><Anno>/<Genere>/</option>
|
||||
<option value="genre_year"><Genere>/<Anno>/</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>
|
||||
|
||||
<!-- ===== VIEW: CHARTS ===== -->
|
||||
<section class="view" id="view-charts">
|
||||
<header class="hero hero-indigo">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow indigo">CHARTS</div>
|
||||
<h1 class="hero-title">Esplora le classifiche</h1>
|
||||
<p class="hero-subtitle">Del momento (iTunes/Spotify), dance italiana (M2O) o storiche per genere (Last.fm) — aggiungi in un click alla coda Scarica.</p>
|
||||
</div>
|
||||
<div class="hero-deco">📊</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Sorgente</h2>
|
||||
<div class="card">
|
||||
<div class="charts-source-row">
|
||||
<label class="charts-radio">
|
||||
<input type="radio" name="charts-source" value="itunes" checked />
|
||||
<span>🎵 Del momento (iTunes)</span>
|
||||
</label>
|
||||
<label class="charts-radio">
|
||||
<input type="radio" name="charts-source" value="spotify" />
|
||||
<span>🟢 Spotify Charts</span>
|
||||
</label>
|
||||
<label class="charts-radio">
|
||||
<input type="radio" name="charts-source" value="m2o" />
|
||||
<span>🔴 M2O Chart (dance italiana)</span>
|
||||
</label>
|
||||
<label class="charts-radio">
|
||||
<input type="radio" name="charts-source" value="lastfm" />
|
||||
<span>📻 Storiche per genere (Last.fm)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="charts-filters" id="charts-filters-itunes">
|
||||
<div class="field inline">
|
||||
<label class="field-label">Paese</label>
|
||||
<select id="charts-itunes-country" class="input pill input-sm">
|
||||
<option value="it">🇮🇹 Italia</option>
|
||||
<option value="us">🇺🇸 USA</option>
|
||||
<option value="gb">🇬🇧 UK</option>
|
||||
<option value="ww">🌐 Mondo</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-filters" id="charts-filters-spotify" hidden>
|
||||
<div class="field inline">
|
||||
<label class="field-label">Playlist chart</label>
|
||||
<select id="charts-spotify-playlist" class="input pill input-sm">
|
||||
<option value="top50_global">Top 50 Global</option>
|
||||
<option value="top50_italy">Top 50 Italy</option>
|
||||
<option value="top50_usa">Top 50 USA</option>
|
||||
<option value="top50_uk">Top 50 UK</option>
|
||||
<option value="viral50_global">Viral 50 Global</option>
|
||||
<option value="viral50_italy">Viral 50 Italy</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="hint" style="margin-top:8px;">
|
||||
<span class="hint-ico">ⓘ</span>
|
||||
<div>
|
||||
Le playlist Spotify richiedono le tue credenziali API (Impostazioni → Spotify).
|
||||
Senza credenziali questa sorgente resta vuota.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-filters" id="charts-filters-m2o" hidden>
|
||||
<div class="hint">
|
||||
<span class="hint-ico">ⓘ</span>
|
||||
<div>
|
||||
Chart dance italiana settimanale di M2O. Nessun filtro: premi "Aggiorna" per caricare la classifica corrente.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-filters" id="charts-filters-lastfm" hidden>
|
||||
<div class="field inline">
|
||||
<label class="field-label">Decade</label>
|
||||
<select id="charts-lastfm-decade" class="input pill input-sm">
|
||||
<option value="">Nessuna</option>
|
||||
<option value="70s">Anni 70</option>
|
||||
<option value="80s">Anni 80</option>
|
||||
<option value="90s" selected>Anni 90</option>
|
||||
<option value="2000s">Anni 2000</option>
|
||||
<option value="2010s">Anni 2010</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field inline">
|
||||
<label class="field-label">Genere</label>
|
||||
<select id="charts-lastfm-genre" class="input pill input-sm">
|
||||
<option value="">Nessuno</option>
|
||||
<option value="pop">Pop</option>
|
||||
<option value="rock">Rock</option>
|
||||
<option value="dance" selected>Dance</option>
|
||||
<option value="house">House</option>
|
||||
<option value="hip hop">Hip Hop</option>
|
||||
<option value="latin">Latin</option>
|
||||
<option value="r&b">R&B</option>
|
||||
<option value="italo disco">Italo Disco</option>
|
||||
<option value="reggae">Reggae</option>
|
||||
<option value="electronic">Electronic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="hint" style="margin-top:8px;">
|
||||
<span class="hint-ico">ⓘ</span>
|
||||
<div>
|
||||
Last.fm richiede una API key: nell'app c'è una chiave demo, se non funziona
|
||||
registrane una gratuita su <code>last.fm/api/account/create</code> e mettila nel campo
|
||||
<code>lastfm_api_key</code> di <code>config.json</code>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="beatport-header" style="margin-top:14px;gap:12px;">
|
||||
<button id="charts-fetch-btn" class="btn btn-primary pill">
|
||||
<span class="ico">▶</span> Aggiorna classifica
|
||||
</button>
|
||||
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
|
||||
<input type="checkbox" id="charts-force" />
|
||||
<span>Ignora cache (force refresh)</span>
|
||||
</label>
|
||||
<span id="charts-status" class="beatport-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="charts-results-card" class="card" hidden>
|
||||
<div class="charts-actions-row">
|
||||
<span id="charts-summary-line" class="catalog-summary"></span>
|
||||
<div class="charts-actions-btns">
|
||||
<button id="charts-select-all-btn" class="btn btn-ghost pill btn-sm">Seleziona tutti</button>
|
||||
<button id="charts-deselect-all-btn" class="btn btn-ghost pill btn-sm">Deseleziona tutti</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="charts-table-wrap" class="charts-scroll">
|
||||
<table class="charts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="charts-col-check">
|
||||
<input type="checkbox" id="charts-select-all" title="Seleziona/deseleziona tutti" />
|
||||
</th>
|
||||
<th class="charts-col-rank">#</th>
|
||||
<th class="charts-col-artist">Artista</th>
|
||||
<th class="charts-col-title">Titolo</th>
|
||||
<th class="charts-col-album">Album</th>
|
||||
<th class="charts-col-pop">Popolarità</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="charts-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-footer-bar" id="charts-footer-bar" hidden>
|
||||
<span class="counter" id="charts-selected-count">0 brani selezionati</span>
|
||||
<button id="charts-add-queue-btn" class="btn btn-primary pill" disabled>
|
||||
<span class="ico">➕</span> Aggiungi alla coda Scarica
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Log</h2>
|
||||
<div class="card log-card">
|
||||
<div class="log" id="charts-log"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1108,6 +1782,49 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============== MODAL: Match locale Upgrade ============== -->
|
||||
<div class="modal-backdrop" id="upgradePickerModal" hidden>
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<h2>Match locali trovati</h2>
|
||||
<button class="modal-close" id="upgradePickerCloseBtn">×</button>
|
||||
</div>
|
||||
<div class="modal-body upgrade-picker-body">
|
||||
<div class="modal-sub" id="upgradePickerFilename"></div>
|
||||
<div id="upgradePickerList" class="upgrade-picker-list"></div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn btn-primary pill" id="upgradePickerUseBtn" disabled>Usa selezionato</button>
|
||||
<button class="btn btn-ghost pill" id="upgradePickerYoutubeBtn">Cerca su YouTube</button>
|
||||
<button class="btn btn-ghost pill" id="upgradePickerSkipBtn">Salta</button>
|
||||
<button class="btn btn-danger pill" id="upgradePickerCancelAllBtn">■ Annulla tutto</button>
|
||||
</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><Anno>/<Genere>/</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 ============== -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
|
||||
+2311
-18
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user