Compare commits

...
39 Commits
Author SHA1 Message Date
luciano 2689f686ec Merge feat/traxsource-charts: MusicTools v1.9.0
Nuova tab Traxsource per caricare la Top 100 mensile per genere.
23 generi, bypass Cloudflare via curl_cffi session, HTML scraping con
BeautifulSoup, cache 15min. Riusa pipeline download + tag ID3.

29 nuovi test unitari, 90/90 test verdi totali.
2026-08-01 12:15:36 +02:00
luciano f8e2ee66bd ui: TraxsourceUI (init, loadChart, render, download) 2026-08-01 12:10:28 +02:00
luciano a07f6c34ca ui: markup tab Traxsource 2026-08-01 12:08:56 +02:00
luciano 6c473eaec9 bridge: 4 metodi Api Traxsource (parallelo Beatport, metadata album=label) 2026-08-01 12:07:56 +02:00
luciano 16c8b8ee67 config: v1.9.0 + traxsource_last_genre nei DEFAULTS 2026-08-01 12:05:11 +02:00
luciano 88e59ad37f traxsource: fetch_top100 con session curl_cffi + retry + cache 15min 2026-08-01 12:01:00 +02:00
luciano 784b56682f traxsource: _discover_top100_url + _parse_tracks con BeautifulSoup 2026-08-01 11:59:55 +02:00
luciano e1e629f8a2 traxsource: helper _split_title_mix + _format_artists + _large_cover + eccezioni 2026-08-01 11:57:42 +02:00
luciano a95912f78c traxsource: GENRES + TraxsourceTrack + list_genres 2026-08-01 11:55:30 +02:00
luciano 7feda99720 traxsource: fixture HTML + bs4 dep + script refresh generi 2026-08-01 11:54:55 +02:00
luciano 191c245fc1 plan: implementation Traxsource charts v1.9.0 2026-08-01 11:50:44 +02:00
luciano 98235625ba spec: feature Traxsource charts (v1.9.0 target) 2026-08-01 11:47:32 +02:00
luciano 72e700c85d config: bump VERSION v1.8.6 2026-07-21 17:51:22 +02:00
luciano a774906814 ui: ConvertUI (file picker, opzioni, batch conversione) 2026-07-21 17:48:31 +02:00
luciano 9f41f9d302 ui: markup tab Converti 2026-07-21 17:47:16 +02:00
luciano b0cd046375 bridge: 4 metodi Api per convertitore WAV -> MP3 2026-07-21 17:44:59 +02:00
luciano 5fc9f8ac2b converter: modulo ffmpeg WAV -> MP3 con VBR/CBR + stop 2026-07-21 17:44:36 +02:00
luciano c4e6d6e322 fix: bundle certifi CA per SSL su Windows/macOS + bump v1.8.5
- requirements.txt: certifi come dep esplicita
- main.py: setta SSL_CERT_FILE/REQUESTS_CA_BUNDLE su certifi.where() al boot
- build_windows.py + build_macos.py: --collect-data certifi nel PyInstaller

Bugfix: senza certifi bundlato, le chiamate HTTPS dal build Windows
falliscono con CERTIFICATE_VERIFY_FAILED (attivazione licenza impossibile).
2026-07-17 16:26:16 +02:00
luciano c7dfe95890 config: bump VERSION v1.8.4 2026-07-15 13:04:06 +02:00
luciano 25b646d754 bridge: metadata pipeline per tag ID3 su download search 2026-07-15 13:00:19 +02:00
luciano 73b4480890 downloader: param output_filenames + post_download_callback 2026-07-15 12:56:56 +02:00
luciano d53b8f20ed spotify: get_artist_genres + enrich_from_youtube_title + cover_url_large 2026-07-15 12:54:59 +02:00
luciano 0f7cd24703 tagger: modulo mutagen per ID3 tag + cover art da URL 2026-07-15 12:54:26 +02:00
luciano f1437e8e24 config: bump VERSION v1.8.3 2026-07-15 11:46:50 +02:00
luciano acbd9c0b1f ui: hero sticky in cima al pannello scrollabile di ogni tab 2026-07-15 11:45:43 +02:00
luciano f226c3fa8e ui: cover art in Beatport, Spotify e YouTube search
- core/beatport.py: BeatportTrack.image_url estratto da image.dynamic_uri (95x95)
- core/spotify_client.py: _track_to_dict include image_url (Spotify torna 3 taglie,
  prendo la ~64px). search_artist_discography inietta album.images anche negli
  album-tracks per la cover
- core/youtube_search.py: image_url = i.ytimg.com/vi/<id>/mqdefault.jpg
- webui/index.html: nuova <th class="col-cover"></th> nelle 3 tabelle
- webui/js/app.js: 3 renderTable inseriscono <td class="col-cover"><img>
  con loading=lazy, referrerpolicy=no-referrer e onerror fallback
- webui/css/style.css: .beatport-table .col-cover img { 40x40, border-radius 4px,
  object-fit: cover, bg fallback var(--bg-input) }
2026-07-15 11:45:28 +02:00
luciano 85f7ec0993 config: bump VERSION v1.8.2 2026-07-14 22:15:02 +02:00
luciano 9dbdd74b2f ui: fix light mode — colori brand come testo + hover/focus adattivi
- Introdotte varianti --*-text (green/blue/purple/pink/amber/indigo/rec-red)
  scure in light mode per contrasto AA su bg bianco
- Convertite le rule che usano brand colors come foreground (log, badge,
  quota, footer, percent, info-row, update-status, save-status, toast,
  beatport-status, colonna "già scaricato")
- Nav-item hover da #1A1A1A hardcoded → var(--bg-card-2)
- Input focus da #333 hardcoded → var(--bg-input-hover)
- Scrollbar thumb da #404040/#606060 hardcoded → var(--text-3)/var(--text-2)
- .hint code bg da rgba(255,255,255,0.08) → var(--bg-input)
- Table hover da rgba(99,102,241,0.06) → var(--bg-input) (visibile in light)
- .log .l-warn da #FDCB6E hardcoded → var(--amber-text)
2026-07-14 22:12:50 +02:00
luciano 8c73651312 Merge feat/music-search: MusicTools v1.8.1
Nuove tab Spotify e YouTube per cercare brani per titolo/artista.
Spotify con toggle "Solo artista" per scaricare tutta la discografia.
YouTube per mix DJ, unreleased, bootleg via yt-dlp ytsearch.

40 test verdi. Testato end-to-end in dev su macOS.
2026-07-14 17:34:22 +02:00
luciano 1fa49fabc4 ui: SpotifyUI + YoutubeUI (search, table, download, log) 2026-07-14 17:31:48 +02:00
luciano 83c47df29c ui: markup tab Spotify + YouTube search 2026-07-14 17:31:41 +02:00
luciano 9ea14eb81c bridge: 6 metodi Api per music search (Spotify + YouTube) 2026-07-14 17:25:58 +02:00
luciano 91cfc173d4 config: v1.8.1 + 3 campi DEFAULTS per music search 2026-07-14 17:23:49 +02:00
luciano 67e48e54f7 downloader+bridge: supporto download da URL diretti (YouTube search flow)
Aggiunge download_urls() in core/downloader.py che scarica direttamente da
URL YouTube gia noti (bypassa lo step di ricerca). Usa gli stessi flag di
download_playlist (mp3, embed thumbnail/metadata, output template, cookies,
ffmpeg location) e il parsing progress via stdout.

In api/bridge.py aggiunge start_urls_download() e _urls_worker() speculari a
start_tracks_download/_tracks_worker. Consumati dal tab "YouTube Search"
dopo che l'utente ha selezionato i risultati.
2026-07-14 17:22:46 +02:00
luciano 0949df031a youtube: search_youtube via yt-dlp ytsearchN + parse JSON 2026-07-14 17:18:54 +02:00
luciano 2771fa8075 spotify: search_artist_discography con dedupe top-tracks+album 2026-07-14 17:16:19 +02:00
luciano 252d76b907 spotify: search_tracks() free-form con limit configurabile 2026-07-14 17:14:02 +02:00
luciano 58d4896ffe plan: implementation Music Search (Spotify + YouTube) v1.8.1 2026-07-14 17:11:13 +02:00
luciano 91aa1bd6a3 spec: feature Music Search (Spotify + YouTube tabs) v1.8.1 2026-07-14 17:05:56 +02:00
28 changed files with 16530 additions and 42 deletions

No files matched your search

+712 -5
View File
@@ -10,10 +10,12 @@ import time
import webbrowser
from dataclasses import asdict
from pathlib import Path
from typing import Any, Optional
from typing import Any, Callable, Optional
import requests
from core.config import load_config, save_config, VERSION, LICENSE_API_URL
from core import beatport
from core import beatport, spotify_client, traxsource
from core import license as license_mod
from core.downloader import (
download_playlist,
@@ -30,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,
@@ -514,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:
@@ -539,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()
@@ -549,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")
@@ -559,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
@@ -598,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:
@@ -1006,3 +1164,552 @@ class Api:
"output_dir": out_root,
"subfolder": subfolder,
})
# ================================================================
# Traxsource charts
# ================================================================
def _traxsource_output_dir(self, out_root: str, genre_name: str) -> Path:
"""Cartella dove finiscono i file Traxsource. Coerente con la
sanitizzazione di `start_tracks_download` (slash -> underscore)."""
safe_genre = genre_name.replace("/", "_").replace("\\", "_").strip()
subfolder = f"Traxsource_{safe_genre}" if safe_genre else "Traxsource"
return Path(out_root) / subfolder
def traxsource_genres(self) -> list:
"""Lista dei generi Traxsource per il dropdown UI."""
return traxsource.list_genres()
def traxsource_fetch_chart(self, slug: str, force_refresh: bool = False) -> dict:
"""Fetches la Top 100 corrente per il genere. Salva l'ultimo genere in config."""
try:
cfg = load_config()
cfg["traxsource_last_genre"] = slug
save_config(cfg)
except Exception:
pass
try:
tracks = traxsource.fetch_top100(slug, force_refresh=force_refresh)
except ValueError as e:
return {"ok": False, "error": "invalid_genre", "message": str(e)}
except traxsource.TraxsourceUnreachableError as e:
return {"ok": False, "error": "unreachable", "message": str(e)}
except traxsource.TraxsourceParseError as e:
return {"ok": False, "error": "parse", "message": str(e)}
return {"ok": True, "tracks": [asdict(t) for t in tracks]}
def traxsource_check_existing(self, tracks: list, genre_name: str) -> list:
"""True per ogni track già presente nella cartella output Traxsource.
Match euristico: filename (senza extension) deve contenere 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._traxsource_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
result.append(any(
(title in stem and first_artist in stem)
for stem in existing_stems
))
return result
def traxsource_download_selected(self, tracks: list, genre_name: str) -> dict:
"""Converte i TraxsourceTrack in tracklist compatibile con
start_tracks_download + costruisce metadata paralleli per il tagger ID3
(album=label Traxsource, date=YYYY-MM corrente, genre=nome genere)."""
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._traxsource_output_dir(out_root, genre_name)
subfolder = target.name # es. "Traxsource_Tech House"
import datetime as _dt
current_month = _dt.date.today().strftime("%Y-%m")
converted: list = []
metadata: list = []
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})
metadata.append({
"title": title,
"artist": artists,
"album": (t.get("label") or "").strip() or f"Traxsource Top 100 {current_month}",
"date": current_month,
"genre": genre_name,
"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,
})
# ================================================================
# 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,
})
# ================================================================
# WAV -> MP3 converter
# ================================================================
def convert_pick_wav_files(self) -> list:
"""Apre file picker multi-selezione per .wav. Ritorna list di path."""
if not self.window:
return []
try:
filetypes = ("WAV files (*.wav)",)
result = self.window.create_file_dialog(
self._open_dialog_type(),
allow_multiple=True,
file_types=filetypes,
)
except Exception:
return []
if not result:
return []
return [str(p) for p in result if str(p).lower().endswith(".wav")]
def convert_pick_wav_folder(self, recursive: bool = True) -> list:
"""Apre folder picker, scan .wav (opzionalmente ricorsivo)."""
from core import converter
if not self.window:
return []
try:
result = self.window.create_file_dialog(self._folder_dialog_type())
except Exception:
return []
if not result:
return []
folder = str(result[0]) if isinstance(result, (list, tuple)) else str(result)
return converter.list_wav_files(folder, recursive=recursive)
def convert_start(self, payload: dict) -> dict:
"""Avvia conversione batch. Payload:
{files: [str], bitrate: int, vbr: bool, output_dir: str | None}
Se output_dir e vuoto/None, il .mp3 va accanto al .wav.
"""
if self._any_job_running():
return {"ok": False, "error": "Un'operazione e' gia in corso"}
files = payload.get("files") or []
if not files:
return {"ok": False, "error": "Nessun file selezionato"}
try:
bitrate = int(payload.get("bitrate", 320))
except (TypeError, ValueError):
bitrate = 320
if bitrate not in (128, 192, 256, 320):
return {"ok": False, "error": "Bitrate non valido"}
vbr = bool(payload.get("vbr", False))
output_dir = (payload.get("output_dir") or "").strip() or None
gate = self._gate("audio")
if gate:
return gate
self._download_thread = threading.Thread(
target=self._convert_worker,
args=(list(files), bitrate, vbr, output_dir),
daemon=True,
)
self._download_thread.start()
return {"ok": True}
def convert_stop(self) -> dict:
from core import converter as conv
conv.request_stop()
self._log("convert", "[INFO] Interruzione richiesta...")
return {"ok": True}
def _convert_worker(self, files: list, bitrate: int, vbr: bool,
output_dir: Optional[str]) -> None:
from core import converter as conv
conv.reset_stop()
view = "convert"
total = len(files)
mode = "VBR" if vbr else "CBR"
self._log(view, f"[INFO] Conversione di {total} file (MP3 {bitrate}k {mode})")
if output_dir:
self._log(view, f"[INFO] Destinazione: {output_dir}")
else:
self._log(view, "[INFO] Destinazione: accanto al file originale")
_last = [0.0]
def make_progress_cb(idx: int, filename: str):
def cb(pct: int):
import time as _t
if pct == -1:
# interruzione
self._emit("convert:progress", {
"idx": idx, "total": total, "file": filename,
"status": "stopped", "pct": 0,
})
return
# throttle
now = _t.monotonic()
if pct < 100 and now - _last[0] < 0.10:
return
_last[0] = now
self._emit("convert:progress", {
"idx": idx, "total": total, "file": filename,
"status": "converting" if pct < 100 else "done",
"pct": pct,
"overall": min(((idx + pct / 100) / total), 1.0) if total else 0,
})
return cb
for i, src_path in enumerate(files):
if conv.is_stopped():
self._log(view, "[INFO] Conversione interrotta.")
break
src = Path(src_path)
if not src.exists():
self._log(view, f"[ERRORE] File mancante: {src.name}")
continue
dst_dir = Path(output_dir) if output_dir else src.parent
dst = dst_dir / (src.stem + ".mp3")
# Skip se il .mp3 esiste gia (protezione contro overwrite)
if dst.exists():
self._log(view, f"[SKIP] {dst.name} esiste gia'")
self._emit("convert:progress", {
"idx": i, "total": total, "file": src.name,
"status": "skipped", "pct": 100,
"overall": min(((i + 1) / total), 1.0) if total else 1,
})
continue
self._log(view, f"[CONVERTI] {src.name}")
self._emit("convert:progress", {
"idx": i, "total": total, "file": src.name,
"status": "converting", "pct": 0,
})
try:
conv.convert_wav_to_mp3(
str(src), str(dst),
bitrate=bitrate, vbr=vbr,
progress_callback=make_progress_cb(i, src.name),
)
self._log(view, f"[OK] {dst.name}")
except Exception as e:
self._log(view, f"[ERRORE] {src.name}: {e}")
self._emit("convert:progress", {
"idx": i, "total": total, "file": src.name,
"status": f"error: {e}", "pct": 0,
})
self._emit("convert:done", {"ok": True})
+2
View File
@@ -226,6 +226,8 @@ def run_pyinstaller():
"--hidden-import", "webview.platforms.cocoa",
"--hidden-import", "requests",
"--collect-all", "mutagen",
# certifi ha cacert.pem necessario per SSL/HTTPS
"--collect-data", "certifi",
# Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs)
*add_binaries,
# Entry point
+2
View File
@@ -133,6 +133,8 @@ def run_pyinstaller():
"--collect-all", "pythonnet",
"--collect-all", "clr_loader",
"--collect-all", "mutagen",
# certifi ha il file dati cacert.pem necessario per SSL/HTTPS
"--collect-data", "certifi",
# collect-data prende anche i file .json/.dll non-Python che
# collect-all potrebbe saltare (Python.Runtime.runtimeconfig.json
# in particolare).
+13
View File
@@ -23,6 +23,7 @@ class BeatportTrack:
artists: str # es. "A, B & C" già formattato
duration_sec: int
beatport_id: int
image_url: str = "" # URL cover art (default vuoto per retrocompatibilità test)
@property
def display(self) -> str:
@@ -150,6 +151,17 @@ def _format_artists(artists_field: object) -> str:
return ", ".join(names[:-1]) + " & " + names[-1]
def _extract_image_url(image_field: object, size: int = 95) -> str:
"""Estrae URL cover art dall'oggetto `image` di Beatport.
Preferisce `dynamic_uri` sostituendo {w}x{h}, fallback a `uri` fisso."""
if not isinstance(image_field, dict):
return ""
dyn = image_field.get("dynamic_uri") or ""
if isinstance(dyn, str) and "{w}" in dyn and "{h}" in dyn:
return dyn.replace("{w}", str(size)).replace("{h}", str(size))
return image_field.get("uri") or ""
def _parse_tracks(data: dict) -> list:
"""Trasforma i track dict di Beatport in BeatportTrack ordinati per posizione."""
raw = _find_tracks_results(data)
@@ -164,6 +176,7 @@ def _parse_tracks(data: dict) -> list:
artists=_format_artists(item.get("artists")),
duration_sec=length_ms // 1000,
beatport_id=int(item.get("id") or 0),
image_url=_extract_image_url(item.get("image")),
)
except (TypeError, ValueError) as e:
raise BeatportParseError(f"track[{i}] shape inattesa: {e}") from e
+7 -1
View File
@@ -5,7 +5,7 @@ import os
import sys
from pathlib import Path
VERSION = "v1.8.0"
VERSION = "v1.9.0"
APP_NAME = "MusicTools"
@@ -72,6 +72,12 @@ DEFAULTS = {
"theme": "dark",
# ---- Beatport ----
"beatport_last_genre": "melodic-house-techno", # ultimo genere Top 100 caricato
# ---- Traxsource ----
"traxsource_last_genre": "tech-house",
# ---- Music Search (Spotify + YouTube) ----
"spotify_search_last_query": "",
"spotify_search_artist_mode": False,
"youtube_search_last_query": "",
# ---- Licenza ----
"license_key": "", # chiave fornita all'utente via email
"license_email": "", # email associata all'acquisto
+176
View File
@@ -0,0 +1,176 @@
"""Conversione WAV -> MP3 via ffmpeg subprocess."""
from __future__ import annotations
import re
import subprocess
import threading
from pathlib import Path
from typing import Callable, Optional
from core.paths import find_ffmpeg_dir, subprocess_flags
_stop_event = threading.Event()
_current_process: Optional[subprocess.Popen] = None
_process_lock = threading.Lock()
def request_stop() -> None:
_stop_event.set()
with _process_lock:
if _current_process and _current_process.poll() is None:
_current_process.terminate()
def reset_stop() -> None:
_stop_event.clear()
def is_stopped() -> bool:
return _stop_event.is_set()
# VBR quality mapping da bitrate label a -q:a (libmp3lame). Piu basso = migliore qualita.
# Vedi https://trac.ffmpeg.org/wiki/Encode/MP3
_VBR_QUALITY = {
128: 5,
192: 2,
256: 0,
320: 0, # V0 e' ~245k medio; per >V0 c'e' solo CBR 320
}
def _find_ffmpeg() -> str:
"""Trova il binario ffmpeg dentro bundle_bin o PATH.
Solleva RuntimeError se non trovato.
"""
ffmpeg_dir = find_ffmpeg_dir()
if ffmpeg_dir:
for name in ("ffmpeg", "ffmpeg.exe"):
p = Path(ffmpeg_dir) / name
if p.exists():
return str(p)
# Fallback PATH
import shutil
ff = shutil.which("ffmpeg")
if ff:
return ff
raise RuntimeError("ffmpeg non trovato nel bundle o nel PATH")
def convert_wav_to_mp3(
input_path: str,
output_path: str,
bitrate: int = 320,
vbr: bool = False,
progress_callback: Optional[Callable] = None,
) -> None:
"""Converte un file WAV in MP3.
Args:
input_path: file .wav sorgente
output_path: file .mp3 destinazione
bitrate: 128 / 192 / 256 / 320
vbr: True per Variable Bit Rate (-q:a), False per Constant (-b:a)
progress_callback: opzionale, chiamato con (percent 0..100)
Raises:
RuntimeError: se ffmpeg non trovato o exit non-zero.
FileNotFoundError: se input_path non esiste.
"""
src = Path(input_path)
if not src.exists():
raise FileNotFoundError(f"File sorgente non trovato: {input_path}")
dst = Path(output_path)
dst.parent.mkdir(parents=True, exist_ok=True)
ffmpeg = _find_ffmpeg()
cmd = [ffmpeg, "-y", "-i", str(src)]
if vbr:
quality = _VBR_QUALITY.get(bitrate, 2)
cmd.extend(["-c:a", "libmp3lame", "-q:a", str(quality)])
else:
cmd.extend(["-c:a", "libmp3lame", "-b:a", f"{bitrate}k"])
cmd.extend([
"-map_metadata", "0", # copia metadata WAV se presenti
"-id3v2_version", "3",
"-progress", "pipe:2", # progress su stderr in formato key=value
str(dst),
])
# Prova a ottenere la durata totale per calcolare % — leggendo lo stream INFO
duration_us = _probe_duration_us(ffmpeg, str(src))
global _current_process
with _process_lock:
_current_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
**subprocess_flags(),
)
proc = _current_process
try:
for line in proc.stderr:
if is_stopped():
proc.terminate()
if progress_callback:
progress_callback(-1) # segnale di interruzione
return
if not progress_callback or not duration_us:
continue
# ffmpeg -progress emette "out_time_us=<microsecondi>"
m = re.match(r"out_time_us=(\d+)", line.strip())
if m:
elapsed = int(m.group(1))
pct = min(100, int(elapsed * 100 / duration_us))
progress_callback(pct)
rc = proc.wait()
if rc != 0:
raise RuntimeError(f"ffmpeg exit {rc}")
if progress_callback:
progress_callback(100)
finally:
with _process_lock:
_current_process = None
def _probe_duration_us(ffmpeg: str, input_path: str) -> int:
"""Estrae la durata in microsecondi via ffmpeg -f null. Ritorna 0 se non riesce."""
try:
# ffprobe potrebbe non essere sempre bundlato; usa ffmpeg
result = subprocess.run(
[ffmpeg, "-i", input_path, "-f", "null", "-"],
capture_output=True,
text=True,
timeout=10,
**subprocess_flags(),
)
# Cerca "Duration: HH:MM:SS.ms"
m = re.search(r"Duration:\s+(\d+):(\d+):(\d+)\.(\d+)", result.stderr)
if m:
h, mi, s, ms = map(int, m.groups())
total_sec = h * 3600 + mi * 60 + s + ms / 100
return int(total_sec * 1_000_000)
except Exception:
pass
return 0
def list_wav_files(directory: str, recursive: bool = True) -> list:
"""Scan cartella per file .wav. Ritorna list di path stringa ordinati."""
root = Path(directory)
if not root.is_dir():
return []
pattern = "**/*.wav" if recursive else "*.wav"
return sorted(str(p) for p in root.glob(pattern) if p.is_file())
+166 -1
View File
@@ -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":
+186
View File
@@ -3,6 +3,8 @@
from __future__ import annotations
import re
import time
import requests
@@ -280,3 +282,187 @@ def search_track(token: str, query: str):
"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
View File
@@ -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
+277
View File
@@ -0,0 +1,277 @@
"""Fetch Top 100 Traxsource per genere.
Approccio: session curl_cffi (bypass CF via cookie) + BeautifulSoup HTML scraping.
Spec: docs/superpowers/specs/2026-08-01-traxsource-charts-design.md.
"""
from __future__ import annotations
import re
import time
from dataclasses import dataclass
# Generi musicali Traxsource (slug -> (id, display_name)).
# Aggiornato via scripts/refresh_traxsource_genres.py. Esclusi "sounds/samples/loops",
# "acapella", "beats", "efx-dj-tools", "stems" (non generi ma tipi di prodotto).
GENRES: dict = {
"afro-house": (27, "Afro House"),
"afro-latin-brazilian": (23, "Afro / Latin / Brazilian"),
"broken-beat-nu-jazz": (2, "Broken Beat / Nu Jazz"),
"classic-house": (12, "Classic House"),
"deep-house": (13, "Deep House"),
"drum-and-bass": (31, "Drum & Bass"),
"electro-house": (11, "Electro House"),
"electronica": (5, "Electronica"),
"garage": (29, "Garage"),
"house": (4, "House"),
"jackin-house": (15, "Jackin House"),
"leftfield": (14, "Leftfield"),
"lounge-chill-out": (1, "Lounge / Chill Out"),
"melodic-progressive-house": (19, "Melodic / Progressive House"),
"minimal-deep-tech": (16, "Minimal / Deep Tech"),
"nu-disco-indie-dance": (17, "Nu Disco / Indie Dance"),
"pop-dance": (32, "Pop Dance"),
"r-and-b-hip-hop": (6, "R&B / Hip Hop"),
"soul-funk-disco": (3, "Soul / Funk / Disco"),
"soulful-house": (24, "Soulful House"),
"tech-house": (18, "Tech House"),
"techno": (20, "Techno"),
"world": (30, "World"),
}
@dataclass(frozen=True)
class TraxsourceTrack:
position: int
title: str
mix: str
artists: str
label: str
traxsource_id: int
slug: str
image_url: str = ""
cover_url_large: str = ""
def list_genres() -> list:
result = [
{"slug": slug, "id": gid, "name": name}
for slug, (gid, name) in GENRES.items()
]
result.sort(key=lambda g: g["name"].casefold())
return result
class TraxsourceError(Exception):
"""Base per errori Traxsource."""
class TraxsourceUnreachableError(TraxsourceError):
"""Rete / 5xx dopo retry."""
class TraxsourceParseError(TraxsourceError):
"""HTML ricevuto ma non conforme allo schema atteso."""
_MIX_PAREN_RE = re.compile(r"^(.*)\s*\(([^()]+)\)\s*$")
_SIZE_RE = re.compile(r"/\d+x\d+/")
def _split_title_mix(full_title: str) -> tuple:
"""Estrae mix dalle parentesi finali. 'Foo (Extended Mix)' -> ('Foo', 'Extended Mix').
Se non ci sono parentesi finali, mix = ''."""
if not full_title:
return ("", "")
m = _MIX_PAREN_RE.match(full_title.strip())
if m:
return (m.group(1).strip(), m.group(2).strip())
return (full_title.strip(), "")
def _format_artists(names: list) -> str:
"""['A', 'B', 'C'] -> 'A, B & C'. Strips whitespace."""
clean = [n.strip() for n in names if n and n.strip()]
if not clean:
return ""
if len(clean) == 1:
return clean[0]
return ", ".join(clean[:-1]) + " & " + clean[-1]
def _large_cover(url: str) -> str:
"""Sostituisce /NxN/ nel path con /500x500/. Se pattern assente, ritorna invariato."""
if not url:
return ""
return _SIZE_RE.sub("/500x500/", url)
_TOP100_LINK_RE = re.compile(r'href="(/title/\d+/top-100-[a-z0-9-]+)"')
def _discover_top100_url(genre_html: str) -> str:
"""Estrae il path relativo della playlist Top 100 corrente dalla pagina di un genere."""
m = _TOP100_LINK_RE.search(genre_html)
if not m:
raise TraxsourceParseError("link Top 100 non trovato nella pagina genere")
return m.group(1)
def _parse_tracks(html: str) -> list:
"""Parsa la pagina Top 100 (title playlist) e ritorna list[TraxsourceTrack].
Selettori (verificati su fixture tech-house 2026-07):
row = div.trk-row.play-trk (data-trid=<int>)
position = div.tnum (inside div.tnum-pos)
title <a> = div.trk-cell.title a[href^="/track/"]
version = span.version (contiene child span.duration da rimuovere)
artists = a.com-artists (uno o piu)
label <a> = div.trk-cell.label a
cover img = div.trk-cell.thumb img (src /scripts/image.php/52x52/...)
"""
# Lazy import — bs4 non e' hard-dep del modulo (importato solo quando serve).
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
rows = soup.select("div.trk-row.play-trk")
if not rows:
raise TraxsourceParseError("nessuna track (div.trk-row.play-trk) trovata")
out: list = []
for i, row in enumerate(rows, 1):
try:
trid = int(row.get("data-trid") or 0)
pos_el = row.select_one("div.tnum")
position = i # fallback su enumerate se pos manca / non e' un numero
if pos_el:
pos_txt = pos_el.get_text(strip=True)
if pos_txt.isdigit():
position = int(pos_txt)
title_a = row.select_one('div.trk-cell.title a[href^="/track/"]')
if not title_a:
continue
title = title_a.get_text(strip=True)
href = title_a.get("href") or ""
slug = href.rsplit("/", 1)[-1]
# Mix version: contenuto di span.version, escluso span.duration
mix = ""
version_el = row.select_one("span.version")
if version_el:
dur_el = version_el.select_one("span.duration")
if dur_el:
dur_el.extract()
mix = version_el.get_text(strip=True)
artist_names = [a.get_text(strip=True) for a in row.select("a.com-artists")]
artists = _format_artists(artist_names)
label_a = row.select_one("div.trk-cell.label a")
label = label_a.get_text(strip=True) if label_a else ""
img = row.select_one('div.trk-cell.thumb img[src*="/scripts/image.php/"]')
image_url = (img.get("src") or "") if img else ""
cover_large = _large_cover(image_url)
out.append(TraxsourceTrack(
position=position,
title=title,
mix=mix,
artists=artists,
label=label,
traxsource_id=trid,
slug=slug,
image_url=image_url,
cover_url_large=cover_large,
))
except Exception as e:
raise TraxsourceParseError(f"errore parse track[{i}]: {e}") from e
return out
# --- fetch_top100 con session curl_cffi + retry + cache ------------------
_IMPERSONATE = "chrome131"
_REQUEST_TIMEOUT = 15
_MAX_ATTEMPTS = 3
_BACKOFF_SEC = [1, 3]
_CACHE_TTL_SEC = 15 * 60
_cache: dict = {}
_session_singleton = None
def _session():
"""Ritorna la Session curl_cffi singleton, preriscaldata con GET a /."""
global _session_singleton
if _session_singleton is None:
from curl_cffi import requests as _cffi
_session_singleton = _cffi.Session(impersonate=_IMPERSONATE)
try:
_session_singleton.get("https://www.traxsource.com/", timeout=_REQUEST_TIMEOUT)
except Exception:
pass # cookie CF possono arrivare comunque
return _session_singleton
def _do_get(session, url: str) -> str:
"""GET con retry + backoff. Include Referer per pagine interne."""
last_exc = None
for attempt in range(_MAX_ATTEMPTS):
try:
resp = session.get(
url,
timeout=_REQUEST_TIMEOUT,
headers={"Referer": "https://www.traxsource.com/"},
)
if resp.status_code >= 500 or resp.status_code == 403:
raise Exception(f"HTTP {resp.status_code}")
resp.raise_for_status()
return resp.text
except Exception as e:
last_exc = e
if attempt < _MAX_ATTEMPTS - 1:
time.sleep(_BACKOFF_SEC[attempt])
raise TraxsourceUnreachableError(
f"Traxsource irraggiungibile dopo {_MAX_ATTEMPTS} tentativi: {last_exc}"
)
def fetch_top100(slug: str, force_refresh: bool = False) -> list:
"""Fetches Top 100 corrente per il genere.
1. Verifica slug in GENRES
2. Fetch pagina genre -> _discover_top100_url
3. Fetch pagina Top 100 -> _parse_tracks
4. Cache 15 min
Raises:
ValueError: slug non in GENRES
TraxsourceUnreachableError: rete/5xx dopo retry
TraxsourceParseError: HTML non conforme
"""
if slug not in GENRES:
raise ValueError(f"slug genere non valido: {slug!r}")
now = time.time()
if not force_refresh:
cached = _cache.get(slug)
if cached and (now - cached[0]) < _CACHE_TTL_SEC:
return cached[1]
gid, _name = GENRES[slug]
sess = _session()
genre_url = f"https://www.traxsource.com/genre/{gid}/{slug}"
genre_html = _do_get(sess, genre_url)
top100_path = _discover_top100_url(genre_html)
top100_url = "https://www.traxsource.com" + top100_path
top100_html = _do_get(sess, top100_url)
tracks = _parse_tracks(top100_html)
_cache[slug] = (now, tracks)
return tracks
+74
View File
@@ -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,260 @@
# Music Search (Spotify + YouTube) — Design Spec
- **Data:** 2026-07-14
- **Autore:** LuZa + Claude
- **Stato:** Approvato, pronto per implementation plan
- **Target release:** MusicTools v1.8.1
## Obiettivo
Aggiungere due tab distinte all'app che permettano di **cercare brani/artisti/parole libere** e scaricarli:
1. **Tab 🟢 Spotify** — ricerca via Spotify API (canonica, metadata pulita). Toggle "Solo artista" per ottenere tutta la discografia di un artista invece della ricerca libera.
2. **Tab ▶ YouTube** — ricerca diretta via `yt-dlp ytsearch:` (grezza, senza distinzione artista/album, ma copre mix DJ, unreleased, bootleg, live, video-only content).
Il download in entrambi i casi riusa la pipeline esistente (`start_tracks_download` in `api/bridge.py`) con licenza gate `_gate("audio")`.
## Non-goals
- Un'unica tab "Cerca" con radio Spotify/YouTube (scartato: UX molto diversa per le due sorgenti)
- Ricerca combinata Spotify+YouTube in un'unica lista (nessuna deduplica cross-source affidabile)
- Playlist temporanee salvabili
- Preview audio
- Filtri per genere/anno/BPM (non richiesto, YAGNI)
- Ricerca album, artisti, playlist su Spotify (limitata a track)
- Autocomplete / suggestions come digiti
## Approcci scelti
### Spotify
- Free-form search: `GET /v1/search?q=<query>&type=track&limit=50`
- Ritorna fino a 50 track ordinati per rilevanza/popolarità (default Spotify)
- Query può essere qualsiasi cosa: titolo, artista, "artista - titolo", parola singola
- Artist-mode (toggle attivo):
1. `GET /v1/search?q=<query>&type=artist&limit=5` → prendi il match con `name.lower() == query.lower()` altrimenti il più popolare
2. `GET /v1/artists/{id}/top-tracks?market=IT` → ~10 top track
3. `GET /v1/artists/{id}/albums?include_groups=album,single&limit=50&market=IT` → lista album
4. Per ogni album (max 50): `GET /v1/albums/{id}/tracks?limit=50` → tracce
5. Deduplica su `(name.lower().strip() + '|' + first_artist.lower().strip())`
6. Rate limit interno: `time.sleep(0.1)` tra chiamate `/albums/{id}/tracks`
7. Total: ~100-500 track per artista prolifico
**Vantaggi:** metadata pulita, download pipeline già rodata (Spotify→YouTube via yt-dlp).
### YouTube
- `yt-dlp --flat-playlist --dump-single-json "ytsearch50:<query>"` → JSON con `entries[]`
- Per ogni entry: `{title, uploader/channel, duration, id, url}`
- Nessuna modalità artista (YouTube search non ha channel-exact disambiguation affidabile)
**Vantaggi:** trova ciò che Spotify non ha (mix, unreleased, bootleg, live).
## Architettura
### Nuovi moduli / estensioni
#### `core/spotify_client.py` (estensione)
```python
def search_tracks(token: str, query: str, limit: int = 50) -> list:
"""Ricerca free-form. Ritorna list[dict] con {id, url, name, artists, album, duration_sec}."""
def search_artist_discography(token: str, artist_name: str) -> list:
"""Trova l'artista esatto e ritorna tutti i suoi brani (top tracks + tracce da album/singles).
Deduplica per (title, first_artist) normalizzato. Solleva ValueError se nessun artista trovato."""
```
Nota: la funzione `search_track` (singolare) aggiunta in Task 7 di Beatport resta com'è per retro-compatibilità. La nuova `search_tracks` (plurale, con limit) è quella usata da questa feature.
#### `core/youtube_search.py` (nuovo)
```python
def search_youtube(query: str, limit: int = 50) -> list:
"""Cerca su YouTube via `yt-dlp ytsearchN:query`.
Ritorna list[dict] con {id, url, title, channel, duration_sec}."""
```
Riusa `find_ytdlp()` e `subprocess_flags()` da `core/paths.py`. Timeout subprocess 30s.
#### `api/bridge.py` — nuovi metodi
- `spotify_search(query: str, artist_mode: bool) → dict` — salva query+toggle in config, poi chiama la funzione giusta e ritorna `{ok, tracks}` o `{ok:false, error, message}`
- `spotify_search_download(tracks: list) → dict` — wrapper del pattern Beatport: converte in `[{name, artist}]` e chiama `start_tracks_download` con `subfolder="Spotify"`
- `youtube_search(query: str) → dict` — salva query in config, chiama `core.youtube_search`, ritorna `{ok, tracks}`
- `youtube_search_download(tracks: list) → dict` — chiama nuovo helper `start_urls_download` (see below) con URL YouTube e `subfolder="YouTube"`
#### `api/bridge.py` — nuovo helper (se non c'è già)
- `start_urls_download(payload: dict) → dict` — analogo a `start_tracks_download` ma accetta `{urls: [str], output_dir, subfolder}` e chiama `download_playlist_from_urls(urls, ...)` in `core/downloader.py`. Se non esiste una funzione equivalente in `downloader.py`, va aggiunta.
**Verifica in implementazione:** controlla se `download_playlist(tracks, ...)` può ricevere tracks contenenti solo `url` (bypass search); in tal caso riusa quella. Altrimenti aggiungi il nuovo path.
### Frontend
- 2 nuove tab in sidebar (`data-view="spotify"` e `data-view="youtube"`), entrambe con `data-feature="audio"` (stesso license gate del Beatport)
- 2 nuove section `<section id="view-spotify">` e `<section id="view-youtube">` in `webui/index.html`
- Modulo JS `SpotifyUI` e `YoutubeUI` in `webui/js/app.js`, entrambi con pattern init/loadResults/renderTable/updateSelection/startDownload (parallelo a `BeatportUI`)
- Stili in `webui/css/style.css` — riusa `.beatport-table` come base, aggiunge varianti dove serve
### Persistenza
3 nuovi campi in `core/config.py::DEFAULTS`:
- `spotify_search_last_query: str = ""`
- `spotify_search_artist_mode: bool = False`
- `youtube_search_last_query: str = ""`
Salvati dal backend a ogni ricerca (analogo a `beatport_last_genre`).
### Cartelle output
- `{output_dir}/Spotify/` (piatta, nessun sub-folder per query)
- `{output_dir}/YouTube/` (piatta)
Sanitizzazione slash coerente con il pattern di `start_tracks_download` (subfolder singolo, no nested).
## Data flow
### Spotify search
```
[JS] User digita query, opzionale toggle "Solo artista", click Cerca
↓
[JS] api.spotify_search(query, artist_mode)
↓
[PY] api/bridge.py::spotify_search():
├─ Salva {spotify_search_last_query, spotify_search_artist_mode} in config
├─ Verifica creds Spotify (client_id, client_secret) → altrimenti {ok:false, error:"no_creds"}
├─ Get token (spotify_client.get_access_token)
├─ Se artist_mode:
│ └─ spotify_client.search_artist_discography(token, query)
├─ Altrimenti:
│ └─ spotify_client.search_tracks(token, query, limit=50)
└─ Ritorna {ok:True, tracks: [...]}
↓
[JS] Riceve lista, chiama api.spotify_check_existing() per pre-deselezionare i già-scaricati
↓
[JS] Renderizza tabella (colonne: check, #, artista, titolo, album, durata, stato)
↓
[JS] User seleziona, click "Scarica selezionati"
↓
[JS] api.spotify_search_download(tracks_selected)
↓
[PY] converte in [{name, artist}] e chiama self.start_tracks_download({..., subfolder: "Spotify"})
↓
[EXISTING] pipeline yt-dlp, log/progress su canale "download"
```
### YouTube search
```
[JS] User digita query, click Cerca
↓
[JS] api.youtube_search(query)
↓
[PY] api/bridge.py::youtube_search():
├─ Salva youtube_search_last_query in config
├─ core.youtube_search.search_youtube(query, limit=50)
│ └─ subprocess: yt-dlp --flat-playlist --dump-single-json "ytsearch50:<query>"
└─ Ritorna {ok:True, tracks: [{id, url, title, channel, duration_sec}, ...]}
↓
[JS] Renderizza tabella (colonne: check, #, titolo video, canale, durata, stato)
↓
[JS] User seleziona, click "Scarica selezionati"
↓
[JS] api.youtube_search_download(tracks_selected) # passa URL, non {name,artist}
↓
[PY] start_urls_download({urls, subfolder: "YouTube"})
↓
[EXISTING] pipeline yt-dlp diretto sugli URL
```
## Matrice errori & recovery
| Errore | Dove | Comportamento |
|---|---|---|
| Query vuota | UI JS | Bottone "Cerca" disabilitato, nessuna richiesta |
| Creds Spotify mancanti | spotify_search | `{ok:false, error:"no_creds"}` → banner giallo con link Impostazioni |
| Spotify 401 (token scaduto/invalid) | get_access_token | Refresh token nel handler; se persiste, banner rosso |
| Spotify 429 rate limit | search endpoint | `{ok:false, error:"rate_limit", message:"Attendi qualche secondo"}` → banner arancione, retry manuale |
| Spotify 5xx | search endpoint | `{ok:false, error:"server", message:...}` → banner rosso, retry |
| Nessun risultato Spotify | search endpoint | `{ok:true, tracks:[]}` → messaggio grigio "Nessun brano trovato per '<query>'" |
| Artist-mode senza match esatto | search_artist_discography | Solleva ValueError → `{ok:false, error:"artist_not_found", message:"Artista '<q>' non trovato — disattiva toggle per ricerca libera"}` |
| yt-dlp non trovato | search_youtube | RuntimeError → banner rosso "yt-dlp non installato" |
| yt-dlp timeout (rete lenta) | search_youtube | subprocess.TimeoutExpired → banner rosso "Timeout ricerca YouTube" |
| yt-dlp errore generico | search_youtube | Non-zero exit → banner rosso con stderr tail |
| Nessun risultato YouTube | search_youtube | `{ok:true, tracks:[]}` → messaggio grigio |
| User preme Stop durante download | download pipeline | Comportamento invariato (esistente) |
## Licenza / piani
Entrambe le tab sono audio download → gate `_gate("audio")` automatico via `start_tracks_download` / `start_urls_download`. Il `daily_limit` del piano si applica. Nessuna nuova feature-flag.
## Testing
### Unit tests
**`tests/test_spotify_client.py` (estensione):**
- `test_search_tracks_returns_list_up_to_limit` — mock response con 50 items, verifica list length
- `test_search_tracks_maps_fields_correctly` — sample dict → verifica keys {id, url, name, artists, album, duration_sec}
- `test_search_tracks_empty_query_returns_empty` — mock 0 items
- `test_search_artist_discography_exact_match_wins` — mock search artist con 3 candidati diversi, verifica quello esatto (case-insensitive)
- `test_search_artist_discography_top_tracks_and_albums_combined` — mock 3 endpoint, verifica deduplica
- `test_search_artist_discography_raises_when_no_match` — mock search artist con 0 risultati → ValueError
- `test_search_artist_discography_deduplicates` — mock top-tracks e album-tracks con overlap → verifica no duplicati
**`tests/test_youtube_search.py` (nuovo):**
- `test_search_youtube_parses_entries` — mock `subprocess.run` con JSON stub (5 entries), verifica list mapping
- `test_search_youtube_empty_result` — mock JSON senza entries → []
- `test_search_youtube_ytdlp_not_found` — mock `find_ytdlp` che ritorna None → RuntimeError
- `test_search_youtube_timeout` — mock subprocess.TimeoutExpired → RuntimeError
### Test manuale end-to-end
**Tab Spotify:**
1. Query semplice: "Solomun" senza toggle → 50 risultati, ordinati per rilevanza
2. Query composta: "Kapuchon Hot Sauce" → 5-10 risultati pertinenti
3. Artist mode: "Solomun" con toggle attivo → 100-300 risultati (top tracks + tutti album)
4. Artist mode con nome inesistente: "sadgjhkasdg" → banner "Artista non trovato"
5. Download di 3 brani → file in `MUSICA/Spotify/`
6. Ricerca ripetuta → "già scaricato" mostrato correttamente
7. Riavvio app → ultima query + toggle ricordati
**Tab YouTube:**
1. Query: "Kapuchon Hot Sauce" → 50 risultati con canali diversi
2. Query set DJ: "Solomun Cocoricò 2024" → set lunghi (60+ min) in lista
3. Download di 2 brani → file in `MUSICA/YouTube/`
4. Query vuota → bottone disabilitato
5. Riavvio → ultima query ricordata
## Rollout
1. Branch `feat/music-search`
2. Bump `core/config.py::VERSION` → `v1.8.1`
3. Note release `/tmp/notes-v1.8.1.md`:
> **Nuove tab Spotify e YouTube 🔎** — cerca brani per titolo o artista su Spotify (con toggle "solo artista" per scaricare tutta la discografia), oppure cerca su YouTube per trovare mix DJ, unreleased, bootleg e brani non presenti su Spotify.
4. Commit + tag `v1.8.1` → CI + release notes background task
5. Update DB `releases` tabella sul server (macos + windows rows)
Zero cambi server-side backend, zero migration.
## Struttura file impattati
**Nuovi:**
- `core/youtube_search.py`
- `tests/test_youtube_search.py`
**Modificati:**
- `core/spotify_client.py` — 2 nuove funzioni pubbliche (`search_tracks`, `search_artist_discography`)
- `core/downloader.py` — se serve, aggiunta `download_playlist_from_urls()` per il flusso YouTube
- `core/config.py` — VERSION bump + 3 nuovi campi in DEFAULTS
- `api/bridge.py` — 6 nuovi metodi Api: `spotify_search`, `spotify_check_existing`, `spotify_search_download`, `youtube_search`, `youtube_check_existing`, `youtube_search_download` + eventuale helper `start_urls_download`
- `tests/test_spotify_client.py` — 7 nuovi test
- `webui/index.html` — 2 nav-item + 2 sezioni view
- `webui/js/app.js` — moduli `SpotifyUI` e `YoutubeUI` (~200 righe cadauno, parallelo a `BeatportUI`)
- `webui/css/style.css` — piccole estensioni (o riuso classi Beatport)
- `requirements.txt` — nessuna nuova dep
## Riduzione della duplicazione
Se durante l'implementazione emerge che i 3 pannelli (Beatport, Spotify, YouTube) hanno logica JS quasi identica (renderTable con checkbox, updateSelectionCount, startDownload wrapper), **valuta** l'estrazione di un `SelectableTracksTable` component in `webui/js/app.js`. Se l'astrazione è chiara e riduce codice significativamente, fallo. Se costringe a hooks/callbacks tortuosi per gestire differenze di colonne, lascia stare — 3 istanze non sono tante e YAGNI.
@@ -0,0 +1,256 @@
# Traxsource Charts — Design Spec
- **Data:** 2026-08-01
- **Autore:** LuZa + Claude
- **Stato:** Approvato, pronto per implementation plan
- **Target release:** MusicTools v1.9.0
## Obiettivo
Nuova tab "▲ Traxsource" nell'app che permette di:
1. Scegliere un genere musicale da un elenco di ~15 generi Traxsource
2. Caricare la **Top 100** ufficiale del mese corrente per quel genere
3. Anteprima checkbox + download riusando il pipeline esistente (Spotify search → yt-dlp → tag ID3 + cover)
Sostituisce l'attività manuale di copiare/incollare tracklist Traxsource.
## Non-goals
- Chart diverse dalla Top 100 (Must Have, Just Added, ecc.)
- Ricerca artista/label specifica su Traxsource
- Preview audio (Traxsource ha DRM)
- Duration tracks (non è nell'HTML della lista; sarebbe una richiesta extra per track)
- Login/account Traxsource
## Ricognizione tecnica (fatta 2026-08-01)
- **Cloudflare Managed Challenge attivo** su tutte le pagine tranne home e /top. Bypass: session-based con `curl_cffi.Session(impersonate='chrome131')`, prima GET a `/`, poi le pagine sensibili con `Referer: https://www.traxsource.com/`.
- **Non è Next.js** → no `__NEXT_DATA__`. HTML server-rendered.
- **Struttura URL:**
- Genre index: `/genre/<id>/<slug>` — es. `/genre/18/tech-house`. Contiene solo 10 top track più altri widget
- Top 100 corrente: `/title/<title_id>/top-100-<genre>-of-<month>-<year>` — es. `/title/2847986/top-100-tech-house-of-july-2026`. Contiene 100 track completi in un unico documento (~394KB)
- **Il title_id cambia ogni mese.** Non è hardcodabile. Va scoperto dinamicamente
- **Discovery Top 100 URL:** nella pagina `/genre/<id>/<slug>` c'è (almeno un) `<a href="/title/<id>/top-100-<slug>-of-<month>-<year>">`. Estrai col regex `href="(/title/\d+/top-100-[a-z0-9-]+)"`
- **Track markup (stabile):**
```html
<div data-trid="14842066" class="top-item play-trk ptk-14842066">
<div class="ttib position">1</div>
<div class="image"><img src="https://.../52x52/HASH.jpg" /></div>
<div class="ttib info">
<a href="/track/14842066/badman-sound-extended-mix" class="com-title">Badman Sound (Extended Mix)</a>
<a href="/artist/92248/hannah-wants" class="com-artists">Hannah Wants</a>,
<a href="/artist/321517/trace" class="com-artists">Trace</a>
<a href="/label/325/nervous" class="com-label">Nervous</a>
</div>
</div>
```
Class names sono stabili (semantici, non generati da bundler).
- **Cover art:** URL contiene la size (`/52x52/`). Sostituendo con `/500x500/` si ottiene la versione grande per tagging ID3.
## Approccio scelto
- Session `curl_cffi` con `impersonate='chrome131'`. Cookie CF conservati automaticamente. Preriscaldamento con GET a `/` al primo utilizzo.
- Parser: `BeautifulSoup4` con selettori CSS puliti — `div.top-item.play-trk` per righe, `a.com-title` per titolo, `a.com-artists` per artisti, `a.com-label` per label, `div.ttib.position` per rank, `img` per cover URL.
- Regex sul titolo per estrarre "mix name" tra parentesi: `Song Title (Extended Mix)` → title="Song Title", mix="Extended Mix".
- Cache in-memory 15 min per la lista, come Beatport.
- Genere → (numeric_id, slug, display_name) map hardcoded (estratti dalla home /top).
**Alternative scartate:**
- `requests` puro: bloccato da CF, verificato
- API interna Traxsource: non esistono API pubbliche gratuite documentate; reverse engineering rischio ban
- Playwright: overkill (~100MB dep) quando session + BeautifulSoup basta
## Architettura
### Nuovo modulo `core/traxsource.py`
```python
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
# Mappa slug → (numeric_id, display_name)
# Verificati 2026-08-01 dalla home /top di Traxsource
GENRES: dict = {
"afro-house": (33, "Afro House"),
"afro-latin-brazilian": (23, "Afro / Latin / Brazilian"),
"amapiano": (37, "Amapiano"),
"bass-club": (25, "Bass / Club"),
"breaks-uk-bass": (14, "Breaks / UK Bass"),
"classic-house": (12, "Classic House"),
"deep-house": (13, "Deep House"),
"dj-tools": (16, "DJ Tools"),
"downtempo-nu-disco-indie-dance": (22, "Downtempo / Nu Disco / Indie Dance"),
"electronica": (5, "Electronica"),
"electro-house": (11, "Electro House"),
"funky-jackin-groovy": (15, "Funky / Jackin / Groovy"),
"garage": (29, "Garage"),
"house": (1, "House"),
"lounge-chill-out": (10, "Lounge / Chill Out"),
"melodic-house-techno-progressive-house": (34, "Melodic House / Techno / Progressive House"),
"minimal-deep-tech": (27, "Minimal / Deep Tech"),
"organic-house-downtempo": (36, "Organic House / Downtempo"),
"progressive-house": (28, "Progressive House"),
"r-and-b-hip-hop": (6, "R&B / Hip Hop"),
"soulful-house": (24, "Soulful House"),
"tech-house": (18, "Tech House"),
"techno-peak-time-driving": (26, "Techno (Peak Time / Driving)"),
"techno-raw-deep-hypnotic": (32, "Techno (Raw / Deep / Hypnotic)"),
"trance": (30, "Trance"),
"world-reggae": (21, "World / Reggae"),
}
# NOTA: la mappa è enumerata in fase di implementazione fetchando /top con curl_cffi
# e cercando href="/genre/<id>/<slug>". Vedi Task 1 del plan.
@dataclass(frozen=True)
class TraxsourceTrack:
position: int
title: str
mix: str
artists: str # "A, B & C" formattato
label: str
traxsource_id: int
slug: str
image_url: str = "" # URL thumbnail (per UI)
cover_url_large: str = "" # URL cover 500x500 (per tagging)
class TraxsourceError(Exception): pass
class TraxsourceUnreachableError(TraxsourceError): pass
class TraxsourceParseError(TraxsourceError): pass
def list_genres() -> list:
"""Ritorna [{slug, id, name}, ...] ordinato alfabeticamente per name."""
def fetch_top100(slug: str, force_refresh: bool = False) -> list[TraxsourceTrack]:
"""Fetches Top 100 corrente per il genere.
1. Scopre l'URL della playlist Top 100 del mese via GET /genre/<id>/<slug>
2. Fetch della playlist /title/<id>/top-100-...
3. Parse HTML con BeautifulSoup
4. Cache in-memory 15 min
Raises:
ValueError se slug non in GENRES
TraxsourceUnreachableError su rete/5xx dopo retry
TraxsourceParseError su HTML non conforme (title link non trovato, meno di 50 track, ecc.)
"""
```
**Internals:**
- `_session()` singleton — inizializza `curl_cffi.Session(impersonate='chrome131')` e fa GET a `/` una volta per riscaldare CF cookies. Riusata per tutte le fetch successive.
- `_discover_top100_url(session, slug) → str` — fetcha `/genre/<id>/<slug>`, regex per il link Top 100
- `_parse_tracks(html) → list[TraxsourceTrack]` — BeautifulSoup, itera `div.top-item.play-trk`, estrae campi
- `_split_title_mix(full_title) → (title, mix)` — parsa `"Foo (Extended Mix)"` → `("Foo", "Extended Mix")`. Se no parentesi, mix = "".
- `_format_artists(anchors) → str` — join `[a.text for a in anchors]` con formattazione "A, B & C"
- `_large_cover(url) → str` — sostituisce `/52x52/` con `/500x500/` nel path
**Retry + timeout:** riusa gli stessi pattern di `core/beatport.py`: 3 tentativi con backoff [1, 3]s, timeout 15s.
### Nuovi metodi in `api/bridge.py`
Paralleli ai metodi Beatport (stesso schema):
- `traxsource_genres() → list[dict]`
- `traxsource_fetch_chart(slug, force_refresh) → {ok, tracks} | {ok:false, error, message}` — salva anche `traxsource_last_genre` in config
- `traxsource_check_existing(tracks, genre_name) → list[bool]` — check in `<output_dir>/Traxsource_<genre>/`
- `traxsource_download_selected(tracks, genre_name) → dict` — costruisce metadata (title/artist/album=label/date=YYYY-MM/genre=display_name/cover_url) e chiama `start_tracks_download` riusando il pipeline con tagging ID3
**Metadata per il tagger:**
- title, artist: dai campi track
- album: label Traxsource (best proxy — non c'è "album" per singole tracce)
- date: mese/anno corrente (dato che la Top 100 è mensile)
- genre: display_name del genere
- cover_url: `cover_url_large` (500x500)
### Frontend
Nuova tab in sidebar dopo YouTube (o Beatport):
```html
<button class="nav-item" data-view="traxsource" data-feature="audio">
<span class="nav-icon">▲</span><span>Traxsource</span>
</button>
```
Section `#view-traxsource` con struttura identica a Beatport (dropdown genere + Carica Top 100 + tabella + toolbar + log). Riusa tutte le classi `.beatport-*` per lo stile.
Modulo `TraxsourceUI` in `webui/js/app.js` — copia diretta di `BeatportUI` con selettori cambiati (`traxsource-*` invece di `beatport-*`) e chiamate API alle nuove funzioni.
**Ordinamento colonne** (sortable) e **cover art** già supportati riusando la stessa struttura tabella + modulo helper `_sortPairs`/`_bindSortableHeaders`/`_updateSortArrows` esistenti.
### Persistenza
Nuovo campo in `core/config.py::DEFAULTS`:
```python
"traxsource_last_genre": "tech-house",
```
### Dipendenze
- `beautifulsoup4` — nuova dep runtime. Aggiungere in `requirements.txt`. Serve `--collect-data bs4` in build_windows.py + build_macos.py? Verificare — bs4 di solito no.
## Testing
**Unit tests `tests/test_traxsource.py` (senza rete, con fixture HTML):**
Serve una fixture HTML reale della pagina Top 100 (~400KB). Task 1 la genera con `curl_cffi` come fatto per Beatport (`beatport_melodic_top100.html`).
| Test | Verifica |
|---|---|
| `test_list_genres_shape` | Lista dict con {slug, id, name}, ordinati |
| `test_parse_top100_extracts_100_tracks` | Fixture HTML → 100 track |
| `test_parse_positions_sequential` | 1..100 senza buchi |
| `test_parse_track_shape` | Ogni track ha title, artists, label, cover URLs non vuoti |
| `test_split_title_mix_with_parens` | `"Foo (Extended Mix)"` → `("Foo", "Extended Mix")` |
| `test_split_title_mix_no_parens` | `"Foo"` → `("Foo", "")` |
| `test_format_artists_multi` | `["A", "B", "C"]` → `"A, B & C"` |
| `test_large_cover_url_substitution` | `.../52x52/x.jpg` → `.../500x500/x.jpg` |
| `test_discover_top100_url_returns_playlist_link` | HTML genre stub → estrae `/title/N/top-100-...` |
| `test_fetch_top100_invalid_slug_raises_value_error` | slug non in GENRES → ValueError early |
Mock HTTP con `unittest.mock.patch("core.traxsource._session")` (patch della session singleton).
**Test manuale end-to-end:**
1. Avvia app → tab Traxsource
2. Selezione "Tech House" → Carica Top 100 → tabella 100 righe in <5s
3. Verifica cover art (thumbnail nella tabella)
4. Deseleziona 90, tieni 10 → Scarica selezionati
5. File in `MUSICA/Traxsource_Tech House/` con tag ID3 completi (title, artist, album=label, genre=Tech House, cover 500x500)
6. Ricarica stesso genere → cache hit
7. Riavvio → ultimo genere ricordato
## Rollout
1. Branch `feat/traxsource-charts`
2. Bump `core/config.py::VERSION` → `v1.9.0` (minor: nuova tab visibile)
3. Note release `/tmp/notes-v1.9.0.md`
4. Merge → tag → CI → server DB update (stesso flusso di sempre)
## Struttura file impattati
**Nuovi:**
- `core/traxsource.py` (~250 LOC)
- `tests/test_traxsource.py` (~200 LOC)
- `tests/fixtures/traxsource_tech_house_top100.html` (~400KB)
- `scripts/refresh_traxsource_genres.py` (~50 LOC, opzionale)
**Modificati:**
- `requirements.txt` — `beautifulsoup4>=4.12.0`
- `build_windows.py` + `build_macos.py` — verificare se serve `--collect-data bs4`
- `core/config.py` — VERSION bump + `traxsource_last_genre`
- `api/bridge.py` — 4 metodi Api `traxsource_*` (~150 LOC)
- `webui/index.html` — nav-item + section (~90 LOC)
- `webui/js/app.js` — modulo `TraxsourceUI` (~250 LOC, copia BeatportUI adattata)
## Considerazioni operative
- **Fragility scraping:** i class name di Traxsource (`com-title`, `com-artists`, `top-item`, ecc.) sono usati anche nel JS del sito → stabili nel tempo. Rischio: se Traxsource migra a un framework nuovo (React/Next), parser va rifatto.
- **Rate limiting:** un solo GET per la genre + un GET per la playlist Top 100 = 2 richieste per caricare la chart. Cache 15 min. Zero rischio ban in uso normale.
- **Legal:** stesso perimetro di Beatport — lettura di pagine pubbliche, no ToS violation esplicito.
- **Comparabilità con Beatport:** un utente power potrebbe usare entrambe le tab per triangolare le release del mese. UX consistente = curva apprendimento zero.
+15
View File
@@ -4,6 +4,21 @@ import os
import sys
from pathlib import Path
# ============================================================
# Fix SSL CA bundle: quando l'app e' frozen (PyInstaller) i moduli
# ssl e requests non trovano nessun CA bundle di default -> tutte
# le chiamate HTTPS falliscono con CERTIFICATE_VERIFY_FAILED.
# Puntiamo esplicitamente al bundle di certifi (che il build script
# include via `--collect-data certifi`).
# ============================================================
try:
import certifi as _certifi
_ca_bundle = _certifi.where()
os.environ.setdefault("SSL_CERT_FILE", _ca_bundle)
os.environ.setdefault("REQUESTS_CA_BUNDLE", _ca_bundle)
except Exception as _e:
print(f"[bootstrap] certifi setup failed: {_e}")
# Windows: forza il caricamento del runtime pythonnet PRIMA di importare
# webview. Con PyInstaller il lazy-loader fallisce con
# "Failed to resolve Python.Runtime.Loader.Initialize" perche'
+2
View File
@@ -1,5 +1,6 @@
pywebview>=5.0
requests>=2.31.0
certifi>=2024.0.0
yt-dlp>=2024.0.0
mutagen>=1.47.0
pyobjc-core>=10.0 ; sys_platform == "darwin"
@@ -9,6 +10,7 @@ pyobjc-framework-AVFoundation>=10.0 ; sys_platform == "darwin"
pythonnet==3.0.5 ; sys_platform == "win32"
clr-loader==0.2.7.post0 ; sys_platform == "win32"
curl_cffi>=0.9.0
beautifulsoup4>=4.12.0
# --- dev only ---
pytest>=8.0.0
+34
View File
@@ -0,0 +1,34 @@
"""Estrae la lista dei generi Traxsource dalla pagina /top.
Uso: python3 scripts/refresh_traxsource_genres.py > /tmp/tx_genres.txt
Poi copia manualmente in core/traxsource.py::GENRES."""
from __future__ import annotations
import re
import sys
from curl_cffi import requests
def main() -> int:
s = requests.Session(impersonate="chrome131")
s.get("https://www.traxsource.com/", timeout=15)
r = s.get("https://www.traxsource.com/top", timeout=15,
headers={"Referer": "https://www.traxsource.com/"})
if r.status_code != 200:
print(f"HTTP {r.status_code}", file=sys.stderr)
return 1
# href="/genre/<id>/<slug>"
hits = re.findall(r'href="/genre/(\d+)/([a-z0-9-]+)"', r.text)
seen = set()
for gid, slug in hits:
if slug in seen:
continue
seen.add(slug)
# Display name = slug con capitalizzazione euristica (l'utente lo raffina a mano)
name = " ".join(w.capitalize() for w in slug.replace("-", " ").split())
print(f' "{slug}": ({gid}, "{name}"),')
return 0
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+104
View File
@@ -0,0 +1,104 @@
"""Test per core.converter — file scanning + stop event.
I test evitano di invocare ffmpeg reale (che potrebbe non essere in PATH
in CI). La funzione di conversione vera e propria e' coperta manualmente.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from core import converter
class TestListWavFiles:
def test_scans_recursively(self, tmp_path: Path):
# Struttura:
# root/a.wav
# root/b.WAV (case insensitive: glob su POSIX distingue,
# quindi controllo esplicito che almeno i .wav
# minuscoli siano restituiti)
# root/c.mp3
# root/sub/d.wav
(tmp_path / "a.wav").write_bytes(b"RIFF")
(tmp_path / "c.mp3").write_bytes(b"ID3")
sub = tmp_path / "sub"
sub.mkdir()
(sub / "d.wav").write_bytes(b"RIFF")
got = converter.list_wav_files(str(tmp_path), recursive=True)
assert len(got) == 2
names = {Path(p).name for p in got}
assert names == {"a.wav", "d.wav"}
# Ordinamento stabile
assert got == sorted(got)
def test_non_recursive_ignores_subfolders(self, tmp_path: Path):
(tmp_path / "a.wav").write_bytes(b"RIFF")
sub = tmp_path / "sub"
sub.mkdir()
(sub / "b.wav").write_bytes(b"RIFF")
got = converter.list_wav_files(str(tmp_path), recursive=False)
assert len(got) == 1
assert Path(got[0]).name == "a.wav"
def test_empty_dir_returns_empty(self, tmp_path: Path):
assert converter.list_wav_files(str(tmp_path)) == []
def test_missing_dir_returns_empty(self, tmp_path: Path):
missing = tmp_path / "does-not-exist"
assert converter.list_wav_files(str(missing)) == []
def test_ignores_non_wav_files(self, tmp_path: Path):
(tmp_path / "song.mp3").write_bytes(b"ID3")
(tmp_path / "song.flac").write_bytes(b"fLaC")
(tmp_path / "notes.txt").write_text("hello")
assert converter.list_wav_files(str(tmp_path)) == []
class TestStopEvent:
def setup_method(self):
# Ogni test parte con lo stop event pulito.
converter.reset_stop()
def teardown_method(self):
converter.reset_stop()
def test_reset_initially_not_stopped(self):
converter.reset_stop()
assert converter.is_stopped() is False
def test_request_stop_sets_flag(self):
converter.request_stop()
assert converter.is_stopped() is True
def test_reset_after_stop_clears_flag(self):
converter.request_stop()
assert converter.is_stopped() is True
converter.reset_stop()
assert converter.is_stopped() is False
class TestConvertWavToMp3Errors:
def test_missing_input_raises(self, tmp_path: Path):
missing = tmp_path / "nope.wav"
out = tmp_path / "out.mp3"
with pytest.raises(FileNotFoundError):
converter.convert_wav_to_mp3(str(missing), str(out))
class TestVbrQualityMap:
def test_all_bitrates_have_mapping(self):
for br in (128, 192, 256, 320):
assert br in converter._VBR_QUALITY
def test_higher_bitrate_lower_or_equal_quality_number(self):
# -q:a: piu' basso = migliore. Coerenza monotona sulle chiavi.
vals = [converter._VBR_QUALITY[br] for br in (128, 192, 256, 320)]
assert vals == sorted(vals, reverse=True)
+263
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from unittest.mock import patch
import pytest
import responses
@@ -68,3 +70,264 @@ class TestSearchTrack:
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
+57
View File
@@ -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",
})
+217
View File
@@ -0,0 +1,217 @@
"""Test per core.traxsource."""
from __future__ import annotations
import pytest
from core import traxsource
class TestListGenres:
def test_returns_list_of_dicts(self):
result = traxsource.list_genres()
assert isinstance(result, list)
assert len(result) >= 10
for g in result:
assert set(g.keys()) == {"slug", "id", "name"}
assert isinstance(g["slug"], str) and g["slug"]
assert isinstance(g["id"], int) and g["id"] > 0
assert isinstance(g["name"], str) and g["name"]
def test_sorted_alphabetically_by_name(self):
result = traxsource.list_genres()
names = [g["name"] for g in result]
assert names == sorted(names, key=str.casefold)
def test_tech_house_present(self):
result = traxsource.list_genres()
slugs = [g["slug"] for g in result]
assert "tech-house" in slugs
class TestTraxsourceTrack:
def test_frozen(self):
t = traxsource.TraxsourceTrack(
position=1, title="X", mix="Y", artists="A", label="L",
traxsource_id=1, slug="x",
)
with pytest.raises(Exception):
t.title = "Z"
def test_defaults(self):
t = traxsource.TraxsourceTrack(
position=1, title="X", mix="", artists="A", label="",
traxsource_id=1, slug="x",
)
assert t.image_url == ""
assert t.cover_url_large == ""
class TestSplitTitleMix:
def test_with_parens(self):
assert traxsource._split_title_mix("Foo (Extended Mix)") == ("Foo", "Extended Mix")
def test_without_parens(self):
assert traxsource._split_title_mix("Foo") == ("Foo", "")
def test_multiple_parens_takes_last(self):
# es. "Foo (feat. Bar) (Original Mix)" -> mix = "Original Mix"
assert traxsource._split_title_mix("Foo (feat. Bar) (Original Mix)") == ("Foo (feat. Bar)", "Original Mix")
def test_empty(self):
assert traxsource._split_title_mix("") == ("", "")
class TestFormatArtists:
def test_single(self):
assert traxsource._format_artists(["Kapuchon"]) == "Kapuchon"
def test_two(self):
assert traxsource._format_artists(["A", "B"]) == "A & B"
def test_three(self):
assert traxsource._format_artists(["A", "B", "C"]) == "A, B & C"
def test_empty(self):
assert traxsource._format_artists([]) == ""
def test_strips_whitespace(self):
assert traxsource._format_artists([" A ", " B "]) == "A & B"
class TestLargeCover:
def test_substitutes_52x52_with_500x500(self):
url = "https://www.traxsource.com/scripts/image.php/52x52/abc.jpg"
assert traxsource._large_cover(url) == "https://www.traxsource.com/scripts/image.php/500x500/abc.jpg"
def test_no_change_when_no_pattern(self):
assert traxsource._large_cover("https://example.com/x.jpg") == "https://example.com/x.jpg"
def test_empty(self):
assert traxsource._large_cover("") == ""
class TestExceptions:
def test_exceptions_are_subclasses(self):
assert issubclass(traxsource.TraxsourceUnreachableError, traxsource.TraxsourceError)
assert issubclass(traxsource.TraxsourceParseError, traxsource.TraxsourceError)
class TestDiscoverTop100Url:
def test_extracts_link_from_genre_page(self, fixtures_dir):
html = (fixtures_dir / "traxsource_tech_house_genre.html").read_text()
url = traxsource._discover_top100_url(html)
assert url.startswith("/title/")
assert "top-100" in url
def test_raises_when_no_link(self):
with pytest.raises(traxsource.TraxsourceParseError, match="Top 100"):
traxsource._discover_top100_url("<html>nulla</html>")
class TestParseTracks:
def test_parses_100_tracks(self, fixtures_dir):
html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
tracks = traxsource._parse_tracks(html)
assert len(tracks) == 100
def test_positions_sequential_1_to_100(self, fixtures_dir):
html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
tracks = traxsource._parse_tracks(html)
positions = [t.position for t in tracks]
assert positions == list(range(1, 101))
def test_track_shape(self, fixtures_dir):
html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
tracks = traxsource._parse_tracks(html)
first = tracks[0]
assert first.title
assert first.artists
assert first.traxsource_id > 0
assert first.slug
assert first.image_url.startswith("https://")
assert first.cover_url_large.startswith("https://")
assert "500x500" in first.cover_url_large
def test_raises_when_no_tracks(self):
with pytest.raises(traxsource.TraxsourceParseError, match="track"):
traxsource._parse_tracks("<html>vuoto</html>")
from unittest.mock import patch, MagicMock
def _mock_response(text: str, status_code: int = 200) -> MagicMock:
resp = MagicMock()
resp.text = text
resp.status_code = status_code
def _raise():
if status_code >= 400:
raise Exception(f"HTTP {status_code}")
resp.raise_for_status = _raise
return resp
class TestFetchTop100:
@pytest.fixture
def genre_html(self, fixtures_dir):
return (fixtures_dir / "traxsource_tech_house_genre.html").read_text()
@pytest.fixture
def top100_html(self, fixtures_dir):
return (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
def test_success_returns_100_tracks(self, genre_html, top100_html):
traxsource._cache.clear()
mock_sess = MagicMock()
mock_sess.get.side_effect = [
_mock_response(genre_html, 200),
_mock_response(top100_html, 200),
]
with patch("core.traxsource._session", return_value=mock_sess):
tracks = traxsource.fetch_top100("tech-house")
assert len(tracks) == 100
def test_invalid_slug_raises_value_error(self):
with pytest.raises(ValueError, match="slug"):
traxsource.fetch_top100("not-a-genre")
def test_5xx_retries_and_raises_unreachable(self):
traxsource._cache.clear()
mock_sess = MagicMock()
mock_sess.get.return_value = _mock_response("", 503)
with patch("core.traxsource._session", return_value=mock_sess), \
patch("core.traxsource.time.sleep"):
with pytest.raises(traxsource.TraxsourceUnreachableError):
traxsource.fetch_top100("tech-house")
# 3 tentativi
assert mock_sess.get.call_count == 3
def test_cache_hit_within_ttl(self, genre_html, top100_html):
traxsource._cache.clear()
mock_sess = MagicMock()
mock_sess.get.side_effect = [
_mock_response(genre_html, 200),
_mock_response(top100_html, 200),
]
with patch("core.traxsource._session", return_value=mock_sess):
traxsource.fetch_top100("tech-house")
traxsource.fetch_top100("tech-house")
# 2 chiamate al primo fetch (genre + top100), 0 al secondo
assert mock_sess.get.call_count == 2
def test_force_refresh_bypasses_cache(self, genre_html, top100_html):
traxsource._cache.clear()
mock_sess = MagicMock()
# 4 risposte (2 fetch x 2 richieste ciascuno)
mock_sess.get.side_effect = [
_mock_response(genre_html, 200),
_mock_response(top100_html, 200),
_mock_response(genre_html, 200),
_mock_response(top100_html, 200),
]
with patch("core.traxsource._session", return_value=mock_sess):
traxsource.fetch_top100("tech-house")
traxsource.fetch_top100("tech-house", force_refresh=True)
assert mock_sess.get.call_count == 4
+84
View File
@@ -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)
+55 -25
View File
@@ -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); }
@@ -1581,7 +1602,7 @@ input[type="number"]::-webkit-inner-spin-button {
.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); font-weight: 600; }
.beatport-status.ok { color: var(--green-text); font-weight: 600; }
.beatport-output-info {
margin-top: 6px;
@@ -1592,7 +1613,7 @@ input[type="number"]::-webkit-inner-spin-button {
}
.beatport-output-info.warn {
color: var(--amber-hover);
color: var(--amber-text);
font-family: inherit;
}
@@ -1635,7 +1656,7 @@ input[type="number"]::-webkit-inner-spin-button {
}
.beatport-table tbody tr:hover {
background: rgba(99, 102, 241, 0.06);
background: var(--bg-input);
}
.beatport-table tbody tr.already-downloaded td {
@@ -1643,11 +1664,20 @@ input[type="number"]::-webkit-inner-spin-button {
}
.beatport-table tbody tr.already-downloaded .col-state {
color: var(--green);
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; }
+283 -4
View File
@@ -33,6 +33,22 @@
<span class="nav-icon">🎧</span>
<span>Beatport</span>
</button>
<button class="nav-item" data-view="traxsource" data-feature="audio">
<span class="nav-icon">▲</span>
<span>Traxsource</span>
</button>
<button class="nav-item" data-view="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="convert" data-feature="audio">
<span class="nav-icon">🔄</span>
<span>Converti</span>
</button>
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
<span class="nav-icon">⚡</span>
<span>Upgrade</span>
@@ -883,10 +899,12 @@
<thead>
<tr>
<th class="col-check"><input type="checkbox" id="beatport-select-all" /></th>
<th class="col-pos">#</th>
<th>Artista</th>
<th>Titolo (Mix)</th>
<th class="col-dur">Durata</th>
<th class="col-cover"></th>
<th class="col-pos sortable" data-sort="position">#</th>
<th class="sortable" data-sort="artists">Artista</th>
<th class="sortable" data-sort="title">Titolo (Mix)</th>
<th class="col-dur sortable" data-sort="duration_sec">Durata</th>
<th class="col-date sortable" data-sort="release_date">Data</th>
<th class="col-state">Stato</th>
</tr>
</thead>
@@ -910,6 +928,267 @@
</div>
</section>
<!-- ===== VIEW: TRAXSOURCE ===== -->
<section class="view" id="view-traxsource">
<header class="hero hero-purple">
<div class="hero-content">
<div class="hero-eyebrow purple">TRAXSOURCE CHARTS</div>
<h1 class="hero-title">Top 100 Traxsource per genere</h1>
<p class="hero-subtitle">La classifica mensile ufficiale. Scarica in un click con tag ID3 completi (label, cover art, genere).</p>
</div>
<div class="hero-deco">▲</div>
</header>
<h2 class="section-label">Genere</h2>
<div class="card">
<div class="beatport-header">
<select id="traxsource-genre" class="input pill"></select>
<button class="btn btn-primary pill" id="traxsource-load-btn">
<span class="ico">↓</span> Carica Top 100
</button>
<span class="hint-inline">Shift+click per bypassare la cache</span>
</div>
<div id="traxsource-status" class="beatport-status"></div>
<div id="traxsource-output-info" class="beatport-output-info"></div>
</div>
<div class="card beatport-table-card">
<div class="beatport-table-wrap">
<table id="traxsource-table" class="beatport-table" hidden>
<thead>
<tr>
<th class="col-check"><input type="checkbox" id="traxsource-select-all" /></th>
<th class="col-cover"></th>
<th class="col-pos sortable" data-sort="position">#</th>
<th class="sortable" data-sort="artists">Artista</th>
<th class="sortable" data-sort="title">Titolo (Mix)</th>
<th class="sortable" data-sort="label">Label</th>
<th class="col-state">Stato</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="beatport-toolbar" id="traxsource-toolbar" hidden>
<span class="counter" id="traxsource-selected-count">0/0 selezionati</span>
<button class="btn btn-primary pill" id="traxsource-download-btn" disabled>
<span class="ico">▶</span> Scarica selezionati
</button>
<button class="btn btn-danger pill" id="traxsource-stop-btn" hidden>
<span class="ico">◼</span> Interrompi
</button>
</div>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="traxsource-log"></div>
</div>
</section>
<!-- ===== VIEW: SPOTIFY SEARCH ===== -->
<section class="view" id="view-spotify">
<header class="hero hero-indigo">
<div class="hero-content">
<div class="hero-eyebrow indigo">RICERCA SPOTIFY</div>
<h1 class="hero-title">Cerca brani su Spotify</h1>
<p class="hero-subtitle">Digita un artista, un titolo, o entrambi. Attiva "Solo artista" per scaricare tutta la discografia.</p>
</div>
<div class="hero-deco">🟢</div>
</header>
<h2 class="section-label">Ricerca</h2>
<div class="card">
<div class="beatport-header">
<input type="text" id="spotify-query" class="input pill" placeholder="Es: Solomun, Hot Sauce, Kapuchon Hot Sauce…" style="flex:1; min-width:260px;" />
<label style="display:flex; gap:6px; align-items:center; color:var(--text-2); font-size:13px;">
<input type="checkbox" id="spotify-artist-mode" />
<span>Solo artista (discografia)</span>
</label>
<button class="btn btn-primary pill" id="spotify-search-btn">
<span class="ico">🔎</span> Cerca
</button>
</div>
<div id="spotify-status" class="beatport-status"></div>
<div id="spotify-output-info" class="beatport-output-info"></div>
</div>
<div class="card beatport-table-card" id="spotify-table-wrap" hidden>
<div class="beatport-table-wrap">
<table class="beatport-table" id="spotify-table">
<thead>
<tr>
<th class="col-check"><input type="checkbox" id="spotify-select-all" checked /></th>
<th class="col-cover"></th>
<th class="col-pos sortable" data-sort="position">#</th>
<th class="sortable" data-sort="artists">Artista</th>
<th class="sortable" data-sort="name">Titolo</th>
<th class="sortable" data-sort="album">Album</th>
<th class="col-dur sortable" data-sort="duration_sec">Durata</th>
<th class="col-date sortable" data-sort="release_date">Data</th>
<th class="col-state"></th>
</tr>
</thead>
<tbody id="spotify-tbody"></tbody>
</table>
</div>
<div class="beatport-toolbar" id="spotify-toolbar">
<span class="counter" id="spotify-selected-count">0/0 selezionati</span>
<button class="btn btn-primary pill" id="spotify-download-btn" disabled>
<span class="ico">▶</span> Scarica selezionati
</button>
<button class="btn btn-danger pill" id="spotify-stop-btn" hidden>
<span class="ico">◼</span> Interrompi
</button>
</div>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="spotify-log"></div>
</div>
</section>
<!-- ===== VIEW: YOUTUBE SEARCH ===== -->
<section class="view" id="view-youtube">
<header class="hero hero-indigo">
<div class="hero-content">
<div class="hero-eyebrow indigo">RICERCA YOUTUBE</div>
<h1 class="hero-title">Cerca video su YouTube</h1>
<p class="hero-subtitle">Perfetto per mix DJ, unreleased, bootleg e brani non presenti su Spotify.</p>
</div>
<div class="hero-deco">▶</div>
</header>
<h2 class="section-label">Ricerca</h2>
<div class="card">
<div class="beatport-header">
<input type="text" id="youtube-query" class="input pill" placeholder="Es: Solomun Cocoricò 2024, Kapuchon Hot Sauce remix…" style="flex:1; min-width:260px;" />
<button class="btn btn-primary pill" id="youtube-search-btn">
<span class="ico">🔎</span> Cerca
</button>
</div>
<div id="youtube-status" class="beatport-status"></div>
<div id="youtube-output-info" class="beatport-output-info"></div>
</div>
<div class="card beatport-table-card" id="youtube-table-wrap" hidden>
<div class="beatport-table-wrap">
<table class="beatport-table" id="youtube-table">
<thead>
<tr>
<th class="col-check"><input type="checkbox" id="youtube-select-all" checked /></th>
<th class="col-cover"></th>
<th class="col-pos sortable" data-sort="position">#</th>
<th class="sortable" data-sort="title">Titolo video</th>
<th class="sortable" data-sort="channel">Canale</th>
<th class="col-dur sortable" data-sort="duration_sec">Durata</th>
<th class="col-date sortable" data-sort="release_date">Data</th>
<th class="col-state"></th>
</tr>
</thead>
<tbody id="youtube-tbody"></tbody>
</table>
</div>
<div class="beatport-toolbar" id="youtube-toolbar">
<span class="counter" id="youtube-selected-count">0/0 selezionati</span>
<button class="btn btn-primary pill" id="youtube-download-btn" disabled>
<span class="ico">▶</span> Scarica selezionati
</button>
<button class="btn btn-danger pill" id="youtube-stop-btn" hidden>
<span class="ico">◼</span> Interrompi
</button>
</div>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="youtube-log"></div>
</div>
</section>
<!-- ===== VIEW: CONVERTI (WAV -> MP3) ===== -->
<section class="view" id="view-convert">
<header class="hero hero-purple">
<div class="hero-content">
<div class="hero-eyebrow purple">WAV → MP3</div>
<h1 class="hero-title">Converti file WAV in MP3</h1>
<p class="hero-subtitle">Bitrate configurabile, CBR o VBR, output accanto al file o in cartella custom.</p>
</div>
<div class="hero-deco">🔄</div>
</header>
<h2 class="section-label">Sorgente</h2>
<div class="card">
<div class="beatport-header">
<button id="convert-pick-files" class="btn btn-primary pill">📄 Scegli file .wav</button>
<button id="convert-pick-folder" class="btn btn-ghost pill">📁 Scegli cartella</button>
<button id="convert-clear" class="btn btn-ghost pill">Svuota</button>
</div>
<div class="beatport-header" style="margin-top:12px;gap:16px;">
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
Bitrate:
<select id="convert-bitrate" class="input pill input-sm">
<option value="128">128 kbps</option>
<option value="192">192 kbps</option>
<option value="256">256 kbps</option>
<option value="320" selected>320 kbps</option>
</select>
</label>
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
<input type="checkbox" id="convert-vbr" />
<span>VBR (qualità variabile)</span>
</label>
</div>
<div class="beatport-header" style="margin-top:12px;gap:12px;">
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
<input type="radio" name="convert-out" id="convert-out-same" checked />
<span>Accanto al file originale</span>
</label>
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
<input type="radio" name="convert-out" id="convert-out-custom" />
<span>Cartella custom:</span>
</label>
<button id="convert-out-browse" class="btn btn-ghost pill btn-sm" disabled>Sfoglia…</button>
<span id="convert-out-path" class="beatport-output-info" style="margin:0;"></span>
</div>
<div id="convert-status" class="beatport-status"></div>
</div>
<div class="card beatport-table-card" id="convert-table-wrap" hidden>
<div class="beatport-table-wrap">
<table class="beatport-table" id="convert-table">
<thead>
<tr>
<th class="col-check"><input type="checkbox" id="convert-select-all" checked /></th>
<th class="col-pos">#</th>
<th>File</th>
<th class="col-state">Stato</th>
</tr>
</thead>
<tbody id="convert-tbody"></tbody>
</table>
</div>
<div class="beatport-toolbar" id="convert-toolbar">
<span class="counter" id="convert-selected-count">0/0 selezionati</span>
<button id="convert-run-btn" class="btn btn-primary pill" disabled>
<span class="ico">▶</span> Converti selezionati
</button>
<button id="convert-stop-btn" class="btn btn-danger pill" hidden>
<span class="ico">◼</span> Interrompi
</button>
</div>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="convert-log"></div>
</div>
</section>
</main>
</div>
+1172 -6
View File
File diff suppressed because it is too large. Load diff