Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
+553
-4
@@ -8,9 +8,14 @@ import re
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from typing import Any, Optional
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from core.config import load_config, save_config, VERSION, LICENSE_API_URL
|
||||
from core import beatport, spotify_client
|
||||
from core import license as license_mod
|
||||
from core.downloader import (
|
||||
download_playlist,
|
||||
@@ -27,6 +32,7 @@ from core.spotify_client import (
|
||||
SpotifyAuthRequired,
|
||||
)
|
||||
from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS
|
||||
from core import tagger
|
||||
from core.recorder import (
|
||||
list_input_devices,
|
||||
start_recording,
|
||||
@@ -511,13 +517,20 @@ class Api:
|
||||
def start_tracks_download(self, payload: dict) -> dict:
|
||||
"""Avvia il download di una tracklist gia parsata
|
||||
(lista di {name, artist}). Se 'subfolder' e presente,
|
||||
scarica in output_dir/subfolder."""
|
||||
scarica in output_dir/subfolder.
|
||||
|
||||
`payload['metadata']` opzionale: lista parallela a `tracks` con dict
|
||||
per il tagging ID3 (title, artist, album, date, genre, tracknumber,
|
||||
cover_url). Se presente, i file MP3 vengono nominati come
|
||||
"Artista - Titolo.mp3" e taggati post-download.
|
||||
"""
|
||||
if self._any_job_running():
|
||||
return {"ok": False, "error": "Un download gia in corso"}
|
||||
|
||||
tracks = payload.get("tracks") or []
|
||||
output_dir = (payload.get("output_dir") or "").strip()
|
||||
subfolder = (payload.get("subfolder") or "").strip()
|
||||
metadata_list = payload.get("metadata") or None
|
||||
if not tracks:
|
||||
return {"ok": False, "error": "Nessuna traccia fornita"}
|
||||
if not output_dir:
|
||||
@@ -536,6 +549,49 @@ class Api:
|
||||
self._download_thread = threading.Thread(
|
||||
target=self._tracks_worker,
|
||||
args=(list(tracks), output_dir),
|
||||
kwargs={"metadata_list": list(metadata_list) if metadata_list else None},
|
||||
daemon=True,
|
||||
)
|
||||
self._download_thread.start()
|
||||
return {"ok": True}
|
||||
|
||||
def start_urls_download(self, payload: dict) -> dict:
|
||||
"""Analogo a start_tracks_download ma accetta URL YouTube gia noti
|
||||
(bypass search). Usato dal flow del tab 'YouTube Search'.
|
||||
|
||||
`payload['metadata']` opzionale: lista parallela a `urls` con dict
|
||||
per il tagging ID3 (title, artist, album, date, genre, tracknumber,
|
||||
cover_url). Se presente, i file MP3 vengono nominati come
|
||||
"Artista - Titolo.mp3" e taggati post-download.
|
||||
"""
|
||||
if self._any_job_running():
|
||||
return {"ok": False, "error": "Un download gia in corso"}
|
||||
|
||||
urls = payload.get("urls") or []
|
||||
titles = payload.get("titles") or []
|
||||
output_dir = (payload.get("output_dir") or "").strip()
|
||||
subfolder = (payload.get("subfolder") or "").strip()
|
||||
metadata_list = payload.get("metadata") or None
|
||||
if not urls:
|
||||
return {"ok": False, "error": "Nessuna URL fornita"}
|
||||
if len(urls) != len(titles):
|
||||
return {"ok": False, "error": "urls e titles devono avere stessa lunghezza"}
|
||||
if not output_dir:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
if subfolder:
|
||||
safe = subfolder.replace("/", "_").replace("\\", "_").strip()
|
||||
if safe:
|
||||
output_dir = os.path.join(output_dir, safe)
|
||||
|
||||
gate = self._gate("audio")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
self._download_thread = threading.Thread(
|
||||
target=self._urls_worker,
|
||||
args=(list(urls), list(titles), output_dir),
|
||||
kwargs={"metadata_list": list(metadata_list) if metadata_list else None},
|
||||
daemon=True,
|
||||
)
|
||||
self._download_thread.start()
|
||||
@@ -546,7 +602,8 @@ class Api:
|
||||
self._log("download", "[INFO] Interruzione richiesta...")
|
||||
return {"ok": True}
|
||||
|
||||
def _tracks_worker(self, tracks: list, output_dir: str) -> None:
|
||||
def _tracks_worker(self, tracks: list, output_dir: str,
|
||||
metadata_list: Optional[list] = None) -> None:
|
||||
reset_download_stop()
|
||||
cfg = load_config()
|
||||
bitrate = cfg.get("bitrate", "320K")
|
||||
@@ -556,6 +613,31 @@ class Api:
|
||||
self._log(view, f"[INFO] Tracklist: {len(tracks)} brani da cercare su YouTube")
|
||||
self._log(view, f"[INFO] Destinazione: {output_dir}")
|
||||
|
||||
# Pipeline tagging: se metadata_list fornito, calcola i filename
|
||||
# ("Artista - Titolo") e prepara callback per scrivere ID3 tag
|
||||
# + cover art dopo ogni download riuscito.
|
||||
output_filenames: Optional[list] = None
|
||||
post_cb: Optional[Callable] = None
|
||||
if metadata_list:
|
||||
output_filenames = []
|
||||
for md in metadata_list:
|
||||
md = md or {}
|
||||
stem = tagger.build_filename_stem(
|
||||
md.get("artist", ""),
|
||||
md.get("title", ""),
|
||||
)
|
||||
output_filenames.append(stem)
|
||||
|
||||
def post_cb(idx: int, filepath: str,
|
||||
_md_list=metadata_list) -> None:
|
||||
if idx < 0 or idx >= len(_md_list):
|
||||
return
|
||||
md = _md_list[idx] or {}
|
||||
try:
|
||||
tagger.write_tags(filepath, md)
|
||||
except Exception:
|
||||
pass # tagging best-effort, non blocca il download
|
||||
|
||||
_last = [0.0]
|
||||
_THROTTLE = 0.10
|
||||
|
||||
@@ -595,7 +677,86 @@ class Api:
|
||||
|
||||
self._emit("download:progress", payload_evt)
|
||||
|
||||
download_playlist(tracks, output_dir, bitrate, cookies_path, progress_cb)
|
||||
download_playlist(
|
||||
tracks, output_dir, bitrate, cookies_path, progress_cb,
|
||||
output_filenames=output_filenames,
|
||||
post_download_callback=post_cb,
|
||||
)
|
||||
self._emit("download:done", {"ok": True})
|
||||
|
||||
def _urls_worker(self, urls: list, titles: list, output_dir: str,
|
||||
metadata_list: Optional[list] = None) -> None:
|
||||
from core.downloader import download_urls
|
||||
reset_download_stop()
|
||||
cfg = load_config()
|
||||
bitrate = cfg.get("bitrate", "320K")
|
||||
cookies_path = cfg.get("cookies_path", "")
|
||||
view = "download"
|
||||
|
||||
self._log(view, f"[INFO] URL list: {len(urls)} da scaricare da YouTube")
|
||||
self._log(view, f"[INFO] Destinazione: {output_dir}")
|
||||
|
||||
# Stessa pipeline tagging di _tracks_worker
|
||||
output_filenames: Optional[list] = None
|
||||
post_cb: Optional[Callable] = None
|
||||
if metadata_list:
|
||||
output_filenames = []
|
||||
for md in metadata_list:
|
||||
md = md or {}
|
||||
stem = tagger.build_filename_stem(
|
||||
md.get("artist", ""),
|
||||
md.get("title", ""),
|
||||
)
|
||||
output_filenames.append(stem)
|
||||
|
||||
def post_cb(idx: int, filepath: str,
|
||||
_md_list=metadata_list) -> None:
|
||||
if idx < 0 or idx >= len(_md_list):
|
||||
return
|
||||
md = _md_list[idx] or {}
|
||||
try:
|
||||
tagger.write_tags(filepath, md)
|
||||
except Exception:
|
||||
pass # tagging best-effort, non blocca il download
|
||||
|
||||
_last = [0.0]
|
||||
_THROTTLE = 0.10
|
||||
|
||||
def progress_cb(idx, total, title, status, pct):
|
||||
if status == "downloading":
|
||||
now = time.monotonic()
|
||||
if now - _last[0] < _THROTTLE:
|
||||
return
|
||||
_last[0] = now
|
||||
|
||||
payload_evt = {
|
||||
"idx": idx, "total": total, "track": title,
|
||||
"status": status, "pct": pct,
|
||||
"url_idx": 0, "url_total": 1,
|
||||
}
|
||||
if status == "skipped":
|
||||
self._log(view, f"[SKIP] {title} (gia scaricato)")
|
||||
payload_evt["overall"] = min((idx + 1) / total, 1.0) if total else 1
|
||||
elif status == "downloading":
|
||||
payload_evt["overall"] = min((idx / total) + (pct / 100 / total), 1.0) if total else 0
|
||||
elif status == "done":
|
||||
self._log(view, f"[OK] {title}")
|
||||
payload_evt["overall"] = min((idx + 1) / total, 1.0) if total else 1
|
||||
elif status == "stopped":
|
||||
self._log(view, "[INFO] Download interrotto.")
|
||||
elif status == "completed":
|
||||
payload_evt["overall"] = 1.0
|
||||
self._log(view, "[INFO] Download completato!")
|
||||
elif status.startswith("error"):
|
||||
self._log(view, f"[ERRORE] {title}: {status}")
|
||||
|
||||
self._emit("download:progress", payload_evt)
|
||||
|
||||
download_urls(
|
||||
urls, titles, output_dir, bitrate, cookies_path, progress_cb,
|
||||
output_filenames=output_filenames,
|
||||
post_download_callback=post_cb,
|
||||
)
|
||||
self._emit("download:done", {"ok": True})
|
||||
|
||||
def _download_worker(self, urls: list, output_dir: str) -> None:
|
||||
@@ -903,3 +1064,391 @@ class Api:
|
||||
})
|
||||
|
||||
self._emit("video:done", {"ok": True})
|
||||
|
||||
# ================================================================
|
||||
# Beatport charts
|
||||
# ================================================================
|
||||
def beatport_genres(self) -> list:
|
||||
"""Lista dei generi disponibili per il dropdown UI."""
|
||||
return beatport.list_genres()
|
||||
|
||||
def beatport_fetch_chart(self, slug: str, force_refresh: bool = False) -> dict:
|
||||
"""Fetches la Top 100 per il genere. Salva anche l'ultimo genere in config.
|
||||
Ritorna {ok: True, tracks: [...]} oppure {ok: False, error, message}."""
|
||||
# Memoria: ricorda l'ultimo genere usato
|
||||
try:
|
||||
cfg = load_config()
|
||||
cfg["beatport_last_genre"] = slug
|
||||
save_config(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
tracks = beatport.fetch_top100(slug, force_refresh=force_refresh)
|
||||
except ValueError as e:
|
||||
return {"ok": False, "error": "invalid_genre", "message": str(e)}
|
||||
except beatport.BeatportUnreachableError as e:
|
||||
return {"ok": False, "error": "unreachable", "message": str(e)}
|
||||
except beatport.BeatportParseError as e:
|
||||
return {"ok": False, "error": "parse", "message": str(e)}
|
||||
|
||||
return {"ok": True, "tracks": [asdict(t) for t in tracks]}
|
||||
|
||||
def _beatport_output_dir(self, out_root: str, genre_name: str) -> Path:
|
||||
"""Calcola la cartella dove finiscono i file Beatport.
|
||||
Coerente col comportamento di `start_tracks_download`, che sanitizza
|
||||
gli slash nel subfolder (`/` -> `_`), quindi `Beatport/<genre>`
|
||||
diventa flat: `Beatport_<genre>`."""
|
||||
safe_genre = genre_name.replace("/", "_").replace("\\", "_").strip()
|
||||
subfolder = f"Beatport_{safe_genre}" if safe_genre else "Beatport"
|
||||
return Path(out_root) / subfolder
|
||||
|
||||
def beatport_check_existing(self, tracks: list, genre_name: str) -> list:
|
||||
"""Per ogni track ritorna True se il file esiste gia' nella cartella
|
||||
di output Beatport (stessa dove `beatport_download_selected` scrive).
|
||||
Match euristico: nel filename (senza extension) devono comparire sia
|
||||
il titolo che il primo artista (case-insensitive)."""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return [False] * len(tracks)
|
||||
|
||||
out_dir = self._beatport_output_dir(out_root, genre_name)
|
||||
if not out_dir.exists():
|
||||
return [False] * len(tracks)
|
||||
|
||||
existing_stems = [p.stem.lower() for p in out_dir.glob("*.mp3")]
|
||||
result = []
|
||||
for t in tracks:
|
||||
title = (t.get("title") or "").lower().strip()
|
||||
artists = (t.get("artists") or "")
|
||||
first_artist = artists.split(",")[0].split("&")[0].strip().lower()
|
||||
if not title or not first_artist:
|
||||
result.append(False)
|
||||
continue
|
||||
found = any(
|
||||
(title in stem and first_artist in stem)
|
||||
for stem in existing_stems
|
||||
)
|
||||
result.append(found)
|
||||
return result
|
||||
|
||||
def beatport_download_selected(self, tracks: list, genre_name: str) -> dict:
|
||||
"""Converte i BeatportTrack in tracklist compatibile con start_tracks_download
|
||||
e lancia il download riusando l'infrastruttura esistente (license gate,
|
||||
skip file gia' scaricati, log su canale 'download', ecc.)."""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
# Sfrutta lo stesso computed path del check_existing per garantire
|
||||
# che i due metodi restino allineati (senza affidarsi a start_tracks_download
|
||||
# per la sanitizzazione).
|
||||
target_dir = self._beatport_output_dir(out_root, genre_name)
|
||||
subfolder = target_dir.name # es. "Beatport_melodic-house-techno"
|
||||
|
||||
converted = []
|
||||
for t in tracks:
|
||||
title = (t.get("title") or "").strip()
|
||||
artists = (t.get("artists") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
converted.append({"name": title, "artist": artists})
|
||||
|
||||
if not converted:
|
||||
return {"ok": False, "error": "Nessun brano valido"}
|
||||
|
||||
return self.start_tracks_download({
|
||||
"tracks": converted,
|
||||
"output_dir": out_root,
|
||||
"subfolder": subfolder,
|
||||
})
|
||||
|
||||
# ================================================================
|
||||
# Music Search — Spotify + YouTube
|
||||
# ================================================================
|
||||
|
||||
def _music_output_dir(self, out_root: str, source: str) -> Path:
|
||||
"""Cartella target per Spotify/YouTube search. Coerente col pattern
|
||||
di start_tracks_download (subfolder singolo, no nested)."""
|
||||
safe = source.replace("/", "_").replace("\\", "_").strip()
|
||||
return Path(out_root) / safe
|
||||
|
||||
# ---- Spotify ----
|
||||
|
||||
def spotify_search(self, query: str, artist_mode: bool = False) -> dict:
|
||||
"""Cerca su Spotify. Free-form (limit 50) o artist-mode (discografia)."""
|
||||
query = (query or "").strip()
|
||||
if not query:
|
||||
return {"ok": False, "error": "empty_query", "message": "Query vuota"}
|
||||
|
||||
# Salva stato
|
||||
try:
|
||||
cfg = load_config()
|
||||
cfg["spotify_search_last_query"] = query
|
||||
cfg["spotify_search_artist_mode"] = bool(artist_mode)
|
||||
save_config(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cfg = load_config()
|
||||
cid = (cfg.get("client_id") or "").strip()
|
||||
secret = (cfg.get("client_secret") or "").strip()
|
||||
if not cid or not secret:
|
||||
return {"ok": False, "error": "no_creds", "message": "Credenziali Spotify mancanti"}
|
||||
|
||||
try:
|
||||
token = spotify_client.get_access_token(cid, secret)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": "auth", "message": str(e)}
|
||||
|
||||
try:
|
||||
if artist_mode:
|
||||
tracks = spotify_client.search_artist_discography(token, query)
|
||||
else:
|
||||
tracks = spotify_client.search_tracks(token, query, limit=50)
|
||||
except ValueError as e:
|
||||
return {"ok": False, "error": "artist_not_found", "message": str(e)}
|
||||
except requests.HTTPError as e:
|
||||
code = e.response.status_code if e.response is not None else 0
|
||||
if code == 429:
|
||||
return {"ok": False, "error": "rate_limit", "message": "Spotify limitante - attendi qualche secondo"}
|
||||
return {"ok": False, "error": "server", "message": f"Spotify HTTP {code}"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": "unknown", "message": str(e)}
|
||||
|
||||
return {"ok": True, "tracks": tracks}
|
||||
|
||||
def spotify_check_existing(self, tracks: list) -> list:
|
||||
"""True per ogni track già presente in output_dir/Spotify/."""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return [False] * len(tracks)
|
||||
out_dir = self._music_output_dir(out_root, "Spotify")
|
||||
if not out_dir.exists():
|
||||
return [False] * len(tracks)
|
||||
existing_stems = [p.stem.lower() for p in out_dir.glob("*.mp3")]
|
||||
result = []
|
||||
for t in tracks:
|
||||
title = (t.get("name") or "").lower().strip()
|
||||
artists = (t.get("artists") or "")
|
||||
first_artist = artists.split(",")[0].strip().lower()
|
||||
if not title or not first_artist:
|
||||
result.append(False)
|
||||
continue
|
||||
result.append(any((title in stem and first_artist in stem) for stem in existing_stems))
|
||||
return result
|
||||
|
||||
def spotify_search_download(self, tracks: list) -> dict:
|
||||
"""Scarica i track Spotify selezionati (name+artist → YouTube search).
|
||||
|
||||
Costruisce anche `metadata` (una entry per track) per il tagging ID3
|
||||
+ cover art post-download. Se le creds Spotify sono presenti,
|
||||
arricchisce con i generi dell'artista (cache locale per artist_id
|
||||
per evitare N+1 chiamate).
|
||||
"""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
target = self._music_output_dir(out_root, "Spotify")
|
||||
subfolder = target.name
|
||||
|
||||
# Tentativo di token Spotify per fetch generi (best-effort, opzionale)
|
||||
cid = (cfg.get("client_id") or "").strip()
|
||||
secret = (cfg.get("client_secret") or "").strip()
|
||||
token: Optional[str] = None
|
||||
if cid and secret:
|
||||
try:
|
||||
token = spotify_client.get_access_token(cid, secret)
|
||||
except Exception:
|
||||
token = None
|
||||
|
||||
genre_cache: dict = {} # artist_id -> [genres]
|
||||
|
||||
converted: list = []
|
||||
metadata: list = []
|
||||
for t in tracks:
|
||||
title = (t.get("name") or "").strip()
|
||||
artists = (t.get("artists") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
converted.append({"name": title, "artist": artists})
|
||||
|
||||
# Genere: primo genere dell'artista (se disponibile e token OK)
|
||||
genre = ""
|
||||
artist_id = (t.get("artist_id") or "").strip()
|
||||
if token and artist_id:
|
||||
if artist_id not in genre_cache:
|
||||
genre_cache[artist_id] = spotify_client.get_artist_genres(token, artist_id)
|
||||
genres = genre_cache.get(artist_id) or []
|
||||
if genres:
|
||||
genre = genres[0]
|
||||
|
||||
tn = t.get("track_number") or 0
|
||||
metadata.append({
|
||||
"title": title,
|
||||
"artist": artists,
|
||||
"album": (t.get("album") or "").strip(),
|
||||
"date": (t.get("release_date") or "").strip(),
|
||||
"genre": genre,
|
||||
"tracknumber": str(tn) if tn else "",
|
||||
"cover_url": (t.get("cover_url_large") or t.get("image_url") or "").strip(),
|
||||
})
|
||||
|
||||
if not converted:
|
||||
return {"ok": False, "error": "Nessun brano valido"}
|
||||
|
||||
return self.start_tracks_download({
|
||||
"tracks": converted,
|
||||
"output_dir": out_root,
|
||||
"subfolder": subfolder,
|
||||
"metadata": metadata,
|
||||
})
|
||||
|
||||
# ---- YouTube ----
|
||||
|
||||
def youtube_search(self, query: str) -> dict:
|
||||
"""Cerca 50 risultati su YouTube via yt-dlp."""
|
||||
from core import youtube_search as yts
|
||||
|
||||
query = (query or "").strip()
|
||||
if not query:
|
||||
return {"ok": False, "error": "empty_query", "message": "Query vuota"}
|
||||
|
||||
try:
|
||||
cfg = load_config()
|
||||
cfg["youtube_search_last_query"] = query
|
||||
save_config(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
results = yts.search_youtube(query, limit=50)
|
||||
except RuntimeError as e:
|
||||
return {"ok": False, "error": "ytdlp", "message": str(e)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": "unknown", "message": str(e)}
|
||||
|
||||
return {"ok": True, "tracks": results}
|
||||
|
||||
def youtube_check_existing(self, tracks: list) -> list:
|
||||
"""True per ogni track già presente in output_dir/YouTube/. Match sul titolo video."""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return [False] * len(tracks)
|
||||
out_dir = self._music_output_dir(out_root, "YouTube")
|
||||
if not out_dir.exists():
|
||||
return [False] * len(tracks)
|
||||
existing_stems = [p.stem.lower() for p in out_dir.glob("*.mp3")]
|
||||
result = []
|
||||
for t in tracks:
|
||||
title = (t.get("title") or "").lower().strip()
|
||||
if not title:
|
||||
result.append(False)
|
||||
continue
|
||||
result.append(any(
|
||||
(title in stem or stem in title)
|
||||
for stem in existing_stems
|
||||
))
|
||||
return result
|
||||
|
||||
def youtube_search_download(self, tracks: list) -> dict:
|
||||
"""Scarica direttamente gli URL YouTube selezionati (no re-search).
|
||||
|
||||
Per ogni video prova a dedurre i metadati:
|
||||
1) Se le creds Spotify sono presenti, cerca il titolo su Spotify
|
||||
e usa i tag Spotify se c'è un match (best-effort).
|
||||
2) Altrimenti fallback: parsing "Artista - Titolo" dal titolo del
|
||||
video; uploader come fallback per l'album.
|
||||
"""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
target = self._music_output_dir(out_root, "YouTube")
|
||||
subfolder = target.name
|
||||
|
||||
# Token Spotify opzionale per enrichment
|
||||
cid = (cfg.get("client_id") or "").strip()
|
||||
secret = (cfg.get("client_secret") or "").strip()
|
||||
token: Optional[str] = None
|
||||
if cid and secret:
|
||||
try:
|
||||
token = spotify_client.get_access_token(cid, secret)
|
||||
except Exception:
|
||||
token = None
|
||||
|
||||
genre_cache: dict = {} # artist_id -> [genres]
|
||||
|
||||
urls: list = []
|
||||
titles: list = []
|
||||
metadata: list = []
|
||||
for t in tracks:
|
||||
url = (t.get("url") or "").strip()
|
||||
title = (t.get("title") or "").strip()
|
||||
if not url or not title:
|
||||
continue
|
||||
urls.append(url)
|
||||
titles.append(title)
|
||||
|
||||
md: dict = {}
|
||||
# 1) Enrichment via Spotify search (se token)
|
||||
if token:
|
||||
sp = spotify_client.enrich_from_youtube_title(token, title)
|
||||
if sp:
|
||||
# Genere: opzionale
|
||||
genre = ""
|
||||
artist_id = (sp.get("artist_id") or "").strip()
|
||||
if artist_id:
|
||||
if artist_id not in genre_cache:
|
||||
genre_cache[artist_id] = spotify_client.get_artist_genres(token, artist_id)
|
||||
genres = genre_cache.get(artist_id) or []
|
||||
if genres:
|
||||
genre = genres[0]
|
||||
tn = sp.get("track_number") or 0
|
||||
md = {
|
||||
"title": (sp.get("name") or "").strip(),
|
||||
"artist": (sp.get("artists") or "").strip(),
|
||||
"album": (sp.get("album") or "").strip(),
|
||||
"date": (sp.get("release_date") or "").strip(),
|
||||
"genre": genre,
|
||||
"tracknumber": str(tn) if tn else "",
|
||||
"cover_url": (sp.get("cover_url_large") or sp.get("image_url") or "").strip(),
|
||||
}
|
||||
|
||||
# 2) Fallback: parsing del titolo YouTube
|
||||
if not md.get("title") or not md.get("artist"):
|
||||
parsed = _parse_track_line(title)
|
||||
if parsed:
|
||||
fb_artist = parsed["artist"]
|
||||
fb_title = parsed["name"]
|
||||
else:
|
||||
fb_artist = ""
|
||||
fb_title = title
|
||||
md = {
|
||||
"title": fb_title,
|
||||
"artist": fb_artist,
|
||||
"album": (t.get("channel") or "").strip(),
|
||||
"date": (t.get("release_date") or "").strip(),
|
||||
"genre": "",
|
||||
"tracknumber": "",
|
||||
"cover_url": (t.get("image_url") or "").strip(),
|
||||
}
|
||||
metadata.append(md)
|
||||
|
||||
if not urls:
|
||||
return {"ok": False, "error": "Nessun URL valido"}
|
||||
|
||||
return self.start_urls_download({
|
||||
"urls": urls,
|
||||
"titles": titles,
|
||||
"output_dir": out_root,
|
||||
"subfolder": subfolder,
|
||||
"metadata": metadata,
|
||||
})
|
||||
+34
-30
@@ -239,7 +239,12 @@ def run_pyinstaller():
|
||||
if app_path.exists():
|
||||
log(f"Build completata: {app_path}")
|
||||
patch_info_plist(app_path)
|
||||
wrap_subprocess_in_bundle(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())
|
||||
@@ -253,45 +258,44 @@ def run_pyinstaller():
|
||||
|
||||
|
||||
def patch_info_plist(app_path):
|
||||
"""Aggiunge le chiavi TCC (Transparency, Consent and Control) richieste
|
||||
da macOS nel bundle .app, altrimenti macOS nega silenziosamente l'accesso
|
||||
al microfono e l'app NON compare in Impostazioni > Privacy e sicurezza.
|
||||
"""Aggiunge le chiavi TCC nell'Info.plist del bundle .app.
|
||||
|
||||
Senza NSMicrophoneUsageDescription, qualsiasi tentativo di ffmpeg di
|
||||
aprire un dispositivo audio (incluso BlackHole) viene rifiutato dal
|
||||
sistema e non viene mostrato nessun prompt all'utente.
|
||||
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
|
||||
|
||||
pb = "/usr/libexec/PlistBuddy"
|
||||
|
||||
# Helper: aggiungi una chiave; se gia presente sovrascrivila.
|
||||
def add_or_set(key, value, ptype="string"):
|
||||
# Tenta Add; se esiste, fa Set.
|
||||
r = subprocess.run(
|
||||
[pb, "-c", f'Add :{key} {ptype} "{value}"', str(info_plist)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if r.returncode != 0 and "already exists" in (r.stderr + r.stdout).lower():
|
||||
subprocess.run(
|
||||
[pb, "-c", f'Set :{key} "{value}"', str(info_plist)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
log("Patching Info.plist con le chiavi TCC...")
|
||||
add_or_set(
|
||||
"NSMicrophoneUsageDescription",
|
||||
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).",
|
||||
"(ad esempio tramite BlackHole o un altro dispositivo loopback)."
|
||||
)
|
||||
# Sicurezza extra: dichiarare bundle identifier stabile aiuta il
|
||||
# database TCC a riconoscere coerentemente l'app tra le sessioni.
|
||||
add_or_set("CFBundleIdentifier", "com.djluza.musictools")
|
||||
add_or_set("LSApplicationCategoryType", "public.app-category.music")
|
||||
log("Info.plist aggiornato.")
|
||||
# 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"
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""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)
|
||||
|
||||
@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")),
|
||||
)
|
||||
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
|
||||
+7
-1
@@ -5,7 +5,7 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "v1.7.13"
|
||||
VERSION = "v1.8.4"
|
||||
|
||||
|
||||
APP_NAME = "MusicTools"
|
||||
@@ -70,6 +70,12 @@ DEFAULTS = {
|
||||
"cookies_path": str(_project_dir / "cookies.txt"),
|
||||
"output_dir": str(_project_dir / "MUSICA"),
|
||||
"theme": "dark",
|
||||
# ---- Beatport ----
|
||||
"beatport_last_genre": "melodic-house-techno", # ultimo genere Top 100 caricato
|
||||
# ---- Music Search (Spotify + YouTube) ----
|
||||
"spotify_search_last_query": "",
|
||||
"spotify_search_artist_mode": False,
|
||||
"youtube_search_last_query": "",
|
||||
# ---- Licenza ----
|
||||
"license_key": "", # chiave fornita all'utente via email
|
||||
"license_email": "", # email associata all'acquisto
|
||||
|
||||
+166
-1
@@ -121,6 +121,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 +132,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 +191,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,7 +210,7 @@ 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()
|
||||
@@ -231,6 +248,12 @@ 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:
|
||||
@@ -399,6 +422,148 @@ 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])
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", 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":
|
||||
|
||||
+349
-225
@@ -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,54 +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.
|
||||
|
||||
IMPORTANTE: come 'id' usiamo il NOME del device (non l'index numerico).
|
||||
AVFoundation rinumera silenziosamente i device quando cambia il set di
|
||||
hardware collegato (es. iPhone in continuita', Multi-Output Device, ecc).
|
||||
Il nome e' invece stabile; ffmpeg AVFoundation accetta sia ':<index>'
|
||||
sia ':<name>' come specifica del device 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": name, # nome stabile (vedi docstring)
|
||||
"index": idx, # solo informativo per debug
|
||||
"id": uid,
|
||||
"name": name,
|
||||
"is_virtual": _is_virtual(name),
|
||||
})
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
@@ -162,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:
|
||||
@@ -174,130 +161,295 @@ 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(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=False,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
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():
|
||||
break
|
||||
rc = _current_proc.poll()
|
||||
with _recording_lock:
|
||||
if not _recording_state.get("active"):
|
||||
break
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
break
|
||||
elapsed = int(time.monotonic() - start)
|
||||
@@ -305,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,74 @@
|
||||
"""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 ""
|
||||
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,
|
||||
})
|
||||
return result
|
||||
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.
|
||||
+22
-138
@@ -4,10 +4,10 @@
|
||||
<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" />
|
||||
|
||||
@@ -28,11 +28,10 @@
|
||||
<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>
|
||||
|
||||
@@ -40,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.
|
||||
@@ -48,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">
|
||||
@@ -157,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>
|
||||
@@ -177,113 +176,18 @@
|
||||
<!-- ============== 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">
|
||||
@@ -356,27 +260,12 @@
|
||||
|
||||
<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">
|
||||
@@ -441,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>
|
||||
@@ -452,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>
|
||||
|
||||
@@ -479,7 +364,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://assets.lemonsqueezy.com/lemon.js" defer></script>
|
||||
<script src="js/main.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,5 +5,12 @@ 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"
|
||||
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
|
||||
|
||||
# --- 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,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'"
|
||||
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
|
||||
@@ -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,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,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)
|
||||
+179
-21
@@ -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);
|
||||
|
||||
@@ -192,13 +211,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 +233,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 +273,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 +333,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 +490,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 +516,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; }
|
||||
@@ -663,8 +684,8 @@ 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); }
|
||||
|
||||
.progress {
|
||||
@@ -711,10 +732,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 +795,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 +859,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 +883,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); }
|
||||
|
||||
@@ -893,7 +914,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
}
|
||||
|
||||
.save-status {
|
||||
color: var(--green);
|
||||
color: var(--green-text);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
@@ -1003,7 +1024,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)
|
||||
@@ -1011,11 +1032,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); }
|
||||
@@ -1550,3 +1571,140 @@ 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-state { width: 130px; font-size: 12px; }
|
||||
|
||||
.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; }
|
||||
}
|
||||
@@ -29,6 +29,18 @@
|
||||
<span class="nav-icon">🎬</span>
|
||||
<span>Video</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="beatport" data-feature="audio">
|
||||
<span class="nav-icon">🎧</span>
|
||||
<span>Beatport</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="spotify" data-feature="audio">
|
||||
<span class="nav-icon">🟢</span>
|
||||
<span>Spotify</span>
|
||||
</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="upgrade" data-feature="upgrade">
|
||||
<span class="nav-icon">⚡</span>
|
||||
<span>Upgrade</span>
|
||||
@@ -849,6 +861,183 @@
|
||||
</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">#</th>
|
||||
<th>Artista</th>
|
||||
<th>Titolo (Mix)</th>
|
||||
<th class="col-dur">Durata</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: 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">#</th>
|
||||
<th>Artista</th>
|
||||
<th>Titolo</th>
|
||||
<th>Album</th>
|
||||
<th class="col-dur">Durata</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">#</th>
|
||||
<th>Titolo video</th>
|
||||
<th>Canale</th>
|
||||
<th class="col-dur">Durata</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>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
+778
@@ -397,6 +397,8 @@ const logEls = {
|
||||
download: () => $("#dlLog"),
|
||||
upgrade: () => $("#upLog"),
|
||||
video: () => $("#videoLog"),
|
||||
spotify: () => $("#spotify-log"),
|
||||
youtube: () => $("#youtube-log"),
|
||||
};
|
||||
|
||||
function classifyLog(msg) {
|
||||
@@ -501,6 +503,13 @@ async function init() {
|
||||
$("#recPathDisplay").classList.remove("empty");
|
||||
}
|
||||
await refreshRecDevices();
|
||||
|
||||
// Beatport tab — popola generi, ripristina ultimo genere, aggancia handler
|
||||
await BeatportUI.init();
|
||||
|
||||
// Spotify + YouTube search tabs
|
||||
await SpotifyUI.init();
|
||||
await YoutubeUI.init();
|
||||
}
|
||||
|
||||
async function refreshRecDevices() {
|
||||
@@ -1170,6 +1179,775 @@ $("#quotaUpgradeBtn")?.addEventListener("click", async () => {
|
||||
try { await window.pywebview.api.open_purchase_page(); } catch (_e) {}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// BEATPORT tab — carica Top 100 per genere, seleziona tracce, scarica
|
||||
// (il download riusa start_tracks_download, quindi passa dagli stessi
|
||||
// canali "download:progress" / "download:done" / log view="download")
|
||||
// ============================================================
|
||||
const BeatportUI = (function () {
|
||||
let genres = [];
|
||||
let currentTracks = [];
|
||||
let existing = [];
|
||||
let currentGenreName = "";
|
||||
let currentGenreSlug = "";
|
||||
let downloading = false;
|
||||
|
||||
function fmtDur(sec) {
|
||||
const n = Number(sec) || 0;
|
||||
const m = Math.floor(n / 60);
|
||||
const s = Math.floor(n % 60);
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function safeGenreFolder(name) {
|
||||
return String(name || "").replace(/[\/\\]/g, "_").trim();
|
||||
}
|
||||
|
||||
function populateSelect() {
|
||||
const sel = $("#beatport-genre");
|
||||
if (!sel) return;
|
||||
sel.innerHTML = "";
|
||||
for (const g of genres) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = g.slug;
|
||||
opt.textContent = g.name;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
function updateOutputInfo() {
|
||||
const info = $("#beatport-output-info");
|
||||
if (!info) return;
|
||||
const root = (state.config && state.config.output_dir) || "";
|
||||
if (!root) {
|
||||
info.textContent = "⚠ Imposta la cartella output in Impostazioni prima di scaricare";
|
||||
info.className = "beatport-output-info warn";
|
||||
return;
|
||||
}
|
||||
const folder = safeGenreFolder(currentGenreName);
|
||||
info.textContent = folder
|
||||
? `Destinazione: ${root}/Beatport/${folder}`
|
||||
: `Destinazione: ${root}/Beatport`;
|
||||
info.className = "beatport-output-info";
|
||||
}
|
||||
|
||||
function setStatus(text, kind) {
|
||||
const el = $("#beatport-status");
|
||||
if (!el) return;
|
||||
el.textContent = text || "";
|
||||
el.className = "beatport-status" + (kind ? " " + kind : "");
|
||||
}
|
||||
|
||||
async function loadChart(forceRefresh) {
|
||||
const sel = $("#beatport-genre");
|
||||
const slug = sel && sel.value;
|
||||
if (!slug) return;
|
||||
const genreName = (sel.selectedOptions[0] && sel.selectedOptions[0].textContent) || slug;
|
||||
currentGenreSlug = slug;
|
||||
currentGenreName = genreName;
|
||||
|
||||
setStatus(forceRefresh ? "Ricarico Top 100 (bypass cache)…" : "Caricamento Top 100…", "loading");
|
||||
$("#beatport-table").hidden = true;
|
||||
$("#beatport-toolbar").hidden = true;
|
||||
$("#beatport-table").querySelector("tbody").innerHTML = "";
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await window.pywebview.api.beatport_fetch_chart(slug, !!forceRefresh);
|
||||
} catch (e) {
|
||||
setStatus("Errore: " + ((e && e.message) || e), "error");
|
||||
return;
|
||||
}
|
||||
if (!res || !res.ok) {
|
||||
const code = res && res.error;
|
||||
let msg = (res && res.message) || "Errore sconosciuto";
|
||||
if (code === "invalid_genre") msg = "Genere non valido";
|
||||
else if (code === "unreachable") msg = "Beatport non raggiungibile — riprova più tardi";
|
||||
else if (code === "parse") msg = "Impossibile leggere i dati (schema pagina cambiato?)";
|
||||
setStatus(msg, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
currentTracks = res.tracks || [];
|
||||
try {
|
||||
existing = await window.pywebview.api.beatport_check_existing(currentTracks, currentGenreName);
|
||||
} catch (_e) {
|
||||
existing = currentTracks.map(() => false);
|
||||
}
|
||||
setStatus(`${currentTracks.length} brani caricati`, "ok");
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const table = $("#beatport-table");
|
||||
const tbody = table.querySelector("tbody");
|
||||
tbody.innerHTML = "";
|
||||
|
||||
currentTracks.forEach((t, i) => {
|
||||
const tr = document.createElement("tr");
|
||||
if (existing[i]) tr.classList.add("already-downloaded");
|
||||
|
||||
const tdCheck = document.createElement("td");
|
||||
tdCheck.className = "col-check";
|
||||
const cb = document.createElement("input");
|
||||
cb.type = "checkbox";
|
||||
cb.dataset.idx = String(i);
|
||||
cb.checked = !existing[i];
|
||||
cb.addEventListener("change", updateSelectionCount);
|
||||
tdCheck.appendChild(cb);
|
||||
tr.appendChild(tdCheck);
|
||||
|
||||
const tdCover = document.createElement("td");
|
||||
tdCover.className = "col-cover";
|
||||
if (t.image_url) {
|
||||
const img = document.createElement("img");
|
||||
img.src = t.image_url;
|
||||
img.alt = "";
|
||||
img.loading = "lazy";
|
||||
img.referrerPolicy = "no-referrer";
|
||||
img.onerror = () => { img.style.visibility = "hidden"; };
|
||||
tdCover.appendChild(img);
|
||||
}
|
||||
tr.appendChild(tdCover);
|
||||
|
||||
const tdPos = document.createElement("td");
|
||||
tdPos.className = "col-pos";
|
||||
tdPos.textContent = String(t.position || (i + 1));
|
||||
tr.appendChild(tdPos);
|
||||
|
||||
const tdArtist = document.createElement("td");
|
||||
tdArtist.textContent = t.artists || "";
|
||||
tr.appendChild(tdArtist);
|
||||
|
||||
const tdTitle = document.createElement("td");
|
||||
tdTitle.textContent = t.mix ? `${t.title} (${t.mix})` : (t.title || "");
|
||||
tr.appendChild(tdTitle);
|
||||
|
||||
const tdDur = document.createElement("td");
|
||||
tdDur.className = "col-dur";
|
||||
tdDur.textContent = fmtDur(t.duration_sec);
|
||||
tr.appendChild(tdDur);
|
||||
|
||||
const tdState = document.createElement("td");
|
||||
tdState.className = "col-state";
|
||||
if (existing[i]) tdState.textContent = "✓ già scaricato";
|
||||
tr.appendChild(tdState);
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
table.hidden = false;
|
||||
$("#beatport-toolbar").hidden = false;
|
||||
updateOutputInfo();
|
||||
updateSelectionCount();
|
||||
}
|
||||
|
||||
function updateSelectionCount() {
|
||||
const boxes = $$("#beatport-table tbody input[type=checkbox]");
|
||||
const total = boxes.length;
|
||||
const checked = boxes.filter((b) => b.checked).length;
|
||||
$("#beatport-selected-count").textContent = `${checked}/${total} selezionati`;
|
||||
const dlBtn = $("#beatport-download-btn");
|
||||
if (dlBtn) dlBtn.disabled = downloading || checked === 0;
|
||||
|
||||
const master = $("#beatport-select-all");
|
||||
if (master) {
|
||||
if (total === 0) { master.checked = false; master.indeterminate = false; }
|
||||
else if (checked === 0) { master.checked = false; master.indeterminate = false; }
|
||||
else if (checked === total) { master.checked = true; master.indeterminate = false; }
|
||||
else { master.checked = false; master.indeterminate = true; }
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAll(checked) {
|
||||
$$("#beatport-table tbody input[type=checkbox]").forEach((b) => { b.checked = checked; });
|
||||
updateSelectionCount();
|
||||
}
|
||||
|
||||
function appendBeatportLog(msg) {
|
||||
const el = $("#beatport-log");
|
||||
if (!el) return;
|
||||
const cls = classifyLog(msg);
|
||||
const line = document.createElement("div");
|
||||
if (cls) line.className = cls;
|
||||
line.textContent = msg;
|
||||
el.appendChild(line);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
async function startDownload() {
|
||||
const boxes = $$("#beatport-table tbody input[type=checkbox]");
|
||||
const selected = [];
|
||||
boxes.forEach((b) => {
|
||||
if (b.checked) {
|
||||
const idx = parseInt(b.dataset.idx, 10);
|
||||
if (!isNaN(idx) && currentTracks[idx]) selected.push(currentTracks[idx]);
|
||||
}
|
||||
});
|
||||
if (!selected.length) {
|
||||
toast("Seleziona almeno un brano", "error");
|
||||
return;
|
||||
}
|
||||
if (!state.config || !state.config.output_dir) {
|
||||
toast("Imposta la cartella output in Impostazioni", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
downloading = true;
|
||||
$("#beatport-download-btn").disabled = true;
|
||||
$("#beatport-stop-btn").hidden = false;
|
||||
$("#beatport-stop-btn").disabled = false;
|
||||
$("#beatport-log").innerHTML = "";
|
||||
appendBeatportLog(`[INFO] Avvio download di ${selected.length} brani da ${currentGenreName}…`);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await window.pywebview.api.beatport_download_selected(selected, currentGenreName);
|
||||
} catch (e) {
|
||||
appendBeatportLog("[ERRORE] " + ((e && e.message) || e));
|
||||
finishDownload();
|
||||
return;
|
||||
}
|
||||
if (!res || !res.ok) {
|
||||
const errMsg = (res && (res.error || res.message)) || "Impossibile avviare il download";
|
||||
appendBeatportLog("[ERRORE] " + errMsg);
|
||||
if (!handleGateBlock(res || {})) toast(errMsg, "error");
|
||||
finishDownload();
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDownload() {
|
||||
try {
|
||||
await window.pywebview.api.stop_download();
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function finishDownload() {
|
||||
downloading = false;
|
||||
$("#beatport-stop-btn").hidden = true;
|
||||
$("#beatport-stop-btn").disabled = true;
|
||||
updateSelectionCount();
|
||||
// Re-check existing per aggiornare i badge "già scaricato"
|
||||
if (currentTracks.length) {
|
||||
window.pywebview.api.beatport_check_existing(currentTracks, currentGenreName)
|
||||
.then((res) => { existing = res || existing; renderTable(); })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// Popola dropdown generi
|
||||
try {
|
||||
genres = await window.pywebview.api.beatport_genres();
|
||||
} catch (e) {
|
||||
console.error("Beatport: impossibile caricare i generi", e);
|
||||
genres = [];
|
||||
}
|
||||
populateSelect();
|
||||
|
||||
// Ripristina l'ultimo genere selezionato (state.config gia' caricato)
|
||||
const last = (state.config && state.config.beatport_last_genre) || "";
|
||||
const sel = $("#beatport-genre");
|
||||
if (sel && last && genres.some((g) => g.slug === last)) {
|
||||
sel.value = last;
|
||||
}
|
||||
updateOutputInfo();
|
||||
|
||||
// Bind eventi
|
||||
$("#beatport-load-btn").addEventListener("click", (e) => loadChart(e.shiftKey));
|
||||
$("#beatport-download-btn").addEventListener("click", startDownload);
|
||||
$("#beatport-stop-btn").addEventListener("click", stopDownload);
|
||||
$("#beatport-select-all").addEventListener("change", (e) => toggleAll(e.target.checked));
|
||||
|
||||
// Aggancia agli event listener esistenti del canale "download" senza rompere
|
||||
// il comportamento della tab Scarica (wrapping additivo).
|
||||
const origProgress = bridgeHandlers["download:progress"];
|
||||
bridgeHandlers["download:progress"] = (p) => {
|
||||
if (origProgress) origProgress(p);
|
||||
// I contatori del tab Scarica sono aggiornati dall'handler originale;
|
||||
// qui potremmo aggiungere un contatore Beatport, ma i log sono sufficienti.
|
||||
};
|
||||
const origDone = bridgeHandlers["download:done"];
|
||||
bridgeHandlers["download:done"] = (p) => {
|
||||
if (origDone) origDone(p);
|
||||
if (downloading) finishDownload();
|
||||
};
|
||||
const origLog = bridgeHandlers["log"];
|
||||
bridgeHandlers["log"] = ({ view, msg }) => {
|
||||
if (origLog) origLog({ view, msg });
|
||||
if (downloading && view === "download") appendBeatportLog(msg);
|
||||
};
|
||||
}
|
||||
|
||||
return { init };
|
||||
})();
|
||||
|
||||
// =====================================================================
|
||||
// Spotify search
|
||||
// =====================================================================
|
||||
const SpotifyUI = (() => {
|
||||
// NOTE: local module state — named `mstate` to NOT shadow the outer
|
||||
// module-level `state` (which holds `state.config`).
|
||||
const mstate = { tracks: [], existing: [], downloading: false };
|
||||
|
||||
function _escape(s) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s == null ? "" : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function _fmtDuration(sec) {
|
||||
const n = Math.max(0, Math.floor(Number(sec) || 0));
|
||||
const m = Math.floor(n / 60);
|
||||
const s = n % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function updateSelectionCount() {
|
||||
const boxes = $$("#spotify-tbody input[type=checkbox]");
|
||||
const total = boxes.length;
|
||||
const selected = boxes.filter((b) => b.checked).length;
|
||||
$("#spotify-selected-count").textContent = `${selected}/${total} selezionati`;
|
||||
const btn = $("#spotify-download-btn");
|
||||
if (btn) {
|
||||
btn.disabled = mstate.downloading || selected === 0;
|
||||
btn.innerHTML = `<span class="ico">▶</span> Scarica selezionati${selected ? ` (${selected})` : ""}`;
|
||||
}
|
||||
const sa = $("#spotify-select-all");
|
||||
if (sa) {
|
||||
if (total === 0) { sa.checked = false; sa.indeterminate = false; }
|
||||
else if (selected === 0) { sa.checked = false; sa.indeterminate = false; }
|
||||
else if (selected === total) { sa.checked = true; sa.indeterminate = false; }
|
||||
else { sa.checked = false; sa.indeterminate = true; }
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = $("#spotify-tbody");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
mstate.tracks.forEach((t, i) => {
|
||||
const already = !!mstate.existing[i];
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.idx = String(i);
|
||||
if (already) tr.classList.add("already-downloaded");
|
||||
const coverImg = t.image_url
|
||||
? `<img src="${_escape(t.image_url)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"/>`
|
||||
: "";
|
||||
tr.innerHTML = `
|
||||
<td class="col-check"><input type="checkbox" data-idx="${i}" ${already ? "" : "checked"} /></td>
|
||||
<td class="col-cover">${coverImg}</td>
|
||||
<td class="col-pos">${i + 1}</td>
|
||||
<td>${_escape(t.artists)}</td>
|
||||
<td>${_escape(t.name)}</td>
|
||||
<td>${_escape(t.album)}</td>
|
||||
<td class="col-dur">${_fmtDuration(t.duration_sec)}</td>
|
||||
<td class="col-state">${already ? "✓ già scaricato" : ""}</td>
|
||||
`;
|
||||
const cb = tr.querySelector("input[type=checkbox]");
|
||||
if (cb) cb.addEventListener("change", updateSelectionCount);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
updateSelectionCount();
|
||||
}
|
||||
|
||||
function setStatus(text, kind) {
|
||||
const el = $("#spotify-status");
|
||||
if (!el) return;
|
||||
el.textContent = text || "";
|
||||
el.className = "beatport-status" + (kind ? " " + kind : "");
|
||||
}
|
||||
|
||||
function updateOutputInfo() {
|
||||
const info = $("#spotify-output-info");
|
||||
if (!info) return;
|
||||
const root = (state.config && state.config.output_dir) || "";
|
||||
if (!root) {
|
||||
info.textContent = "⚠ Imposta la cartella output in Impostazioni prima di scaricare";
|
||||
info.className = "beatport-output-info warn";
|
||||
} else {
|
||||
info.textContent = `Destinazione: ${root}/Spotify`;
|
||||
info.className = "beatport-output-info";
|
||||
}
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
const q = ($("#spotify-query").value || "").trim();
|
||||
if (!q) return;
|
||||
const artistMode = $("#spotify-artist-mode").checked;
|
||||
const wrap = $("#spotify-table-wrap");
|
||||
|
||||
setStatus("Ricerca in corso…", "loading");
|
||||
if (wrap) wrap.hidden = true;
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await window.pywebview.api.spotify_search(q, artistMode);
|
||||
} catch (e) {
|
||||
setStatus("Errore: " + ((e && e.message) || e), "error");
|
||||
return;
|
||||
}
|
||||
if (!res || !res.ok) {
|
||||
const errKey = res && res.error;
|
||||
const messages = {
|
||||
empty_query: "Inserisci una query di ricerca.",
|
||||
no_creds: "Credenziali Spotify mancanti. Vai su Impostazioni.",
|
||||
auth: "Impossibile autenticarsi con Spotify. Verifica le credenziali.",
|
||||
};
|
||||
setStatus(messages[errKey] || (res && res.message) || "Errore sconosciuto", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
mstate.tracks = res.tracks || [];
|
||||
if (mstate.tracks.length === 0) {
|
||||
setStatus(`Nessun brano trovato per "${q}".`, "");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
mstate.existing = await window.pywebview.api.spotify_check_existing(mstate.tracks);
|
||||
} catch (_e) {
|
||||
mstate.existing = mstate.tracks.map(() => false);
|
||||
}
|
||||
|
||||
setStatus(`${mstate.tracks.length} risultati`, "ok");
|
||||
updateOutputInfo();
|
||||
renderTable();
|
||||
if (wrap) wrap.hidden = false;
|
||||
}
|
||||
|
||||
function appendSpotifyLog(msg) {
|
||||
const el = $("#spotify-log");
|
||||
if (!el) return;
|
||||
const cls = classifyLog(msg);
|
||||
const line = document.createElement("div");
|
||||
if (cls) line.className = cls;
|
||||
line.textContent = msg;
|
||||
el.appendChild(line);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
async function startDownload() {
|
||||
const boxes = $$("#spotify-tbody input[type=checkbox]");
|
||||
const selected = [];
|
||||
boxes.forEach((b) => {
|
||||
if (b.checked) {
|
||||
const idx = parseInt(b.dataset.idx, 10);
|
||||
if (!isNaN(idx) && mstate.tracks[idx]) selected.push(mstate.tracks[idx]);
|
||||
}
|
||||
});
|
||||
if (!selected.length) {
|
||||
toast("Seleziona almeno un brano", "error");
|
||||
return;
|
||||
}
|
||||
if (!state.config || !state.config.output_dir) {
|
||||
toast("Imposta la cartella output in Impostazioni", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
mstate.downloading = true;
|
||||
$("#spotify-download-btn").disabled = true;
|
||||
$("#spotify-stop-btn").hidden = false;
|
||||
$("#spotify-stop-btn").disabled = false;
|
||||
$("#spotify-log").innerHTML = "";
|
||||
appendSpotifyLog(`[INFO] Avvio download di ${selected.length} brani…`);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await window.pywebview.api.spotify_search_download(selected);
|
||||
} catch (e) {
|
||||
appendSpotifyLog("[ERRORE] " + ((e && e.message) || e));
|
||||
finishDownload();
|
||||
return;
|
||||
}
|
||||
if (!res || !res.ok) {
|
||||
const errMsg = (res && (res.error || res.message)) || "Impossibile avviare il download";
|
||||
appendSpotifyLog("[ERRORE] " + errMsg);
|
||||
if (!handleGateBlock(res || {})) toast(errMsg, "error");
|
||||
finishDownload();
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDownload() {
|
||||
try { await window.pywebview.api.stop_download(); } catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function finishDownload() {
|
||||
mstate.downloading = false;
|
||||
$("#spotify-stop-btn").hidden = true;
|
||||
$("#spotify-stop-btn").disabled = true;
|
||||
updateSelectionCount();
|
||||
if (mstate.tracks.length) {
|
||||
window.pywebview.api.spotify_check_existing(mstate.tracks)
|
||||
.then((r) => { mstate.existing = r || mstate.existing; renderTable(); })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// Pre-compila da config
|
||||
try {
|
||||
const q = (state.config && state.config.spotify_search_last_query) || "";
|
||||
const am = !!(state.config && state.config.spotify_search_artist_mode);
|
||||
const qi = $("#spotify-query");
|
||||
if (qi && q) qi.value = q;
|
||||
const ac = $("#spotify-artist-mode");
|
||||
if (ac) ac.checked = am;
|
||||
} catch (_e) { /* ignora */ }
|
||||
|
||||
updateOutputInfo();
|
||||
|
||||
$("#spotify-search-btn").addEventListener("click", doSearch);
|
||||
$("#spotify-query").addEventListener("keydown", (e) => { if (e.key === "Enter") doSearch(); });
|
||||
$("#spotify-select-all").addEventListener("change", (e) => {
|
||||
$$("#spotify-tbody input[type=checkbox]").forEach((b) => { b.checked = e.target.checked; });
|
||||
updateSelectionCount();
|
||||
});
|
||||
$("#spotify-download-btn").addEventListener("click", startDownload);
|
||||
$("#spotify-stop-btn").addEventListener("click", stopDownload);
|
||||
|
||||
// Wrap bridge handlers additivamente
|
||||
const origLog = bridgeHandlers["log"];
|
||||
bridgeHandlers["log"] = (payload) => {
|
||||
if (origLog) origLog(payload);
|
||||
if (mstate.downloading && payload && payload.view === "download") {
|
||||
appendSpotifyLog(payload.msg);
|
||||
}
|
||||
};
|
||||
const origDone = bridgeHandlers["download:done"];
|
||||
bridgeHandlers["download:done"] = (payload) => {
|
||||
if (origDone) origDone(payload);
|
||||
if (mstate.downloading) finishDownload();
|
||||
};
|
||||
}
|
||||
|
||||
return { init };
|
||||
})();
|
||||
|
||||
// =====================================================================
|
||||
// YouTube search
|
||||
// =====================================================================
|
||||
const YoutubeUI = (() => {
|
||||
const mstate = { tracks: [], existing: [], downloading: false };
|
||||
|
||||
function _escape(s) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s == null ? "" : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
function _fmtDuration(sec) {
|
||||
const n = Math.max(0, Math.floor(Number(sec) || 0));
|
||||
const m = Math.floor(n / 60);
|
||||
const s = n % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function updateSelectionCount() {
|
||||
const boxes = $$("#youtube-tbody input[type=checkbox]");
|
||||
const total = boxes.length;
|
||||
const selected = boxes.filter((b) => b.checked).length;
|
||||
$("#youtube-selected-count").textContent = `${selected}/${total} selezionati`;
|
||||
const btn = $("#youtube-download-btn");
|
||||
if (btn) {
|
||||
btn.disabled = mstate.downloading || selected === 0;
|
||||
btn.innerHTML = `<span class="ico">▶</span> Scarica selezionati${selected ? ` (${selected})` : ""}`;
|
||||
}
|
||||
const sa = $("#youtube-select-all");
|
||||
if (sa) {
|
||||
if (total === 0) { sa.checked = false; sa.indeterminate = false; }
|
||||
else if (selected === 0) { sa.checked = false; sa.indeterminate = false; }
|
||||
else if (selected === total) { sa.checked = true; sa.indeterminate = false; }
|
||||
else { sa.checked = false; sa.indeterminate = true; }
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = $("#youtube-tbody");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
mstate.tracks.forEach((t, i) => {
|
||||
const already = !!mstate.existing[i];
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.idx = String(i);
|
||||
if (already) tr.classList.add("already-downloaded");
|
||||
const coverImg = t.image_url
|
||||
? `<img src="${_escape(t.image_url)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"/>`
|
||||
: "";
|
||||
tr.innerHTML = `
|
||||
<td class="col-check"><input type="checkbox" data-idx="${i}" ${already ? "" : "checked"} /></td>
|
||||
<td class="col-cover">${coverImg}</td>
|
||||
<td class="col-pos">${i + 1}</td>
|
||||
<td>${_escape(t.title)}</td>
|
||||
<td>${_escape(t.channel)}</td>
|
||||
<td class="col-dur">${_fmtDuration(t.duration_sec)}</td>
|
||||
<td class="col-state">${already ? "✓ già scaricato" : ""}</td>
|
||||
`;
|
||||
const cb = tr.querySelector("input[type=checkbox]");
|
||||
if (cb) cb.addEventListener("change", updateSelectionCount);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
updateSelectionCount();
|
||||
}
|
||||
|
||||
function setStatus(text, kind) {
|
||||
const el = $("#youtube-status");
|
||||
if (!el) return;
|
||||
el.textContent = text || "";
|
||||
el.className = "beatport-status" + (kind ? " " + kind : "");
|
||||
}
|
||||
|
||||
function updateOutputInfo() {
|
||||
const info = $("#youtube-output-info");
|
||||
if (!info) return;
|
||||
const root = (state.config && state.config.output_dir) || "";
|
||||
if (!root) {
|
||||
info.textContent = "⚠ Imposta la cartella output in Impostazioni prima di scaricare";
|
||||
info.className = "beatport-output-info warn";
|
||||
} else {
|
||||
info.textContent = `Destinazione: ${root}/YouTube`;
|
||||
info.className = "beatport-output-info";
|
||||
}
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
const q = ($("#youtube-query").value || "").trim();
|
||||
if (!q) return;
|
||||
const wrap = $("#youtube-table-wrap");
|
||||
|
||||
setStatus("Ricerca su YouTube in corso…", "loading");
|
||||
if (wrap) wrap.hidden = true;
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await window.pywebview.api.youtube_search(q);
|
||||
} catch (e) {
|
||||
setStatus("Errore: " + ((e && e.message) || e), "error");
|
||||
return;
|
||||
}
|
||||
if (!res || !res.ok) {
|
||||
setStatus((res && res.message) || "Errore sconosciuto", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
mstate.tracks = res.tracks || [];
|
||||
if (mstate.tracks.length === 0) {
|
||||
setStatus(`Nessun video trovato per "${q}".`, "");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
mstate.existing = await window.pywebview.api.youtube_check_existing(mstate.tracks);
|
||||
} catch (_e) {
|
||||
mstate.existing = mstate.tracks.map(() => false);
|
||||
}
|
||||
|
||||
setStatus(`${mstate.tracks.length} risultati`, "ok");
|
||||
updateOutputInfo();
|
||||
renderTable();
|
||||
if (wrap) wrap.hidden = false;
|
||||
}
|
||||
|
||||
function appendYoutubeLog(msg) {
|
||||
const el = $("#youtube-log");
|
||||
if (!el) return;
|
||||
const cls = classifyLog(msg);
|
||||
const line = document.createElement("div");
|
||||
if (cls) line.className = cls;
|
||||
line.textContent = msg;
|
||||
el.appendChild(line);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
async function startDownload() {
|
||||
const boxes = $$("#youtube-tbody input[type=checkbox]");
|
||||
const selected = [];
|
||||
boxes.forEach((b) => {
|
||||
if (b.checked) {
|
||||
const idx = parseInt(b.dataset.idx, 10);
|
||||
if (!isNaN(idx) && mstate.tracks[idx]) selected.push(mstate.tracks[idx]);
|
||||
}
|
||||
});
|
||||
if (!selected.length) {
|
||||
toast("Seleziona almeno un video", "error");
|
||||
return;
|
||||
}
|
||||
if (!state.config || !state.config.output_dir) {
|
||||
toast("Imposta la cartella output in Impostazioni", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
mstate.downloading = true;
|
||||
$("#youtube-download-btn").disabled = true;
|
||||
$("#youtube-stop-btn").hidden = false;
|
||||
$("#youtube-stop-btn").disabled = false;
|
||||
$("#youtube-log").innerHTML = "";
|
||||
appendYoutubeLog(`[INFO] Avvio download di ${selected.length} video…`);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await window.pywebview.api.youtube_search_download(selected);
|
||||
} catch (e) {
|
||||
appendYoutubeLog("[ERRORE] " + ((e && e.message) || e));
|
||||
finishDownload();
|
||||
return;
|
||||
}
|
||||
if (!res || !res.ok) {
|
||||
const errMsg = (res && (res.error || res.message)) || "Impossibile avviare il download";
|
||||
appendYoutubeLog("[ERRORE] " + errMsg);
|
||||
if (!handleGateBlock(res || {})) toast(errMsg, "error");
|
||||
finishDownload();
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDownload() {
|
||||
try { await window.pywebview.api.stop_download(); } catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function finishDownload() {
|
||||
mstate.downloading = false;
|
||||
$("#youtube-stop-btn").hidden = true;
|
||||
$("#youtube-stop-btn").disabled = true;
|
||||
updateSelectionCount();
|
||||
if (mstate.tracks.length) {
|
||||
window.pywebview.api.youtube_check_existing(mstate.tracks)
|
||||
.then((r) => { mstate.existing = r || mstate.existing; renderTable(); })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const q = (state.config && state.config.youtube_search_last_query) || "";
|
||||
const qi = $("#youtube-query");
|
||||
if (qi && q) qi.value = q;
|
||||
} catch (_e) { /* ignora */ }
|
||||
|
||||
updateOutputInfo();
|
||||
|
||||
$("#youtube-search-btn").addEventListener("click", doSearch);
|
||||
$("#youtube-query").addEventListener("keydown", (e) => { if (e.key === "Enter") doSearch(); });
|
||||
$("#youtube-select-all").addEventListener("change", (e) => {
|
||||
$$("#youtube-tbody input[type=checkbox]").forEach((b) => { b.checked = e.target.checked; });
|
||||
updateSelectionCount();
|
||||
});
|
||||
$("#youtube-download-btn").addEventListener("click", startDownload);
|
||||
$("#youtube-stop-btn").addEventListener("click", stopDownload);
|
||||
|
||||
const origLog = bridgeHandlers["log"];
|
||||
bridgeHandlers["log"] = (payload) => {
|
||||
if (origLog) origLog(payload);
|
||||
if (mstate.downloading && payload && payload.view === "download") {
|
||||
appendYoutubeLog(payload.msg);
|
||||
}
|
||||
};
|
||||
const origDone = bridgeHandlers["download:done"];
|
||||
bridgeHandlers["download:done"] = (payload) => {
|
||||
if (origDone) origDone(payload);
|
||||
if (mstate.downloading) finishDownload();
|
||||
};
|
||||
}
|
||||
|
||||
return { init };
|
||||
})();
|
||||
|
||||
// ============================================================
|
||||
// Boot
|
||||
// ============================================================
|
||||
|
||||
Reference in new issue
Block a user