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 | ||
|
|
85f7ec0993 | ||
|
|
9dbdd74b2f | ||
|
|
8c73651312 | ||
|
|
1fa49fabc4 | ||
|
|
83c47df29c | ||
|
|
9ea14eb81c | ||
|
|
91cfc173d4 | ||
|
|
67e48e54f7 | ||
|
|
0949df031a | ||
|
|
2771fa8075 | ||
|
|
252d76b907 | ||
|
|
58d4896ffe | ||
|
|
91aa1bd6a3 | ||
|
|
48a6e15c7a | ||
|
|
f850e86a88 | ||
|
|
6bff24ff7d | ||
|
|
faf74dfb11 | ||
|
|
d2f3ebe361 | ||
|
|
33c6bc7703 | ||
|
|
be4f7e1024 | ||
|
|
1c52d52986 | ||
|
|
cc5716e87d | ||
|
|
3f31e240be | ||
|
|
13c8e60712 | ||
|
|
f1e57905c1 | ||
|
|
4221a5992b | ||
|
|
70cdfe8983 | ||
|
|
e1aaa83fe9 | ||
|
|
c037da4901 | ||
|
|
21ef3a5699 | ||
|
|
e4bfd76a79 | ||
|
|
e2ac1ca275 | ||
|
|
dc6ae1fbe1 | ||
|
|
0e14d161d0 | ||
|
|
7dd793253b | ||
|
|
5771d631fd | ||
|
|
b74a0a53ec | ||
|
|
415175ec4d | ||
|
|
d8349fb79f | ||
|
|
1414c5199c | ||
|
|
6e96e8c6b7 | ||
|
|
c2599a72a1 | ||
|
|
2cc6c5e9c6 |
No files matched your search
@@ -87,6 +87,14 @@ jobs:
|
||||
- name: Build exe
|
||||
run: python build_windows.py
|
||||
|
||||
- name: Unblock files (rimuovi MOTW pre-zip)
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Per ridurre l'incidenza del bug "Failed to resolve
|
||||
# Python.Runtime.Loader.Initialize" causato da Mark of the Web.
|
||||
Get-ChildItem -Path dist/MusicTools -Recurse -Include *.dll,*.exe,*.pyd `
|
||||
| Unblock-File
|
||||
|
||||
- name: Package -> zip
|
||||
shell: pwsh
|
||||
run: |
|
||||
|
||||
@@ -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/
|
||||
+1504
-10
File diff suppressed because it is too large.
Load diff
+293
-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
|
||||
@@ -238,6 +284,14 @@ def run_pyinstaller():
|
||||
app_path = DIST_DIR / "MusicTools.app"
|
||||
if app_path.exists():
|
||||
log(f"Build completata: {app_path}")
|
||||
patch_info_plist(app_path)
|
||||
# NOTA: dal v1.7.14 il microfono viene letto direttamente da
|
||||
# PyObjC/AVCaptureSession nel processo Python (che ha il TCC del
|
||||
# bundle), non da ffmpeg subprocess. ffmpeg fa solo la conversione
|
||||
# CAF -> MP3 (no microfono = no TCC). Per questo non serve piu'
|
||||
# incapsularlo in un sub-bundle .app con NSMicrophoneUsageDescription.
|
||||
# wrap_subprocess_in_bundle(app_path)
|
||||
adhoc_codesign(app_path)
|
||||
# Mostra dimensione
|
||||
size = sum(f.stat().st_size for f in app_path.rglob("*") if f.is_file())
|
||||
log(f"Dimensione: {size / 1024 / 1024:.0f} MB")
|
||||
@@ -249,6 +303,236 @@ def run_pyinstaller():
|
||||
log(f"Trovata directory: {alt}")
|
||||
|
||||
|
||||
def patch_info_plist(app_path):
|
||||
"""Aggiunge le chiavi TCC nell'Info.plist del bundle .app.
|
||||
|
||||
NSMicrophoneUsageDescription e' obbligatoria: senza, macOS uccide
|
||||
l'app con SIGABRT quando AVFoundation tenta di aprire il microfono.
|
||||
|
||||
Usiamo plistlib invece di PlistBuddy perche' i valori con apostrofi
|
||||
(es. "l'audio") rompono il parser shell-based di PlistBuddy.
|
||||
"""
|
||||
import plistlib
|
||||
|
||||
info_plist = app_path / "Contents" / "Info.plist"
|
||||
if not info_plist.exists():
|
||||
log("ATTENZIONE: Info.plist non trovato, skip patch TCC")
|
||||
return
|
||||
|
||||
log("Patching Info.plist con le chiavi TCC...")
|
||||
with open(info_plist, "rb") as f:
|
||||
data = plistlib.load(f)
|
||||
|
||||
data["NSMicrophoneUsageDescription"] = (
|
||||
"MusicTools usa il microfono per registrare l'audio del sistema "
|
||||
"(ad esempio tramite BlackHole o un altro dispositivo loopback)."
|
||||
)
|
||||
# Bundle identifier stabile aiuta TCC a riconoscere l'app tra le sessioni
|
||||
data["CFBundleIdentifier"] = "com.djluza.musictools"
|
||||
data["LSApplicationCategoryType"] = "public.app-category.music"
|
||||
|
||||
with open(info_plist, "wb") as f:
|
||||
plistlib.dump(data, f)
|
||||
|
||||
log(f"Info.plist aggiornato. Chiavi: {sorted(data.keys())[-5:]}")
|
||||
# Verifica
|
||||
with open(info_plist, "rb") as f:
|
||||
verify = plistlib.load(f)
|
||||
if "NSMicrophoneUsageDescription" not in verify:
|
||||
log("ERRORE CRITICO: NSMicrophoneUsageDescription NON e' stata scritta!")
|
||||
else:
|
||||
log("Verifica OK: NSMicrophoneUsageDescription presente.")
|
||||
|
||||
|
||||
_BUNDLE_ID = "com.djluza.musictools"
|
||||
|
||||
|
||||
def _subprocess_info_plist(executable, bundle_id):
|
||||
"""Info.plist per un mini-bundle che incapsula un binario subprocess.
|
||||
|
||||
NSMicrophoneUsageDescription qui dentro e' la chiave: senza di essa,
|
||||
macOS uccide il subprocess con SIGABRT appena tenta di aprire un
|
||||
device audio, anche se il main MusicTools ha il proprio TCC concesso.
|
||||
"""
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
|
||||
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
|
||||
'<plist version="1.0">\n'
|
||||
'<dict>\n'
|
||||
f' <key>CFBundleExecutable</key><string>{executable}</string>\n'
|
||||
f' <key>CFBundleIdentifier</key><string>{bundle_id}</string>\n'
|
||||
f' <key>CFBundleName</key><string>{executable}</string>\n'
|
||||
' <key>CFBundlePackageType</key><string>APPL</string>\n'
|
||||
' <key>CFBundleShortVersionString</key><string>1.0</string>\n'
|
||||
' <key>CFBundleVersion</key><string>1</string>\n'
|
||||
' <key>LSBackgroundOnly</key><true/>\n'
|
||||
' <key>LSUIElement</key><true/>\n'
|
||||
' <key>NSMicrophoneUsageDescription</key>\n'
|
||||
' <string>MusicTools usa il microfono per registrare l\'audio del '
|
||||
'sistema (BlackHole o altri dispositivi loopback).</string>\n'
|
||||
'</dict>\n'
|
||||
'</plist>\n'
|
||||
)
|
||||
|
||||
|
||||
def wrap_subprocess_in_bundle(app_path):
|
||||
"""Sposta ffmpeg e ffprobe in mini-bundle .app dentro Frameworks/.
|
||||
|
||||
macOS 14+ ENFORCE il fatto che ogni binario che apre device
|
||||
privacy-sensitive (microfono, camera, ...) debba avere un proprio
|
||||
Info.plist accessibile con NSMicrophoneUsageDescription. Quando
|
||||
ffmpeg viene lanciato come binario standalone (anche se figlio di
|
||||
MusicTools), non c'e' Info.plist associato -> SIGABRT.
|
||||
|
||||
Soluzione: incapsulare ffmpeg in MusicTools.app/Contents/Frameworks/
|
||||
ffmpeg.app/Contents/MacOS/ffmpeg, con Info.plist nel suo bundle.
|
||||
Stesso per ffprobe.
|
||||
"""
|
||||
log("Wrapping ffmpeg e ffprobe in sub-bundle .app...")
|
||||
fw = app_path / "Contents" / "Frameworks"
|
||||
if not fw.exists():
|
||||
log("ATTENZIONE: Contents/Frameworks non trovato, skip wrapping.")
|
||||
return
|
||||
|
||||
for name in ("ffmpeg", "ffprobe"):
|
||||
src = fw / name
|
||||
if not src.exists() or not src.is_file():
|
||||
log(f" skip {name}: non trovato in Frameworks/")
|
||||
continue
|
||||
wrap = fw / f"{name}.app"
|
||||
if wrap.exists():
|
||||
shutil.rmtree(wrap)
|
||||
macos = wrap / "Contents" / "MacOS"
|
||||
macos.mkdir(parents=True, exist_ok=True)
|
||||
dest = macos / name
|
||||
shutil.move(str(src), str(dest))
|
||||
dest.chmod(0o755)
|
||||
|
||||
# Aggiungi un nuovo rpath che punta a Contents/Frameworks/ (dove
|
||||
# stanno le dylib del bundle MusicTools).
|
||||
# @executable_path = ffmpeg.app/Contents/MacOS/
|
||||
# ../../../ = MusicTools.app/Contents/Frameworks/
|
||||
subprocess.run(
|
||||
["install_name_tool", "-add_rpath",
|
||||
"@executable_path/../../../", str(dest)],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Crea Info.plist con NSMicrophoneUsageDescription.
|
||||
info = wrap / "Contents" / "Info.plist"
|
||||
bundle_id = f"com.djluza.musictools.{name}"
|
||||
info.write_text(_subprocess_info_plist(name, bundle_id))
|
||||
log(f" wrap: {name} -> {dest.relative_to(app_path)}")
|
||||
|
||||
|
||||
def adhoc_codesign(app_path):
|
||||
"""Ad-hoc codesign dell'intero bundle .app con identifier coerente.
|
||||
|
||||
macOS tratta ogni binario Mach-O firmato come una "app" distinta per
|
||||
le decisioni TCC (microfono, camera, ecc.). Con `codesign --deep`
|
||||
standard, i nested binary (ffmpeg, ffprobe, yt-dlp) ricevono ognuno
|
||||
un identifier auto-generato del tipo `<nome>-<hash>`, diverso da
|
||||
quello del bundle principale -> TCC NEGA al subprocess.
|
||||
|
||||
Soluzione: firmiamo PRIMA ogni binario interno con `--identifier
|
||||
com.djluza.musictools`, poi il bundle .app intero. Cosi' TCC vede
|
||||
tutti i Mach-O come parte dello stesso "team" e propaga il permesso
|
||||
del main ai subprocess.
|
||||
"""
|
||||
log("Ad-hoc codesign del bundle (identifier coerente per TCC)...")
|
||||
try:
|
||||
# 1) Pulisci attributi estesi (rimuove quarantine residui dal build)
|
||||
subprocess.run(["xattr", "-cr", str(app_path)], capture_output=True)
|
||||
|
||||
# 2) Trova tutti i Mach-O nested (binari ed eventuali .dylib).
|
||||
# Li firmiamo uno a uno con identifier coerente, dal piu' profondo
|
||||
# al piu' esterno (richiesta da codesign).
|
||||
nested_machos = []
|
||||
for p in sorted(app_path.rglob("*"), key=lambda x: -len(x.parts)):
|
||||
if not p.is_file():
|
||||
continue
|
||||
# Skip risorse non eseguibili
|
||||
if p.suffix in (".plist", ".nib", ".png", ".icns", ".svg",
|
||||
".html", ".css", ".js", ".json", ".md", ".txt"):
|
||||
continue
|
||||
try:
|
||||
with open(p, "rb") as f:
|
||||
magic = f.read(4)
|
||||
except Exception:
|
||||
continue
|
||||
# Mach-O magic numbers (32/64-bit, big/little endian, fat)
|
||||
if magic in (b"\xcf\xfa\xed\xfe", b"\xce\xfa\xed\xfe",
|
||||
b"\xfe\xed\xfa\xce", b"\xfe\xed\xfa\xcf",
|
||||
b"\xca\xfe\xba\xbe", b"\xbe\xba\xfe\xca"):
|
||||
nested_machos.append(p)
|
||||
|
||||
log(f"Trovati {len(nested_machos)} binari Mach-O da firmare.")
|
||||
|
||||
for p in nested_machos:
|
||||
# Per ffmpeg/ffprobe dentro al sub-bundle .app, usa l'identifier
|
||||
# del SUB-bundle (TCC li tratta come app separate con Info.plist
|
||||
# proprio). Per gli altri binari nested (dylib, framework), usa
|
||||
# l'identifier del bundle principale.
|
||||
ident = _BUNDLE_ID
|
||||
parts = p.relative_to(app_path).parts
|
||||
for sub in ("ffmpeg.app", "ffprobe.app"):
|
||||
if sub in parts:
|
||||
ident = f"{_BUNDLE_ID}.{sub.replace('.app', '')}"
|
||||
break
|
||||
r = subprocess.run(
|
||||
["codesign", "--force", "--sign", "-",
|
||||
"--identifier", ident,
|
||||
"--timestamp=none",
|
||||
str(p)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
log(f" warn: firma {p.name} fallita: {r.stderr.strip()}")
|
||||
|
||||
# Firma anche i sub-bundle .app come bundle (con il loro Info.plist).
|
||||
for sub in ("ffmpeg.app", "ffprobe.app"):
|
||||
sub_path = app_path / "Contents" / "Frameworks" / sub
|
||||
if not sub_path.exists():
|
||||
continue
|
||||
sub_ident = f"{_BUNDLE_ID}.{sub.replace('.app', '')}"
|
||||
r = subprocess.run(
|
||||
["codesign", "--force", "--sign", "-",
|
||||
"--identifier", sub_ident,
|
||||
"--timestamp=none",
|
||||
str(sub_path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
log(f" warn: firma {sub} fallita: {r.stderr.strip()}")
|
||||
|
||||
# 3) Firma del bundle .app principale (l'identifier corretto viene
|
||||
# letto dall'Info.plist - CFBundleIdentifier che gia' settiamo).
|
||||
r = subprocess.run(
|
||||
["codesign", "--force", "--sign", "-",
|
||||
"--identifier", _BUNDLE_ID,
|
||||
"--timestamp=none",
|
||||
str(app_path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
log(f"ATTENZIONE: firma bundle fallita: {r.stderr.strip()}")
|
||||
else:
|
||||
log("Codesign ad-hoc completato.")
|
||||
|
||||
# 4) Verifica finale
|
||||
verify = subprocess.run(
|
||||
["codesign", "--verify", "--deep", "--strict", str(app_path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if verify.returncode == 0:
|
||||
log("Verifica firma OK.")
|
||||
else:
|
||||
log(f"NOTA verifica firma: {verify.stderr.strip()}")
|
||||
except FileNotFoundError:
|
||||
log("ATTENZIONE: 'codesign' non trovato nel PATH, skip ad-hoc signing")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Main
|
||||
# =========================================================================
|
||||
@@ -271,6 +555,7 @@ def main():
|
||||
shutil.rmtree(BUNDLE_DIR)
|
||||
|
||||
download_ytdlp()
|
||||
download_fpcalc()
|
||||
bundle_ffmpeg()
|
||||
run_pyinstaller()
|
||||
|
||||
|
||||
+47
-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
|
||||
# =========================================================================
|
||||
@@ -106,6 +133,9 @@ def run_pyinstaller():
|
||||
"--windowed",
|
||||
"--onedir",
|
||||
"--noconfirm",
|
||||
# Forza rebuild pulita (senza, residui di build vecchi possono
|
||||
# confondere il bundling di pythonnet/clr_loader).
|
||||
"--clean",
|
||||
]
|
||||
if icon_path.exists():
|
||||
cmd.extend(["--icon", str(icon_path)])
|
||||
@@ -118,6 +148,9 @@ def run_pyinstaller():
|
||||
"--hidden-import", "webview.platforms.winforms",
|
||||
"--hidden-import", "clr",
|
||||
"--hidden-import", "clr_loader",
|
||||
"--hidden-import", "clr_loader.netfx",
|
||||
"--hidden-import", "clr_loader.types",
|
||||
"--hidden-import", "pythonnet",
|
||||
"--hidden-import", "requests",
|
||||
# Collegamenti completi: webview, pythonnet, clr_loader (porta
|
||||
# con se Python.Runtime.dll + nethost.dll + runtimeconfig.json
|
||||
@@ -127,6 +160,13 @@ 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).
|
||||
"--collect-data", "pythonnet",
|
||||
"--collect-data", "clr_loader",
|
||||
"--copy-metadata", "pythonnet",
|
||||
"--copy-metadata", "clr_loader",
|
||||
# Binari bundled
|
||||
@@ -174,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
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Fetch Top 100 Beatport per genere.
|
||||
|
||||
Approccio: estrai il JSON `__NEXT_DATA__` dal HTML della pagina Next.js.
|
||||
Bypass Cloudflare via curl_cffi (TLS impersonation).
|
||||
Vedi docs/superpowers/specs/2026-07-14-beatport-charts-design.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from curl_cffi import requests as _cffi_requests
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BeatportTrack:
|
||||
position: int
|
||||
title: str
|
||||
mix: str # es. "Extended Mix", "Original Mix", ""
|
||||
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:
|
||||
"""Formato testo compatibile coi file .txt curati a mano:
|
||||
'Artista – Titolo (Mix) (M:SS)'."""
|
||||
m, s = divmod(self.duration_sec, 60)
|
||||
return f"{self.artists} – {self.title} ({self.mix}) ({m}:{s:02d})"
|
||||
|
||||
@property
|
||||
def spotify_query(self) -> str:
|
||||
"""Query pura per la search Spotify (senza mix name, che disturba
|
||||
il matching su titoli tipo 'Extended Mix')."""
|
||||
return f"{self.artists} {self.title}"
|
||||
|
||||
|
||||
class BeatportError(Exception):
|
||||
"""Base per errori Beatport."""
|
||||
|
||||
|
||||
class BeatportUnreachableError(BeatportError):
|
||||
"""Rete o server Beatport non raggiungibile / 5xx."""
|
||||
|
||||
|
||||
class BeatportParseError(BeatportError):
|
||||
"""HTML/JSON ricevuto ma non conforme allo schema atteso."""
|
||||
|
||||
|
||||
_NEXT_DATA_RE = re.compile(
|
||||
r'<script id="__NEXT_DATA__"[^>]*>(.+?)</script>',
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _extract_next_data(html: str) -> dict:
|
||||
"""Estrae il payload JSON dallo script <__NEXT_DATA__> di Next.js."""
|
||||
m = _NEXT_DATA_RE.search(html)
|
||||
if not m:
|
||||
raise BeatportParseError("__NEXT_DATA__ non trovato nella pagina Beatport")
|
||||
try:
|
||||
return json.loads(m.group(1))
|
||||
except json.JSONDecodeError as e:
|
||||
raise BeatportParseError(f"__NEXT_DATA__ JSON malformato: {e}") from e
|
||||
|
||||
|
||||
# Mappa slug URL Beatport -> (numeric_id, display_name)
|
||||
# Enumerata via scripts/refresh_beatport_genres.py (Task 1).
|
||||
# Copia dell'output di /tmp/genres_output.txt (31 generi).
|
||||
GENRES: dict = {
|
||||
"drum-bass": (1, "Drum & Bass"),
|
||||
"hard-techno": (2, "Hard Techno"),
|
||||
"electronica": (3, "Electronica"),
|
||||
"house": (5, "House"),
|
||||
"techno-peak-time-driving": (6, "Techno (Peak Time / Driving)"),
|
||||
"trance-main-floor": (7, "Trance (Main Floor)"),
|
||||
"hard-dance-hardcore-neo-rave": (8, "Hard Dance / Hardcore / Neo Rave"),
|
||||
"breaks-breakbeat-uk-bass": (9, "Breaks / Breakbeat / UK Bass"),
|
||||
"tech-house": (11, "Tech House"),
|
||||
"deep-house": (12, "Deep House"),
|
||||
"psy-trance": (13, "Psy-Trance"),
|
||||
"minimal-deep-tech": (14, "Minimal / Deep Tech"),
|
||||
"progressive-house": (15, "Progressive House"),
|
||||
"dubstep": (18, "Dubstep"),
|
||||
"indie-dance": (37, "Indie Dance"),
|
||||
"trap-future-bass": (38, "Trap / Future Bass"),
|
||||
"dance-pop": (39, "Dance / Pop"),
|
||||
"nu-disco-disco": (50, "Nu Disco / Disco"),
|
||||
"funky-house": (81, "Funky House"),
|
||||
"bass-club": (85, "Bass / Club"),
|
||||
"uk-garage-bassline": (86, "UK Garage / Bassline"),
|
||||
"afro-house": (89, "Afro House"),
|
||||
"melodic-house-techno": (90, "Melodic House & Techno"),
|
||||
"bass-house": (91, "Bass House"),
|
||||
"techno-raw-deep-hypnotic": (92, "Techno (Raw / Deep / Hypnotic)"),
|
||||
"organic-house": (93, "Organic House"),
|
||||
"electro-classic-detroit-modern": (94, "Electro (Classic / Detroit / Modern)"),
|
||||
"140-deep-dubstep-grime": (95, "140 / Deep Dubstep / Grime"),
|
||||
"mainstage": (96, "Mainstage"),
|
||||
"jackin-house": (97, "Jackin House"),
|
||||
"trance-raw-deep-hypnotic": (99, "Trance (Raw / Deep / Hypnotic)"),
|
||||
}
|
||||
|
||||
|
||||
def list_genres() -> list:
|
||||
"""Ritorna [{slug, id, name}, ...] ordinato alfabeticamente per name."""
|
||||
result = [
|
||||
{"slug": slug, "id": gid, "name": name}
|
||||
for slug, (gid, name) in GENRES.items()
|
||||
]
|
||||
result.sort(key=lambda g: g["name"].casefold())
|
||||
return result
|
||||
|
||||
|
||||
def _find_tracks_results(data: dict) -> list:
|
||||
"""Cerca dentro le queries dehydrated il primo `results` che ha almeno 50 elementi
|
||||
e la shape di una track (chiave `id` presente)."""
|
||||
try:
|
||||
queries = data["props"]["pageProps"]["dehydratedState"]["queries"]
|
||||
except (KeyError, TypeError) as e:
|
||||
raise BeatportParseError(
|
||||
f"schema JSON inatteso, `results` non localizzabile (chiave mancante: {e})"
|
||||
) from e
|
||||
|
||||
for q in queries:
|
||||
state_data = (q or {}).get("state", {}).get("data")
|
||||
if not isinstance(state_data, dict):
|
||||
continue
|
||||
results = state_data.get("results")
|
||||
if isinstance(results, list) and len(results) >= 50:
|
||||
if results and isinstance(results[0], dict) and "id" in results[0]:
|
||||
return results
|
||||
raise BeatportParseError("nessun `results` di 50+ track trovato in __NEXT_DATA__")
|
||||
|
||||
|
||||
def _format_artists(artists_field: object) -> str:
|
||||
"""Beatport ritorna artists come lista di dict {name, ...}.
|
||||
Formatta come 'A, B & C' (& prima dell'ultimo)."""
|
||||
if not isinstance(artists_field, list) or not artists_field:
|
||||
return ""
|
||||
names = [a.get("name", "") for a in artists_field if isinstance(a, dict)]
|
||||
names = [n for n in names if n]
|
||||
if not names:
|
||||
return ""
|
||||
if len(names) == 1:
|
||||
return names[0]
|
||||
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)
|
||||
out: list = []
|
||||
for i, item in enumerate(raw, 1):
|
||||
try:
|
||||
length_ms = int(item.get("length_ms") or 0)
|
||||
track = BeatportTrack(
|
||||
position=i,
|
||||
title=str(item.get("name") or "").strip(),
|
||||
mix=str(item.get("mix_name") or "").strip(),
|
||||
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
|
||||
out.append(track)
|
||||
return out
|
||||
|
||||
|
||||
_IMPERSONATE = "chrome131" # aggiorna se CF rompe il fingerprint
|
||||
_REQUEST_TIMEOUT = 15
|
||||
_MAX_ATTEMPTS = 3
|
||||
_BACKOFF_SEC = [1, 3] # attese fra tentativi
|
||||
_CACHE_TTL_SEC = 15 * 60
|
||||
|
||||
# Cache in-memory: slug → (timestamp_epoch, list[BeatportTrack])
|
||||
_cache: dict = {}
|
||||
|
||||
|
||||
def _url_for(slug: str) -> str:
|
||||
gid, _ = GENRES[slug]
|
||||
return f"https://www.beatport.com/genre/{slug}/{gid}/top-100"
|
||||
|
||||
|
||||
def _do_get(url: str) -> str:
|
||||
"""GET con retry e backoff. Solleva BeatportUnreachableError su fallimento definitivo."""
|
||||
last_exc = None
|
||||
for attempt in range(_MAX_ATTEMPTS):
|
||||
try:
|
||||
resp = _cffi_requests.get(
|
||||
url,
|
||||
impersonate=_IMPERSONATE,
|
||||
timeout=_REQUEST_TIMEOUT,
|
||||
)
|
||||
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 BeatportUnreachableError(f"Beatport irraggiungibile dopo {_MAX_ATTEMPTS} tentativi: {last_exc}")
|
||||
|
||||
|
||||
def fetch_top100(slug: str, force_refresh: bool = False) -> list:
|
||||
"""Fetches la Top 100 Beatport per il genere dato.
|
||||
|
||||
Cache in-memory 15 min. `force_refresh=True` bypassa la cache.
|
||||
|
||||
Raises:
|
||||
ValueError: se slug non è in GENRES.
|
||||
BeatportUnreachableError: rete/5xx dopo i retry.
|
||||
BeatportParseError: HTML/JSON non conforme allo schema.
|
||||
"""
|
||||
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]
|
||||
|
||||
html = _do_get(_url_for(slug))
|
||||
data = _extract_next_data(html)
|
||||
tracks = _parse_tracks(data)
|
||||
_cache[slug] = (now, tracks)
|
||||
return tracks
|
||||
+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()
|
||||
+26
-1
@@ -5,7 +5,7 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "v1.7.5"
|
||||
VERSION = "v1.13.0"
|
||||
|
||||
|
||||
APP_NAME = "MusicTools"
|
||||
@@ -68,8 +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}
|
||||
+212
-14
@@ -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:
|
||||
@@ -399,6 +458,147 @@ def download_direct_url(
|
||||
progress_callback(total, total, "", "completed", 100)
|
||||
|
||||
|
||||
def download_urls(
|
||||
urls: list,
|
||||
titles: list,
|
||||
output_dir: str,
|
||||
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).
|
||||
|
||||
Usa gli stessi flag yt-dlp di download_playlist. La differenza e che
|
||||
salta lo step di ricerca perche l'URL e gia noto (usato dal flow del
|
||||
tab "YouTube Search" dopo che l'utente ha selezionato i risultati).
|
||||
|
||||
Args:
|
||||
urls: lista URL YouTube (parallela a titles)
|
||||
titles: lista titoli video (per logging/dedupe)
|
||||
output_dir: cartella destinazione
|
||||
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()
|
||||
total = len(urls)
|
||||
out_path = Path(output_dir)
|
||||
out_path.mkdir(parents=True, exist_ok=True)
|
||||
done_file = out_path / ".downloaded_tracks"
|
||||
done_set = _load_done_set(done_file)
|
||||
existing_files = _scan_existing_files(out_path)
|
||||
ytdlp = find_ytdlp()
|
||||
|
||||
for i, (url, title) in enumerate(zip(urls, titles)):
|
||||
if is_stopped():
|
||||
if progress_callback:
|
||||
progress_callback(i, total, "", "stopped", 0)
|
||||
return
|
||||
|
||||
key = title or url
|
||||
|
||||
# Salta se gia scaricato (file di tracking)
|
||||
if key in done_set:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, "skipped", 100)
|
||||
continue
|
||||
|
||||
# Salta se esiste gia un file corrispondente nella cartella
|
||||
if title and _file_exists_for_query(title, existing_files):
|
||||
_mark_done(done_file, key)
|
||||
done_set.add(key)
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, "skipped", 100)
|
||||
continue
|
||||
|
||||
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",
|
||||
"--audio-format", "mp3",
|
||||
"--audio-quality", bitrate.rstrip("Kk"),
|
||||
"--embed-thumbnail",
|
||||
"--add-metadata",
|
||||
"--no-check-certificates",
|
||||
"--newline",
|
||||
"--output", out_template,
|
||||
url,
|
||||
]
|
||||
ffmpeg_dir = find_ffmpeg_dir()
|
||||
if ffmpeg_dir:
|
||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||
cmd.extend(_cookie_args(cookies_path))
|
||||
|
||||
try:
|
||||
with _process_lock:
|
||||
_current_process = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
|
||||
for line in _current_process.stdout:
|
||||
if is_stopped():
|
||||
_current_process.terminate()
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, "stopped", 0)
|
||||
return
|
||||
|
||||
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
|
||||
if pct_match and progress_callback:
|
||||
pct = int(float(pct_match.group(1)))
|
||||
progress_callback(i, total, key, "downloading", pct)
|
||||
|
||||
return_code = _current_process.wait()
|
||||
|
||||
with _process_lock:
|
||||
_current_process = None
|
||||
|
||||
if return_code == 0:
|
||||
_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:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, f"error: yt-dlp exit {return_code}", 0)
|
||||
|
||||
except Exception as e:
|
||||
with _process_lock:
|
||||
_current_process = None
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, f"error: {e}", 0)
|
||||
|
||||
if progress_callback and not is_stopped():
|
||||
progress_callback(total, total, "", "completed", 100)
|
||||
|
||||
|
||||
def _video_format(quality: str) -> str:
|
||||
"""Costruisce la format string yt-dlp per la qualita richiesta."""
|
||||
if not quality or quality == "best":
|
||||
@@ -443,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())
|
||||
@@ -519,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
|
||||
+62
-33
@@ -33,11 +33,25 @@ def _bundle_dirs() -> list[Path]:
|
||||
# Directory dell'eseguibile
|
||||
exe_dir = Path(sys.executable).parent
|
||||
dirs.append(exe_dir)
|
||||
# macOS: Contents/Frameworks/ (PyInstaller onedir .app)
|
||||
# macOS: Contents/Frameworks/ (PyInstaller onedir .app) + sub-bundle
|
||||
# ffmpeg.app / ffprobe.app dove sono incapsulati per TCC.
|
||||
if not _IS_WINDOWS:
|
||||
frameworks = exe_dir.parent / "Frameworks"
|
||||
if frameworks.exists():
|
||||
dirs.append(frameworks)
|
||||
# Mini-bundle .app per i subprocess (vedi build_macos.py
|
||||
# wrap_subprocess_in_bundle).
|
||||
for sub in ("ffmpeg.app", "ffprobe.app"):
|
||||
sub_macos = frameworks / sub / "Contents" / "MacOS"
|
||||
if sub_macos.exists():
|
||||
dirs.append(sub_macos)
|
||||
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
|
||||
|
||||
|
||||
@@ -96,55 +110,70 @@ def find_ytdlp() -> str:
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
|
||||
def find_ffmpeg_dir() -> Optional[str]:
|
||||
"""Trova la directory contenente ffmpeg e ffprobe.
|
||||
|
||||
Ordine di ricerca:
|
||||
1. Bundle PyInstaller
|
||||
2. Percorsi noti per OS
|
||||
3. Qualsiasi posizione nel PATH
|
||||
"""
|
||||
ffmpeg_name = _exe("ffmpeg")
|
||||
|
||||
# 1. Bundle
|
||||
def _find_binary(name: str) -> Optional[str]:
|
||||
"""Trova un binario (ffmpeg o ffprobe) nelle dir del bundle o nel sistema."""
|
||||
exe_name = _exe(name)
|
||||
# 1. Bundle (incluso eventuali sub-bundle .app su macOS)
|
||||
for d in _bundle_dirs():
|
||||
if (d / ffmpeg_name).exists():
|
||||
return str(d)
|
||||
|
||||
# 2. Percorsi noti
|
||||
candidate = d / exe_name
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
# 2. Percorsi noti per OS
|
||||
if _IS_WINDOWS:
|
||||
# Posizioni comuni su Windows
|
||||
for search in (
|
||||
Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "ffmpeg" / "bin",
|
||||
Path("C:/ffmpeg/bin"),
|
||||
Path(os.environ.get("ProgramFiles", "")) / "ffmpeg" / "bin",
|
||||
):
|
||||
if (search / "ffmpeg.exe").exists():
|
||||
return str(search)
|
||||
cand = search / exe_name
|
||||
if cand.exists():
|
||||
return str(cand)
|
||||
else:
|
||||
for search_path in ("/opt/homebrew/bin", "/usr/local/bin"):
|
||||
if (Path(search_path) / "ffmpeg").exists():
|
||||
return search_path
|
||||
|
||||
cand = Path(search_path) / exe_name
|
||||
if cand.exists():
|
||||
return str(cand)
|
||||
# 3. PATH generico
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if ffmpeg:
|
||||
return str(Path(ffmpeg).parent)
|
||||
found = shutil.which(name)
|
||||
return found if found else None
|
||||
|
||||
return None
|
||||
|
||||
def find_ffmpeg_dir() -> Optional[str]:
|
||||
"""Ritorna la directory contenente ffmpeg (utile per PATH env)."""
|
||||
ff = _find_binary("ffmpeg")
|
||||
return str(Path(ff).parent) if ff else None
|
||||
|
||||
|
||||
def find_ffmpeg() -> Optional[str]:
|
||||
"""Ritorna il path completo di ffmpeg."""
|
||||
d = find_ffmpeg_dir()
|
||||
if d:
|
||||
return str(Path(d) / _exe("ffmpeg"))
|
||||
return None
|
||||
return _find_binary("ffmpeg")
|
||||
|
||||
|
||||
def find_ffprobe() -> Optional[str]:
|
||||
"""Ritorna il path completo di ffprobe."""
|
||||
d = find_ffmpeg_dir()
|
||||
if d:
|
||||
return str(Path(d) / _exe("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
|
||||
+347
-211
@@ -1,7 +1,10 @@
|
||||
"""Registrazione audio da un dispositivo di input via ffmpeg.
|
||||
"""Registrazione audio.
|
||||
|
||||
macOS: usa AVFoundation (ingresso fisico + driver virtuali tipo BlackHole)
|
||||
Windows: usa DirectShow
|
||||
macOS: AVCaptureSession via PyObjC dentro al processo Python principale.
|
||||
Il bundle MusicTools.app ha NSMicrophoneUsageDescription e il TCC viene
|
||||
concesso al main, quindi AVFoundation registra senza problemi. ffmpeg
|
||||
viene usato SOLO per convertire il file CAF a MP3 (no microfono = no TCC).
|
||||
Windows: ffmpeg DirectShow (qui il problema TCC non esiste).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,38 +28,33 @@ _IS_WIN = sys.platform == "win32"
|
||||
# ============================================================
|
||||
# Stato globale (un solo recording alla volta)
|
||||
# ============================================================
|
||||
_current_proc: Optional[subprocess.Popen] = None
|
||||
_proc_lock = threading.Lock()
|
||||
_stop_event = threading.Event()
|
||||
_recording_thread: Optional[threading.Thread] = None
|
||||
_recording_lock = threading.Lock()
|
||||
_recording_state: dict = {}
|
||||
_stderr_buf: list = []
|
||||
_stderr_lock = threading.Lock()
|
||||
|
||||
|
||||
def is_recording() -> bool:
|
||||
with _proc_lock:
|
||||
return _current_proc is not None and _current_proc.poll() is None
|
||||
with _recording_lock:
|
||||
return bool(_recording_state.get("active"))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Lista dispositivi
|
||||
# ============================================================
|
||||
def list_input_devices() -> list[dict]:
|
||||
"""Ritorna la lista dei dispositivi audio di input.
|
||||
Ogni voce: {"id": "0", "name": "...", "is_virtual": bool}.
|
||||
def get_last_stderr(max_lines: int = 50) -> list[str]:
|
||||
"""Espone le ultime righe di log diagnostico ai fini diagnostici.
|
||||
|
||||
is_virtual = True per driver di loopback noti (BlackHole, Soundflower,
|
||||
Loopback, Audio Hijack, VB-Cable, ...)
|
||||
Su macOS contiene messaggi del backend AVCaptureSession + stderr di
|
||||
ffmpeg durante la conversione CAF->MP3. Su Windows contiene lo
|
||||
stderr di ffmpeg per la registrazione DirectShow.
|
||||
"""
|
||||
ffmpeg = find_ffmpeg()
|
||||
if not ffmpeg:
|
||||
return []
|
||||
with _stderr_lock:
|
||||
return list(_stderr_buf[-max_lines:])
|
||||
|
||||
if _IS_MAC:
|
||||
return _list_macos(ffmpeg)
|
||||
if _IS_WIN:
|
||||
return _list_windows(ffmpeg)
|
||||
return []
|
||||
|
||||
def _log_diag(line: str) -> None:
|
||||
with _stderr_lock:
|
||||
_stderr_buf.append(line)
|
||||
if len(_stderr_buf) > 200:
|
||||
del _stderr_buf[0]
|
||||
|
||||
|
||||
_VIRTUAL_KEYWORDS = (
|
||||
@@ -71,42 +69,44 @@ def _is_virtual(name: str) -> bool:
|
||||
return any(k in n for k in _VIRTUAL_KEYWORDS)
|
||||
|
||||
|
||||
def _list_macos(ffmpeg: str) -> list[dict]:
|
||||
"""Parsa l'output di `ffmpeg -f avfoundation -list_devices true -i ""`.
|
||||
L'output va su stderr."""
|
||||
# ============================================================
|
||||
# Lista dispositivi
|
||||
# ============================================================
|
||||
def list_input_devices() -> list[dict]:
|
||||
"""Ritorna la lista dei dispositivi audio di input.
|
||||
|
||||
Ogni voce: {"id": "...", "name": "...", "is_virtual": bool}.
|
||||
Su macOS l'`id` e' l'uniqueID di AVCaptureDevice (stabile).
|
||||
Su Windows e' il nome del device (richiesto da DirectShow).
|
||||
"""
|
||||
if _IS_MAC:
|
||||
return _list_macos_avf()
|
||||
if _IS_WIN:
|
||||
ffmpeg = find_ffmpeg()
|
||||
return _list_windows(ffmpeg) if ffmpeg else []
|
||||
return []
|
||||
|
||||
|
||||
def _list_macos_avf() -> list[dict]:
|
||||
"""Enumera input audio via AVFoundation (no ffmpeg subprocess)."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ffmpeg, "-hide_banner", "-f", "avfoundation",
|
||||
"-list_devices", "true", "-i", ""],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
except Exception:
|
||||
from AVFoundation import AVCaptureDevice, AVMediaTypeAudio
|
||||
except ImportError as e:
|
||||
_log_diag(f"[avf] AVFoundation non disponibile: {e}")
|
||||
return []
|
||||
|
||||
devices: list[dict] = []
|
||||
in_audio = False
|
||||
audio_section_re = re.compile(r"AVFoundation\s+audio\s+devices", re.IGNORECASE)
|
||||
other_section_re = re.compile(r"AVFoundation\s+(?:video)\s+devices", re.IGNORECASE)
|
||||
line_re = re.compile(r"\[(\d+)\]\s+(.+?)\s*$")
|
||||
|
||||
for raw in proc.stderr.splitlines():
|
||||
line = raw.strip()
|
||||
if audio_section_re.search(line):
|
||||
in_audio = True
|
||||
for d in AVCaptureDevice.devicesWithMediaType_(AVMediaTypeAudio):
|
||||
try:
|
||||
name = str(d.localizedName())
|
||||
uid = str(d.uniqueID())
|
||||
except Exception:
|
||||
continue
|
||||
if other_section_re.search(line):
|
||||
in_audio = False
|
||||
continue
|
||||
if not in_audio:
|
||||
continue
|
||||
clean = re.sub(r"^\[AVFoundation[^\]]*\]\s*", "", line)
|
||||
m = line_re.match(clean)
|
||||
if not m:
|
||||
continue
|
||||
idx, name = m.group(1), m.group(2).strip()
|
||||
devices.append({"id": idx, "name": name, "is_virtual": _is_virtual(name)})
|
||||
|
||||
devices.append({
|
||||
"id": uid,
|
||||
"name": name,
|
||||
"is_virtual": _is_virtual(name),
|
||||
})
|
||||
return devices
|
||||
|
||||
|
||||
@@ -150,7 +150,6 @@ def _list_windows(ffmpeg: str) -> list[dict]:
|
||||
name = mn.group(1)
|
||||
devices.append({"id": name, "name": name, "is_virtual": _is_virtual(name)})
|
||||
|
||||
# Dedup preservando l'ordine
|
||||
seen = set()
|
||||
out = []
|
||||
for d in devices:
|
||||
@@ -162,88 +161,250 @@ def _list_windows(ffmpeg: str) -> list[dict]:
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Registrazione
|
||||
# Permesso microfono (macOS)
|
||||
# ============================================================
|
||||
def _build_record_cmd(ffmpeg: str, device_id: str, output_path: str,
|
||||
bitrate: str) -> list[str]:
|
||||
"""Comando ffmpeg per registrare su file MP3."""
|
||||
if _IS_MAC:
|
||||
return [
|
||||
ffmpeg, "-hide_banner", "-y",
|
||||
"-f", "avfoundation",
|
||||
"-i", f":{device_id}",
|
||||
"-acodec", "libmp3lame",
|
||||
"-b:a", bitrate,
|
||||
"-vn",
|
||||
output_path,
|
||||
]
|
||||
if _IS_WIN:
|
||||
return [
|
||||
ffmpeg, "-hide_banner", "-y",
|
||||
"-f", "dshow",
|
||||
"-i", f"audio={device_id}",
|
||||
"-acodec", "libmp3lame",
|
||||
"-b:a", bitrate,
|
||||
"-vn",
|
||||
output_path,
|
||||
]
|
||||
raise RuntimeError("Sistema operativo non supportato per la registrazione")
|
||||
def _ensure_macos_mic_permission() -> tuple[bool, str]:
|
||||
"""Verifica/richiede il permesso microfono (TCC) per il processo corrente.
|
||||
|
||||
|
||||
def start_recording(
|
||||
device_id: str,
|
||||
output_path: str,
|
||||
bitrate: str = "320k",
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> dict:
|
||||
"""Avvia una registrazione in un thread.
|
||||
Ritorna {ok, error?, output_path?}.
|
||||
|
||||
progress_callback(status, payload) viene chiamato con:
|
||||
("started", {"output_path": ...})
|
||||
("tick", {"seconds": int})
|
||||
("stopped", {"output_path": ..., "seconds": int})
|
||||
("error", {"error": "..."})
|
||||
Ritorna (granted, reason).
|
||||
"""
|
||||
global _current_proc, _recording_thread
|
||||
try:
|
||||
from AVFoundation import AVCaptureDevice, AVMediaTypeAudio
|
||||
except ImportError as e:
|
||||
return False, f"AVFoundation non disponibile: {e}"
|
||||
|
||||
if is_recording():
|
||||
return {"ok": False, "error": "Una registrazione e gia in corso"}
|
||||
# 0=notDetermined, 1=restricted, 2=denied, 3=authorized
|
||||
status = AVCaptureDevice.authorizationStatusForMediaType_(AVMediaTypeAudio)
|
||||
if status == 3:
|
||||
return True, "authorized"
|
||||
if status in (1, 2):
|
||||
return False, (
|
||||
"Permesso microfono negato.\n"
|
||||
"Impostazioni di Sistema -> Privacy e sicurezza -> Microfono\n"
|
||||
"Attiva MusicTools e riavvia l'app."
|
||||
)
|
||||
# notDetermined -> chiedi il prompt in modo sincrono.
|
||||
ev = threading.Event()
|
||||
granted = [False]
|
||||
|
||||
def cb(ok):
|
||||
granted[0] = bool(ok)
|
||||
ev.set()
|
||||
|
||||
AVCaptureDevice.requestAccessForMediaType_completionHandler_(AVMediaTypeAudio, cb)
|
||||
# PyObjC pumpa il run loop automaticamente in attesa del callback.
|
||||
ev.wait(60)
|
||||
if not granted[0]:
|
||||
return False, "Permesso microfono negato dall'utente."
|
||||
return True, "granted"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Registrazione macOS via AVCaptureSession
|
||||
# ============================================================
|
||||
def _start_recording_macos(device_id: str, output_path: str, bitrate: str,
|
||||
progress_callback: Optional[Callable]) -> dict:
|
||||
from AVFoundation import (
|
||||
AVCaptureSession, AVCaptureDevice, AVCaptureDeviceInput,
|
||||
AVCaptureAudioFileOutput, AVMediaTypeAudio,
|
||||
)
|
||||
from Foundation import NSURL, NSObject
|
||||
|
||||
ok, reason = _ensure_macos_mic_permission()
|
||||
if not ok:
|
||||
return {"ok": False, "error": reason}
|
||||
|
||||
# Trova il device: prima per uniqueID, poi per nome (fallback)
|
||||
dev = AVCaptureDevice.deviceWithUniqueID_(device_id)
|
||||
if dev is None:
|
||||
for d in AVCaptureDevice.devicesWithMediaType_(AVMediaTypeAudio):
|
||||
if str(d.localizedName()) == device_id:
|
||||
dev = d
|
||||
break
|
||||
if dev is None:
|
||||
return {"ok": False, "error": "Dispositivo audio non trovato."}
|
||||
|
||||
session = AVCaptureSession.alloc().init()
|
||||
inp, err = AVCaptureDeviceInput.deviceInputWithDevice_error_(dev, None)
|
||||
if err is not None:
|
||||
return {"ok": False, "error": f"Errore input: {err.localizedDescription()}"}
|
||||
if not session.canAddInput_(inp):
|
||||
return {"ok": False, "error": "Impossibile aggiungere il device alla session."}
|
||||
session.addInput_(inp)
|
||||
|
||||
out = AVCaptureAudioFileOutput.alloc().init()
|
||||
if not session.canAddOutput_(out):
|
||||
return {"ok": False, "error": "Impossibile aggiungere l'output alla session."}
|
||||
session.addOutput_(out)
|
||||
|
||||
out_path = Path(output_path)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
caf_path = out_path.with_suffix(".caf")
|
||||
|
||||
# Delegate: aspetta che lo stop produca il file finale
|
||||
class _Delegate(NSObject):
|
||||
def captureOutput_didStartRecordingToOutputFileAtURL_fromConnections_(
|
||||
self, output, url, conns):
|
||||
self.started = True
|
||||
|
||||
def captureOutput_didFinishRecordingToOutputFileAtURL_fromConnections_error_(
|
||||
self, output, url, conns, err):
|
||||
self.done = True
|
||||
self.err = err.localizedDescription() if err is not None else None
|
||||
|
||||
delegate = _Delegate.alloc().init()
|
||||
delegate.started = False
|
||||
delegate.done = False
|
||||
delegate.err = None
|
||||
|
||||
session.startRunning()
|
||||
out.startRecordingToOutputFileURL_outputFileType_recordingDelegate_(
|
||||
NSURL.fileURLWithPath_(str(caf_path)),
|
||||
"com.apple.coreaudio-format",
|
||||
delegate,
|
||||
)
|
||||
|
||||
with _recording_lock:
|
||||
_recording_state.update({
|
||||
"active": True,
|
||||
"session": session,
|
||||
"output": out,
|
||||
"delegate": delegate,
|
||||
"caf_path": str(caf_path),
|
||||
"mp3_path": str(out_path),
|
||||
"bitrate": bitrate,
|
||||
"started_at": time.monotonic(),
|
||||
"progress_cb": progress_callback,
|
||||
})
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("started", {"output_path": str(out_path)})
|
||||
|
||||
# Tick thread per emettere "seconds" elapsed (la UI lo mostra)
|
||||
threading.Thread(target=_tick_worker, daemon=True).start()
|
||||
|
||||
_log_diag(f"[avf] Recording started -> {caf_path}")
|
||||
return {"ok": True, "output_path": str(out_path)}
|
||||
|
||||
|
||||
def _tick_worker():
|
||||
while is_recording():
|
||||
with _recording_lock:
|
||||
cb = _recording_state.get("progress_cb")
|
||||
started_at = _recording_state.get("started_at", time.monotonic())
|
||||
elapsed = int(time.monotonic() - started_at)
|
||||
if cb:
|
||||
cb("tick", {"seconds": elapsed})
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
def _stop_recording_macos() -> dict:
|
||||
with _recording_lock:
|
||||
if not _recording_state.get("active"):
|
||||
return {"ok": True}
|
||||
state = dict(_recording_state)
|
||||
_recording_state["active"] = False
|
||||
|
||||
session = state["session"]
|
||||
out = state["output"]
|
||||
delegate = state["delegate"]
|
||||
caf_path = Path(state["caf_path"])
|
||||
mp3_path = Path(state["mp3_path"])
|
||||
bitrate = state["bitrate"]
|
||||
cb = state["progress_cb"]
|
||||
started_at = state["started_at"]
|
||||
|
||||
# Stop recording (chiama il delegate didFinish dopo aver finalizzato il file)
|
||||
out.stopRecording()
|
||||
# Aspetta il completamento del file
|
||||
deadline = time.time() + 8
|
||||
while not getattr(delegate, "done", False) and time.time() < deadline:
|
||||
time.sleep(0.1)
|
||||
session.stopRunning()
|
||||
|
||||
if getattr(delegate, "err", None):
|
||||
_log_diag(f"[avf] delegate error: {delegate.err}")
|
||||
|
||||
seconds = int(time.monotonic() - started_at)
|
||||
|
||||
# Conversione CAF -> MP3 via ffmpeg (NO microfono: niente TCC)
|
||||
if caf_path.exists():
|
||||
ffmpeg = find_ffmpeg()
|
||||
if ffmpeg:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ffmpeg, "-hide_banner", "-y", "-i", str(caf_path),
|
||||
"-acodec", "libmp3lame", "-b:a", bitrate,
|
||||
"-vn", str(mp3_path)],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
_log_diag(f"[ffmpeg-convert] rc={proc.returncode}")
|
||||
for ln in (proc.stderr or "").splitlines()[-10:]:
|
||||
_log_diag(ln)
|
||||
# Cleanup CAF temporaneo
|
||||
try:
|
||||
caf_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as e:
|
||||
_log_diag(f"[ffmpeg-convert] exception: {e}")
|
||||
else:
|
||||
_log_diag("[ffmpeg-convert] ffmpeg non trovato, lascio il file CAF.")
|
||||
# Se ffmpeg non c'e', rinomina il CAF in MP3 (estensione errata
|
||||
# ma l'utente ha qualcosa)
|
||||
try:
|
||||
caf_path.rename(mp3_path.with_suffix(".caf"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if cb:
|
||||
if mp3_path.exists():
|
||||
cb("stopped", {"output_path": str(mp3_path), "seconds": seconds})
|
||||
else:
|
||||
cb("error", {"error": "File registrato non trovato dopo lo stop."})
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Registrazione Windows via ffmpeg DirectShow (invariato)
|
||||
# ============================================================
|
||||
def _start_recording_windows(device_id: str, output_path: str, bitrate: str,
|
||||
progress_callback: Optional[Callable]) -> dict:
|
||||
ffmpeg = find_ffmpeg()
|
||||
if not ffmpeg:
|
||||
return {"ok": False, "error": "ffmpeg non trovato"}
|
||||
|
||||
out = Path(output_path)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
cmd = [
|
||||
ffmpeg, "-hide_banner", "-y",
|
||||
"-f", "dshow",
|
||||
"-i", f"audio={device_id}",
|
||||
"-acodec", "libmp3lame",
|
||||
"-b:a", bitrate,
|
||||
"-vn",
|
||||
str(out),
|
||||
]
|
||||
|
||||
cmd = _build_record_cmd(ffmpeg, device_id, str(out), bitrate)
|
||||
|
||||
_stop_event.clear()
|
||||
with _stderr_lock:
|
||||
_stderr_buf.clear()
|
||||
|
||||
def _drain_stderr(proc):
|
||||
"""Drena lo stderr di ffmpeg in modo continuo per evitare blocchi
|
||||
del pipe e per salvare le ultime righe ai fini diagnostici."""
|
||||
def _drain(proc):
|
||||
try:
|
||||
for raw in iter(proc.stderr.readline, b""):
|
||||
if not raw:
|
||||
break
|
||||
line = raw.decode("utf-8", errors="ignore").rstrip()
|
||||
with _stderr_lock:
|
||||
_stderr_buf.append(line)
|
||||
# Mantieni solo le ultime 50 righe per non sforare la RAM
|
||||
if len(_stderr_buf) > 50:
|
||||
del _stderr_buf[0]
|
||||
_log_diag(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _worker():
|
||||
global _current_proc
|
||||
try:
|
||||
with _proc_lock:
|
||||
_current_proc = subprocess.Popen(
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
@@ -251,41 +412,44 @@ def start_recording(
|
||||
text=False,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
with _recording_lock:
|
||||
_recording_state.update({
|
||||
"active": True,
|
||||
"proc": proc,
|
||||
"output_path": str(out),
|
||||
"started_at": time.monotonic(),
|
||||
"progress_cb": progress_callback,
|
||||
})
|
||||
threading.Thread(target=_drain, args=(proc,), daemon=True).start()
|
||||
|
||||
drain_t = threading.Thread(target=_drain_stderr,
|
||||
args=(_current_proc,), daemon=True)
|
||||
drain_t.start()
|
||||
|
||||
# Aspetta brevemente che ffmpeg apra il device. Se fallisce nei
|
||||
# primi ~1.5s, segnala errore prima di emettere 'started'.
|
||||
start = time.monotonic()
|
||||
opened = False
|
||||
for _ in range(15):
|
||||
if _current_proc.poll() is not None:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
# ffmpeg stampa "Press [q] to stop" quando il device e' aperto
|
||||
with _stderr_lock:
|
||||
if any("Press [q]" in l for l in _stderr_buf):
|
||||
opened = True
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
rc_early = _current_proc.poll()
|
||||
if not opened and rc_early is not None:
|
||||
# ffmpeg e' uscito prima di iniziare a registrare
|
||||
with _proc_lock:
|
||||
_current_proc = None
|
||||
if not opened and proc.poll() is not None:
|
||||
with _recording_lock:
|
||||
_recording_state["active"] = False
|
||||
if progress_callback:
|
||||
progress_callback("error", {"error": _extract_friendly_error(rc_early)})
|
||||
progress_callback("error", {
|
||||
"error": _extract_friendly_error_windows(proc.returncode),
|
||||
})
|
||||
return
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("started", {"output_path": str(out)})
|
||||
|
||||
while True:
|
||||
if _stop_event.is_set():
|
||||
with _recording_lock:
|
||||
if not _recording_state.get("active"):
|
||||
break
|
||||
rc = _current_proc.poll()
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
break
|
||||
elapsed = int(time.monotonic() - start)
|
||||
@@ -293,123 +457,95 @@ def start_recording(
|
||||
progress_callback("tick", {"seconds": elapsed})
|
||||
time.sleep(1.0)
|
||||
|
||||
# Stop gracefully
|
||||
if _current_proc.poll() is None:
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
_current_proc.stdin.write(b"q\n")
|
||||
_current_proc.stdin.flush()
|
||||
proc.stdin.write(b"q\n")
|
||||
proc.stdin.flush()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_current_proc.wait(timeout=5)
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
_current_proc.terminate()
|
||||
proc.terminate()
|
||||
try:
|
||||
_current_proc.wait(timeout=3)
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
_current_proc.kill()
|
||||
proc.kill()
|
||||
|
||||
rc = _current_proc.returncode
|
||||
seconds = int(time.monotonic() - start)
|
||||
|
||||
with _proc_lock:
|
||||
_current_proc = None
|
||||
with _recording_lock:
|
||||
_recording_state["active"] = False
|
||||
|
||||
if progress_callback:
|
||||
if rc == 0 or out.exists():
|
||||
if proc.returncode == 0 or out.exists():
|
||||
progress_callback("stopped", {"output_path": str(out), "seconds": seconds})
|
||||
else:
|
||||
progress_callback("error", {"error": _extract_friendly_error(rc)})
|
||||
progress_callback("error", {
|
||||
"error": _extract_friendly_error_windows(proc.returncode),
|
||||
})
|
||||
except Exception as e:
|
||||
with _proc_lock:
|
||||
_current_proc = None
|
||||
with _recording_lock:
|
||||
_recording_state["active"] = False
|
||||
if progress_callback:
|
||||
progress_callback("error", {"error": str(e)})
|
||||
|
||||
_recording_thread = threading.Thread(target=_worker, daemon=True)
|
||||
_recording_thread.start()
|
||||
progress_callback("error", {"error": f"{type(e).__name__}: {e}"})
|
||||
|
||||
threading.Thread(target=_worker, daemon=True).start()
|
||||
return {"ok": True, "output_path": str(out)}
|
||||
|
||||
|
||||
def _extract_friendly_error(rc: int) -> str:
|
||||
"""Trasforma rc + stderr di ffmpeg in un messaggio leggibile.
|
||||
Riconosce i casi tipici: permessi microfono, device occupato,
|
||||
device non disponibile, clock drift di BlackHole, ecc.
|
||||
|
||||
Fallback: se non riconosce il pattern, ritorna le ultime righe
|
||||
significative dello stderr cosi' l'utente puo' diagnosticare
|
||||
o aprire un ticket.
|
||||
"""
|
||||
def _extract_friendly_error_windows(rc: int) -> str:
|
||||
with _stderr_lock:
|
||||
lines = list(_stderr_buf)
|
||||
|
||||
text = "\n".join(lines).lower()
|
||||
is_mac = sys.platform == "darwin"
|
||||
|
||||
# ---- Errori specifici (priorita' alta) ----
|
||||
|
||||
if "input/output error" in text or "errno 22" in text:
|
||||
return (
|
||||
"Dispositivo audio non pronto (BlackHole o scheda virtuale). "
|
||||
"Spesso si risolve cosi':\n"
|
||||
" 1) Smetti la registrazione e riprova fra 5 secondi\n"
|
||||
" 2) Se persiste, da Terminale: 'sudo killall coreaudiod'\n"
|
||||
" 3) Verifica che nessun'altra app stia gia' registrando il device"
|
||||
)
|
||||
|
||||
if "device not configured" in text or "device not available" in text:
|
||||
return "Dispositivo non configurato. Premi 'Aggiorna' e riseleziona."
|
||||
|
||||
if "permission" in text or "not authorized" in text or "not permitted" in text \
|
||||
or "tcc" in text or "denied" in text:
|
||||
return _permission_message()
|
||||
|
||||
if "no such device" in text or "no such audio device" in text or "invalid device" in text:
|
||||
return "Dispositivo non trovato. Premi 'Aggiorna' e riseleziona dalla lista."
|
||||
|
||||
# ---- Macro per rc=-6 (SIGABRT) su macOS ----
|
||||
# Su macOS rc=-6 e' quasi sempre il sintomo di un crash di ffmpeg
|
||||
# dovuto al permesso Microfono mancante. La parola 'abort' non
|
||||
# sempre compare nello stderr di ffmpeg recenti.
|
||||
if rc == -6 and is_mac:
|
||||
return _permission_message()
|
||||
|
||||
# ---- Fallback: cerca le ultime righe significative ----
|
||||
if "no such device" in text or "could not find" in text:
|
||||
return "Dispositivo non trovato. Premi 'Aggiorna' e riseleziona."
|
||||
if "permission" in text or "denied" in text:
|
||||
return "Permesso microfono mancante. Impostazioni Windows -> Privacy -> Microfono."
|
||||
for line in reversed(lines):
|
||||
l = line.strip()
|
||||
if not l:
|
||||
continue
|
||||
low = l.lower()
|
||||
# Riga AVFoundation/dshow specifica (es. "[avfoundation @ 0x...] Could not...")
|
||||
if l.startswith("[avfoundation") or l.startswith("[dshow") or "@ 0x" in l:
|
||||
if "error" in low or "fail" in low or "denied" in low:
|
||||
return l
|
||||
if "error" in low or "fail" in low or "denied" in low or "cannot" in low:
|
||||
return l
|
||||
|
||||
# Ultima spiaggia: rc + ultime 3 righe di stderr per debug
|
||||
tail = " | ".join(line for line in lines[-3:] if line.strip()) or "(nessuno)"
|
||||
return f"ffmpeg exit {rc}. Ultimo stderr: {tail}"
|
||||
|
||||
|
||||
def _permission_message() -> str:
|
||||
"""Messaggio standardizzato per il problema permesso Microfono macOS."""
|
||||
return (
|
||||
"Permesso Microfono mancante. Su macOS:\n"
|
||||
" Impostazioni di Sistema -> Privacy e sicurezza -> Microfono\n"
|
||||
" Abilita 'Terminal' (se lanci l'app da terminale) o 'MusicTools'\n"
|
||||
" (se usi l'app installata). Poi riavvia l'app."
|
||||
)
|
||||
def _stop_recording_windows() -> dict:
|
||||
with _recording_lock:
|
||||
if not _recording_state.get("active"):
|
||||
return {"ok": True}
|
||||
# Il worker thread vede 'active'=False e termina ffmpeg con 'q'.
|
||||
_recording_state["active"] = False
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def get_last_stderr(max_lines: int = 50) -> list[str]:
|
||||
"""Espone le ultime righe di stderr ai fini diagnostici.
|
||||
Chiamato dal bridge per la UI 'Mostra log tecnici'."""
|
||||
# ============================================================
|
||||
# API pubblica
|
||||
# ============================================================
|
||||
def start_recording(
|
||||
device_id: str,
|
||||
output_path: str,
|
||||
bitrate: str = "320k",
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> dict:
|
||||
if is_recording():
|
||||
return {"ok": False, "error": "Una registrazione e gia in corso"}
|
||||
|
||||
with _stderr_lock:
|
||||
return list(_stderr_buf[-max_lines:])
|
||||
_stderr_buf.clear()
|
||||
|
||||
if _IS_MAC:
|
||||
return _start_recording_macos(device_id, output_path, bitrate, progress_callback)
|
||||
if _IS_WIN:
|
||||
return _start_recording_windows(device_id, output_path, bitrate, progress_callback)
|
||||
return {"ok": False, "error": "Sistema operativo non supportato per la registrazione"}
|
||||
|
||||
|
||||
def stop_recording() -> dict:
|
||||
"""Ferma la registrazione corrente (se attiva)."""
|
||||
_stop_event.set()
|
||||
if _IS_MAC:
|
||||
return _stop_recording_macos()
|
||||
if _IS_WIN:
|
||||
return _stop_recording_windows()
|
||||
return {"ok": True}
|
||||
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
@@ -255,3 +257,212 @@ def resolve_spotify_track_no_auth(url: str) -> tuple[str, str, list[dict]]:
|
||||
|
||||
track = {"name": name, "artist": artist}
|
||||
return "Brano", name, [track]
|
||||
|
||||
|
||||
def search_track(token: str, query: str):
|
||||
"""Cerca un brano su Spotify e ritorna il primo match.
|
||||
|
||||
Ritorna un dict con {id, url, name, artists} oppure None se nessun match.
|
||||
Solleva requests.HTTPError su errori server / auth.
|
||||
"""
|
||||
resp = requests.get(
|
||||
"https://api.spotify.com/v1/search",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
params={"q": query, "type": "track", "limit": 1},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
items = resp.json().get("tracks", {}).get("items", [])
|
||||
if not items:
|
||||
return None
|
||||
t = items[0]
|
||||
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", [])),
|
||||
}
|
||||
|
||||
|
||||
def search_tracks(token: str, query: str, limit: int = 50) -> list:
|
||||
"""Cerca brani su Spotify e ritorna una lista di dict.
|
||||
|
||||
Args:
|
||||
token: access token Spotify
|
||||
query: query libera (titolo, artista, misto)
|
||||
limit: max risultati (Spotify cap = 50)
|
||||
|
||||
Returns:
|
||||
list[dict] con {id, url, name, artists, album, duration_sec}. Vuota se nessun match.
|
||||
"""
|
||||
resp = requests.get(
|
||||
"https://api.spotify.com/v1/search",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
params={"q": query, "type": "track", "limit": min(limit, 50)},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
items = resp.json().get("tracks", {}).get("items", [])
|
||||
return [_track_to_dict(t) for t in items]
|
||||
|
||||
|
||||
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 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.
|
||||
Deduplica per (name.lower().strip(), first_artist.lower().strip()).
|
||||
|
||||
Raises:
|
||||
ValueError: se nessun artista trovato per il nome dato.
|
||||
"""
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# 1. Cerca artista
|
||||
resp = requests.get(
|
||||
"https://api.spotify.com/v1/search",
|
||||
headers=headers,
|
||||
params={"q": artist_name, "type": "artist", "limit": 5},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
candidates = resp.json().get("artists", {}).get("items", [])
|
||||
if not candidates:
|
||||
raise ValueError(f"Artista '{artist_name}' non trovato")
|
||||
|
||||
# Match esatto (case-insensitive) se possibile, altrimenti piu' popolare
|
||||
query_lower = artist_name.lower().strip()
|
||||
exact = [c for c in candidates if c.get("name", "").lower().strip() == query_lower]
|
||||
if exact:
|
||||
artist = exact[0]
|
||||
else:
|
||||
artist = max(candidates, key=lambda c: c.get("popularity", 0))
|
||||
artist_id = artist["id"]
|
||||
|
||||
collected: list = []
|
||||
|
||||
# 2. Top tracks
|
||||
r_top = requests.get(
|
||||
f"https://api.spotify.com/v1/artists/{artist_id}/top-tracks",
|
||||
headers=headers,
|
||||
params={"market": "IT"},
|
||||
timeout=15,
|
||||
)
|
||||
r_top.raise_for_status()
|
||||
for t in r_top.json().get("tracks", []):
|
||||
collected.append(_track_to_dict(t))
|
||||
|
||||
# 3. Albums (album + single)
|
||||
r_alb = requests.get(
|
||||
f"https://api.spotify.com/v1/artists/{artist_id}/albums",
|
||||
headers=headers,
|
||||
params={"include_groups": "album,single", "limit": 50, "market": "IT"},
|
||||
timeout=15,
|
||||
)
|
||||
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
|
||||
# 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
|
||||
r_at = requests.get(
|
||||
f"https://api.spotify.com/v1/albums/{alb_id}/tracks",
|
||||
headers=headers,
|
||||
params={"limit": 50},
|
||||
timeout=15,
|
||||
)
|
||||
r_at.raise_for_status()
|
||||
for t in r_at.json().get("items", []):
|
||||
t = dict(t)
|
||||
t.setdefault("album", {
|
||||
"name": alb_name,
|
||||
"images": alb_images,
|
||||
"release_date": alb_release_date,
|
||||
})
|
||||
collected.append(_track_to_dict(t))
|
||||
|
||||
# 5. Dedupe
|
||||
seen: set = set()
|
||||
unique: list = []
|
||||
for t in collected:
|
||||
key = (t["name"].lower().strip(), t["artists"].split(",")[0].lower().strip())
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(t)
|
||||
return unique
|
||||
+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
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Ricerca su YouTube via yt-dlp (subprocess, flat playlist metadata)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
from core.paths import find_ytdlp, subprocess_flags
|
||||
|
||||
|
||||
_TIMEOUT_SEC = 30
|
||||
|
||||
|
||||
def search_youtube(query: str, limit: int = 50) -> list:
|
||||
"""Cerca su YouTube (ytsearchN:query) e ritorna metadata "flat" (senza scaricare).
|
||||
|
||||
Args:
|
||||
query: testo di ricerca libero
|
||||
limit: numero massimo di risultati (default 50)
|
||||
|
||||
Returns:
|
||||
list[dict] con {id, url, title, channel, duration_sec}. Vuota se nessun match.
|
||||
|
||||
Raises:
|
||||
RuntimeError: se yt-dlp non disponibile, timeout, o exit non-zero.
|
||||
"""
|
||||
ytdlp = find_ytdlp()
|
||||
if not ytdlp:
|
||||
raise RuntimeError("yt-dlp non trovato nel bundle / PATH")
|
||||
|
||||
cmd = [
|
||||
ytdlp,
|
||||
"--flat-playlist",
|
||||
"--dump-single-json",
|
||||
"--no-warnings",
|
||||
f"ytsearch{limit}:{query}",
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_TIMEOUT_SEC,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise RuntimeError(f"Timeout ricerca YouTube ({_TIMEOUT_SEC}s)") from e
|
||||
|
||||
if proc.returncode != 0:
|
||||
tail = (proc.stderr or "").strip().splitlines()[-3:]
|
||||
raise RuntimeError(f"yt-dlp error: {' | '.join(tail) or 'unknown'}")
|
||||
|
||||
try:
|
||||
data = json.loads(proc.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
raise RuntimeError(f"yt-dlp output non-JSON: {e}") from e
|
||||
|
||||
entries = data.get("entries") or []
|
||||
result: list = []
|
||||
for e in entries:
|
||||
if not isinstance(e, dict):
|
||||
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
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,325 @@
|
||||
# Beatport Charts — Design Spec
|
||||
|
||||
- **Data:** 2026-07-14
|
||||
- **Autore:** LuZa + Claude
|
||||
- **Stato:** Approvato, pronto per implementation plan
|
||||
- **Target release:** MusicTools v1.8.0
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Aggiungere una nuova tab all'app desktop MusicTools che permetta di:
|
||||
|
||||
1. Scegliere un genere musicale da un elenco di ~30 generi Beatport
|
||||
2. Caricare la classifica **Top 100** ufficiale di quel genere dal sito Beatport
|
||||
3. Visualizzare i 100 brani in tabella con checkbox per scegliere quali scaricare
|
||||
4. Scaricare i brani selezionati riusando il flusso esistente Spotify search → yt-dlp
|
||||
|
||||
Sostituisce il lavoro manuale attuale (l'utente copia/incolla la Top 100 in un file `.txt` che poi passa al downloader).
|
||||
|
||||
## Non-goals
|
||||
|
||||
Esplicitamente **fuori scope** in questa versione:
|
||||
|
||||
- Preview audio dei brani (snippet Beatport sono DRM protetti)
|
||||
- Chart diverse dalla Top 100 (Hype 100, DJ Charts, weekly picks) — rinviato a v2
|
||||
- Ricerca / filtro nella tabella (100 righe si scorrono senza)
|
||||
- Refresh automatico della classifica
|
||||
- Esportazione della lista come file `.txt`
|
||||
- Auto-update della mappa dei generi (resta hardcoded, aggiornata a ogni release)
|
||||
- Beatport account / login / preferiti utente
|
||||
|
||||
## Approccio scelto — Parse `__NEXT_DATA__`
|
||||
|
||||
Beatport è costruito su Next.js. Ogni pagina di classifica include un tag `<script id="__NEXT_DATA__" type="application/json">…</script>` con il payload completo della pagina in JSON strutturato: id, artist, title, mix, duration, chart position, label, key, BPM. Il parser:
|
||||
|
||||
1. `GET https://www.beatport.com/genre/<slug>/<id>/top-100` con User-Agent browser
|
||||
2. `re.search(r'<script id="__NEXT_DATA__"[^>]*>(.+?)</script>', html)` per estrarre il JSON
|
||||
3. `json.loads` + walk fino ai 100 track object
|
||||
4. Mappa in dataclass `BeatportTrack`
|
||||
|
||||
**Vantaggi:** dati strutturati, robusto ai cambi di layout CSS, un solo GET per 100 brani.
|
||||
|
||||
**Alternative scartate:**
|
||||
- BeautifulSoup su CSS class → i class name Next.js sono hash generati, si rompono a ogni build Beatport
|
||||
- API interna `api.beatport.com` → richiede OAuth token, User-Agent specifici, ToS espressamente vietano l'uso non autorizzato → rischio ban
|
||||
|
||||
## Architettura
|
||||
|
||||
### Nuovi moduli
|
||||
|
||||
#### `core/beatport.py` (nuovo)
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
GENRES: dict[str, tuple[int, str]] = {
|
||||
"melodic-house-techno": (90, "Melodic House & Techno"),
|
||||
"techno-peak-time-driving": (6, "Techno (Peak Time / Driving)"),
|
||||
"techno-raw-deep-hypnotic": (92, "Techno (Raw / Deep / Hypnotic)"),
|
||||
"deep-house": (12, "Deep House"),
|
||||
"tech-house": (11, "Tech House"),
|
||||
"progressive-house": (15, "Progressive House"),
|
||||
"house": (5, "House"),
|
||||
"afro-house": (89, "Afro House"),
|
||||
"organic-house-downtempo": (93, "Organic House / Downtempo"),
|
||||
"trance": (7, "Trance"),
|
||||
"drum-bass": (1, "Drum & Bass"),
|
||||
"dubstep": (18, "Dubstep"),
|
||||
"minimal-deep-tech": (14, "Minimal / Deep Tech"),
|
||||
"indie-dance": (37, "Indie Dance"),
|
||||
# NOTA: la mappa completa (~30 slug/id) va enumerata in fase di
|
||||
# implementazione recuperando gli slug + numeric id dalla pagina
|
||||
# https://www.beatport.com/genres — lo script che li estrae va
|
||||
# committato in `scripts/refresh_beatport_genres.py` per potervi
|
||||
# rifare la refresh a mano quando Beatport aggiunge/rimuove generi.
|
||||
}
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BeatportTrack:
|
||||
position: int
|
||||
title: str
|
||||
mix: str
|
||||
artists: str # "A, B & C" già formattato
|
||||
duration_sec: int
|
||||
beatport_id: int
|
||||
|
||||
@property
|
||||
def display(self) -> str:
|
||||
"""Formato compatibile con i file .txt esistenti:
|
||||
'Artista – Titolo (Mix) (M:SS)'"""
|
||||
m, s = divmod(self.duration_sec, 60)
|
||||
return f"{self.artists} – {self.title} ({self.mix}) ({m}:{s:02d})"
|
||||
|
||||
@property
|
||||
def spotify_query(self) -> str:
|
||||
return f"{self.artists} {self.title}"
|
||||
|
||||
|
||||
class BeatportError(Exception): pass
|
||||
class BeatportUnreachableError(BeatportError): pass
|
||||
class BeatportParseError(BeatportError): pass
|
||||
|
||||
|
||||
def fetch_top100(slug: str, force_refresh: bool = False) -> list[BeatportTrack]:
|
||||
"""Fetches Top 100 per il genere. Cache in-memory 15 min.
|
||||
Solleva ValueError se slug non valido, BeatportUnreachableError su rete,
|
||||
BeatportParseError su schema cambiato."""
|
||||
...
|
||||
|
||||
def list_genres() -> list[dict]:
|
||||
"""Ritorna [{slug, id, name}, ...] ordinato alfabeticamente per name."""
|
||||
...
|
||||
```
|
||||
|
||||
Cache in-memory: `dict[slug, tuple[timestamp, list[BeatportTrack]]]`, TTL 900s.
|
||||
|
||||
Retry: 2 tentativi con backoff 1s, 3s.
|
||||
|
||||
User-Agent: `"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"` (aggiornato per release).
|
||||
|
||||
#### `main.py` — nuove API pywebview esposte al JS
|
||||
|
||||
```python
|
||||
class Api:
|
||||
# …esistenti
|
||||
|
||||
def beatport_genres(self) -> list[dict]:
|
||||
return beatport.list_genres()
|
||||
|
||||
def beatport_fetch_chart(self, slug: str, force_refresh: bool = False) -> dict:
|
||||
try:
|
||||
tracks = beatport.fetch_top100(slug, force_refresh=force_refresh)
|
||||
return {"ok": True, "tracks": [asdict(t) for t in tracks]}
|
||||
except ValueError as e:
|
||||
return {"ok": False, "error": "invalid_genre", "message": str(e)}
|
||||
except BeatportUnreachableError as e:
|
||||
return {"ok": False, "error": "unreachable", "message": str(e)}
|
||||
except BeatportParseError as e:
|
||||
return {"ok": False, "error": "parse", "message": str(e)}
|
||||
|
||||
def beatport_check_existing(self, tracks: list[dict], genre_name: str) -> list[bool]:
|
||||
"""Ritorna una lista di boolean: True se il file esiste già in output_dir/Beatport/<Genre>/."""
|
||||
...
|
||||
|
||||
def beatport_download_selected(
|
||||
self,
|
||||
tracks: list[dict],
|
||||
genre_name: str,
|
||||
) -> None:
|
||||
"""Loop: per ogni track, spotify search → downloader.
|
||||
Emette log/progress sugli stessi eventi degli altri download."""
|
||||
...
|
||||
```
|
||||
|
||||
### Riuso codice esistente
|
||||
|
||||
- `core/spotify_client.py`: usa `search_track(query) -> Optional[str]` per ottenere lo Spotify URI (o il track object). **Da verificare in fase di implementazione** se il metodo esiste già come funzione pubblica riutilizzabile — se no, estrai la logica di search dal path esistente `download_from_spotify_url` in una funzione pubblica dedicata (refactor mirato, no cambi di comportamento per gli altri consumatori).
|
||||
- `core/downloader.py`: usa il path Spotify URI → yt-dlp esistente. `request_stop()` / `reset_stop()` già disponibili.
|
||||
- `webui/js/app.js`: riusa il pattern log/progress già in uso per le altre tab.
|
||||
- `core/config.py`: aggiunge un solo nuovo campo `beatport_last_genre: str` ai DEFAULTS.
|
||||
|
||||
### Frontend
|
||||
|
||||
**Nuova tab in `webui/index.html`:**
|
||||
|
||||
```html
|
||||
<button data-tab="beatport" class="tab-btn">🎧 Beatport</button>
|
||||
<section id="tab-beatport" class="tab-panel">
|
||||
<div class="beatport-header">
|
||||
<label>Genere:
|
||||
<select id="beatport-genre"></select>
|
||||
</label>
|
||||
<button id="beatport-load">Carica Top 100</button>
|
||||
<div id="beatport-output-info"></div>
|
||||
</div>
|
||||
|
||||
<div id="beatport-status"></div>
|
||||
|
||||
<table id="beatport-table" hidden>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="beatport-select-all"></th>
|
||||
<th>#</th><th>Artista</th><th>Titolo</th><th>Durata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<div class="beatport-toolbar" hidden>
|
||||
<span id="beatport-selected-count">0/0 selezionati</span>
|
||||
<button id="beatport-download">⬇ Scarica selezionati</button>
|
||||
<button id="beatport-stop" hidden>■ Interrompi</button>
|
||||
</div>
|
||||
|
||||
<div id="beatport-log" class="log-area"></div>
|
||||
</section>
|
||||
```
|
||||
|
||||
**JavaScript in `webui/js/app.js`:** funzioni `beatportInit()`, `beatportLoadChart()`, `beatportRenderTable(tracks)`, `beatportUpdateSelection()`, `beatportStartDownload()`. Stato locale: `let currentChart = { slug, name, tracks: [] }`.
|
||||
|
||||
### Persistenza
|
||||
|
||||
- Ultimo genere selezionato → `config.beatport_last_genre`
|
||||
- File scaricati → rilevati con `os.path.exists()` sul filename generato dal downloader (stesso pattern degli altri tab)
|
||||
- Cache classifica → solo in-memory, si perde a restart (voluto)
|
||||
|
||||
### Percorso output
|
||||
|
||||
`{output_dir}/Beatport/{Genre Display Name}/`
|
||||
|
||||
Es. `/Users/luciano/MUSICA/Beatport/Melodic House & Techno/Kapuchon - Hot Sauce (Extended).mp3`
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
[JS] User seleziona genere, click "Carica Top 100"
|
||||
↓
|
||||
[JS] api.beatport_fetch_chart("melodic-house-techno")
|
||||
↓
|
||||
[PY] core/beatport.fetch_top100():
|
||||
├─ Cache hit (< 15 min)? → ritorna cached
|
||||
└─ GET Beatport, estrai __NEXT_DATA__, parse, mappa in BeatportTrack[]
|
||||
↓
|
||||
[JS] Riceve lista, chiama api.beatport_check_existing() per pre-deselezionare i già-scaricati
|
||||
↓
|
||||
[JS] Renderizza tabella
|
||||
↓
|
||||
[JS] User seleziona/deseleziona, click "Scarica selezionati"
|
||||
↓
|
||||
[JS] api.beatport_download_selected(tracks_selected, genre_name)
|
||||
↓
|
||||
[PY] Loop su tracks:
|
||||
├─ emit log "[i/N] Cerco su Spotify: <artista – titolo>"
|
||||
├─ spotify_uri = spotify_client.search_track(track.spotify_query)
|
||||
├─ Se None: emit ✗, continua
|
||||
├─ emit log "[i/N] Download da YouTube…"
|
||||
├─ downloader.download_from_spotify_uri(uri, output_dir=<beatport-subdir>)
|
||||
├─ emit log "[i/N] ✓" o "[i/N] ✗ <motivo>"
|
||||
└─ Se stop_event.is_set(): break
|
||||
```
|
||||
|
||||
## Matrice errori & recovery
|
||||
|
||||
| Errore | Dove | Cosa succede |
|
||||
|---|---|---|
|
||||
| Beatport 5xx / timeout | fetch_top100 | Retry ×2 con backoff 1s, 3s. Poi solleva `BeatportUnreachableError`. UI: banner rosso "Beatport irraggiungibile. Riprova." con retry |
|
||||
| `__NEXT_DATA__` mancante / schema cambiato | fetch_top100 | Dump primi 500 char HTML in `~/Library/Logs/MusicTools/beatport-<ts>.log`, solleva `BeatportParseError`. UI: banner arancione "Impossibile leggere la classifica. Segnala il bug." |
|
||||
| Genere non valido | fetch_top100 | `ValueError` early, nessuna richiesta di rete |
|
||||
| Spotify search no match | download loop | Log riga singola, marca riga tabella ✗ "non trovato", continua |
|
||||
| Spotify creds mancanti | download loop, primo brano | Interrompe subito, banner giallo con link a tab Impostazioni |
|
||||
| yt-dlp fallisce | download loop | Log riga singola ✗, continua con il resto |
|
||||
| Cartella output non scrivibile | pre-check prima del loop | Errore + banner rosso, non parte |
|
||||
| User preme Stop | download loop | `stop_event.set()`, si ferma dopo il brano corrente |
|
||||
| Cache stale | fetch_top100 | Shift+click su "Carica" invalida (`force_refresh=True`); comunque expira dopo 15 min |
|
||||
|
||||
## Rispetto Beatport / rate limiting
|
||||
|
||||
- Un solo GET per classifica (100 brani in un JSON)
|
||||
- Cache 15 min → max 4 richieste/ora anche con click ripetuti
|
||||
- User-Agent browser, no scraping massivo
|
||||
- Zero rischio ban IP in uso normale
|
||||
|
||||
## Licenza / piani
|
||||
|
||||
La feature è un audio download a tutti gli effetti → il `daily_limit` del piano si applica automaticamente (ogni brano scaricato conta come 1). Il piano `basic` con limite 10/giorno può scaricare al massimo 10 brani dalla Top 100. **Nessuna nuova feature-flag in `server/src/plans.js`.** Il gating avviene già nel downloader esistente.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests — `tests/test_beatport.py`
|
||||
|
||||
Nessuna rete durante i test. Fixture: snapshot HTML reale della pagina Beatport in `tests/fixtures/beatport_melodic_top100.html`.
|
||||
|
||||
| Test | Verifica |
|
||||
|---|---|
|
||||
| `test_parse_next_data_extracts_100_tracks` | Il parser trova esattamente 100 track dall'HTML fixture |
|
||||
| `test_track_shape` | Ogni track ha position, title, mix, artists, duration_sec, beatport_id |
|
||||
| `test_positions_sequential` | Posizioni 1..100 senza buchi |
|
||||
| `test_display_format_matches_txt_files` | `BeatportTrack.display` produce esattamente il formato dei file `.txt` attuali |
|
||||
| `test_parse_missing_next_data_raises` | HTML senza `<script id="__NEXT_DATA__">` → `BeatportParseError` |
|
||||
| `test_parse_malformed_json_raises` | `__NEXT_DATA__` con JSON rotto → `BeatportParseError` |
|
||||
| `test_parse_schema_change_raises` | JSON valido ma senza `results[]` → `BeatportParseError` con contesto |
|
||||
| `test_cache_hit_second_call` | 2 chiamate ravvicinate → una sola richiesta HTTP simulata |
|
||||
| `test_cache_expires_after_15min` | Freeze time + 16min → nuova richiesta |
|
||||
| `test_force_refresh_bypasses_cache` | `force_refresh=True` ignora cache anche fresca |
|
||||
| `test_invalid_genre_slug_early_error` | Slug non in `GENRES` → `ValueError` senza toccare la rete |
|
||||
|
||||
### Test HTTP mockato
|
||||
|
||||
`responses` (o `httpx` mock) per verificare timeout, retry policy, User-Agent inviato.
|
||||
|
||||
### Test manuale end-to-end (obbligatorio prima del release)
|
||||
|
||||
1. Avvia MusicTools, apri tab Beatport
|
||||
2. Seleziona "Melodic House & Techno" → click "Carica Top 100" → tabella si popola in <3s
|
||||
3. Deseleziona 90 brani, lascia i primi 10 → click "Scarica selezionati (10)"
|
||||
4. Verifica file .mp3 in `{output_dir}/Beatport/Melodic House & Techno/`, metadata Spotify complete
|
||||
5. Ricarica lo stesso genere → cache hit (nessuna nuova request osservabile in dev tools)
|
||||
6. Cambia genere → nuova fetch
|
||||
7. Interrompi a metà download → si ferma pulito dopo il brano corrente
|
||||
8. Riavvia app → dropdown ricorda l'ultimo genere
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Branch `feat/beatport-charts` (opzionale — la feature è additive, zero rischi per il resto)
|
||||
2. Bump `core/config.py` VERSION → `v1.8.0`
|
||||
3. Note release in `/tmp/notes-v1.8.0.md`:
|
||||
> **Nuova tab Beatport 🎧** — carica le Top 100 per genere direttamente da Beatport e scarica i brani in un click. 30+ generi disponibili.
|
||||
4. Commit + tag `v1.8.0` → CI builda macOS + Windows come da flusso standard
|
||||
5. Zero cambi lato server (`~/api/`), zero migration DB
|
||||
|
||||
## Struttura file impattati
|
||||
|
||||
**Nuovi:**
|
||||
- `core/beatport.py`
|
||||
- `tests/test_beatport.py`
|
||||
- `tests/fixtures/beatport_melodic_top100.html`
|
||||
|
||||
**Modificati:**
|
||||
- `main.py` — 4 nuovi metodi `Api.beatport_*`
|
||||
- `core/config.py` — VERSION bump + campo `beatport_last_genre` in DEFAULTS
|
||||
- `core/spotify_client.py` — espone `search_track()` come funzione pubblica se non lo è già
|
||||
- `webui/index.html` — nuovo tab button + section
|
||||
- `webui/js/app.js` — funzioni `beatport*`
|
||||
- `webui/css/*.css` — stile tabella e toolbar (riuso classi esistenti dove possibile)
|
||||
- `requirements.txt` — nessuna nuova dipendenza runtime (`requests` è già presente per gli altri client HTTP; `__NEXT_DATA__` si estrae con `re.search` + `json.loads`, no BeautifulSoup necessario). Solo dev dep: `responses` per i test HTTP mockati se non già presente
|
||||
@@ -0,0 +1,260 @@
|
||||
# Music Search (Spotify + YouTube) — Design Spec
|
||||
|
||||
- **Data:** 2026-07-14
|
||||
- **Autore:** LuZa + Claude
|
||||
- **Stato:** Approvato, pronto per implementation plan
|
||||
- **Target release:** MusicTools v1.8.1
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Aggiungere due tab distinte all'app che permettano di **cercare brani/artisti/parole libere** e scaricarli:
|
||||
|
||||
1. **Tab 🟢 Spotify** — ricerca via Spotify API (canonica, metadata pulita). Toggle "Solo artista" per ottenere tutta la discografia di un artista invece della ricerca libera.
|
||||
2. **Tab ▶ YouTube** — ricerca diretta via `yt-dlp ytsearch:` (grezza, senza distinzione artista/album, ma copre mix DJ, unreleased, bootleg, live, video-only content).
|
||||
|
||||
Il download in entrambi i casi riusa la pipeline esistente (`start_tracks_download` in `api/bridge.py`) con licenza gate `_gate("audio")`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Un'unica tab "Cerca" con radio Spotify/YouTube (scartato: UX molto diversa per le due sorgenti)
|
||||
- Ricerca combinata Spotify+YouTube in un'unica lista (nessuna deduplica cross-source affidabile)
|
||||
- Playlist temporanee salvabili
|
||||
- Preview audio
|
||||
- Filtri per genere/anno/BPM (non richiesto, YAGNI)
|
||||
- Ricerca album, artisti, playlist su Spotify (limitata a track)
|
||||
- Autocomplete / suggestions come digiti
|
||||
|
||||
## Approcci scelti
|
||||
|
||||
### Spotify
|
||||
|
||||
- Free-form search: `GET /v1/search?q=<query>&type=track&limit=50`
|
||||
- Ritorna fino a 50 track ordinati per rilevanza/popolarità (default Spotify)
|
||||
- Query può essere qualsiasi cosa: titolo, artista, "artista - titolo", parola singola
|
||||
- Artist-mode (toggle attivo):
|
||||
1. `GET /v1/search?q=<query>&type=artist&limit=5` → prendi il match con `name.lower() == query.lower()` altrimenti il più popolare
|
||||
2. `GET /v1/artists/{id}/top-tracks?market=IT` → ~10 top track
|
||||
3. `GET /v1/artists/{id}/albums?include_groups=album,single&limit=50&market=IT` → lista album
|
||||
4. Per ogni album (max 50): `GET /v1/albums/{id}/tracks?limit=50` → tracce
|
||||
5. Deduplica su `(name.lower().strip() + '|' + first_artist.lower().strip())`
|
||||
6. Rate limit interno: `time.sleep(0.1)` tra chiamate `/albums/{id}/tracks`
|
||||
7. Total: ~100-500 track per artista prolifico
|
||||
|
||||
**Vantaggi:** metadata pulita, download pipeline già rodata (Spotify→YouTube via yt-dlp).
|
||||
|
||||
### YouTube
|
||||
|
||||
- `yt-dlp --flat-playlist --dump-single-json "ytsearch50:<query>"` → JSON con `entries[]`
|
||||
- Per ogni entry: `{title, uploader/channel, duration, id, url}`
|
||||
- Nessuna modalità artista (YouTube search non ha channel-exact disambiguation affidabile)
|
||||
|
||||
**Vantaggi:** trova ciò che Spotify non ha (mix, unreleased, bootleg, live).
|
||||
|
||||
## Architettura
|
||||
|
||||
### Nuovi moduli / estensioni
|
||||
|
||||
#### `core/spotify_client.py` (estensione)
|
||||
|
||||
```python
|
||||
def search_tracks(token: str, query: str, limit: int = 50) -> list:
|
||||
"""Ricerca free-form. Ritorna list[dict] con {id, url, name, artists, album, duration_sec}."""
|
||||
|
||||
def search_artist_discography(token: str, artist_name: str) -> list:
|
||||
"""Trova l'artista esatto e ritorna tutti i suoi brani (top tracks + tracce da album/singles).
|
||||
Deduplica per (title, first_artist) normalizzato. Solleva ValueError se nessun artista trovato."""
|
||||
```
|
||||
|
||||
Nota: la funzione `search_track` (singolare) aggiunta in Task 7 di Beatport resta com'è per retro-compatibilità. La nuova `search_tracks` (plurale, con limit) è quella usata da questa feature.
|
||||
|
||||
#### `core/youtube_search.py` (nuovo)
|
||||
|
||||
```python
|
||||
def search_youtube(query: str, limit: int = 50) -> list:
|
||||
"""Cerca su YouTube via `yt-dlp ytsearchN:query`.
|
||||
Ritorna list[dict] con {id, url, title, channel, duration_sec}."""
|
||||
```
|
||||
|
||||
Riusa `find_ytdlp()` e `subprocess_flags()` da `core/paths.py`. Timeout subprocess 30s.
|
||||
|
||||
#### `api/bridge.py` — nuovi metodi
|
||||
|
||||
- `spotify_search(query: str, artist_mode: bool) → dict` — salva query+toggle in config, poi chiama la funzione giusta e ritorna `{ok, tracks}` o `{ok:false, error, message}`
|
||||
- `spotify_search_download(tracks: list) → dict` — wrapper del pattern Beatport: converte in `[{name, artist}]` e chiama `start_tracks_download` con `subfolder="Spotify"`
|
||||
- `youtube_search(query: str) → dict` — salva query in config, chiama `core.youtube_search`, ritorna `{ok, tracks}`
|
||||
- `youtube_search_download(tracks: list) → dict` — chiama nuovo helper `start_urls_download` (see below) con URL YouTube e `subfolder="YouTube"`
|
||||
|
||||
#### `api/bridge.py` — nuovo helper (se non c'è già)
|
||||
|
||||
- `start_urls_download(payload: dict) → dict` — analogo a `start_tracks_download` ma accetta `{urls: [str], output_dir, subfolder}` e chiama `download_playlist_from_urls(urls, ...)` in `core/downloader.py`. Se non esiste una funzione equivalente in `downloader.py`, va aggiunta.
|
||||
|
||||
**Verifica in implementazione:** controlla se `download_playlist(tracks, ...)` può ricevere tracks contenenti solo `url` (bypass search); in tal caso riusa quella. Altrimenti aggiungi il nuovo path.
|
||||
|
||||
### Frontend
|
||||
|
||||
- 2 nuove tab in sidebar (`data-view="spotify"` e `data-view="youtube"`), entrambe con `data-feature="audio"` (stesso license gate del Beatport)
|
||||
- 2 nuove section `<section id="view-spotify">` e `<section id="view-youtube">` in `webui/index.html`
|
||||
- Modulo JS `SpotifyUI` e `YoutubeUI` in `webui/js/app.js`, entrambi con pattern init/loadResults/renderTable/updateSelection/startDownload (parallelo a `BeatportUI`)
|
||||
- Stili in `webui/css/style.css` — riusa `.beatport-table` come base, aggiunge varianti dove serve
|
||||
|
||||
### Persistenza
|
||||
|
||||
3 nuovi campi in `core/config.py::DEFAULTS`:
|
||||
- `spotify_search_last_query: str = ""`
|
||||
- `spotify_search_artist_mode: bool = False`
|
||||
- `youtube_search_last_query: str = ""`
|
||||
|
||||
Salvati dal backend a ogni ricerca (analogo a `beatport_last_genre`).
|
||||
|
||||
### Cartelle output
|
||||
|
||||
- `{output_dir}/Spotify/` (piatta, nessun sub-folder per query)
|
||||
- `{output_dir}/YouTube/` (piatta)
|
||||
|
||||
Sanitizzazione slash coerente con il pattern di `start_tracks_download` (subfolder singolo, no nested).
|
||||
|
||||
## Data flow
|
||||
|
||||
### Spotify search
|
||||
|
||||
```
|
||||
[JS] User digita query, opzionale toggle "Solo artista", click Cerca
|
||||
↓
|
||||
[JS] api.spotify_search(query, artist_mode)
|
||||
↓
|
||||
[PY] api/bridge.py::spotify_search():
|
||||
├─ Salva {spotify_search_last_query, spotify_search_artist_mode} in config
|
||||
├─ Verifica creds Spotify (client_id, client_secret) → altrimenti {ok:false, error:"no_creds"}
|
||||
├─ Get token (spotify_client.get_access_token)
|
||||
├─ Se artist_mode:
|
||||
│ └─ spotify_client.search_artist_discography(token, query)
|
||||
├─ Altrimenti:
|
||||
│ └─ spotify_client.search_tracks(token, query, limit=50)
|
||||
└─ Ritorna {ok:True, tracks: [...]}
|
||||
↓
|
||||
[JS] Riceve lista, chiama api.spotify_check_existing() per pre-deselezionare i già-scaricati
|
||||
↓
|
||||
[JS] Renderizza tabella (colonne: check, #, artista, titolo, album, durata, stato)
|
||||
↓
|
||||
[JS] User seleziona, click "Scarica selezionati"
|
||||
↓
|
||||
[JS] api.spotify_search_download(tracks_selected)
|
||||
↓
|
||||
[PY] converte in [{name, artist}] e chiama self.start_tracks_download({..., subfolder: "Spotify"})
|
||||
↓
|
||||
[EXISTING] pipeline yt-dlp, log/progress su canale "download"
|
||||
```
|
||||
|
||||
### YouTube search
|
||||
|
||||
```
|
||||
[JS] User digita query, click Cerca
|
||||
↓
|
||||
[JS] api.youtube_search(query)
|
||||
↓
|
||||
[PY] api/bridge.py::youtube_search():
|
||||
├─ Salva youtube_search_last_query in config
|
||||
├─ core.youtube_search.search_youtube(query, limit=50)
|
||||
│ └─ subprocess: yt-dlp --flat-playlist --dump-single-json "ytsearch50:<query>"
|
||||
└─ Ritorna {ok:True, tracks: [{id, url, title, channel, duration_sec}, ...]}
|
||||
↓
|
||||
[JS] Renderizza tabella (colonne: check, #, titolo video, canale, durata, stato)
|
||||
↓
|
||||
[JS] User seleziona, click "Scarica selezionati"
|
||||
↓
|
||||
[JS] api.youtube_search_download(tracks_selected) # passa URL, non {name,artist}
|
||||
↓
|
||||
[PY] start_urls_download({urls, subfolder: "YouTube"})
|
||||
↓
|
||||
[EXISTING] pipeline yt-dlp diretto sugli URL
|
||||
```
|
||||
|
||||
## Matrice errori & recovery
|
||||
|
||||
| Errore | Dove | Comportamento |
|
||||
|---|---|---|
|
||||
| Query vuota | UI JS | Bottone "Cerca" disabilitato, nessuna richiesta |
|
||||
| Creds Spotify mancanti | spotify_search | `{ok:false, error:"no_creds"}` → banner giallo con link Impostazioni |
|
||||
| Spotify 401 (token scaduto/invalid) | get_access_token | Refresh token nel handler; se persiste, banner rosso |
|
||||
| Spotify 429 rate limit | search endpoint | `{ok:false, error:"rate_limit", message:"Attendi qualche secondo"}` → banner arancione, retry manuale |
|
||||
| Spotify 5xx | search endpoint | `{ok:false, error:"server", message:...}` → banner rosso, retry |
|
||||
| Nessun risultato Spotify | search endpoint | `{ok:true, tracks:[]}` → messaggio grigio "Nessun brano trovato per '<query>'" |
|
||||
| Artist-mode senza match esatto | search_artist_discography | Solleva ValueError → `{ok:false, error:"artist_not_found", message:"Artista '<q>' non trovato — disattiva toggle per ricerca libera"}` |
|
||||
| yt-dlp non trovato | search_youtube | RuntimeError → banner rosso "yt-dlp non installato" |
|
||||
| yt-dlp timeout (rete lenta) | search_youtube | subprocess.TimeoutExpired → banner rosso "Timeout ricerca YouTube" |
|
||||
| yt-dlp errore generico | search_youtube | Non-zero exit → banner rosso con stderr tail |
|
||||
| Nessun risultato YouTube | search_youtube | `{ok:true, tracks:[]}` → messaggio grigio |
|
||||
| User preme Stop durante download | download pipeline | Comportamento invariato (esistente) |
|
||||
|
||||
## Licenza / piani
|
||||
|
||||
Entrambe le tab sono audio download → gate `_gate("audio")` automatico via `start_tracks_download` / `start_urls_download`. Il `daily_limit` del piano si applica. Nessuna nuova feature-flag.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests
|
||||
|
||||
**`tests/test_spotify_client.py` (estensione):**
|
||||
- `test_search_tracks_returns_list_up_to_limit` — mock response con 50 items, verifica list length
|
||||
- `test_search_tracks_maps_fields_correctly` — sample dict → verifica keys {id, url, name, artists, album, duration_sec}
|
||||
- `test_search_tracks_empty_query_returns_empty` — mock 0 items
|
||||
- `test_search_artist_discography_exact_match_wins` — mock search artist con 3 candidati diversi, verifica quello esatto (case-insensitive)
|
||||
- `test_search_artist_discography_top_tracks_and_albums_combined` — mock 3 endpoint, verifica deduplica
|
||||
- `test_search_artist_discography_raises_when_no_match` — mock search artist con 0 risultati → ValueError
|
||||
- `test_search_artist_discography_deduplicates` — mock top-tracks e album-tracks con overlap → verifica no duplicati
|
||||
|
||||
**`tests/test_youtube_search.py` (nuovo):**
|
||||
- `test_search_youtube_parses_entries` — mock `subprocess.run` con JSON stub (5 entries), verifica list mapping
|
||||
- `test_search_youtube_empty_result` — mock JSON senza entries → []
|
||||
- `test_search_youtube_ytdlp_not_found` — mock `find_ytdlp` che ritorna None → RuntimeError
|
||||
- `test_search_youtube_timeout` — mock subprocess.TimeoutExpired → RuntimeError
|
||||
|
||||
### Test manuale end-to-end
|
||||
|
||||
**Tab Spotify:**
|
||||
1. Query semplice: "Solomun" senza toggle → 50 risultati, ordinati per rilevanza
|
||||
2. Query composta: "Kapuchon Hot Sauce" → 5-10 risultati pertinenti
|
||||
3. Artist mode: "Solomun" con toggle attivo → 100-300 risultati (top tracks + tutti album)
|
||||
4. Artist mode con nome inesistente: "sadgjhkasdg" → banner "Artista non trovato"
|
||||
5. Download di 3 brani → file in `MUSICA/Spotify/`
|
||||
6. Ricerca ripetuta → "già scaricato" mostrato correttamente
|
||||
7. Riavvio app → ultima query + toggle ricordati
|
||||
|
||||
**Tab YouTube:**
|
||||
1. Query: "Kapuchon Hot Sauce" → 50 risultati con canali diversi
|
||||
2. Query set DJ: "Solomun Cocoricò 2024" → set lunghi (60+ min) in lista
|
||||
3. Download di 2 brani → file in `MUSICA/YouTube/`
|
||||
4. Query vuota → bottone disabilitato
|
||||
5. Riavvio → ultima query ricordata
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Branch `feat/music-search`
|
||||
2. Bump `core/config.py::VERSION` → `v1.8.1`
|
||||
3. Note release `/tmp/notes-v1.8.1.md`:
|
||||
> **Nuove tab Spotify e YouTube 🔎** — cerca brani per titolo o artista su Spotify (con toggle "solo artista" per scaricare tutta la discografia), oppure cerca su YouTube per trovare mix DJ, unreleased, bootleg e brani non presenti su Spotify.
|
||||
4. Commit + tag `v1.8.1` → CI + release notes background task
|
||||
5. Update DB `releases` tabella sul server (macos + windows rows)
|
||||
|
||||
Zero cambi server-side backend, zero migration.
|
||||
|
||||
## Struttura file impattati
|
||||
|
||||
**Nuovi:**
|
||||
- `core/youtube_search.py`
|
||||
- `tests/test_youtube_search.py`
|
||||
|
||||
**Modificati:**
|
||||
- `core/spotify_client.py` — 2 nuove funzioni pubbliche (`search_tracks`, `search_artist_discography`)
|
||||
- `core/downloader.py` — se serve, aggiunta `download_playlist_from_urls()` per il flusso YouTube
|
||||
- `core/config.py` — VERSION bump + 3 nuovi campi in DEFAULTS
|
||||
- `api/bridge.py` — 6 nuovi metodi Api: `spotify_search`, `spotify_check_existing`, `spotify_search_download`, `youtube_search`, `youtube_check_existing`, `youtube_search_download` + eventuale helper `start_urls_download`
|
||||
- `tests/test_spotify_client.py` — 7 nuovi test
|
||||
- `webui/index.html` — 2 nav-item + 2 sezioni view
|
||||
- `webui/js/app.js` — moduli `SpotifyUI` e `YoutubeUI` (~200 righe cadauno, parallelo a `BeatportUI`)
|
||||
- `webui/css/style.css` — piccole estensioni (o riuso classi Beatport)
|
||||
- `requirements.txt` — nessuna nuova dep
|
||||
|
||||
## Riduzione della duplicazione
|
||||
|
||||
Se durante l'implementazione emerge che i 3 pannelli (Beatport, Spotify, YouTube) hanno logica JS quasi identica (renderTable con checkbox, updateSelectionCount, startDownload wrapper), **valuta** l'estrazione di un `SelectableTracksTable` component in `webui/js/app.js`. Se l'astrazione è chiara e riduce codice significativamente, fallo. Se costringe a hooks/callbacks tortuosi per gestire differenze di colonne, lascia stare — 3 istanze non sono tante e YAGNI.
|
||||
@@ -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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -91,6 +91,13 @@ a { color: inherit; text-decoration: none; }
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Quando il logo e' un'immagine, niente gradient ne' radius forzato */
|
||||
.brand-logo-img {
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 26px;
|
||||
|
||||
+35
-2
@@ -91,6 +91,30 @@
|
||||
.dl-support { text-align: center; color: var(--text-muted); font-size: 14px; padding-top: 24px; border-top: 1px solid var(--divider); margin-top: 24px; }
|
||||
.dl-support a { color: var(--accent); }
|
||||
|
||||
.dl-code {
|
||||
display: block;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
margin: 8px 0 14px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
user-select: all;
|
||||
word-break: break-all;
|
||||
}
|
||||
.dl-sub {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.dl-sub li { margin-bottom: 4px; }
|
||||
.dl-sub strong { color: var(--text); }
|
||||
.dl-sub code { background: var(--bg-card-2); padding: 1px 6px; border-radius: 4px; font-size: 12.5px; }
|
||||
|
||||
.dl-status {
|
||||
max-width: 460px;
|
||||
margin: -16px auto 24px;
|
||||
@@ -177,8 +201,17 @@
|
||||
<ol>
|
||||
<li>Apri il file <code>.dmg</code> scaricato — si monta come volume "MusicTools"</li>
|
||||
<li>Trascina <strong>MusicTools.app</strong> sull'alias <strong>Applicazioni</strong> dentro il DMG</li>
|
||||
<li>Doppio click su <strong>Fix Gatekeeper.command</strong> (dentro il DMG): autorizza l'app in 1 click. Si chiude da sola appena finito.</li>
|
||||
<li>Apri MusicTools dalle Applicazioni, inserisci email e chiave per attivare</li>
|
||||
<li>
|
||||
Autorizza l'app (una sola volta, perche' non e' firmata con Developer ID Apple).<br>
|
||||
<strong>Modo piu' semplice:</strong> apri il <strong>Terminale</strong> (cmd+spazio → "Terminale"), incolla questa riga e premi Invio:
|
||||
<p class="dl-code">xattr -dr com.apple.quarantine /Applications/MusicTools.app</p>
|
||||
<strong>In alternativa</strong> (senza Terminale):
|
||||
<ul class="dl-sub">
|
||||
<li><strong>Tasto destro</strong> su <code>Fix Gatekeeper.command</code> nel DMG → <strong>Apri</strong> → <strong>Apri</strong>. Se compare il blocco "non e' in grado di verificare", clicca <strong>Annulla</strong> e procedi col passo successivo.</li>
|
||||
<li>Apri <strong>Impostazioni di Sistema</strong> → <strong>Privacy e sicurezza</strong>. In fondo vedi "Fix Gatekeeper.command e' stato bloccato": clicca <strong>Apri comunque</strong>.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Apri <strong>MusicTools</strong> dalle Applicazioni, inserisci email e chiave per attivare</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
|
||||
+52
-158
@@ -4,14 +4,16 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MusicTools — Scarica, modifica e registra la tua musica</title>
|
||||
<meta name="description" content="App desktop per Mac e Windows. Scarica brani da Spotify e YouTube in MP3 320K, registra l'audio del sistema, modifica i tag. Piani da 2,99 €/mese." />
|
||||
<meta name="description" content="App desktop per Mac e Windows. Scarica brani da Spotify e YouTube in MP3 320K, registra l'audio del sistema, modifica i tag." />
|
||||
|
||||
<meta property="og:title" content="MusicTools — La toolbox della tua musica" />
|
||||
<meta property="og:description" content="Scarica, modifica e registra. Tutto in un'app per Mac e Windows. Piani da 2,99 €/mese o 49,90 €/anno una tantum." />
|
||||
<meta property="og:description" content="Scarica, modifica e registra. Tutto in un'app per Mac e Windows." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://musictools.djluza.com" />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="assets/favicon-192.png" />
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="assets/favicon-192.png" />
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -20,17 +22,16 @@
|
||||
<header class="nav">
|
||||
<div class="container nav-row">
|
||||
<a href="#top" class="brand">
|
||||
<span class="brand-logo">♫</span>
|
||||
<img class="brand-logo brand-logo-img" src="assets/icon.png" alt="MusicTools" />
|
||||
<span class="brand-name">MusicTools</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="#features">Funzioni</a>
|
||||
<a href="#how">Come funziona</a>
|
||||
<a href="#pricing">Piani</a>
|
||||
<a href="#faq">FAQ</a>
|
||||
<a href="/download">Download</a>
|
||||
</nav>
|
||||
<a href="#pricing" class="btn btn-primary btn-sm">Vedi i piani</a>
|
||||
<a href="mailto:info@djluza.com" class="btn btn-ghost btn-sm">Contatti</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -38,7 +39,7 @@
|
||||
<section class="hero" id="top">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-text">
|
||||
<span class="eyebrow">Per Mac e Windows · Da 2,99 €/mese</span>
|
||||
<span class="eyebrow">Per Mac e Windows</span>
|
||||
<h1>La tua musica, ovunque la trovi.<br/><span class="accent">In un'unica app.</span></h1>
|
||||
<p class="lead">
|
||||
Scarica brani da Spotify, YouTube, SoundCloud e altri 1000 siti in MP3 320K.
|
||||
@@ -46,13 +47,13 @@
|
||||
Modifica i metadati e le copertine. Tutto offline, senza account.
|
||||
</p>
|
||||
<div class="hero-cta">
|
||||
<a href="#pricing" class="btn btn-primary btn-lg">Vedi i piani</a>
|
||||
<a href="#features" class="btn btn-ghost btn-lg">Scopri di più</a>
|
||||
<a href="#features" class="btn btn-primary btn-lg">Scopri le funzioni</a>
|
||||
<a href="/download" class="btn btn-ghost btn-lg">Ho già una licenza</a>
|
||||
</div>
|
||||
<ul class="hero-bullets">
|
||||
<li>✓ Mensili da 2,99 € o annuale 49,90 € una tantum</li>
|
||||
<li>✓ Funziona offline · Su 3 dispositivi (Mac + Windows)</li>
|
||||
<li>✓ Aggiornamenti gratuiti inclusi</li>
|
||||
<li>✓ Funziona offline · Su 3 dispositivi</li>
|
||||
<li>✓ Scarica brani, video, registra l'audio di sistema, modifica i tag</li>
|
||||
</ul>
|
||||
|
||||
<p class="hero-customer">
|
||||
@@ -155,8 +156,8 @@
|
||||
<div class="how-grid">
|
||||
<div class="how-step">
|
||||
<div class="how-num">1</div>
|
||||
<h3>Compra la licenza</h3>
|
||||
<p>Checkout sicuro tramite Lemon Squeezy. Ricevi la chiave via email entro pochi secondi.</p>
|
||||
<h3>Ottieni la licenza</h3>
|
||||
<p>Ricevi la chiave via email. L'app non è ancora in vendita: scrivici per essere informato.</p>
|
||||
</div>
|
||||
<div class="how-step">
|
||||
<div class="how-num">2</div>
|
||||
@@ -175,127 +176,34 @@
|
||||
<!-- ============== PRICING ============== -->
|
||||
<section class="section pricing" id="pricing">
|
||||
<div class="container">
|
||||
<span class="eyebrow center">PIANI</span>
|
||||
<h2 class="section-title">Scegli il piano giusto per te</h2>
|
||||
<p class="section-lead">Tre abbonamenti mensili o l'opzione una-tantum annuale senza limiti. Tutti i piani includono aggiornamenti gratuiti.</p>
|
||||
|
||||
<div class="price-grid">
|
||||
|
||||
<!-- BASIC -->
|
||||
<div class="price-card price-card-sm">
|
||||
<div class="price-name">Basic</div>
|
||||
<div class="price-row">
|
||||
<span class="price-amount">2,99</span>
|
||||
<span class="price-currency">€</span>
|
||||
<span class="price-once">/mese</span>
|
||||
</div>
|
||||
<ul class="price-features">
|
||||
<li>✓ <strong>Download brani</strong> da Spotify e YouTube</li>
|
||||
<li>✓ <strong>Fino a 10 al giorno</strong></li>
|
||||
<li>✓ MP3 fino a 320kbps</li>
|
||||
<li>✓ 3 dispositivi (Mac + Win)</li>
|
||||
<li>✗ Download video</li>
|
||||
<li>✗ Registrazione, metadati, upgrade libreria</li>
|
||||
</ul>
|
||||
<a href="https://luza.lemonsqueezy.com/checkout/buy/8aca9e44-4775-424b-8ce3-583714044ac9?embed=1"
|
||||
class="btn btn-ghost btn-lg btn-block lemonsqueezy-button">
|
||||
Inizia con Basic
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- PRO -->
|
||||
<div class="price-card price-card-sm">
|
||||
<div class="price-badge">Più scelto</div>
|
||||
<div class="price-name">Pro</div>
|
||||
<div class="price-row">
|
||||
<span class="price-amount">5,99</span>
|
||||
<span class="price-currency">€</span>
|
||||
<span class="price-once">/mese</span>
|
||||
</div>
|
||||
<ul class="price-features">
|
||||
<li>✓ <strong>Tutto di Basic</strong></li>
|
||||
<li>✓ <strong>Download video</strong> (YT, TikTok, IG, FB)</li>
|
||||
<li>✓ <strong>Fino a 30 al giorno</strong></li>
|
||||
<li>✓ Registrazione audio (BlackHole / loopback)</li>
|
||||
<li>✓ Editor metadati + copertine</li>
|
||||
<li>✓ Upgrade qualità libreria</li>
|
||||
</ul>
|
||||
<a href="https://luza.lemonsqueezy.com/checkout/buy/89cb5ab5-9d39-4828-8c44-b67a651aeb46?embed=1"
|
||||
class="btn btn-primary btn-lg btn-block lemonsqueezy-button">
|
||||
Scegli Pro
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- PREMIUM -->
|
||||
<div class="price-card price-card-sm">
|
||||
<div class="price-name">Premium</div>
|
||||
<div class="price-row">
|
||||
<span class="price-amount">9,99</span>
|
||||
<span class="price-currency">€</span>
|
||||
<span class="price-once">/mese</span>
|
||||
</div>
|
||||
<ul class="price-features">
|
||||
<li>✓ <strong>Tutto di Pro</strong></li>
|
||||
<li>✓ <strong>Fino a 150 al giorno</strong></li>
|
||||
<li>✓ Priorità nel supporto via email</li>
|
||||
<li>✓ 3 dispositivi (Mac + Win)</li>
|
||||
</ul>
|
||||
<a href="https://luza.lemonsqueezy.com/checkout/buy/5e027bbf-30ba-4ef3-bb23-5e7ea4aa7e7e?embed=1"
|
||||
class="btn btn-ghost btn-lg btn-block lemonsqueezy-button">
|
||||
Passa a Premium
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ANNUAL ONE-TIME -->
|
||||
<div class="price-card price-card-sm price-card-featured">
|
||||
<div class="price-badge price-badge-gold">Miglior valore</div>
|
||||
<div class="price-name">Annual</div>
|
||||
<div class="price-row">
|
||||
<span class="price-amount">49,90</span>
|
||||
<span class="price-currency">€</span>
|
||||
<span class="price-once">/anno</span>
|
||||
</div>
|
||||
<ul class="price-features">
|
||||
<li>✓ <strong>Tutte le funzioni sbloccate</strong></li>
|
||||
<li>✓ <strong>Nessun limite giornaliero</strong></li>
|
||||
<li>✓ Valido <strong>12 mesi</strong> dall'acquisto</li>
|
||||
<li>✓ Una tantum, niente rinnovo automatico</li>
|
||||
<li>✓ 3 dispositivi (Mac + Win)</li>
|
||||
</ul>
|
||||
<a href="https://luza.lemonsqueezy.com/checkout/buy/85e91098-a93c-4008-a155-954b3456a299?embed=1"
|
||||
class="btn btn-primary btn-lg btn-block lemonsqueezy-button"
|
||||
id="buyBtn">
|
||||
Acquista — 49,90 €
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<span class="eyebrow center">DISPONIBILITÀ</span>
|
||||
<h2 class="section-title">In arrivo</h2>
|
||||
<p class="section-lead">Stiamo definendo i piani commerciali. L'app non è ancora in vendita.</p>
|
||||
|
||||
<div class="price-shared">
|
||||
<p class="price-note">Pagamento sicuro · Carta, PayPal, Apple Pay · Fattura inclusa · IVA gestita da Lemon Squeezy</p>
|
||||
<p class="price-consent">
|
||||
Procedendo con l'acquisto dichiari di aver letto e di accettare i
|
||||
<a href="/legal/terms.html">Termini di servizio</a>,
|
||||
l'<a href="/legal/privacy.html">Informativa privacy</a> e la
|
||||
<a href="/legal/refund.html">Politica rimborsi</a>.
|
||||
</p>
|
||||
<p class="price-already">
|
||||
Hai già una licenza? <a href="/download">Scarica l'app →</a>
|
||||
</p>
|
||||
<p class="price-note">
|
||||
Per restare aggiornato sulla disponibilità scrivici a
|
||||
<a href="mailto:info@djluza.com">info@djluza.com</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="spotify-note">
|
||||
<h3>Per sfruttare al massimo MusicTools serve un account Spotify</h3>
|
||||
<h3>Playlist e album Spotify: serve un account Spotify Premium</h3>
|
||||
<p>
|
||||
L'app raggiunge il <strong>100% delle sue funzioni</strong> quando colleghi
|
||||
le tue <strong>chiavi API Spotify</strong>. Sono <strong>gratuite</strong>:
|
||||
basta un account Spotify (anche <strong>quello gratis</strong>, niente
|
||||
Premium richiesto) e bastano <strong>2 minuti</strong> per generarle dalla
|
||||
Developer Dashboard. La guida passo-passo e' integrata nell'app.
|
||||
MusicTools funziona <strong>al 100% subito</strong> con YouTube,
|
||||
SoundCloud, TikTok, Instagram, Facebook e i singoli brani Spotify
|
||||
(anche con account Spotify gratuito).<br>
|
||||
Per scaricare <strong>playlist e album interi da Spotify</strong> servono
|
||||
le tue <strong>chiavi API Spotify</strong>, che oggi Spotify rilascia solo
|
||||
a chi ha un <strong>account Premium</strong> attivo (le crei tu dal
|
||||
Developer Dashboard, in 2 minuti, con la guida integrata nell'app).
|
||||
</p>
|
||||
<div class="spotify-note-grid">
|
||||
<div class="sn-col sn-with">
|
||||
<div class="sn-title">✓ Con chiavi API Spotify (consigliato)</div>
|
||||
<div class="sn-title">✓ Con Spotify Premium + chiavi API</div>
|
||||
<ul>
|
||||
<li>Scarichi <strong>playlist intere</strong> e <strong>album</strong> da Spotify</li>
|
||||
<li>Brani con <strong>metadati e copertine</strong> ufficiali</li>
|
||||
@@ -303,9 +211,9 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div class="sn-col sn-without">
|
||||
<div class="sn-title">Senza chiavi (modalita' base)</div>
|
||||
<div class="sn-title">Senza Premium (modalita' base)</div>
|
||||
<ul>
|
||||
<li>Da Spotify scarichi <strong>un brano alla volta</strong></li>
|
||||
<li>Da Spotify scarichi <strong>un brano alla volta</strong> incollando il link</li>
|
||||
<li>YouTube, SoundCloud, TikTok, IG, FB: <strong>nessuna limitazione</strong></li>
|
||||
<li>Ricerca per "Artista - Titolo" sempre attiva</li>
|
||||
</ul>
|
||||
@@ -352,32 +260,24 @@
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Su quanti dispositivi posso installarla?</summary>
|
||||
<p>Tutti i piani coprono fino a <strong>3 dispositivi</strong>, anche misti (Mac + Windows). Se ne cambi uno, puoi disattivare dall'app stessa o scriverci e ne liberiamo uno.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Come funziona il limite giornaliero?</summary>
|
||||
<p>Sui piani mensili (Basic, Pro, Premium) c'è un limite di download al giorno (10, 30 o 150 a seconda del piano). Il contatore si resetta a <strong>mezzanotte ora di Roma</strong>. Sull'annuale (49,90 €) non c'è nessun limite per 12 mesi.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Posso passare a un piano superiore?</summary>
|
||||
<p>Sì, in qualsiasi momento. Su Lemon Squeezy puoi cambiare piano dalla tua dashboard; la licenza si aggiorna in automatico al successivo controllo dell'app.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Cosa succede quando scade l'abbonamento annuale?</summary>
|
||||
<p>Il piano Annual è una-tantum: vale 12 mesi dall'acquisto, non si rinnova automaticamente. Allo scadere ricomprilo (o scegli un mensile) e tutto torna a funzionare.</p>
|
||||
<p>La licenza copre fino a <strong>3 dispositivi</strong>, anche misti (Mac + Windows). Se ne cambi uno, puoi disattivare dall'app stessa o scriverci e ne liberiamo uno.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Come funzionano gli aggiornamenti?</summary>
|
||||
<p>Sono <strong>inclusi in tutti i piani</strong>. L'app controlla in automatico le nuove versioni dal menu Impostazioni → Aggiornamenti. Un click per scaricare e installare.</p>
|
||||
<p>Sono <strong>inclusi</strong>. L'app controlla in automatico le nuove versioni dal menu Impostazioni → Aggiornamenti. Un click per scaricare e installare.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Devo creare un account Spotify Premium o YouTube Premium?</summary>
|
||||
<p>No. Per Spotify ti basta un account gratuito (serve solo per ottenere le credenziali API dal loro dashboard, è gratis). YouTube non richiede account.</p>
|
||||
<summary>Mi serve un abbonamento Spotify Premium?</summary>
|
||||
<p>
|
||||
Solo se vuoi scaricare <strong>playlist e album interi</strong> da Spotify:
|
||||
in quel caso servono le chiavi API Spotify, che oggi Spotify rilascia
|
||||
solo agli account Premium. Con un account Spotify gratuito (o senza
|
||||
account Spotify) <strong>puoi comunque scaricare</strong> da YouTube,
|
||||
SoundCloud, TikTok, IG, FB e i singoli brani Spotify incollando il link.
|
||||
</p>
|
||||
<p>YouTube non richiede alcun account.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
@@ -387,12 +287,11 @@
|
||||
scarichi senza limiti, link diretti e ricerca per titolo. Senza setup.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Da Spotify</strong>: senza chiavi puoi scaricare <strong>un
|
||||
brano alla volta</strong> incollando il link al singolo brano (l'app
|
||||
estrae artista e titolo dai metadati pubblici di Spotify e cerca su
|
||||
YouTube). Per <strong>album e playlist</strong> servono le chiavi API
|
||||
— sono gratis (anche con account Spotify free) e la procedura guidata
|
||||
nell'app ci mette 2 minuti.
|
||||
<strong>Da Spotify</strong>: puoi scaricare <strong>un brano alla volta</strong>
|
||||
incollando il link al singolo brano (l'app estrae artista e titolo
|
||||
dai metadati pubblici di Spotify e cerca su YouTube). Per <strong>album
|
||||
e playlist</strong> servono le chiavi API, che Spotify rilascia solo
|
||||
agli account Premium.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -431,10 +330,6 @@
|
||||
<p>Scrivi a <a href="mailto:info@djluza.com">info@djluza.com</a> con prova della titolarità, identificazione del materiale e dichiarazione di buona fede. La procedura completa è descritta al <a href="/legal/terms.html#s8">§8 dei Termini</a>.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Quali metodi di pagamento sono accettati?</summary>
|
||||
<p>Tramite Lemon Squeezy: carta di credito/debito, PayPal, Apple Pay, Google Pay. Riceverai una fattura per il pagamento.</p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -442,9 +337,9 @@
|
||||
<!-- ============== FINAL CTA ============== -->
|
||||
<section class="section cta-final">
|
||||
<div class="container cta-box">
|
||||
<h2>Pronto a semplificarti la vita?</h2>
|
||||
<p>Una sola app per tutto quello che fai con la musica. Inizia con Basic a 2,99 € al mese.</p>
|
||||
<a href="#pricing" class="btn btn-primary btn-lg">Vedi i piani</a>
|
||||
<h2>Resta in contatto</h2>
|
||||
<p>Scrivici per qualsiasi domanda o per essere informato sulla disponibilità.</p>
|
||||
<a href="mailto:info@djluza.com" class="btn btn-primary btn-lg">Scrivici a info@djluza.com</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -452,7 +347,7 @@
|
||||
<footer class="footer">
|
||||
<div class="container footer-row">
|
||||
<div class="footer-brand">
|
||||
<span class="brand-logo">♫</span>
|
||||
<img class="brand-logo brand-logo-img" src="assets/icon.png" alt="MusicTools" />
|
||||
<span>MusicTools</span>
|
||||
<span class="footer-by">by LuZa</span>
|
||||
</div>
|
||||
@@ -469,7 +364,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://assets.lemonsqueezy.com/lemon.js" defer></script>
|
||||
<script src="js/main.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<main class="legal-doc">
|
||||
<h1>Informativa Privacy</h1>
|
||||
<p class="updated">Ultimo aggiornamento: 8 giugno 2026</p>
|
||||
<p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
|
||||
|
||||
<h2>1. Titolare del trattamento</h2>
|
||||
<p>Luciano Zappacosta — email <a href="mailto:info@djluza.com">info@djluza.com</a>.</p>
|
||||
@@ -30,24 +30,26 @@
|
||||
<ul>
|
||||
<li><strong>Email</strong>: associata all'acquisto. Usata per inviare la chiave di licenza, comunicazioni di servizio e supporto.</li>
|
||||
<li><strong>Chiave di licenza, device_id, nome dispositivo, versione app</strong>: salvati nel database licenze per gestire le attivazioni (max 3 dispositivi) e impedire abusi.</li>
|
||||
<li><strong>Piano sottoscritto, id ordine e id sottoscrizione Lemon Squeezy, data scadenza/rinnovo</strong>: necessari per applicare correttamente le funzioni del piano (Basic/Pro/Premium/Annual) e gestire rinnovi, disdette, rimborsi.</li>
|
||||
<li><strong>Contatore download giornaliero</strong>: numero aggregato di download effettuati ogni giorno (azzerato a mezzanotte ora di Roma). Serve esclusivamente ad applicare i limiti del piano (10/30/150 al giorno). Non vengono registrati URL ne' titoli dei brani scaricati.</li>
|
||||
<li><strong>Dati di pagamento</strong>: gestiti esclusivamente da Lemon Squeezy Inc. L'Editore non vede ne' conserva i dati della carta.</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Finalita' e base giuridica</h2>
|
||||
<ul>
|
||||
<li>Esecuzione del contratto (consegna della licenza, supporto, aggiornamenti) — art. 6(1)(b) GDPR.</li>
|
||||
<li>Esecuzione del contratto (consegna della licenza, gestione del piano sottoscritto, rinnovi, supporto, aggiornamenti) — art. 6(1)(b) GDPR.</li>
|
||||
<li>Adempimenti fiscali e contabili — art. 6(1)(c) GDPR.</li>
|
||||
<li>Sicurezza del software (verifica anti-abuso delle attivazioni) — interesse legittimo, art. 6(1)(f) GDPR.</li>
|
||||
<li>Sicurezza del software e applicazione dei limiti del piano (verifica anti-abuso delle attivazioni, contatore giornaliero) — interesse legittimo, art. 6(1)(f) GDPR.</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Conservazione</h2>
|
||||
<p>I dati di licenza vengono conservati per tutta la durata del prodotto e fino a 5 anni dopo la disattivazione, per adempimenti fiscali. I log tecnici (revalidate) sono conservati per 30 giorni.</p>
|
||||
<p>I dati di licenza vengono conservati per tutta la durata del prodotto e fino a 5 anni dopo la disattivazione, per adempimenti fiscali. I log tecnici (revalidate) sono conservati per 30 giorni. Il contatore download giornaliero viene mantenuto per 90 giorni e poi cancellato.</p>
|
||||
|
||||
<h2>5. Terze parti</h2>
|
||||
<ul>
|
||||
<li><strong>Lemon Squeezy Inc.</strong> — Merchant of Record, processore di pagamento.</li>
|
||||
<li><strong>Cloudflare Inc.</strong> — hosting infrastruttura backend (Workers, D1, R2, Pages).</li>
|
||||
<li><strong>Resend Inc.</strong> — invio email transazionali.</li>
|
||||
<li><strong>Lemon Squeezy Inc.</strong> — Merchant of Record, processore di pagamento, gestione abbonamenti.</li>
|
||||
<li><strong>Resend Inc.</strong> — invio email transazionali (consegna chiave di licenza, comunicazioni di servizio).</li>
|
||||
<li><strong>Provider di hosting self-managed</strong> — i dati di licenza e il backend API sono ospitati su server gestiti dall'Editore. I dati non vengono condivisi con altri fornitori cloud.</li>
|
||||
</ul>
|
||||
<p>Tutte queste aziende rispettano standard internazionali di protezione dati e operano con clausole contrattuali standard ove richiesto.</p>
|
||||
|
||||
|
||||
@@ -21,20 +21,31 @@
|
||||
|
||||
<main class="legal-doc">
|
||||
<h1>Politica rimborsi</h1>
|
||||
<p class="updated">Ultimo aggiornamento: 8 giugno 2026</p>
|
||||
<p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
|
||||
|
||||
<h2>Garanzia 14 giorni</h2>
|
||||
<p>Hai 14 giorni di tempo dall'acquisto per chiedere il rimborso completo, senza fornire motivazioni. La richiesta si effettua scrivendo a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto.</p>
|
||||
<h2>Garanzia 14 giorni sul primo acquisto</h2>
|
||||
<p>Hai <strong>14 giorni</strong> di tempo dal <strong>primo acquisto</strong> per chiedere il rimborso completo, senza fornire motivazioni. Vale per tutti i piani: Basic, Pro, Premium e Annual.</p>
|
||||
<p>La richiesta si effettua scrivendo a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto.</p>
|
||||
|
||||
<h2>Cosa succede dopo</h2>
|
||||
<h2>Differenze per piano</h2>
|
||||
<ul>
|
||||
<li><strong>Basic / Pro / Premium</strong> (mensili): puoi disdire l'abbonamento <strong>in qualsiasi momento</strong> dalla dashboard di Lemon Squeezy o scrivendoci. La disdetta interrompe il rinnovo successivo; la licenza resta attiva fino alla scadenza del periodo gia pagato. Il rimborso integrale e' previsto solo entro 14 giorni dal primo addebito. <strong>I rinnovi successivi al primo non sono rimborsabili</strong> ma sono interrompibili con disdetta.</li>
|
||||
<li><strong>Annual</strong> (una tantum, 49,90 € per 12 mesi): rimborso integrale entro 14 giorni dall'acquisto. Oltre tale termine non e' previsto rimborso parziale per il tempo residuo.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Cosa succede dopo la richiesta</h2>
|
||||
<ol>
|
||||
<li>Riceverai una risposta entro 48 ore lavorative.</li>
|
||||
<li>Il rimborso viene processato da Lemon Squeezy sullo stesso metodo di pagamento usato per l'acquisto. Tempi tipici: 5-10 giorni lavorativi.</li>
|
||||
<li>La tua chiave di licenza viene revocata. L'app smette di funzionare al successivo controllo online.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Come disdire un abbonamento mensile</h2>
|
||||
<p>Se sei oltre i 14 giorni e vuoi solo non rinnovare, accedi alla tua dashboard Lemon Squeezy (link nella ricevuta) e clicca "Cancel subscription". In alternativa scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a> e la disattiviamo per te.</p>
|
||||
|
||||
<h2>Limitazioni</h2>
|
||||
<p>Trascorsi 14 giorni dall'acquisto non e' piu' possibile chiedere il rimborso, salvo nei casi di malfunzionamento grave del software non risolvibile entro 30 giorni dalla segnalazione.</p>
|
||||
<p>Trascorsi 14 giorni dal primo acquisto non e' piu' possibile chiedere il rimborso integrale, salvo nei casi di malfunzionamento grave del software non risolvibile entro 30 giorni dalla segnalazione.</p>
|
||||
<p>Spotify potrebbe modificare in qualsiasi momento le proprie API o le condizioni di accesso (es. richiedere un account Premium per generare le credenziali). Tali modifiche di terze parti non costituiscono titolo per il rimborso del piano MusicTools acquistato. La sezione "Esclusione di garanzie" dei <a href="/legal/terms.html">Termini di servizio</a> si applica integralmente.</p>
|
||||
|
||||
<h2>Domande?</h2>
|
||||
<p>Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a>: siamo persone vere, rispondiamo entro un giorno lavorativo.</p>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
<main class="legal-doc">
|
||||
<h1>Termini di servizio e Licenza d'uso</h1>
|
||||
<p class="updated">Ultimo aggiornamento: 8 giugno 2026</p>
|
||||
<p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
|
||||
|
||||
<div class="warn-box">
|
||||
<p><strong>Leggi attentamente.</strong> Acquistando, installando o usando MusicTools accetti i presenti Termini. Se non li accetti, non installare ne' usare il software.</p>
|
||||
@@ -163,6 +163,7 @@
|
||||
<li>Compatibilita' con qualsiasi versione corrente o futura di sistemi operativi, piattaforme online o formati di file.</li>
|
||||
</ul>
|
||||
<p>Le piattaforme di origine (Spotify, YouTube, ecc.) possono modificare in qualsiasi momento le proprie API, formati o politiche, rendendo il Software temporaneamente o permanentemente inutilizzabile per certe funzioni. L'Editore non garantisce alcuna continuita' funzionale.</p>
|
||||
<p><strong>Credenziali Spotify Web API:</strong> per scaricare playlist e album interi da Spotify il Software richiede che l'Utente generi proprie chiavi API (Client ID e Client Secret) dalla Spotify Developer Dashboard e le configuri all'interno del Software. Spotify, in seguito ad aggiornamenti della propria policy, consente la creazione di tali credenziali esclusivamente agli utenti con <strong>account Spotify Premium</strong> attivo. L'Editore non e' in alcun modo affiliato a Spotify, non fornisce, vende o rivende account Spotify, non puo' garantire l'accessibilita' o il funzionamento delle API Spotify per gli account Free, e non e' responsabile per limitazioni, sospensioni o ban applicati da Spotify alle credenziali dell'Utente. I download di singoli brani da Spotify, basati su metadati pubblici, non richiedono credenziali API ne' un abbonamento Premium.</p>
|
||||
|
||||
<h2 id="s10">10. Limitazione di responsabilita</h2>
|
||||
<p>Nei limiti massimi consentiti dalla legge applicabile, in nessun caso l'Editore sara' responsabile verso l'Utente o verso terzi per:</p>
|
||||
@@ -185,14 +186,25 @@
|
||||
<li>Qualsiasi attivita' illecita compiuta dall'Utente tramite il Software.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="s12">12. Pagamenti e fatturazione</h2>
|
||||
<h2 id="s12">12. Piani, prezzi e fatturazione</h2>
|
||||
<p>I pagamenti sono gestiti da <strong>Lemon Squeezy Inc.</strong>, che opera come Merchant of Record (MoR). Lemon Squeezy emette la ricevuta/fattura fiscale, gestisce l'IVA secondo la giurisdizione del cliente (regime MOSS UE) e fornisce assistenza per problematiche di pagamento.</p>
|
||||
<p>Prezzo pubblicato: <strong>39,90 € una tantum</strong> (IVA inclusa secondo paese di residenza dell'Utente).</p>
|
||||
<p>Il Software e' commercializzato in quattro piani, ciascuno con prezzi indicati IVA inclusa secondo paese di residenza dell'Utente:</p>
|
||||
<ul>
|
||||
<li><strong>Basic</strong> — 2,99 € / mese (abbonamento). Include il download di brani audio fino a 10 al giorno; non include video, registrazione, editor metadati, upgrade libreria.</li>
|
||||
<li><strong>Pro</strong> — 5,99 € / mese (abbonamento). Include audio + video + registrazione + editor metadati + upgrade libreria, fino a 30 download al giorno.</li>
|
||||
<li><strong>Premium</strong> — 9,99 € / mese (abbonamento). Stesse funzioni del Pro con limite di 150 download al giorno.</li>
|
||||
<li><strong>Annual</strong> — 49,90 € una tantum. Tutte le funzioni sbloccate, nessun limite giornaliero, validita' 12 (dodici) mesi dalla data di acquisto. Non si rinnova automaticamente.</li>
|
||||
</ul>
|
||||
<p><strong>Limiti giornalieri:</strong> il contatore si azzera ogni giorno a mezzanotte ora di Roma (Europe/Rome). I download non utilizzati non si cumulano.</p>
|
||||
<p><strong>Abbonamenti mensili (Basic, Pro, Premium):</strong> si rinnovano automaticamente alla data di addebito mensile fino a disdetta dell'Utente, che puo' essere effettuata in qualsiasi momento dalla dashboard Lemon Squeezy o scrivendo a <a href="mailto:info@djluza.com">info@djluza.com</a>. La disdetta interrompe il rinnovo successivo; la Licenza resta attiva fino alla scadenza del periodo gia pagato.</p>
|
||||
<p><strong>Piano Annual (una tantum):</strong> il pagamento e' singolo e copre 12 mesi consecutivi. Allo scadere, la Licenza diventa inattiva e per continuare a utilizzare il Software l'Utente puo' acquistare nuovamente un piano a sua scelta.</p>
|
||||
<p>L'Utente puo' passare a un piano superiore (upgrade) o inferiore (downgrade) in qualsiasi momento. Il prorata viene gestito secondo le regole di Lemon Squeezy.</p>
|
||||
|
||||
<h2 id="s13">13. Diritto di recesso e rimborsi</h2>
|
||||
<p>Ai sensi del Codice del Consumo (D.lgs. 206/2005) e del Regolamento UE sui consumatori, l'Utente ha diritto di recedere dal contratto entro <strong>14 (quattordici) giorni</strong> dall'acquisto, senza fornire motivazioni.</p>
|
||||
<p>Ai sensi del Codice del Consumo (D.lgs. 206/2005) e del Regolamento UE sui consumatori, l'Utente ha diritto di recedere dal contratto entro <strong>14 (quattordici) giorni</strong> dal primo acquisto, senza fornire motivazioni. Il diritto di recesso si applica al primo addebito; non si applica ai rinnovi successivi di un abbonamento mensile, per i quali resta possibile la disdetta come previsto al §12.</p>
|
||||
<p>La richiesta va inviata a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto. Il rimborso viene processato da Lemon Squeezy sullo stesso metodo di pagamento; tempi tipici 5-10 giorni lavorativi.</p>
|
||||
<p>A seguito del rimborso, la Licenza viene revocata e il Software smettera' di funzionare al successivo controllo online.</p>
|
||||
<p>L'Utente non puo' richiedere il rimborso dell'importo gia pagato per i mesi di abbonamento gia consumati. Il piano Annual, in caso di recesso entro 14 giorni, viene rimborsato interamente; oltre tale termine non e' previsto rimborso parziale per il tempo residuo.</p>
|
||||
|
||||
<h2 id="s14">14. Aggiornamenti e supporto</h2>
|
||||
<p>L'Editore si impegna a mantenere il Software funzionante e a rilasciare aggiornamenti gratuiti per tutta la durata commerciale del prodotto. Non e' garantito un calendario specifico di rilascio.</p>
|
||||
|
||||
@@ -4,6 +4,72 @@ 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'
|
||||
# Python.Runtime.dll viene bundlata ma il loader non sa risolverla
|
||||
# senza l'inizializzazione esplicita. Fix conferma:
|
||||
# https://github.com/pythonnet/pythonnet/issues/2178
|
||||
if sys.platform == "win32":
|
||||
# ============================================================
|
||||
# Fix Windows: rimuovi Mark of the Web (MOTW) dalle DLL bundled.
|
||||
# Quando l'utente scarica il .zip dal browser, Windows aggiunge
|
||||
# uno stream NTFS Zone.Identifier a TUTTI i file estratti.
|
||||
# .NET Framework 4.x rifiuta silenziosamente di risolvere i
|
||||
# symbol export degli assembly managed con MOTW -> il loader
|
||||
# netfx di pythonnet fallisce con "Failed to resolve
|
||||
# Python.Runtime.Loader.Initialize".
|
||||
# Soluzione: cancelliamo lo stream :Zone.Identifier da ogni
|
||||
# .dll/.exe del bundle al primo avvio (operazione idempotente
|
||||
# e sicura: l'utente ha gia eseguito l'exe esplicitamente).
|
||||
# Vedi: https://github.com/r0x0r/pywebview/issues/1215
|
||||
# ============================================================
|
||||
if getattr(sys, "frozen", False):
|
||||
try:
|
||||
import ctypes
|
||||
_kernel32 = ctypes.windll.kernel32
|
||||
_bundle = Path(sys._MEIPASS) # type: ignore[attr-defined]
|
||||
for _f in _bundle.rglob("*"):
|
||||
if _f.suffix.lower() in (".dll", ".exe", ".pyd"):
|
||||
_kernel32.DeleteFileW(f"{_f}:Zone.Identifier")
|
||||
except Exception as _e:
|
||||
print(f"[bootstrap] MOTW cleanup failed: {_e}")
|
||||
|
||||
try:
|
||||
if getattr(sys, "frozen", False):
|
||||
os.environ["PYTHONNET_RUNTIME"] = "netfx"
|
||||
from pythonnet import load as _pythonnet_load
|
||||
_pythonnet_load("netfx")
|
||||
import clr # noqa: F401 - inizializza il loader
|
||||
except Exception as _e:
|
||||
print(f"[bootstrap] preload pythonnet failed: {_e}")
|
||||
|
||||
import webview
|
||||
|
||||
from api.bridge import Api
|
||||
|
||||
+12
-1
@@ -1,8 +1,19 @@
|
||||
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"
|
||||
pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin"
|
||||
pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin"
|
||||
pythonnet==3.0.3 ; sys_platform == "win32"
|
||||
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
|
||||
responses>=0.25.0
|
||||
freezegun>=1.4.0
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Estrae lo slug + numeric id di tutti i generi Beatport.
|
||||
Uso: python3 scripts/refresh_beatport_genres.py > /tmp/genres.txt
|
||||
Poi copia manualmente in core/beatport.py::GENRES.
|
||||
|
||||
Nota: la pagina /genres (task originale) restituisce 404 sull'attuale sito
|
||||
Beatport. Aggreghiamo quindi la lista dei generi dalla pagina /charts, dove
|
||||
ogni featured chart include un array `genres` completo di slug + id + name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import json
|
||||
import sys
|
||||
from curl_cffi import requests
|
||||
|
||||
|
||||
GENRES_URL = "https://www.beatport.com/genres"
|
||||
CHARTS_URL = "https://www.beatport.com/charts"
|
||||
|
||||
|
||||
def _fetch_next_data(url: str) -> dict:
|
||||
resp = requests.get(url, impersonate="chrome131", timeout=15)
|
||||
resp.raise_for_status()
|
||||
m = re.search(
|
||||
r'<script id="__NEXT_DATA__"[^>]*>(.+?)</script>', resp.text, re.DOTALL
|
||||
)
|
||||
if not m:
|
||||
raise RuntimeError(f"__NEXT_DATA__ non trovato su {url}")
|
||||
return json.loads(m.group(1))
|
||||
|
||||
|
||||
def _collect_genres(node, out: dict) -> None:
|
||||
"""Raccoglie ricorsivamente ogni dict con chiavi slug/id/name che appare
|
||||
dentro un array chiamato "genres"."""
|
||||
if isinstance(node, dict):
|
||||
for k, v in node.items():
|
||||
if (
|
||||
k == "genres"
|
||||
and isinstance(v, list)
|
||||
and v
|
||||
and all(
|
||||
isinstance(x, dict) and "slug" in x and "id" in x and "name" in x
|
||||
for x in v
|
||||
)
|
||||
):
|
||||
for g in v:
|
||||
out[g["id"]] = (g["slug"], g["name"])
|
||||
else:
|
||||
_collect_genres(v, out)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
_collect_genres(item, out)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# Prova /genres (URL originale). Se 404 o schema inatteso, cade su /charts.
|
||||
try:
|
||||
data = _fetch_next_data(GENRES_URL)
|
||||
queries = data["props"]["pageProps"]["dehydratedState"]["queries"]
|
||||
for q in queries:
|
||||
d = q.get("state", {}).get("data", {})
|
||||
if (
|
||||
isinstance(d, dict)
|
||||
and "results" in d
|
||||
and isinstance(d["results"], list)
|
||||
):
|
||||
for g in d["results"]:
|
||||
if "slug" in g and "id" in g and "name" in g:
|
||||
print(f' "{g["slug"]}": ({g["id"]}, "{g["name"]}"),')
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"# /genres non disponibile ({e}), fallback su /charts", file=sys.stderr)
|
||||
|
||||
data = _fetch_next_data(CHARTS_URL)
|
||||
genres: dict = {}
|
||||
_collect_genres(data, genres)
|
||||
if not genres:
|
||||
print("nessun genere trovato su /charts", file=sys.stderr)
|
||||
return 1
|
||||
for gid in sorted(genres):
|
||||
slug, name = genres[gid]
|
||||
print(f' "{slug}": ({gid}, "{name}"),')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
// Genera una licenza nel DB delle licenze MusicTools.
|
||||
//
|
||||
// Da eseguire SUL SERVER (musictools@musictools.djluza.com) dentro ~/api/:
|
||||
// node scripts/gen-license.mjs <email> [plan]
|
||||
//
|
||||
// plan: annual (default, "full" — no limite, no scadenza)
|
||||
// basic | pro | premium (con daily_limit e period_end mensile)
|
||||
//
|
||||
// Da locale, usare lo script wrapper: server/scripts/gen-license.sh
|
||||
|
||||
import { generateLicenseKey } from "../src/license.js";
|
||||
import { getPlan } from "../src/plans.js";
|
||||
import mysql from "mysql2/promise";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const envPath = path.resolve(__dirname, "..", ".env");
|
||||
fs.readFileSync(envPath, "utf-8").split("\n").forEach((l) => {
|
||||
const m = l.match(/^([A-Z_]+)=(.*)$/);
|
||||
if (m) process.env[m[1]] = m[2];
|
||||
});
|
||||
|
||||
const [, , emailArg, planArg] = process.argv;
|
||||
if (!emailArg) {
|
||||
console.error("Uso: node scripts/gen-license.mjs <email> [plan]");
|
||||
console.error("plan: annual (default) | basic | pro | premium");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const email = emailArg.trim().toLowerCase();
|
||||
const planCode = (planArg || "annual").trim().toLowerCase();
|
||||
const plan = getPlan(planCode);
|
||||
if (!plan) {
|
||||
console.error(`Piano sconosciuto: ${planCode}`);
|
||||
console.error("Validi: annual, basic, pro, premium");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: "127.0.0.1",
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
});
|
||||
|
||||
const key = generateLicenseKey();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const isAnnual = planCode === "annual";
|
||||
const dailyLimit = plan.dailyLimit ?? null;
|
||||
const expiresAt = isAnnual ? null : null;
|
||||
const periodEnd = isAnnual ? null : now + 30 * 24 * 3600;
|
||||
|
||||
await pool.execute(
|
||||
`INSERT INTO licenses
|
||||
(license_key, email, status, plan, daily_limit, source,
|
||||
subscription_id, current_period_end,
|
||||
expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, 'active', ?, ?, 'manual', NULL, ?, ?, ?, ?)`,
|
||||
[key, email, planCode, dailyLimit, periodEnd, expiresAt, now, now],
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
key,
|
||||
email,
|
||||
plan: planCode,
|
||||
daily_limit: dailyLimit,
|
||||
expires_at: expiresAt,
|
||||
current_period_end: periodEnd,
|
||||
}, null, 2));
|
||||
|
||||
await pool.end();
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Genera una licenza MusicTools da locale, eseguendo lo script sul server.
|
||||
#
|
||||
# Uso:
|
||||
# ./server/scripts/gen-license.sh <email> [plan]
|
||||
#
|
||||
# plan: annual (default, "full" — no limite, no scadenza)
|
||||
# basic | pro | premium (con daily_limit e period_end mensile)
|
||||
#
|
||||
# Richiede:
|
||||
# - ssh con chiave configurata verso musictools@musictools.djluza.com
|
||||
# - lo script server-side viene sincronizzato in automatico prima dell'esecuzione
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
EMAIL="${1:-}"
|
||||
PLAN="${2:-annual}"
|
||||
|
||||
if [ -z "$EMAIL" ]; then
|
||||
echo "Uso: $0 <email> [plan]" >&2
|
||||
echo "plan: annual (default) | basic | pro | premium" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SSH_TARGET="musictools@musictools.djluza.com"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
ssh "$SSH_TARGET" 'mkdir -p ~/api/scripts'
|
||||
rsync -a --quiet \
|
||||
"$SCRIPT_DIR/gen-license.mjs" \
|
||||
"${SSH_TARGET}:api/scripts/gen-license.mjs"
|
||||
|
||||
ssh "$SSH_TARGET" "cd ~/api && node scripts/gen-license.mjs '$EMAIL' '$PLAN'"
|
||||
+50
-11
@@ -73,18 +73,31 @@ async function handleOrderCreated(payload, attrs, t) {
|
||||
const planCode = planByVariantId(variantId);
|
||||
const plan = planCode ? getPlan(planCode) : null;
|
||||
|
||||
// Subscriptions: il record vero arriva con subscription_created.
|
||||
// Se per qualche motivo arriva prima (ordine pagato ma sottoscrizione
|
||||
// non ancora generata), accettiamo l'evento ma non creiamo nulla.
|
||||
if (plan && plan.is_subscription) {
|
||||
return { status: 200, body: { ok: true, deferred: "wait_for_subscription_created" } };
|
||||
// Se l'order e' gia stato processato (lo riconosciamo dall'order_id),
|
||||
// non duplichiamo licenza ne' email — ack 200 e basta.
|
||||
const existing = await one(
|
||||
"SELECT id FROM licenses WHERE order_id=? LIMIT 1",
|
||||
[orderId],
|
||||
);
|
||||
if (existing) {
|
||||
return { status: 200, body: { ok: true, duplicate: true } };
|
||||
}
|
||||
|
||||
// Annual one-time (o piano non mappato -> trattalo come annual di default
|
||||
// cosi' non lasciamo l'utente senza licenza se ha pagato).
|
||||
// Piano non mappato -> fallback annual cosi' non blocchiamo l'utente
|
||||
// che ha gia pagato (la licenza sara' usabile anche se non sappiamo
|
||||
// i limiti esatti del piano comprato).
|
||||
const planForRecord = plan?.code || "annual";
|
||||
const limit = plan?.daily_limit ?? null;
|
||||
const expiresAt = t + ANNUAL_TTL_SECONDS;
|
||||
const planMeta = plan || getPlan("annual");
|
||||
const limit = planMeta?.daily_limit ?? null;
|
||||
|
||||
// Annual one-time: settiamo expires_at = now + 365d.
|
||||
// Subscription mensili: NON settiamo current_period_end qui — lo fara'
|
||||
// subscription_created/subscription_payment_success quando arriva.
|
||||
// Se quell'evento non arrivasse mai (perche' non abilitato in LS
|
||||
// dashboard), la licenza resta utilizzabile (current_period_end NULL
|
||||
// = no scadenza imposta lato server: la sottoscrizione vivra' o
|
||||
// morira' su LS, e i webhook successivi aggiorneranno lo status).
|
||||
const expiresAt = planMeta?.is_subscription ? null : t + ANNUAL_TTL_SECONDS;
|
||||
const key = generateLicenseKey();
|
||||
|
||||
try {
|
||||
@@ -103,7 +116,10 @@ async function handleOrderCreated(payload, attrs, t) {
|
||||
}
|
||||
|
||||
try {
|
||||
await sendLicenseEmail(email, key, { plan: planForRecord, expiresAt });
|
||||
await sendLicenseEmail(email, key, {
|
||||
plan: planForRecord,
|
||||
expiresAt: expiresAt,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[email] send failed:", e?.message || e);
|
||||
}
|
||||
@@ -141,7 +157,7 @@ async function handleSubscriptionCreated(payload, attrs, t) {
|
||||
const key = generateLicenseKey();
|
||||
|
||||
// Idempotenza: se ricevo lo stesso subscription_created due volte,
|
||||
// riuso la licenza esistente (non ne creo un'altra).
|
||||
// riuso la licenza esistente.
|
||||
const existing = await one(
|
||||
"SELECT id, license_key FROM licenses WHERE subscription_id=? LIMIT 1",
|
||||
[subId],
|
||||
@@ -156,6 +172,29 @@ async function handleSubscriptionCreated(payload, attrs, t) {
|
||||
return { status: 200, body: { ok: true, duplicate: true } };
|
||||
}
|
||||
|
||||
// Caso normale ora: la licenza e' gia stata creata da order_created
|
||||
// (con subscription_id NULL). La aggiorno con subscription_id e
|
||||
// current_period_end senza generare una nuova chiave ne' una nuova email.
|
||||
const byOrder = orderId
|
||||
? await one(
|
||||
`SELECT id FROM licenses
|
||||
WHERE order_id=? AND subscription_id IS NULL LIMIT 1`,
|
||||
[orderId],
|
||||
)
|
||||
: null;
|
||||
if (byOrder) {
|
||||
await exec(
|
||||
`UPDATE licenses
|
||||
SET subscription_id=?, current_period_end=?, plan=?, daily_limit=?,
|
||||
updated_at=?
|
||||
WHERE id=?`,
|
||||
[subId, periodEnd, planCode, plan.daily_limit, t, byOrder.id],
|
||||
);
|
||||
return { status: 200, body: { ok: true, attached_subscription: subId } };
|
||||
}
|
||||
|
||||
// Fallback: nessuna licenza esistente -> ne creiamo una nuova
|
||||
// (puo' succedere se order_created non e' arrivato per qualche motivo).
|
||||
try {
|
||||
await exec(
|
||||
`INSERT INTO licenses
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,12 @@
|
||||
"""Fixtures pytest condivise."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixtures_dir() -> Path:
|
||||
return FIXTURES_DIR
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8" data-next-head=""/><meta name="viewport" content="width=device-width" data-next-head=""/><link rel="canonical" hrefLang="en" href="https://www.beatport.com/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="de" href="https://www.beatport.com/de/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="fr" href="https://www.beatport.com/fr/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="es" href="https://www.beatport.com/es/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="it" href="https://www.beatport.com/it/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="ja" href="https://www.beatport.com/ja/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="pt" href="https://www.beatport.com/pt/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="nl" href="https://www.beatport.com/nl/genre/melodic-house-techno/90/top-100" data-next-head=""/><title data-next-head="">Melodic House & Techno - TOP 100 | Download & Stream Melodic House & Techno on Beatport</title><meta property="og:title" content="Melodic House & Techno - TOP 100 | Download & Stream Melodic House & Techno on Beatport" data-next-head=""/><meta property="og:description" content="Download the best Melodic House & Techno music and stream the top 100 Melodic House & Techno music tracks on Beatport. Explore the Top 100 Melodic House & Techno artists and DJ now. " data-next-head=""/><meta property="og:image" content="https://www.beatport.com/images/beatport-social-image.png" data-next-head=""/><meta name="twitter:card" content="summary_large_image" data-next-head=""/><meta name="twitter:title" content="Melodic House & Techno - TOP 100 | Download & Stream Melodic House & Techno on Beatport" data-next-head=""/><meta name="twitter:description" content="Download the best Melodic House & Techno music and stream the top 100 Melodic House & Techno music tracks on Beatport. Explore the Top 100 Melodic House & Techno artists and DJ now. " data-next-head=""/><meta name="twitter:image" content="https://www.beatport.com/images/beatport-social-image.png" data-next-head=""/><meta name="description" content="Download the best Melodic House & Techno music and stream the top 100 Melodic House & Techno music tracks on Beatport. Explore the Top 100 Melodic House & Techno artists and DJ now. " data-next-head=""/><link rel="icon" type="image/x-icon" href="/images/favicon-48x48.png" data-next-head=""/><link href="https://js.recurly.com/v4/recurly.css" rel="stylesheet" type="text/css" data-next-head=""/><meta name="bp-env" content="production"/><script>window.__SENTRY_RELEASE__="BP-v1.162.0"</script><link rel="preload" href="/icons/sprite.svg" as="image"/><link rel="preload" href="/_next/static/media/9617edd9e02a645a-s.p.ttf" as="font" type="font/ttf" crossorigin="anonymous" data-next-font="size-adjust"/><link rel="preload" href="/_next/static/media/9438673d1e72232d-s.p.ttf" as="font" type="font/ttf" crossorigin="anonymous" data-next-font="size-adjust"/><link rel="preload" href="/_next/static/media/db5c0e5da3a6b3a9-s.p.ttf" as="font" type="font/ttf" crossorigin="anonymous" data-next-font="size-adjust"/><script id="google-analytics" data-nscript="beforeInteractive">
|
||||
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','GTM-TXN4GRJ');
|
||||
</script><link rel="preload" href="/_next/static/css/dbc5caec36a91cd9.css" as="style"/><link rel="stylesheet" href="/_next/static/css/dbc5caec36a91cd9.css" data-n-g=""/><noscript data-n-css=""></noscript><script defer="" nomodule="" src="/_next/static/chunks/polyfills-42372ed130431b0a.js"></script><script id="ab-tasty" src="https://try.abtasty.com/6a6f6df6afac8eb063d6dbbd68afdcc7.js" defer="" data-nscript="beforeInteractive"></script><script src="https://songbird.cardinalcommerce.com/edge/v1/songbird.js" defer="" data-nscript="beforeInteractive"></script><script id="pap_x2s6df8d" src="https://pluginboutique.postaffiliatepro.com/scripts/trackjs.js" defer="" data-nscript="beforeInteractive"></script><script src="/_next/static/chunks/webpack-6f1bbaae2c104d2c.js" defer=""></script><script src="/_next/static/chunks/framework-0353c99733a74dff.js" defer=""></script><script src="/_next/static/chunks/main-6be71f34df367310.js" defer=""></script><script src="/_next/static/chunks/pages/_app-481baf8cf9b61739.js" defer=""></script><script src="/_next/static/chunks/1672-274ad8535921289a.js" defer=""></script><script src="/_next/static/chunks/pages/genre/%5Bdescription%5D/%5Bid%5D/top-100-991e44049fcd48b3.js" defer=""></script><script src="/_next/static/z5IFYCzpmVKqxZJshoItQ/_buildManifest.js" defer=""></script><script src="/_next/static/z5IFYCzpmVKqxZJshoItQ/_ssgManifest.js" defer=""></script><style data-styled="" data-styled-version="6.4.3">.dXSwbq{box-sizing:border-box;display:flex;flex-direction:row;justify-content:center;align-items:center;padding:0.313rem 1rem 0.438rem;gap:0.5rem;border-radius:0.25rem;cursor:pointer;font-family:'aeonikFont','aeonikFont Fallback';font-size:0.875rem;line-height:1.25rem;font-weight:500;color:#141414;}/*!sc*/
|
||||
.dXSwbq.white{background-color:#FFFFFF;}/*!sc*/
|
||||
.dXSwbq.white:hover{background-color:#01CC77;}/*!sc*/
|
||||
.dXSwbq.primary{background:#01FF95;}/*!sc*/
|
||||
.dXSwbq.primary:hover{background:#01CC77;}/*!sc*/
|
||||
.dXSwbq.primary.primary.disabled{pointer-events:none;color:#343434;background:#5A5A5A;}/*!sc*/
|
||||
.dXSwbq.primary.danger{background-color:#CF222E;border-color:transparent;}/*!sc*/
|
||||
.dXSwbq.primary.danger:hover{background:#99242B;}/*!sc*/
|
||||
.dXSwbq.outline{border:0.063rem solid;border-image-source:linear-gradient( 0deg,rgba(0,0,0,0.6),rgba(0,0,0,0.6) ),linear-gradient( 0deg,#01FF95,#01FF95 );color:#01FF95;}/*!sc*/
|
||||
.dXSwbq.outline:hover{border:0.063rem solid;color:#141414;background:#01FF95;}/*!sc*/
|
||||
.dXSwbq.outline.outline.disabled{background:transparent;color:#01FF95;opacity:50%;}/*!sc*/
|
||||
.dXSwbq.outline-secondary{border:0.063rem solid;border-color:#8C8C8C;color:#FFFFFF;}/*!sc*/
|
||||
.dXSwbq.outline-secondary:hover{background:#8C8C8C;}/*!sc*/
|
||||
.dXSwbq.outline-secondary.outline.disabled{background:transparent;color:#FFFFFF;opacity:50%;}/*!sc*/
|
||||
.dXSwbq.outline-secondary.caution:disabled{cursor:auto;border-color:#5A5A5A;color:#5A5A5A;}/*!sc*/
|
||||
.dXSwbq.outline-secondary.caution:disabled svg{fill:#5A5A5A;stroke:#5A5A5A;}/*!sc*/
|
||||
.dXSwbq.outline-secondary.caution:hover:not(:disabled){background:#CF222E;border-color:transparent;}/*!sc*/
|
||||
.dXSwbq.tertiary{color:#FFFFFF;background:#8C8C8C;}/*!sc*/
|
||||
.dXSwbq.tertiary:hover{background:#5A5A5A;}/*!sc*/
|
||||
.dXSwbq.tertiary.tertiary.disabled{background:#8C8C8C;opacity:0.5;}/*!sc*/
|
||||
.dXSwbq.secondary{color:#FFFFFF;background:#5A5A5A;}/*!sc*/
|
||||
.dXSwbq.secondary:hover{background:#8C8C8C;}/*!sc*/
|
||||
.dXSwbq.secondary.disabled{background:#8C8C8C;opacity:0.5;}/*!sc*/
|
||||
.dXSwbq.danger{color:#FFFFFF;background:#CF222E;}/*!sc*/
|
||||
.dXSwbq.danger:hover{background:#CF222E;}/*!sc*/
|
||||
.dXSwbq.danger.disabled{background:#8C8C8C;opacity:0.5;}/*!sc*/
|
||||
.dXSwbq.transparent,.dXSwbq.transparent-danger{color:#FFFFFF;border:0.125rem solid #5A5A5A;padding:0.313rem 0.75rem;gap:0.375rem;}/*!sc*/
|
||||
.dXSwbq.transparent:hover{background:#5A5A5A;}/*!sc*/
|
||||
.dXSwbq.transparent-danger:hover{border:0.125rem solid #CF222E;background:#CF222E;}/*!sc*/
|
||||
.dXSwbq.pill{color:#FFFFFF;background:#343434;border-radius:0.375rem;padding:0.75rem 1rem;height:2.5rem;}/*!sc*/
|
||||
.dXSwbq.pill:hover{background:#494949;}/*!sc*/
|
||||
.dXSwbq.link{color:#FFFFFF;}/*!sc*/
|
||||
.dXSwbq.primary-link{color:#01FF95;}/*!sc*/
|
||||
.dXSwbq.primary-link.disabled{cursor:default;}/*!sc*/
|
||||
.dXSwbq.text{color:#B3B3B3;}/*!sc*/
|
||||
.dXSwbq.text:hover{color:#FFFFFF;text-decoration:underline;}/*!sc*/
|
||||
.dXSwbq.underline-link{color:#B3B3B3;text-decoration:underline;padding:0.25rem;}/*!sc*/
|
||||
.dXSwbq.unstyled{background:none;border:none;padding:0;margin:0;color:inherit;background-color:transparent;}/*!sc*/
|
||||
.dXSwbq.w-100{width:100%;}/*!sc*/
|
||||
.dXSwbq.w-75{width:75%;}/*!sc*/
|
||||
.dXSwbq.w-50{width:50%;}/*!sc*/
|
||||
.dXSwbq.w-25{width:25%;}/*!sc*/
|
||||
.dXSwbq.squared{padding:0.313rem;}/*!sc*/
|
||||
data-styled.g1[id="Button-style__ButtonStyled-sc-e1b4adaf-0"]{content:"dXSwbq,"}/*!sc*/
|
||||
.jVzvJX{position:relative;margin-left:0.75rem;padding:0.5rem 0;}/*!sc*/
|
||||
.jVzvJX >a,.jVzvJX >button{display:flex;flex-direction:row;gap:0.3125rem;cursor:pointer;align-items:center;padding:0.313rem 0.25rem 0.438rem 0.25rem;color:#FFFFFF;}/*!sc*/
|
||||
.jVzvJX >a svg,.jVzvJX >button svg{stroke:#FFFFFF;}/*!sc*/
|
||||
@media (min-width: 1200px){.jVzvJX >a:hover,.jVzvJX >button:hover{color:#01FF95;}.jVzvJX >a:hover svg,.jVzvJX >button:hover svg{stroke:#01FF95;}}/*!sc*/
|
||||
@media (min-width: 1200px){.jVzvJX >a,.jVzvJX >button{padding:0;}.jVzvJX >a svg,.jVzvJX >button svg{width:1.3rem;height:1.3rem;}}/*!sc*/
|
||||
data-styled.g35[id="HeaderCart-style__AccountCartMenuItem-sc-6f1edafa-2"]{content:"jVzvJX,"}/*!sc*/
|
||||
.dkwGxt{position:absolute;top:3.6875rem;left:50%;transform:translate(-50%,1px);transition:opacity 0.2s ease;background:#141414;border-radius:0 0 1rem 1rem;display:block;z-index:31;cursor:default;opacity:0;visibility:hidden;pointer-events:none;box-shadow:0 0.5rem 1.5rem rgba(0,0,0,0.5);}/*!sc*/
|
||||
.dkwGxt[data-dropdown-type='genre']{width:100%;max-width:90rem;}/*!sc*/
|
||||
.dkwGxt[data-dropdown-type='events']{left:auto;min-width:12.5rem;margin:0 0 0 4.75rem;}/*!sc*/
|
||||
.dkwGxt[data-visible='true']{opacity:1;pointer-events:auto;visibility:visible;}/*!sc*/
|
||||
data-styled.g40[id="HeaderDropdownToggle-style__DropdownMenu-sc-fff30e07-0"]{content:"dkwGxt,"}/*!sc*/
|
||||
.jgwRkR{display:flex;flex-direction:column;padding:0.5rem 0;}/*!sc*/
|
||||
.jgwRkR a{padding:0.5rem 1.5rem;color:#B3B3B3;text-decoration:none;display:flex;align-items:center;transition:background-color 0.2s ease;}/*!sc*/
|
||||
.jgwRkR a:hover{color:#FFFFFF;}/*!sc*/
|
||||
.jgwRkR a:hover >svg{stroke:#01FF95;}/*!sc*/
|
||||
data-styled.g42[id="HeaderEventsSubMenu-style__EventsSubMenuWrapper-sc-75ecafea-0"]{content:"jgwRkR,"}/*!sc*/
|
||||
.kNtdbg{display:grid;grid-template-columns:1fr;flex-direction:row;justify-content:space-between;align-items:flex-start;width:100%;padding:1.5rem;gap:2rem;}/*!sc*/
|
||||
data-styled.g48[id="HeaderGenreSubMenu-style__GenreDropDownGrid-sc-5f01336e-4"]{content:"kNtdbg,"}/*!sc*/
|
||||
.gZFJrf{display:flex;flex-direction:row;position:absolute;top:0;left:0;right:0;z-index:40;height:60px;width:100%;font-size:0.875rem;background:#141414;border-bottom:0.0625rem solid #343434;}/*!sc*/
|
||||
.gZFJrf.header-fixed{position:fixed;}/*!sc*/
|
||||
.gZFJrf ul> li{display:inline-flex;vertical-align:middle;}/*!sc*/
|
||||
.gZFJrf span:not(.new_tag){display:flex;flex-direction:row;}/*!sc*/
|
||||
.gZFJrf span:not(.new_tag) >img{margin-right:0.25rem;}/*!sc*/
|
||||
.gZFJrf span:not(.new_tag) svg{stroke:#8C8C8C;}/*!sc*/
|
||||
@keyframes slideDown{from{transform:translateY(-100%);}to{transform:translateY(0);}}/*!sc*/
|
||||
data-styled.g50[id="Header-style__HeaderWrapper-sc-54c5f6f1-0"]{content:"gZFJrf,"}/*!sc*/
|
||||
.kFaHkN{display:flex;flex-direction:row;width:100%;max-width:90rem;margin:0 auto;align-items:center;}/*!sc*/
|
||||
.kFaHkN svg{stroke:#8C8C8C;}/*!sc*/
|
||||
.kFaHkN >ul .dropdown_menu{animation:growDown 300ms ease-in-out forwards;transform-origin:top center;}/*!sc*/
|
||||
.kFaHkN >ul >li{line-height:1.25rem;padding:1.25rem 0.4375rem;cursor:pointer;}/*!sc*/
|
||||
.kFaHkN >ul >li.header_item:not(.dropdown_toggle):hover div{display:flex;}/*!sc*/
|
||||
.kFaHkN >ul >li.account button{padding:0;}/*!sc*/
|
||||
.kFaHkN >ul >li button{font-weight:400;}/*!sc*/
|
||||
.kFaHkN >ul .header_item [aria-expanded="true"] .rotatable svg{stroke:#01FF95;transform:rotate(180deg);margin-top:0.3125rem;}/*!sc*/
|
||||
.kFaHkN >ul .header_item .svg_wrapper{display:flex;align-items:center;height:100%;}/*!sc*/
|
||||
.kFaHkN >ul .header_item .svg_wrapper img{border-radius:50%;}/*!sc*/
|
||||
.kFaHkN >ul .header_item a[target="_blank"]:hover svg{stroke:#01FF95;}/*!sc*/
|
||||
.kFaHkN span{align-items:center;}/*!sc*/
|
||||
.kFaHkN span >img{margin-left:0.125rem;margin-bottom:0.0625rem;}/*!sc*/
|
||||
@keyframes growDown{0%{transform:scaleY(0);}100%{transform:scaleY(1);}}/*!sc*/
|
||||
data-styled.g52[id="Header-style__DesktopHeader-sc-54c5f6f1-2"]{content:"kFaHkN,"}/*!sc*/
|
||||
.eRPXdM{min-width:8rem;margin-left:1rem;}/*!sc*/
|
||||
.eRPXdM >li{padding:0 0 0 0.25rem;}/*!sc*/
|
||||
.eRPXdM >li button{padding:0.8rem 0.75rem 0.5rem 0.25rem;}/*!sc*/
|
||||
@media (min-width: 768px){.eRPXdM{width:100%;}}/*!sc*/
|
||||
@media (min-width: 1200px){.eRPXdM{min-width:44.5rem;}}/*!sc*/
|
||||
data-styled.g53[id="Header-style__LeftRegion-sc-54c5f6f1-3"]{content:"eRPXdM,"}/*!sc*/
|
||||
.exexVn{display:flex;justify-content:flex-end;align-items:center;width:100%;padding-right:1.25rem;}/*!sc*/
|
||||
.exexVn .svg_wrapper{display:flex;align-items:center;height:100%;}/*!sc*/
|
||||
.exexVn div.account_avatar{width:2rem;height:2rem;}/*!sc*/
|
||||
.exexVn div.account_avatar img{border-radius:50%;}/*!sc*/
|
||||
.exexVn div.account_avatar img{aspect-ratio:1/1;object-fit:cover;object-position:center;}/*!sc*/
|
||||
.exexVn .subscription_button a{padding:0.313rem 1rem 0.438rem;border-radius:0.25rem;font-weight:500;color:#141414;background:#01FF95;}/*!sc*/
|
||||
.exexVn .subscription_button a:hover{background:#01CC77;}/*!sc*/
|
||||
@media (min-width: 768px){.exexVn{min-width:9.5rem;max-width:49.375rem;}}/*!sc*/
|
||||
@media (min-width: 1200px){.exexVn li.search:first-child{width:100%;max-width:28.375rem;padding:0 0.5rem 0 0;}.exexVn li.subscription_button{min-width:9rem;}}/*!sc*/
|
||||
data-styled.g54[id="Header-style__RightRegion-sc-54c5f6f1-4"]{content:"exexVn,"}/*!sc*/
|
||||
.dNxFON{height:1.8125rem;width:1.375rem;margin-right:0.625rem;}/*!sc*/
|
||||
data-styled.g57[id="Header-style__BeatportLogoLarge-sc-54c5f6f1-7"]{content:"dNxFON,"}/*!sc*/
|
||||
.koymgh{display:flex;align-items:center;width:100%;max-width:28.375rem;padding:0 1.5rem;}/*!sc*/
|
||||
.koymgh >button>svg{margin-right:1.25rem;height:1.25rem;stroke-width:0.125rem;}/*!sc*/
|
||||
data-styled.g60[id="Header-style__HeaderSearchWrapper-sc-54c5f6f1-10"]{content:"koymgh,"}/*!sc*/
|
||||
.eCACUJ{display:flex;flex:1;width:100%;border:0.0625rem solid #343434;border-radius:0.25rem;background:#343434;}/*!sc*/
|
||||
.eCACUJ:focus-within{border:0.0625rem solid #01FF95;box-shadow:0px 0px 0px 3px rgba(1, 255, 149, 0.15);outline:0.125rem solid transparent;outline-offset:0.125rem;}/*!sc*/
|
||||
.eCACUJ div:first-child{display:flex;align-items:center;pointer-events:none;margin-left:0.3125rem;}/*!sc*/
|
||||
.eCACUJ button{display:flex;align-items:center;margin-right:0.625rem;}/*!sc*/
|
||||
.eCACUJ img{margin-left:0.3125rem;margin-top:0.0625rem;height:1rem;width:1rem;}/*!sc*/
|
||||
.eCACUJ input{height:2rem;margin-left:0.3125rem;width:100%;background:inherit;outline:none;border:none;}/*!sc*/
|
||||
.eCACUJ input::placeholder{color:#8C8C8C;}/*!sc*/
|
||||
data-styled.g61[id="Header-style__HeaderSearch-sc-54c5f6f1-11"]{content:"eCACUJ,"}/*!sc*/
|
||||
.dxGRL{font-size:max(100%,16px);}/*!sc*/
|
||||
.dxGRL:focus::placeholder{color:transparent;}/*!sc*/
|
||||
data-styled.g64[id="Search-style__SearchInput-sc-224b2fdd-1"]{content:"dxGRL,"}/*!sc*/
|
||||
.btldZz{position:fixed;top:3.75rem;left:calc(100% - 370px);width:22.5rem;display:flex;flex-direction:column;gap:0.5rem;font-size:0.875rem;line-height:1.25rem;z-index:60;}/*!sc*/
|
||||
@media (min-width: 580px){.btldZz{left:calc(100% - 385px);}}/*!sc*/
|
||||
@media (min-width: 768px){.btldZz{left:calc(100% - 385px);}}/*!sc*/
|
||||
@media (min-width: 1200px){.btldZz{left:calc(100% - 385px);}}/*!sc*/
|
||||
@media (min-width: 1440px){.btldZz{left:initial;right:calc((100% - 1371px) / 2);}}/*!sc*/
|
||||
data-styled.g98[id="Notifications-style__Wrapper-sc-6faed7f7-1"]{content:"btldZz,"}/*!sc*/
|
||||
.fUBPTR{padding:8px;font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:400;font-size:12px;line-height:14px;color:#8C8C8C;}/*!sc*/
|
||||
.fUBPTR.p-0{padding:0;}/*!sc*/
|
||||
.fUBPTR.px-0{padding-left:0;padding-right:0;}/*!sc*/
|
||||
.fUBPTR.py-0{padding-top:0;padding-bottom:0;}/*!sc*/
|
||||
.fUBPTR .selectable:hover{opacity:1;}/*!sc*/
|
||||
data-styled.g99[id="Table-style__TableCell-sc-ef6c0b11-0"]{content:"fUBPTR,"}/*!sc*/
|
||||
.knBMpy{width:100%;display:flex;flex-direction:row;justify-content:space-between;align-items:center;height:40px;background:#343434;margin:0 0 2px;}/*!sc*/
|
||||
.knBMpy.selected{background:#494949;}/*!sc*/
|
||||
.knBMpy.tombstoned{background:#2D2D2D;}/*!sc*/
|
||||
.knBMpy.tombstoned a{pointer-events:none;}/*!sc*/
|
||||
.knBMpy.tombstoned:hover{background:#494949;}/*!sc*/
|
||||
.knBMpy svg.album-cover{display:none;position:absolute;margin-left:15px;}/*!sc*/
|
||||
.knBMpy:hover{background:#494949;position:relative;}/*!sc*/
|
||||
.knBMpy:hover div.chart-cell{color:white;}/*!sc*/
|
||||
.knBMpy:hover svg.album-cover{display:inline;}/*!sc*/
|
||||
.knBMpy:hover:hover::before{display:none;content:"";position:absolute;top:100%;left:50%;font-weight:400;font-size:12px;line-height:16px;border-radius:8px;transition:background 0.2s;z-index:1;background:#141414;color:#FFFFFF;margin-left:-5px;border:5px solid transparent;border-top-color:#141414;}/*!sc*/
|
||||
.knBMpy div:nth-child(2){flex-grow:1;}/*!sc*/
|
||||
data-styled.g100[id="Table-style__TableRow-sc-ef6c0b11-1"]{content:"knBMpy,"}/*!sc*/
|
||||
.kfOJRI{display:flex;flex-direction:column;justify-content:flex-start;align-items:flex-start;width:100%;}/*!sc*/
|
||||
data-styled.g101[id="Table-style__TableData-sc-ef6c0b11-2"]{content:"kfOJRI,"}/*!sc*/
|
||||
.iJZhfa{width:100%;display:flex;flex-direction:row;justify-content:space-between;align-items:center;}/*!sc*/
|
||||
data-styled.g102[id="Table-style__TableHeader-sc-ef6c0b11-3"]{content:"iJZhfa,"}/*!sc*/
|
||||
.eshZZd{width:100%;display:flex;flex-direction:column;justify-content:flex-start;align-items:flex-start;margin-top:12px;border-top:1px solid #343434;}/*!sc*/
|
||||
.eshZZd a:hover{text-decoration:underline;}/*!sc*/
|
||||
data-styled.g103[id="Table-style__Table-sc-ef6c0b11-4"]{content:"eshZZd,"}/*!sc*/
|
||||
.czBPNh{display:inline-flex;align-items:center;justify-content:center;width:1rem;height:1rem;flex-shrink:0;margin-left:0.25rem;}/*!sc*/
|
||||
data-styled.g104[id="Icons-styles__ExplicitIconContainer-sc-2b56028a-0"]{content:"czBPNh,"}/*!sc*/
|
||||
.fErSXU{display:flex;flex-direction:row;justify-content:center;align-items:center;float:inline-end;}/*!sc*/
|
||||
data-styled.g105[id="AddToCart-style__Control-sc-af8417c8-0"]{content:"fErSXU,"}/*!sc*/
|
||||
.cXOApT{border:none;margin:0;padding:0;font:inherit;background:transparent;cursor:pointer;max-width:100%;display:block;width:3.75rem;white-space:nowrap;background:#FF53A0;transition:background 0.2s ease-in-out,color 0.2s ease-in-out;color:#FFFFFF;border-radius:0.25rem 0rem 0rem 0.25rem;padding:0.25rem 0.5rem;font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:700;font-size:0.75rem;line-height:1rem;text-align:center;}/*!sc*/
|
||||
.row .cXOApT{background:#913B61;color:#B3B3B3;}/*!sc*/
|
||||
.row:hover .cXOApT{background:#FF53A0;color:#FFFFFF;}/*!sc*/
|
||||
data-styled.g108[id="AddToCart-style__PriceButton-sc-af8417c8-3"]{content:"cXOApT,"}/*!sc*/
|
||||
.ewatVW{border:none;margin:0;padding:0;font:inherit;background:transparent;cursor:pointer;background:#D64988;transition:background 0.2s ease-in-out;border-radius:0rem 0.25rem 0.25rem 0rem;padding:0.25rem;font-weight:700;font-size:0.75rem;line-height:1rem;width:1.5rem;min-width:1.5rem;}/*!sc*/
|
||||
.ewatVW i{margin:0.25rem;display:inline-block;vertical-align:middle;transition:border-top-color 0.2s ease-in-out;border-left:0.25rem solid transparent;border-right:0.25rem solid transparent;border-top:0.25rem solid currentColor;}/*!sc*/
|
||||
.ewatVW >div>svg{width:1rem;height:1rem;}/*!sc*/
|
||||
.row .ewatVW{background:#7C3655;}/*!sc*/
|
||||
.row:hover .ewatVW{background:#D64988;}/*!sc*/
|
||||
.row:hover .ewatVW i{border-top-color:#FFFFFF;}/*!sc*/
|
||||
data-styled.g110[id="AddToCart-style__ArrowButton-sc-af8417c8-5"]{content:"ewatVW,"}/*!sc*/
|
||||
.fXQEET{display:none;flex-direction:center;justify-content:center;background:#141414;padding:4px;font-weight:400;font-size:12px;line-height:16px;border-radius:8px;transition:background 0.2s;background:#141414;color:#FFFFFF;text-align:center;padding:4px 4px;width:86px;position:absolute;z-index:2;filter:none;left:50%;margin-left:-43px;}/*!sc*/
|
||||
.fXQEET.top{bottom:155%;}/*!sc*/
|
||||
.fXQEET.top:after{position:absolute;content:"";top:100%;left:50%;margin-left:-5px;border:5px solid transparent;border-top-color:#141414;}/*!sc*/
|
||||
.fXQEET.top-left{bottom:155%;left:-60%;}/*!sc*/
|
||||
.fXQEET.top-left:after{position:absolute;content:"";top:100%;margin-right:-50px;border:5px solid transparent;border-top-color:#141414;}/*!sc*/
|
||||
.fXQEET.top-right{bottom:155%;right:-60%;}/*!sc*/
|
||||
.fXQEET.top-right:after{position:absolute;content:"";top:100%;margin-left:-50px;border:5px solid transparent;border-top-color:#141414;}/*!sc*/
|
||||
.fXQEET.bottom{top:115%;}/*!sc*/
|
||||
.fXQEET.bottom:before{position:absolute;content:"";bottom:100%;left:50%;margin-left:-5px;border:5px solid transparent;border-bottom-color:#141414;}/*!sc*/
|
||||
.fXQEET.left{right:calc(100% + 0.4rem);top:0;bottom:0;margin:auto 0;left:unset;margin-left:unset;height:1.7rem;padding:0.3rem 0.6rem;width:600px;max-width:max-content;}/*!sc*/
|
||||
.fXQEET.left:after{position:absolute;content:"";top:50%;left:100%;margin-top:-5px;border:5px solid transparent;border-left-color:#141414;}/*!sc*/
|
||||
data-styled.g114[id="Tooltip-style__TooltipContainer-sc-6f1e293b-0"]{content:"fXQEET,"}/*!sc*/
|
||||
.gIOTyw{position:relative;}/*!sc*/
|
||||
.gIOTyw:hover .Tooltip-style__TooltipContainer-sc-6f1e293b-0{display:flex;}/*!sc*/
|
||||
data-styled.g115[id="Tooltip-style__Wrapper-sc-6f1e293b-1"]{content:"gIOTyw,"}/*!sc*/
|
||||
.bdZnJo{display:flex;align-items:center;position:relative;color:#B3B3B3;}/*!sc*/
|
||||
.bdZnJo svg:hover{color:#FFFFFF;}/*!sc*/
|
||||
.bdZnJo:disabled svg{filter:unset;}/*!sc*/
|
||||
data-styled.g116[id="AddToPlaylist-style__Control-sc-8c3c49e4-0"]{content:"bdZnJo,"}/*!sc*/
|
||||
.kNIDMA{display:flex;align-items:center;position:relative;color:#B3B3B3;}/*!sc*/
|
||||
.kNIDMA svg:hover{color:#FFFFFF;}/*!sc*/
|
||||
.kNIDMA:disabled svg{filter:unset;}/*!sc*/
|
||||
data-styled.g117[id="AddToQueue-style__Control-sc-1bc05ec0-0"]{content:"kNIDMA,"}/*!sc*/
|
||||
.gkrZWH{display:flex;align-items:center;position:relative;color:#B3B3B3;}/*!sc*/
|
||||
.gkrZWH:hover{--play-btn-bg:#5A5A5A;color:#FFFFFF;}/*!sc*/
|
||||
.gkrZWH:disabled svg{filter:unset;}/*!sc*/
|
||||
data-styled.g118[id="Play-style__Control-sc-6d5ab96d-0"]{content:"gkrZWH,"}/*!sc*/
|
||||
.gxWPvu{text-overflow:ellipsis;overflow:hidden;width:auto;}/*!sc*/
|
||||
data-styled.g123[id="Marquee-style__MarqueeElement-sc-b0373cc7-0"]{content:"gxWPvu,"}/*!sc*/
|
||||
.jkEITr{overflow:hidden;width:100%;display:grid;grid-auto-flow:column;position:relative;max-width:17rem;white-space:nowrap;}/*!sc*/
|
||||
.jkEITr>.Marquee-style__MarqueeElement-sc-b0373cc7-0{display:inline-block;width:max-content;}/*!sc*/
|
||||
.jkEITr>.Marquee-style__MarqueeElement-sc-b0373cc7-0:nth-child(2){margin-left:16px;visibility:hidden;}/*!sc*/
|
||||
.jkEITr>.Marquee-style__MarqueeElement-sc-b0373cc7-0{white-space:nowrap;}/*!sc*/
|
||||
.fjXTTJ{overflow:hidden;width:100%;display:grid;grid-auto-flow:column;position:relative;white-space:nowrap;}/*!sc*/
|
||||
.fjXTTJ>.Marquee-style__MarqueeElement-sc-b0373cc7-0{display:inline-block;width:max-content;}/*!sc*/
|
||||
.fjXTTJ>.Marquee-style__MarqueeElement-sc-b0373cc7-0:nth-child(2){margin-left:16px;visibility:hidden;}/*!sc*/
|
||||
.fjXTTJ>.Marquee-style__MarqueeElement-sc-b0373cc7-0{white-space:nowrap;}/*!sc*/
|
||||
data-styled.g124[id="Marquee-style__MarqueeWrapper-sc-b0373cc7-1"]{content:"jkEITr,fjXTTJ,"}/*!sc*/
|
||||
.gtSAGs{display:grid;grid-template-columns:1fr;width:100%;}/*!sc*/
|
||||
data-styled.g125[id="Marquee-style__MarqueeGrid-sc-b0373cc7-2"]{content:"gtSAGs,"}/*!sc*/
|
||||
.hoWvxf{font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:400;font-size:1.125rem;line-height:1rem;text-align:center;width:2.25rem;min-width:2.25rem;}/*!sc*/
|
||||
.hoWvxf.active{font-size:1rem;color:#FFFFFF;}/*!sc*/
|
||||
.izvHCz{font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:400;font-size:1rem;line-height:1rem;text-align:center;width:2.25rem;min-width:2.25rem;}/*!sc*/
|
||||
.izvHCz.active{font-size:1rem;color:#FFFFFF;}/*!sc*/
|
||||
data-styled.g127[id="Tables-shared-style__TrackNo-sc-d94d4af-1"]{content:"hoWvxf,izvHCz,"}/*!sc*/
|
||||
.fBVOpY{right:0;top:0;height:0.875rem;padding:0 0.063rem;font-size:0.438rem;background:#01FF95;width:100%;color:#141414;position:absolute;font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:700;}/*!sc*/
|
||||
data-styled.g128[id="Tables-shared-style__Exclusive-sc-d94d4af-2"]{content:"fBVOpY,"}/*!sc*/
|
||||
.iHAwiX{display:flex;}/*!sc*/
|
||||
data-styled.g129[id="Tables-shared-style__TitleRow-sc-d94d4af-3"]{content:"iHAwiX,"}/*!sc*/
|
||||
.jkqMEC{font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:700;font-size:0.688rem;line-height:1rem;letter-spacing:0.005em;color:#FFFFFF;}/*!sc*/
|
||||
.jkqMEC span{font-weight:400;}/*!sc*/
|
||||
data-styled.g130[id="Tables-shared-style__ReleaseName-sc-d94d4af-4"]{content:"jkqMEC,"}/*!sc*/
|
||||
.febCWq{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:0.625rem;}/*!sc*/
|
||||
.febCWq a.artwork{position:relative;}/*!sc*/
|
||||
.febCWq img{aspect-ratio:1/1;object-fit:cover;object-position:center;}/*!sc*/
|
||||
.febCWq svg{transition:color 0.2s ease-in-out;color:inherit;}/*!sc*/
|
||||
.row:hover .febCWq svg{color:#FFFFFF;}/*!sc*/
|
||||
data-styled.g131[id="Tables-shared-style__ArtworkAndControls-sc-d94d4af-5"]{content:"febCWq,"}/*!sc*/
|
||||
.gLWMaE .row{justify-content:flex-start;}/*!sc*/
|
||||
.gLWMaE .row.current{background:#494949;}/*!sc*/
|
||||
.gLWMaE .cell{padding-top:0;padding-bottom:0;}/*!sc*/
|
||||
.gLWMaE .cell a:hover{text-decoration:underline;}/*!sc*/
|
||||
.gLWMaE .cell.price{font-size:0.875rem;font-weight:700;color:#FFFFFF;}/*!sc*/
|
||||
.gLWMaE .controls{flex-shrink:0;padding-left:0;width:8.125rem;min-width:8.125rem;}/*!sc*/
|
||||
.gLWMaE .title{width:100%;flex-grow:1;overflow:hidden;}/*!sc*/
|
||||
.gLWMaE .title .container{overflow:hidden;}/*!sc*/
|
||||
.gLWMaE .title .container >div,.gLWMaE .title .container >a>span{overflow:hidden;}/*!sc*/
|
||||
.gLWMaE .format{width:8.5rem;min-width:8.5rem;}/*!sc*/
|
||||
.gLWMaE .price{width:4.5rem;min-width:4.5rem;}/*!sc*/
|
||||
.gLWMaE .cart-actions{width:7.5rem;min-width:7.5rem;display:flex;justify-content:flex-end;}/*!sc*/
|
||||
.gLWMaE .card{width:7.5rem;min-width:7.5rem;display:flex;justify-content:flex-end;}/*!sc*/
|
||||
.gLWMaE.numbers .controls{width:11.125rem;min-width:11.125rem;}/*!sc*/
|
||||
data-styled.g132[id="Tables-shared-style__Wrapper-sc-d94d4af-6"]{content:"gLWMaE,"}/*!sc*/
|
||||
.kOioDs{display:flex;align-items:center;justify-content:flex-start;position:fixed;bottom:calc(0px + 1rem);right:calc(1rem + var(--scrollbar-width,0px));height:2.5rem;padding:0 1rem;font-weight:500;background:#39C0DE;border-radius:2rem;color:#141414;overflow:hidden;transition:max-width 0.3s ease-in-out;z-index:3;max-width:2.5rem;}/*!sc*/
|
||||
.kOioDs >svg{min-width:1rem;min-height:1rem;margin-left:-0.2rem;margin-right:0.5rem;}/*!sc*/
|
||||
.kOioDs >span{transition:opacity 0.4s ease-in-out;opacity:0;}/*!sc*/
|
||||
@media (min-width: 1024px){.kOioDs{bottom:1rem;}.kOioDs:hover{max-width:15rem;}.kOioDs:hover >span{opacity:1;}}/*!sc*/
|
||||
data-styled.g216[id="FeedbackButton-style__ButtonStyled-sc-75292206-0"]{content:"kOioDs,"}/*!sc*/
|
||||
.clBnoV{background-color:#343434;display:flex;min-height:19rem;justify-content:center;padding:3rem 1.25rem calc(3rem + 0px) 1.25rem;}/*!sc*/
|
||||
data-styled.g228[id="Footer-style__FooterContainer-sc-4f2908a0-0"]{content:"clBnoV,"}/*!sc*/
|
||||
.gxyJqq{max-width:90rem;width:100%;margin:0 auto;display:flex;justify-content:space-between;gap:2rem 1rem;flex-direction:column;}/*!sc*/
|
||||
@media (min-width: 768px){.gxyJqq{flex-direction:row;}}/*!sc*/
|
||||
@media (min-width: 1024px){.gxyJqq{gap:2rem;}}/*!sc*/
|
||||
@media (min-width: 1200px){.gxyJqq{padding:0 1.5rem;}}/*!sc*/
|
||||
data-styled.g229[id="Footer-style__FooterMaxWidth-sc-4f2908a0-1"]{content:"gxyJqq,"}/*!sc*/
|
||||
.iLPfwv{display:flex;flex-direction:column;gap:2.5rem;flex:0 1 auto;order:1;min-width:15rem;justify-content:space-between;}/*!sc*/
|
||||
@media (min-width: 1024px){.iLPfwv{min-width:16.25rem;}}/*!sc*/
|
||||
data-styled.g230[id="Footer-style__BrandColumnContainer-sc-4f2908a0-2"]{content:"iLPfwv,"}/*!sc*/
|
||||
.fsTWEU{display:flex;flex-direction:column;justify-content:start;gap:2.5rem;}/*!sc*/
|
||||
data-styled.g231[id="Footer-style__BrandWrapper-sc-4f2908a0-3"]{content:"fsTWEU,"}/*!sc*/
|
||||
.jvSVck{display:flex;flex-wrap:wrap;align-items:center;gap:1rem;padding-right:0.625rem;}/*!sc*/
|
||||
data-styled.g232[id="Footer-style__SocialLinksContainer-sc-4f2908a0-4"]{content:"jvSVck,"}/*!sc*/
|
||||
.dhzLKu{display:flex;flex-wrap:wrap;flex:1 1 auto;row-gap:2rem;max-width:45rem;order:3;justify-content:space-between;gap:1rem;}/*!sc*/
|
||||
@media (min-width: 768px){.dhzLKu{flex-wrap:nowrap;order:2;}}/*!sc*/
|
||||
data-styled.g233[id="Footer-style__LinksColumnContainer-sc-4f2908a0-5"]{content:"dhzLKu,"}/*!sc*/
|
||||
.csFdrZ{width:100%;}/*!sc*/
|
||||
@media (min-width: 580px){.csFdrZ{max-width:10rem;}}/*!sc*/
|
||||
data-styled.g234[id="Footer-style__LinksGroup-sc-4f2908a0-6"]{content:"csFdrZ,"}/*!sc*/
|
||||
.dNLOmf{color:#FFFFFF;font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:400;font-size:1rem;line-height:1.25rem;margin-bottom:1rem;display:block;}/*!sc*/
|
||||
@media (min-width: 580px){.dNLOmf{font-size:0.75rem;line-height:1rem;}}/*!sc*/
|
||||
data-styled.g235[id="Footer-style__GroupTitle-sc-4f2908a0-7"]{content:"dNLOmf,"}/*!sc*/
|
||||
.gUWvku{list-style:none;padding:0;}/*!sc*/
|
||||
data-styled.g236[id="Footer-style__LinksList-sc-4f2908a0-8"]{content:"gUWvku,"}/*!sc*/
|
||||
.kWoBNH{color:#8C8C8C;font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:400;font-size:1rem;line-height:1.25rem;}/*!sc*/
|
||||
.kWoBNH a,.kWoBNH button{cursor:pointer;}/*!sc*/
|
||||
.kWoBNH button{text-align:start;font-weight:inherit;font-size:inherit;line-height:inherit;}/*!sc*/
|
||||
.kWoBNH .listItemLink:hover,.kWoBNH button:hover{color:rgba(255,255,255,0.9);}/*!sc*/
|
||||
@media (min-width: 580px){.kWoBNH{font-size:0.75rem;line-height:1rem;}}/*!sc*/
|
||||
data-styled.g237[id="Footer-style__ListItem-sc-4f2908a0-9"]{content:"kWoBNH,"}/*!sc*/
|
||||
.lesWfY{min-width:10.5rem;min-height:3.25rem;}/*!sc*/
|
||||
data-styled.g238[id="Footer-style__LogoWrapper-sc-4f2908a0-10"]{content:"lesWfY,"}/*!sc*/
|
||||
.UFZKa{display:block;color:#8C8C8C;font-size:0.75rem;font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:400;}/*!sc*/
|
||||
data-styled.g239[id="Footer-style__Copyright-sc-4f2908a0-11"]{content:"UFZKa,"}/*!sc*/
|
||||
.ghoRKa{display:flex;flex-direction:row;gap:1rem;order:2;}/*!sc*/
|
||||
@media (min-width: 768px){.ghoRKa{flex-direction:column;}}/*!sc*/
|
||||
.ghoRKa a{width:fit-content;}/*!sc*/
|
||||
data-styled.g240[id="Footer-style__MobileAppLinks-sc-4f2908a0-12"]{content:"ghoRKa,"}/*!sc*/
|
||||
.fSmyMD{display:flex;flex-direction:row;min-width:171px;width:208px;max-width:320px;position:sticky;max-height:calc(100vh - (0px + 60px + 3.875rem));top:calc(60px + 1.75rem);margin-left:1.5rem;transition:max-height 0.3s ease-in-out;overflow:hidden;}/*!sc*/
|
||||
.fSmyMD .sidebar-content{background-color:#343434;flex:1;}/*!sc*/
|
||||
.fSmyMD .sidebar-resizer{flex-grow:0;flex-shrink:0;flex-basis:0.375rem;justify-self:flex-end;cursor:col-resize;resize:horizontal;}/*!sc*/
|
||||
.fSmyMD .sidebar-resizer:hover{width:0.1875rem;background-color:#141414;}/*!sc*/
|
||||
data-styled.g245[id="Sidebar-style__DesktopSidebar-sc-ec5c0dc3-0"]{content:"fSmyMD,"}/*!sc*/
|
||||
.kARIcT{position:relative;display:flex;align-items:center;font-size:1.25rem;padding:0.25rem 1.5rem;line-height:1.75rem;transition:all 0.1s ease-in-out;width:max-content;cursor:pointer;}/*!sc*/
|
||||
@media (min-width: 1200px){.kARIcT{border-bottom:0.0625rem solid #2D2D2D;padding:0.75rem 1.25rem;margin-left:0;font-size:0.875rem;line-height:1.5rem;width:auto;}.kARIcT:hover{cursor:pointer;background-color:#2D2D2D;box-shadow:inset 0.125rem 0 0 0 #01FF95;}}/*!sc*/
|
||||
.kARIcT svg{margin-right:0.5rem;width:1.5rem!important;height:auto!important;}/*!sc*/
|
||||
.kARIcT .forward-arrow{width:0.9rem!important;height:0.9rem!important;margin-left:0.375rem;}/*!sc*/
|
||||
@media (min-width: 1200px){.kARIcT svg{margin-right:0.375rem;width:1rem!important;height:1rem!important;}}/*!sc*/
|
||||
data-styled.g252[id="Sidebar-style__SidebarLink-sc-ec5c0dc3-7"]{content:"kARIcT,"}/*!sc*/
|
||||
.bOxODG{margin:0.5rem auto;background-color:#343434;width:calc(100% - 2 * 0.9375rem);height:0.0625rem;}/*!sc*/
|
||||
data-styled.g255[id="Sidebar-style__HorizontalBar-sc-ec5c0dc3-10"]{content:"bOxODG,"}/*!sc*/
|
||||
.cpMYKr{color:#01FF95;text-transform:capitalize;font-weight:500;font-size:1rem;align-items:flex-start;}/*!sc*/
|
||||
.cpMYKr a{display:flex;align-items:center;}/*!sc*/
|
||||
.cpMYKr:hover{color:#01CC77;}/*!sc*/
|
||||
@media (min-width: 1200px){.cpMYKr{font-size:0.875rem;}}/*!sc*/
|
||||
.cpMYKr svg{margin-right:0.375rem;}/*!sc*/
|
||||
data-styled.g258[id="Sidebar-style__CreatePlaylist-sc-ec5c0dc3-13"]{content:"cpMYKr,"}/*!sc*/
|
||||
.cgwoae{flex-grow:1;display:flex;flex-direction:column;min-height:0;padding:0 1.25rem;}/*!sc*/
|
||||
.cgwoae .element{margin:0.8rem 0;}/*!sc*/
|
||||
.cgwoae .mobile-playlist-element{margin-bottom:1.5rem;}/*!sc*/
|
||||
.cgwoae .mobile-playlist-element:first-of-type{margin-top:1rem;}/*!sc*/
|
||||
.cgwoae.content{height:3.75rem;min-height:3.75rem;max-height:3.75rem;}/*!sc*/
|
||||
.cgwoae.scrollable{flex-grow:1;overflow:auto;min-height:0;}/*!sc*/
|
||||
.cgwoae.scrollable::-webkit-scrollbar{width:0rem;}/*!sc*/
|
||||
.cgwoae.scrollable::-webkit-scrollbar-track{background-color:#343434;}/*!sc*/
|
||||
.cgwoae.scrollable::-webkit-scrollbar-thumb{background-color:#2D2D2D;}/*!sc*/
|
||||
.cgwoae.scrollable:hover::-webkit-scrollbar{width:0.3125rem;}/*!sc*/
|
||||
data-styled.g260[id="Sidebar-style__PlaylistSectionContent-sc-ec5c0dc3-15"]{content:"cgwoae,"}/*!sc*/
|
||||
.cXCcbl{position:relative;height:calc(100% - 12.9375rem);width:100%;}/*!sc*/
|
||||
.cXCcbl div.wrapper{position:absolute;top:0;bottom:0;left:0;width:100%;display:flex;flex-direction:column;}/*!sc*/
|
||||
data-styled.g261[id="Sidebar-style__PlaylistSectionContainer-sc-ec5c0dc3-16"]{content:"cXCcbl,"}/*!sc*/
|
||||
.ccUqKr{position:relative;width:100%;background:#262626;min-height:calc(100vh - 60px);display:flex;justify-content:center;}/*!sc*/
|
||||
data-styled.g262[id="MainLayout-style__MainWrapper-sc-909801dd-0"]{content:"ccUqKr,"}/*!sc*/
|
||||
.eEEXCV{padding:1.75rem 0;width:100%;max-width:1420px;display:flex;margin-top:60px;}/*!sc*/
|
||||
data-styled.g263[id="MainLayout-style__Main-sc-909801dd-1"]{content:"eEEXCV,"}/*!sc*/
|
||||
.AtWIy{width:100%;padding:0 1.5rem;display:flex;flex-direction:column;justify-content:start;}/*!sc*/
|
||||
@media (min-width: 1024px){.AtWIy{width:calc( 100% - 208px );}}/*!sc*/
|
||||
data-styled.g264[id="MainLayout-style__MainContent-sc-909801dd-2"]{content:"AtWIy,"}/*!sc*/
|
||||
.iLohBb{font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:500;font-size:11px;line-height:16px;letter-spacing:0.005em;color:#39C0DE;}/*!sc*/
|
||||
.iLohBb a:hover{text-decoration:underline;}/*!sc*/
|
||||
data-styled.g265[id="ArtistNames-sc-881a9c20-0"]{content:"iLohBb,"}/*!sc*/
|
||||
.iKReJc{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:0.75rem;height:2.125rem;padding-right:0.75rem;border-radius:1.406rem;transition:background 0.2s;cursor:pointer;}/*!sc*/
|
||||
data-styled.g497[id="CollectionControls-style__Controls-sc-33f388f9-0"]{content:"iKReJc,"}/*!sc*/
|
||||
.fIvIm{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px;}/*!sc*/
|
||||
data-styled.g498[id="CollectionControls-style__Wrapper-sc-33f388f9-1"]{content:"fIvIm,"}/*!sc*/
|
||||
.dMoKwU a.artwork>img{min-width:2.375rem;}/*!sc*/
|
||||
.dMoKwU .droppable{width:100%;}/*!sc*/
|
||||
.dMoKwU .cell.bpm a{white-space:nowrap;overflow:hidden;display:block;text-overflow:ellipsis;}/*!sc*/
|
||||
.dMoKwU .cell.format{padding-left:0;}/*!sc*/
|
||||
.dMoKwU .label{width:7.5rem;min-width:7.5rem;}/*!sc*/
|
||||
.dMoKwU .date{width:7.5rem;min-width:7.5rem;}/*!sc*/
|
||||
.dMoKwU .bpm{width:9.375rem;min-width:9.375rem;display:block;text-overflow:elipsis;white-space:no-wrap;}/*!sc*/
|
||||
.dMoKwU .price{width:3.75rem;min-width:3.75rem;}/*!sc*/
|
||||
.dMoKwU i.arrow{margin:0.25rem;display:inline-block;vertical-align:middle;width:0;height:0;border-left:0.25rem solid transparent;border-right:0.25rem solid transparent;border-top:0.25rem solid #01FF95;}/*!sc*/
|
||||
.dMoKwU i.arrow.desc{transform:rotate(180deg);}/*!sc*/
|
||||
data-styled.g547[id="TracksTable-style__TracksWrapper-sc-fcbf7f55-0"]{content:"dMoKwU,"}/*!sc*/
|
||||
.cNqYNo{padding-bottom:0.1rem;font-weight:400;font-size:0.875rem;line-height:1.25rem;color:#8C8C8C;}/*!sc*/
|
||||
data-styled.g617[id="TitleControls-style__PreText-sc-10266053-1"]{content:"cNqYNo,"}/*!sc*/
|
||||
.kHnlXM{padding-bottom:0;}/*!sc*/
|
||||
@media (min-width: 768px){.kHnlXM{padding-right:1rem;}}/*!sc*/
|
||||
data-styled.g618[id="TitleControls-style__TitleContainer-sc-10266053-2"]{content:"kHnlXM,"}/*!sc*/
|
||||
.eZXJZk{padding-bottom:.25rem;}/*!sc*/
|
||||
.eZXJZk.with-pre{padding-top:1.25rem;}/*!sc*/
|
||||
data-styled.g619[id="TitleControls-style__Controls-sc-10266053-3"]{content:"eZXJZk,"}/*!sc*/
|
||||
.kigZFc{width:100%;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:.5rem;}/*!sc*/
|
||||
.kigZFc>:last-child{margin-left:auto;}/*!sc*/
|
||||
@media (min-width: 768px){.kigZFc{display:flex;}.kigZFc>:last-child{margin-left:inherit;}}/*!sc*/
|
||||
data-styled.g620[id="TitleControls-style__Container-sc-10266053-4"]{content:"kigZFc,"}/*!sc*/
|
||||
</style></head><body class="__variable_a8b512"><div id="__next"><div><div id="bp-header"><div class="Header-style__HeaderWrapper-sc-54c5f6f1-0 gZFJrf header-fixed"><div class="Header-style__DesktopHeader-sc-54c5f6f1-2 kFaHkN"><ul class="Header-style__LeftRegion-sc-54c5f6f1-3 eRPXdM"><li><a href="/"><img src="/images/beatport-logo-icon.svg" alt="Beatport Logo" class="Header-style__BeatportLogoLarge-sc-54c5f6f1-7 dNxFON"/></a></li><li class="header_item dropdown_toggle genre_toggle"><button type="button" class="Button-style__ButtonStyled-sc-e1b4adaf-0 dXSwbq unstyled" id="genre-dropdown-menu-button" aria-haspopup="menu" aria-expanded="false" aria-controls="genre-dropdown-menu" aria-label="Genres menu"><span class="rotatable">Genres<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-drop-down" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#drop-down"></use></svg></span></button><div id="genre-dropdown-menu" aria-labelledby="genre-dropdown-menu-button" data-visible="false" data-dropdown-type="genre" class="HeaderDropdownToggle-style__DropdownMenu-sc-fff30e07-0 dkwGxt"><div role="menu" aria-labelledby="genres-dropdown-menu" class="HeaderGenreSubMenu-style__GenreDropDownGrid-sc-5f01336e-4 kNtdbg"></div></div></li><li class="header_item"><a data-testid="header-mainnav-link-staffpicks" href="/staff-picks"><span>Dig Deeper</span></a></li><li class="header_item"><a data-testid="header-mainnav-link-djapp" target="_blank" rel="noopener noreferrer" href="https://dj.beatport.com/"><span>DJ App<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-menu-arrow" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#menu-arrow"></use></svg></span></a></li><li class="header_item"><a data-testid="header-mainnav-link-beatport" target="_blank" rel="noopener noreferrer" href="https://www.beatportal.com/"><span>Beatportal<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-menu-arrow" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#menu-arrow"></use></svg></span></a></li><li class="header_item"><a data-testid="header-mainnav-link-artists-labels" target="_blank" rel="noopener noreferrer" href="https://greenroom.beatport.com/"><span>For Artists & Labels<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-menu-arrow" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#menu-arrow"></use></svg></span></a></li><li class="header_item dropdown_toggle"><button type="button" class="Button-style__ButtonStyled-sc-e1b4adaf-0 dXSwbq unstyled" id="events-dropdown-menu-button" aria-haspopup="menu" aria-expanded="false" aria-controls="events-dropdown-menu" aria-label="Events menu"><span class="rotatable">Events<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-drop-down" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#drop-down"></use></svg></span></button><div id="events-dropdown-menu" aria-labelledby="events-dropdown-menu-button" data-visible="false" data-dropdown-type="events" class="HeaderDropdownToggle-style__DropdownMenu-sc-fff30e07-0 dkwGxt"><div role="menu" class="HeaderEventsSubMenu-style__EventsSubMenuWrapper-sc-75ecafea-0 jgwRkR"><a href="http://greenroom.beatport.com/tickets?utm_medium=bp_store_banner&utm_source=bp_store&utm_campaign=2025_evergreen_greenroom&utm_content=banner" target="_blank" rel="noopener noreferrer" role="menuitem">For Promoters<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-menu-arrow" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#menu-arrow"></use></svg></a><a href="http://beatportal.com/events?utm_source=beatport&utm_medium=banner&utm_campaign=header_banner" target="_blank" rel="noopener noreferrer" role="menuitem">Upcoming Events<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-menu-arrow" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#menu-arrow"></use></svg></a></div></div></li></ul><ul class="Header-style__RightRegion-sc-54c5f6f1-4 exexVn"><li class="search"><div id="site-search" class="Header-style__HeaderSearchWrapper-sc-54c5f6f1-10 koymgh"><div class="Header-style__HeaderSearch-sc-54c5f6f1-11 eCACUJ"><div><svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-search" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#search"></use></svg></div><input id="search-field" name="search-field" data-testid="header-search-input" placeholder="Search" aria-label="Search" type="search" autoComplete="no-username" class="Search-style__SearchInput-sc-224b2fdd-1 dxGRL" value=""/></div></div></li><li class="subscription_button"><a href="/subscriptions">Add Streaming</a></li><li class="header_item account"><button type="button" class="Button-style__ButtonStyled-sc-e1b4adaf-0 dXSwbq linkLine truncated
|
||||
+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,190 @@
|
||||
"""Test per core.beatport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core import beatport
|
||||
|
||||
|
||||
class TestListGenres:
|
||||
def test_returns_list_of_dicts(self):
|
||||
result = beatport.list_genres()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) >= 10 # almeno 10 generi
|
||||
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 = beatport.list_genres()
|
||||
names = [g["name"] for g in result]
|
||||
assert names == sorted(names, key=str.casefold)
|
||||
|
||||
def test_melodic_house_techno_present(self):
|
||||
result = beatport.list_genres()
|
||||
slugs = [g["slug"] for g in result]
|
||||
assert "melodic-house-techno" in slugs
|
||||
|
||||
|
||||
class TestBeatportTrack:
|
||||
def test_display_format(self):
|
||||
t = beatport.BeatportTrack(
|
||||
position=1,
|
||||
title="Hot Sauce",
|
||||
mix="Extended",
|
||||
artists="Kapuchon, Miss Monique & GLZ",
|
||||
duration_sec=336,
|
||||
beatport_id=12345,
|
||||
)
|
||||
assert t.display == "Kapuchon, Miss Monique & GLZ – Hot Sauce (Extended) (5:36)"
|
||||
|
||||
def test_display_pads_seconds(self):
|
||||
t = beatport.BeatportTrack(
|
||||
position=1, title="X", mix="Y", artists="A",
|
||||
duration_sec=65, beatport_id=1,
|
||||
)
|
||||
assert t.display.endswith("(1:05)")
|
||||
|
||||
def test_spotify_query(self):
|
||||
t = beatport.BeatportTrack(
|
||||
position=1,
|
||||
title="Hot Sauce",
|
||||
mix="Extended",
|
||||
artists="Kapuchon, Miss Monique & GLZ",
|
||||
duration_sec=336,
|
||||
beatport_id=12345,
|
||||
)
|
||||
assert t.spotify_query == "Kapuchon, Miss Monique & GLZ Hot Sauce"
|
||||
|
||||
def test_is_frozen(self):
|
||||
t = beatport.BeatportTrack(
|
||||
position=1, title="X", mix="Y", artists="A",
|
||||
duration_sec=1, beatport_id=1,
|
||||
)
|
||||
with pytest.raises(Exception):
|
||||
t.title = "Z" # frozen=True impedisce mutazione
|
||||
|
||||
|
||||
class TestExtractNextData:
|
||||
def test_extracts_json_from_valid_html(self, fixtures_dir):
|
||||
html = (fixtures_dir / "beatport_melodic_top100.html").read_text()
|
||||
data = beatport._extract_next_data(html)
|
||||
assert isinstance(data, dict)
|
||||
assert "props" in data
|
||||
|
||||
def test_missing_script_raises(self):
|
||||
with pytest.raises(beatport.BeatportParseError, match="__NEXT_DATA__ non trovato"):
|
||||
beatport._extract_next_data("<html><body>nulla</body></html>")
|
||||
|
||||
def test_malformed_json_raises(self):
|
||||
broken = '<script id="__NEXT_DATA__" type="application/json">{not: valid}</script>'
|
||||
with pytest.raises(beatport.BeatportParseError, match="JSON malformato"):
|
||||
beatport._extract_next_data(broken)
|
||||
|
||||
|
||||
class TestParseTracks:
|
||||
def test_extracts_100_tracks(self, fixtures_dir):
|
||||
html = (fixtures_dir / "beatport_melodic_top100.html").read_text()
|
||||
data = beatport._extract_next_data(html)
|
||||
tracks = beatport._parse_tracks(data)
|
||||
assert len(tracks) == 100
|
||||
|
||||
def test_positions_are_sequential_1_to_100(self, fixtures_dir):
|
||||
html = (fixtures_dir / "beatport_melodic_top100.html").read_text()
|
||||
tracks = beatport._parse_tracks(beatport._extract_next_data(html))
|
||||
positions = [t.position for t in tracks]
|
||||
assert positions == list(range(1, 101))
|
||||
|
||||
def test_track_shape(self, fixtures_dir):
|
||||
html = (fixtures_dir / "beatport_melodic_top100.html").read_text()
|
||||
tracks = beatport._parse_tracks(beatport._extract_next_data(html))
|
||||
first = tracks[0]
|
||||
assert isinstance(first, beatport.BeatportTrack)
|
||||
assert first.title
|
||||
assert first.artists
|
||||
assert first.duration_sec > 0
|
||||
assert first.beatport_id > 0
|
||||
|
||||
def test_schema_missing_results_raises(self):
|
||||
with pytest.raises(beatport.BeatportParseError, match="results"):
|
||||
beatport._parse_tracks({"props": {"pageProps": {}}})
|
||||
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from freezegun import freeze_time
|
||||
|
||||
|
||||
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 fixture_html(self, fixtures_dir):
|
||||
return (fixtures_dir / "beatport_melodic_top100.html").read_text()
|
||||
|
||||
def test_success_returns_100_tracks(self, fixture_html):
|
||||
beatport._cache.clear()
|
||||
with patch("core.beatport._cffi_requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response(fixture_html, 200)
|
||||
tracks = beatport.fetch_top100("melodic-house-techno")
|
||||
assert len(tracks) == 100
|
||||
|
||||
def test_invalid_slug_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="slug"):
|
||||
beatport.fetch_top100("not-a-real-genre")
|
||||
|
||||
def test_5xx_retries_and_raises_unreachable(self):
|
||||
beatport._cache.clear()
|
||||
with patch("core.beatport._cffi_requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response("", 503)
|
||||
with patch("core.beatport.time.sleep"): # skip backoff
|
||||
with pytest.raises(beatport.BeatportUnreachableError):
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
assert mock_get.call_count == 3 # 1 + 2 retry
|
||||
|
||||
def test_cache_hit_within_ttl(self, fixture_html):
|
||||
beatport._cache.clear()
|
||||
with patch("core.beatport._cffi_requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response(fixture_html, 200)
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
def test_cache_expires_after_ttl(self, fixture_html):
|
||||
beatport._cache.clear()
|
||||
with patch("core.beatport._cffi_requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response(fixture_html, 200)
|
||||
with freeze_time("2026-01-01 10:00:00") as frozen:
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
frozen.tick(delta=beatport._CACHE_TTL_SEC + 1)
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
def test_force_refresh_bypasses_cache(self, fixture_html):
|
||||
beatport._cache.clear()
|
||||
with patch("core.beatport._cffi_requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response(fixture_html, 200)
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
beatport.fetch_top100("melodic-house-techno", force_refresh=True)
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
def test_uses_chrome_impersonation(self, fixture_html):
|
||||
beatport._cache.clear()
|
||||
with patch("core.beatport._cffi_requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response(fixture_html, 200)
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
call_kwargs = mock_get.call_args.kwargs
|
||||
assert "impersonate" in call_kwargs
|
||||
assert call_kwargs["impersonate"].startswith("chrome")
|
||||
@@ -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,333 @@
|
||||
"""Test per core.spotify_client.search_track()."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from core import spotify_client
|
||||
|
||||
|
||||
class TestSearchTrack:
|
||||
@responses.activate
|
||||
def test_returns_track_dict_when_found(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={
|
||||
"tracks": {
|
||||
"items": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"name": "Hot Sauce",
|
||||
"artists": [{"name": "Kapuchon"}],
|
||||
"external_urls": {"spotify": "https://open.spotify.com/track/abc123"},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
result = spotify_client.search_track("fake-token", "Kapuchon Hot Sauce")
|
||||
assert result is not None
|
||||
assert result["id"] == "abc123"
|
||||
assert result["url"] == "https://open.spotify.com/track/abc123"
|
||||
|
||||
@responses.activate
|
||||
def test_returns_none_when_no_results(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"tracks": {"items": []}},
|
||||
status=200,
|
||||
)
|
||||
result = spotify_client.search_track("fake-token", "no-match")
|
||||
assert result is None
|
||||
|
||||
@responses.activate
|
||||
def test_sends_bearer_token(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"tracks": {"items": []}},
|
||||
status=200,
|
||||
)
|
||||
spotify_client.search_track("my-token", "q")
|
||||
assert responses.calls[0].request.headers["Authorization"] == "Bearer my-token"
|
||||
|
||||
@responses.activate
|
||||
def test_query_params_correct(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"tracks": {"items": []}},
|
||||
status=200,
|
||||
)
|
||||
spotify_client.search_track("t", "Kapuchon Hot Sauce")
|
||||
params = responses.calls[0].request.params
|
||||
assert params["q"] == "Kapuchon Hot Sauce"
|
||||
assert params["type"] == "track"
|
||||
assert params["limit"] == "1"
|
||||
|
||||
|
||||
class TestSearchTracks:
|
||||
@responses.activate
|
||||
def test_returns_list_of_tracks(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={
|
||||
"tracks": {
|
||||
"items": [
|
||||
{
|
||||
"id": f"id{i}",
|
||||
"name": f"Track {i}",
|
||||
"artists": [{"name": "Solomun"}],
|
||||
"album": {"name": "Album X"},
|
||||
"duration_ms": 300000 + i * 1000,
|
||||
"external_urls": {"spotify": f"https://open.spotify.com/track/id{i}"},
|
||||
}
|
||||
for i in range(50)
|
||||
]
|
||||
}
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
result = spotify_client.search_tracks("t", "solomun", limit=50)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 50
|
||||
first = result[0]
|
||||
assert first["id"] == "id0"
|
||||
assert first["name"] == "Track 0"
|
||||
assert first["artists"] == "Solomun"
|
||||
assert first["album"] == "Album X"
|
||||
assert first["duration_sec"] == 300
|
||||
assert first["url"] == "https://open.spotify.com/track/id0"
|
||||
|
||||
@responses.activate
|
||||
def test_multiple_artists_joined_with_comma(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"tracks": {"items": [{
|
||||
"id": "x", "name": "n",
|
||||
"artists": [{"name": "A"}, {"name": "B"}, {"name": "C"}],
|
||||
"album": {"name": "Alb"},
|
||||
"duration_ms": 60000,
|
||||
"external_urls": {"spotify": "u"},
|
||||
}]}},
|
||||
status=200,
|
||||
)
|
||||
result = spotify_client.search_tracks("t", "q", limit=1)
|
||||
assert result[0]["artists"] == "A, B, C"
|
||||
|
||||
@responses.activate
|
||||
def test_empty_query_returns_empty_list(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"tracks": {"items": []}},
|
||||
status=200,
|
||||
)
|
||||
result = spotify_client.search_tracks("t", "no-match", limit=50)
|
||||
assert result == []
|
||||
|
||||
@responses.activate
|
||||
def test_query_params_include_limit(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"tracks": {"items": []}},
|
||||
status=200,
|
||||
)
|
||||
spotify_client.search_tracks("t", "q", limit=25)
|
||||
params = responses.calls[0].request.params
|
||||
assert params["q"] == "q"
|
||||
assert params["type"] == "track"
|
||||
assert params["limit"] == "25"
|
||||
|
||||
|
||||
class TestSearchArtistDiscography:
|
||||
@responses.activate
|
||||
def test_exact_name_match_beats_popular(self):
|
||||
# 3 candidati: uno esatto (case-insensitive), altri popolari
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"artists": {"items": [
|
||||
{"id": "pop", "name": "Solomun Tribute", "popularity": 90},
|
||||
{"id": "exact", "name": "SOLOMUN", "popularity": 60},
|
||||
{"id": "unrelated", "name": "Other", "popularity": 70},
|
||||
]}},
|
||||
status=200,
|
||||
)
|
||||
# Mock top-tracks vuoto per non allungare il test
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/exact/top-tracks",
|
||||
json={"tracks": []},
|
||||
status=200,
|
||||
)
|
||||
# Mock albums vuoto
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/exact/albums",
|
||||
json={"items": []},
|
||||
status=200,
|
||||
)
|
||||
result = spotify_client.search_artist_discography("t", "Solomun")
|
||||
assert result == []
|
||||
# Verifica che sia stato chiamato l'artista "exact", non "pop"
|
||||
top_tracks_calls = [c for c in responses.calls if "/top-tracks" in c.request.url]
|
||||
assert len(top_tracks_calls) == 1
|
||||
assert "/exact/top-tracks" in top_tracks_calls[0].request.url
|
||||
|
||||
@responses.activate
|
||||
def test_falls_back_to_most_popular_if_no_exact_match(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"artists": {"items": [
|
||||
{"id": "a1", "name": "Solomun Fanpage", "popularity": 30},
|
||||
{"id": "a2", "name": "Solomun Live", "popularity": 80},
|
||||
]}},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/a2/top-tracks",
|
||||
json={"tracks": []},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/a2/albums",
|
||||
json={"items": []},
|
||||
status=200,
|
||||
)
|
||||
spotify_client.search_artist_discography("t", "solomun")
|
||||
top_tracks_calls = [c for c in responses.calls if "/top-tracks" in c.request.url]
|
||||
assert "/a2/top-tracks" in top_tracks_calls[0].request.url
|
||||
|
||||
@responses.activate
|
||||
def test_raises_when_no_artist_found(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"artists": {"items": []}},
|
||||
status=200,
|
||||
)
|
||||
with pytest.raises(ValueError, match="Artista"):
|
||||
spotify_client.search_artist_discography("t", "asdgjhkasdgj")
|
||||
|
||||
@responses.activate
|
||||
def test_combines_top_tracks_and_album_tracks(self):
|
||||
# 1 artista esatto
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"artists": {"items": [{"id": "artX", "name": "artX", "popularity": 50}]}},
|
||||
status=200,
|
||||
)
|
||||
# 3 top-tracks
|
||||
top_tracks_data = {"tracks": [
|
||||
{
|
||||
"id": f"t{i}", "name": f"Top{i}",
|
||||
"artists": [{"name": "artX"}],
|
||||
"album": {"name": "AlbTop"},
|
||||
"duration_ms": 200000,
|
||||
"external_urls": {"spotify": f"u{i}"},
|
||||
} for i in range(3)
|
||||
]}
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/artX/top-tracks",
|
||||
json=top_tracks_data,
|
||||
status=200,
|
||||
)
|
||||
# 2 album
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/artX/albums",
|
||||
json={"items": [
|
||||
{"id": "alb1", "name": "Album 1"},
|
||||
{"id": "alb2", "name": "Album 2"},
|
||||
]},
|
||||
status=200,
|
||||
)
|
||||
# album 1: 2 tracce
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/albums/alb1/tracks",
|
||||
json={"items": [
|
||||
{
|
||||
"id": f"a1t{i}", "name": f"Alb1Track{i}",
|
||||
"artists": [{"name": "artX"}],
|
||||
"duration_ms": 180000,
|
||||
"external_urls": {"spotify": f"a1u{i}"},
|
||||
} for i in range(2)
|
||||
]},
|
||||
status=200,
|
||||
)
|
||||
# album 2: 1 traccia
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/albums/alb2/tracks",
|
||||
json={"items": [
|
||||
{
|
||||
"id": "a2t0", "name": "Alb2Track0",
|
||||
"artists": [{"name": "artX"}],
|
||||
"duration_ms": 240000,
|
||||
"external_urls": {"spotify": "a2u0"},
|
||||
}
|
||||
]},
|
||||
status=200,
|
||||
)
|
||||
with patch("core.spotify_client.time.sleep"): # no wait
|
||||
result = spotify_client.search_artist_discography("t", "artX")
|
||||
# 3 top + 2 alb1 + 1 alb2 = 6
|
||||
assert len(result) == 6
|
||||
titles = {r["name"] for r in result}
|
||||
assert "Top0" in titles
|
||||
assert "Alb1Track0" in titles
|
||||
assert "Alb2Track0" in titles
|
||||
|
||||
@responses.activate
|
||||
def test_dedupe_across_top_and_album(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"artists": {"items": [{"id": "aX", "name": "aX", "popularity": 50}]}},
|
||||
status=200,
|
||||
)
|
||||
# Stesso track name+artist in top-tracks e in album (id diverso)
|
||||
common = {
|
||||
"name": "Same Song",
|
||||
"artists": [{"name": "aX"}],
|
||||
"album": {"name": "OG Album"},
|
||||
"duration_ms": 200000,
|
||||
"external_urls": {"spotify": "u"},
|
||||
}
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/aX/top-tracks",
|
||||
json={"tracks": [{**common, "id": "top-id"}]},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/aX/albums",
|
||||
json={"items": [{"id": "alb", "name": "OG"}]},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/albums/alb/tracks",
|
||||
json={"items": [{**common, "id": "alb-id"}]},
|
||||
status=200,
|
||||
)
|
||||
with patch("core.spotify_client.time.sleep"):
|
||||
result = spotify_client.search_artist_discography("t", "aX")
|
||||
assert len(result) == 1
|
||||
@@ -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
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Test per core.youtube_search."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core import youtube_search
|
||||
|
||||
|
||||
def _mock_ytdlp_result(entries: list) -> MagicMock:
|
||||
"""Mock subprocess.CompletedProcess con JSON stub."""
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
result.stdout = json.dumps({"entries": entries})
|
||||
result.stderr = ""
|
||||
return result
|
||||
|
||||
|
||||
class TestSearchYoutube:
|
||||
def test_parses_entries(self):
|
||||
entries = [
|
||||
{
|
||||
"id": "abc123",
|
||||
"url": "https://www.youtube.com/watch?v=abc123",
|
||||
"title": "Kapuchon - Hot Sauce (Official Video)",
|
||||
"uploader": "Kapuchon Official",
|
||||
"duration": 336,
|
||||
},
|
||||
{
|
||||
"id": "def456",
|
||||
"url": "https://www.youtube.com/watch?v=def456",
|
||||
"title": "Solomun @ Cocoricò 2024",
|
||||
"uploader": "Cocoricò",
|
||||
"duration": 3600,
|
||||
},
|
||||
]
|
||||
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||
patch("core.youtube_search.subprocess.run", return_value=_mock_ytdlp_result(entries)):
|
||||
result = youtube_search.search_youtube("kapuchon hot sauce", limit=50)
|
||||
assert len(result) == 2
|
||||
assert result[0]["title"] == "Kapuchon - Hot Sauce (Official Video)"
|
||||
assert result[0]["channel"] == "Kapuchon Official"
|
||||
assert result[0]["duration_sec"] == 336
|
||||
assert result[0]["url"] == "https://www.youtube.com/watch?v=abc123"
|
||||
|
||||
def test_empty_entries_returns_empty(self):
|
||||
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||
patch("core.youtube_search.subprocess.run", return_value=_mock_ytdlp_result([])):
|
||||
result = youtube_search.search_youtube("no-match", limit=50)
|
||||
assert result == []
|
||||
|
||||
def test_raises_when_ytdlp_missing(self):
|
||||
with patch("core.youtube_search.find_ytdlp", return_value=None):
|
||||
with pytest.raises(RuntimeError, match="yt-dlp"):
|
||||
youtube_search.search_youtube("q", limit=50)
|
||||
|
||||
def test_command_uses_ytsearch_with_limit(self):
|
||||
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||
patch("core.youtube_search.subprocess.run", return_value=_mock_ytdlp_result([])) as mock_run:
|
||||
youtube_search.search_youtube("solomun", limit=30)
|
||||
args = mock_run.call_args.args[0]
|
||||
assert args[0] == "/fake/yt-dlp"
|
||||
search_arg = [a for a in args if a.startswith("ytsearch")]
|
||||
assert search_arg == ["ytsearch30:solomun"]
|
||||
|
||||
def test_timeout_raises_runtime_error(self):
|
||||
import subprocess as sp
|
||||
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||
patch("core.youtube_search.subprocess.run", side_effect=sp.TimeoutExpired("yt-dlp", 30)):
|
||||
with pytest.raises(RuntimeError, match="[Tt]imeout"):
|
||||
youtube_search.search_youtube("q", limit=50)
|
||||
|
||||
def test_nonzero_exit_raises(self):
|
||||
bad = MagicMock()
|
||||
bad.returncode = 1
|
||||
bad.stdout = ""
|
||||
bad.stderr = "some yt-dlp error"
|
||||
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||
patch("core.youtube_search.subprocess.run", return_value=bad):
|
||||
with pytest.raises(RuntimeError, match="yt-dlp"):
|
||||
youtube_search.search_youtube("q", limit=50)
|
||||
+829
-22
@@ -49,6 +49,16 @@
|
||||
--text-2: #B3B3B3;
|
||||
--text-3: #727272;
|
||||
|
||||
/* Varianti brand per uso come TESTO (foreground). In dark restano vibranti
|
||||
come le base; in light diventano scure per contrasto AA su sfondo bianco. */
|
||||
--green-text: var(--green);
|
||||
--blue-text: var(--blue);
|
||||
--purple-text: var(--purple);
|
||||
--pink-text: var(--pink);
|
||||
--amber-text: var(--amber);
|
||||
--indigo-text: var(--indigo);
|
||||
--rec-red-text: var(--rec-red);
|
||||
|
||||
--r-sm: 8px;
|
||||
--r-md: 12px;
|
||||
--r-lg: 14px;
|
||||
@@ -84,6 +94,15 @@ html[data-theme="light"] {
|
||||
--rec-red-dim: #FECACA;
|
||||
--indigo-dim: #C7D2FE;
|
||||
|
||||
/* Varianti brand per testo: scurite per contrasto AA su fondo bianco */
|
||||
--green-text: #0F7A38;
|
||||
--blue-text: #1D6BD3;
|
||||
--purple-text: #7E22CE;
|
||||
--pink-text: #BE185D;
|
||||
--amber-text: #B45309;
|
||||
--indigo-text: #4338CA;
|
||||
--rec-red-text: #B91C1C;
|
||||
|
||||
--shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
--shadow-lg: 0 16px 40px rgba(15, 23, 42, 0.1);
|
||||
|
||||
@@ -132,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 {
|
||||
@@ -173,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;
|
||||
@@ -192,13 +235,13 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #1A1A1A;
|
||||
background: var(--bg-card-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #1F1F1F;
|
||||
color: var(--green);
|
||||
background: var(--bg-card-2);
|
||||
color: var(--green-text);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
@@ -214,7 +257,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
}
|
||||
|
||||
.footer-by {
|
||||
color: var(--green);
|
||||
color: var(--green-text);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.4px;
|
||||
@@ -254,7 +297,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
.quota-sep { color: var(--text-3); margin: 0 4px; font-weight: 400; }
|
||||
.quota-plan {
|
||||
font-size: 11px;
|
||||
color: var(--green);
|
||||
color: var(--green-text);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
@@ -314,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;
|
||||
@@ -469,7 +514,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
|
||||
.badge {
|
||||
background: rgba(29, 185, 84, 0.15);
|
||||
color: var(--green);
|
||||
color: var(--green-text);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
@@ -495,8 +540,8 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
.input::placeholder { color: var(--text-3); }
|
||||
|
||||
.input:focus {
|
||||
background: #333;
|
||||
border-color: rgba(29, 185, 84, 0.4);
|
||||
background: var(--bg-input-hover);
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.input-sm { max-width: 200px; }
|
||||
@@ -533,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
|
||||
=============================== */
|
||||
@@ -663,9 +789,10 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.percent.green { color: var(--green); }
|
||||
.percent.blue { color: var(--blue); }
|
||||
.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%;
|
||||
@@ -694,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
|
||||
=============================== */
|
||||
@@ -711,10 +842,10 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.log .l-ok { color: var(--green); }
|
||||
.log .l-ok { color: var(--green-text); }
|
||||
.log .l-err { color: var(--red); }
|
||||
.log .l-info { color: var(--blue); }
|
||||
.log .l-warn { color: #FDCB6E; }
|
||||
.log .l-info { color: var(--blue-text); }
|
||||
.log .l-warn { color: var(--amber-text); }
|
||||
.log .l-muted { color: var(--text-3); }
|
||||
|
||||
/* ===============================
|
||||
@@ -774,7 +905,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
.hint strong { color: var(--text); font-weight: 700; }
|
||||
|
||||
.hint code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--bg-input);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
@@ -838,7 +969,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
INFO ROW (Upgrade tab)
|
||||
=============================== */
|
||||
.info-row {
|
||||
color: var(--green);
|
||||
color: var(--green-text);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 14px;
|
||||
@@ -862,7 +993,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.update-status.ok { color: var(--green); }
|
||||
.update-status.ok { color: var(--green-text); }
|
||||
.update-status.warn { color: #FDCB6E; }
|
||||
.update-status.err { color: var(--red); }
|
||||
|
||||
@@ -873,15 +1004,27 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* Bottone "Salva impostazioni" — sticky in fondo al viewport per restare
|
||||
sempre visibile mentre l'utente scrolla la tab Impostazioni. Niente
|
||||
ricerca del pulsante in coda. */
|
||||
/* Bottone "Salva impostazioni" — sticky in fondo al viewport per restare
|
||||
sempre visibile mentre l'utente scrolla la tab Impostazioni. */
|
||||
.save-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 20px 0 32px;
|
||||
position: sticky;
|
||||
bottom: -24px;
|
||||
padding: 16px 32px;
|
||||
margin: 24px -32px -32px;
|
||||
background: var(--bg);
|
||||
border-top: 1px solid var(--divider);
|
||||
box-shadow: 0 -8px 16px rgba(0, 0, 0, 0.25);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.save-status {
|
||||
color: var(--green);
|
||||
color: var(--green-text);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
@@ -991,7 +1134,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
}
|
||||
|
||||
.toast.toast-error { border-color: var(--red); color: var(--red); }
|
||||
.toast.toast-success { border-color: var(--green); color: var(--green); }
|
||||
.toast.toast-success { border-color: var(--green); color: var(--green-text); }
|
||||
|
||||
/* ===============================
|
||||
SCROLLBARS (WebKit)
|
||||
@@ -999,11 +1142,11 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #404040;
|
||||
background: var(--text-3);
|
||||
border-radius: 5px;
|
||||
border: 2px solid var(--bg);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: #606060; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-2); }
|
||||
|
||||
.log::-webkit-scrollbar-thumb { border-color: var(--bg-card-2); }
|
||||
.main::-webkit-scrollbar-thumb { border-color: var(--bg); }
|
||||
@@ -1538,3 +1681,667 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
BEATPORT
|
||||
=============================== */
|
||||
.beatport-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.beatport-header select.input {
|
||||
min-width: 260px;
|
||||
flex: 0 1 320px;
|
||||
}
|
||||
|
||||
.hint-inline {
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.beatport-status {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.beatport-status.loading { color: var(--text-3); font-style: italic; }
|
||||
.beatport-status.error { color: var(--red); font-weight: 600; }
|
||||
.beatport-status.ok { color: var(--green-text); font-weight: 600; }
|
||||
|
||||
.beatport-output-info {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.beatport-output-info.warn {
|
||||
color: var(--amber-text);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.beatport-table-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.beatport-table-wrap {
|
||||
max-height: 460px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.beatport-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.beatport-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-card-2);
|
||||
color: var(--text-3);
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.beatport-table tbody td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
color: var(--text);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.beatport-table tbody tr:hover {
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.beatport-table tbody tr.already-downloaded td {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.beatport-table tbody tr.already-downloaded .col-state {
|
||||
color: var(--green-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.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;
|
||||
accent-color: var(--indigo, #6366F1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.beatport-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--divider);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.beatport-toolbar .counter {
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.beatport-header { flex-direction: column; align-items: stretch; }
|
||||
.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);
|
||||
}
|
||||
|
||||
+906
-3
@@ -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,18 +30,60 @@
|
||||
<span class="nav-icon">🎬</span>
|
||||
<span>Video</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="spotify" data-feature="audio">
|
||||
<span class="nav-icon">🟢</span>
|
||||
<span>Spotify</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="youtube" data-feature="audio">
|
||||
<span class="nav-icon">▶</span>
|
||||
<span>YouTube</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="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>
|
||||
@@ -252,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">
|
||||
@@ -384,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>
|
||||
@@ -432,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)
|
||||
@@ -522,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">
|
||||
@@ -531,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">
|
||||
@@ -849,6 +982,733 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: BEATPORT ===== -->
|
||||
<section class="view" id="view-beatport">
|
||||
<header class="hero hero-indigo">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow indigo">BEATPORT CHARTS</div>
|
||||
<h1 class="hero-title">Top 100 per genere</h1>
|
||||
<p class="hero-subtitle">Sfoglia la Top 100 di Beatport, seleziona i brani e scaricali via YouTube</p>
|
||||
</div>
|
||||
<div class="hero-deco">🎧</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Genere</h2>
|
||||
<div class="card">
|
||||
<div class="beatport-header">
|
||||
<select id="beatport-genre" class="input pill"></select>
|
||||
<button class="btn btn-primary pill" id="beatport-load-btn">
|
||||
<span class="ico">↓</span> Carica Top 100
|
||||
</button>
|
||||
<span class="hint-inline">Shift+click per bypassare la cache</span>
|
||||
</div>
|
||||
<div id="beatport-status" class="beatport-status"></div>
|
||||
<div id="beatport-output-info" class="beatport-output-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="card beatport-table-card">
|
||||
<div class="beatport-table-wrap">
|
||||
<table id="beatport-table" class="beatport-table" hidden>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" id="beatport-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="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>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="beatport-toolbar" id="beatport-toolbar" hidden>
|
||||
<span class="counter" id="beatport-selected-count">0/0 selezionati</span>
|
||||
<button class="btn btn-primary pill" id="beatport-download-btn" disabled>
|
||||
<span class="ico">▶</span> Scarica selezionati
|
||||
</button>
|
||||
<button class="btn btn-danger pill" id="beatport-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="beatport-log"></div>
|
||||
</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">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow indigo">RICERCA SPOTIFY</div>
|
||||
<h1 class="hero-title">Cerca brani su Spotify</h1>
|
||||
<p class="hero-subtitle">Digita un artista, un titolo, o entrambi. Attiva "Solo artista" per scaricare tutta la discografia.</p>
|
||||
</div>
|
||||
<div class="hero-deco">🟢</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Ricerca</h2>
|
||||
<div class="card">
|
||||
<div class="beatport-header">
|
||||
<input type="text" id="spotify-query" class="input pill" placeholder="Es: Solomun, Hot Sauce, Kapuchon Hot Sauce…" style="flex:1; min-width:260px;" />
|
||||
<label style="display:flex; gap:6px; align-items:center; color:var(--text-2); font-size:13px;">
|
||||
<input type="checkbox" id="spotify-artist-mode" />
|
||||
<span>Solo artista (discografia)</span>
|
||||
</label>
|
||||
<button class="btn btn-primary pill" id="spotify-search-btn">
|
||||
<span class="ico">🔎</span> Cerca
|
||||
</button>
|
||||
</div>
|
||||
<div id="spotify-status" class="beatport-status"></div>
|
||||
<div id="spotify-output-info" class="beatport-output-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="card beatport-table-card" id="spotify-table-wrap" hidden>
|
||||
<div class="beatport-table-wrap">
|
||||
<table class="beatport-table" id="spotify-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" id="spotify-select-all" checked /></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>
|
||||
<tbody id="spotify-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="beatport-toolbar" id="spotify-toolbar">
|
||||
<span class="counter" id="spotify-selected-count">0/0 selezionati</span>
|
||||
<button class="btn btn-primary pill" id="spotify-download-btn" disabled>
|
||||
<span class="ico">▶</span> Scarica selezionati
|
||||
</button>
|
||||
<button class="btn btn-danger pill" id="spotify-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="spotify-log"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: YOUTUBE SEARCH ===== -->
|
||||
<section class="view" id="view-youtube">
|
||||
<header class="hero hero-indigo">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow indigo">RICERCA YOUTUBE</div>
|
||||
<h1 class="hero-title">Cerca video su YouTube</h1>
|
||||
<p class="hero-subtitle">Perfetto per mix DJ, unreleased, bootleg e brani non presenti su Spotify.</p>
|
||||
</div>
|
||||
<div class="hero-deco">▶</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Ricerca</h2>
|
||||
<div class="card">
|
||||
<div class="beatport-header">
|
||||
<input type="text" id="youtube-query" class="input pill" placeholder="Es: Solomun Cocoricò 2024, Kapuchon Hot Sauce remix…" style="flex:1; min-width:260px;" />
|
||||
<button class="btn btn-primary pill" id="youtube-search-btn">
|
||||
<span class="ico">🔎</span> Cerca
|
||||
</button>
|
||||
</div>
|
||||
<div id="youtube-status" class="beatport-status"></div>
|
||||
<div id="youtube-output-info" class="beatport-output-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="card beatport-table-card" id="youtube-table-wrap" hidden>
|
||||
<div class="beatport-table-wrap">
|
||||
<table class="beatport-table" id="youtube-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" id="youtube-select-all" checked /></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>
|
||||
<tbody id="youtube-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="beatport-toolbar" id="youtube-toolbar">
|
||||
<span class="counter" id="youtube-selected-count">0/0 selezionati</span>
|
||||
<button class="btn btn-primary pill" id="youtube-download-btn" disabled>
|
||||
<span class="ico">▶</span> Scarica selezionati
|
||||
</button>
|
||||
<button class="btn btn-danger pill" id="youtube-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="youtube-log"></div>
|
||||
</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>
|
||||
|
||||
@@ -922,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>
|
||||
|
||||
|
||||
+3050
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user