Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdd380d62f | ||
|
|
2689f686ec | ||
|
|
f8e2ee66bd | ||
|
|
a07f6c34ca | ||
|
|
6c473eaec9 | ||
|
|
16c8b8ee67 | ||
|
|
88e59ad37f | ||
|
|
784b56682f | ||
|
|
e1e629f8a2 | ||
|
|
a95912f78c | ||
|
|
7feda99720 | ||
|
|
191c245fc1 | ||
|
|
98235625ba | ||
|
|
72e700c85d | ||
|
|
a774906814 | ||
|
|
9f41f9d302 | ||
|
|
b0cd046375 | ||
|
|
5fc9f8ac2b | ||
|
|
c4e6d6e322 | ||
|
|
c7dfe95890 | ||
|
|
25b646d754 | ||
|
|
73b4480890 | ||
|
|
d53b8f20ed | ||
|
|
0f7cd24703 | ||
|
|
f1437e8e24 | ||
|
|
acbd9c0b1f | ||
|
|
f226c3fa8e | ||
|
|
85f7ec0993 | ||
|
|
9dbdd74b2f |
No files matched your search
+454
-11
@@ -10,12 +10,12 @@ import time
|
|||||||
import webbrowser
|
import webbrowser
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from core.config import load_config, save_config, VERSION, LICENSE_API_URL
|
from core.config import load_config, save_config, VERSION, LICENSE_API_URL
|
||||||
from core import beatport, spotify_client
|
from core import beatport, spotify_client, traxsource
|
||||||
from core import license as license_mod
|
from core import license as license_mod
|
||||||
from core.downloader import (
|
from core.downloader import (
|
||||||
download_playlist,
|
download_playlist,
|
||||||
@@ -32,6 +32,7 @@ from core.spotify_client import (
|
|||||||
SpotifyAuthRequired,
|
SpotifyAuthRequired,
|
||||||
)
|
)
|
||||||
from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS
|
from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS
|
||||||
|
from core import tagger
|
||||||
from core.recorder import (
|
from core.recorder import (
|
||||||
list_input_devices,
|
list_input_devices,
|
||||||
start_recording,
|
start_recording,
|
||||||
@@ -516,13 +517,20 @@ class Api:
|
|||||||
def start_tracks_download(self, payload: dict) -> dict:
|
def start_tracks_download(self, payload: dict) -> dict:
|
||||||
"""Avvia il download di una tracklist gia parsata
|
"""Avvia il download di una tracklist gia parsata
|
||||||
(lista di {name, artist}). Se 'subfolder' e presente,
|
(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():
|
if self._any_job_running():
|
||||||
return {"ok": False, "error": "Un download gia in corso"}
|
return {"ok": False, "error": "Un download gia in corso"}
|
||||||
|
|
||||||
tracks = payload.get("tracks") or []
|
tracks = payload.get("tracks") or []
|
||||||
output_dir = (payload.get("output_dir") or "").strip()
|
output_dir = (payload.get("output_dir") or "").strip()
|
||||||
subfolder = (payload.get("subfolder") or "").strip()
|
subfolder = (payload.get("subfolder") or "").strip()
|
||||||
|
metadata_list = payload.get("metadata") or None
|
||||||
if not tracks:
|
if not tracks:
|
||||||
return {"ok": False, "error": "Nessuna traccia fornita"}
|
return {"ok": False, "error": "Nessuna traccia fornita"}
|
||||||
if not output_dir:
|
if not output_dir:
|
||||||
@@ -541,6 +549,7 @@ class Api:
|
|||||||
self._download_thread = threading.Thread(
|
self._download_thread = threading.Thread(
|
||||||
target=self._tracks_worker,
|
target=self._tracks_worker,
|
||||||
args=(list(tracks), output_dir),
|
args=(list(tracks), output_dir),
|
||||||
|
kwargs={"metadata_list": list(metadata_list) if metadata_list else None},
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
self._download_thread.start()
|
self._download_thread.start()
|
||||||
@@ -548,7 +557,13 @@ class Api:
|
|||||||
|
|
||||||
def start_urls_download(self, payload: dict) -> dict:
|
def start_urls_download(self, payload: dict) -> dict:
|
||||||
"""Analogo a start_tracks_download ma accetta URL YouTube gia noti
|
"""Analogo a start_tracks_download ma accetta URL YouTube gia noti
|
||||||
(bypass search). Usato dal flow del tab 'YouTube Search'."""
|
(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():
|
if self._any_job_running():
|
||||||
return {"ok": False, "error": "Un download gia in corso"}
|
return {"ok": False, "error": "Un download gia in corso"}
|
||||||
|
|
||||||
@@ -556,6 +571,7 @@ class Api:
|
|||||||
titles = payload.get("titles") or []
|
titles = payload.get("titles") or []
|
||||||
output_dir = (payload.get("output_dir") or "").strip()
|
output_dir = (payload.get("output_dir") or "").strip()
|
||||||
subfolder = (payload.get("subfolder") or "").strip()
|
subfolder = (payload.get("subfolder") or "").strip()
|
||||||
|
metadata_list = payload.get("metadata") or None
|
||||||
if not urls:
|
if not urls:
|
||||||
return {"ok": False, "error": "Nessuna URL fornita"}
|
return {"ok": False, "error": "Nessuna URL fornita"}
|
||||||
if len(urls) != len(titles):
|
if len(urls) != len(titles):
|
||||||
@@ -575,6 +591,7 @@ class Api:
|
|||||||
self._download_thread = threading.Thread(
|
self._download_thread = threading.Thread(
|
||||||
target=self._urls_worker,
|
target=self._urls_worker,
|
||||||
args=(list(urls), list(titles), output_dir),
|
args=(list(urls), list(titles), output_dir),
|
||||||
|
kwargs={"metadata_list": list(metadata_list) if metadata_list else None},
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
self._download_thread.start()
|
self._download_thread.start()
|
||||||
@@ -585,7 +602,8 @@ class Api:
|
|||||||
self._log("download", "[INFO] Interruzione richiesta...")
|
self._log("download", "[INFO] Interruzione richiesta...")
|
||||||
return {"ok": True}
|
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()
|
reset_download_stop()
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
bitrate = cfg.get("bitrate", "320K")
|
bitrate = cfg.get("bitrate", "320K")
|
||||||
@@ -595,6 +613,31 @@ class Api:
|
|||||||
self._log(view, f"[INFO] Tracklist: {len(tracks)} brani da cercare su YouTube")
|
self._log(view, f"[INFO] Tracklist: {len(tracks)} brani da cercare su YouTube")
|
||||||
self._log(view, f"[INFO] Destinazione: {output_dir}")
|
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]
|
_last = [0.0]
|
||||||
_THROTTLE = 0.10
|
_THROTTLE = 0.10
|
||||||
|
|
||||||
@@ -634,10 +677,15 @@ class Api:
|
|||||||
|
|
||||||
self._emit("download:progress", payload_evt)
|
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})
|
self._emit("download:done", {"ok": True})
|
||||||
|
|
||||||
def _urls_worker(self, urls: list, titles: list, output_dir: str) -> None:
|
def _urls_worker(self, urls: list, titles: list, output_dir: str,
|
||||||
|
metadata_list: Optional[list] = None) -> None:
|
||||||
from core.downloader import download_urls
|
from core.downloader import download_urls
|
||||||
reset_download_stop()
|
reset_download_stop()
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
@@ -648,6 +696,29 @@ class Api:
|
|||||||
self._log(view, f"[INFO] URL list: {len(urls)} da scaricare da YouTube")
|
self._log(view, f"[INFO] URL list: {len(urls)} da scaricare da YouTube")
|
||||||
self._log(view, f"[INFO] Destinazione: {output_dir}")
|
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]
|
_last = [0.0]
|
||||||
_THROTTLE = 0.10
|
_THROTTLE = 0.10
|
||||||
|
|
||||||
@@ -681,7 +752,11 @@ class Api:
|
|||||||
|
|
||||||
self._emit("download:progress", payload_evt)
|
self._emit("download:progress", payload_evt)
|
||||||
|
|
||||||
download_urls(urls, titles, output_dir, bitrate, cookies_path, progress_cb)
|
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})
|
self._emit("download:done", {"ok": True})
|
||||||
|
|
||||||
def _download_worker(self, urls: list, output_dir: str) -> None:
|
def _download_worker(self, urls: list, output_dir: str) -> None:
|
||||||
@@ -1090,6 +1165,110 @@ class Api:
|
|||||||
"subfolder": subfolder,
|
"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
|
# Music Search — Spotify + YouTube
|
||||||
# ================================================================
|
# ================================================================
|
||||||
@@ -1167,7 +1346,13 @@ class Api:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def spotify_search_download(self, tracks: list) -> dict:
|
def spotify_search_download(self, tracks: list) -> dict:
|
||||||
"""Scarica i track Spotify selezionati (name+artist → YouTube search)."""
|
"""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()
|
cfg = load_config()
|
||||||
out_root = (cfg.get("output_dir") or "").strip()
|
out_root = (cfg.get("output_dir") or "").strip()
|
||||||
if not out_root:
|
if not out_root:
|
||||||
@@ -1176,7 +1361,20 @@ class Api:
|
|||||||
target = self._music_output_dir(out_root, "Spotify")
|
target = self._music_output_dir(out_root, "Spotify")
|
||||||
subfolder = target.name
|
subfolder = target.name
|
||||||
|
|
||||||
converted = []
|
# 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:
|
for t in tracks:
|
||||||
title = (t.get("name") or "").strip()
|
title = (t.get("name") or "").strip()
|
||||||
artists = (t.get("artists") or "").strip()
|
artists = (t.get("artists") or "").strip()
|
||||||
@@ -1184,6 +1382,27 @@ class Api:
|
|||||||
continue
|
continue
|
||||||
converted.append({"name": title, "artist": artists})
|
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:
|
if not converted:
|
||||||
return {"ok": False, "error": "Nessun brano valido"}
|
return {"ok": False, "error": "Nessun brano valido"}
|
||||||
|
|
||||||
@@ -1191,6 +1410,7 @@ class Api:
|
|||||||
"tracks": converted,
|
"tracks": converted,
|
||||||
"output_dir": out_root,
|
"output_dir": out_root,
|
||||||
"subfolder": subfolder,
|
"subfolder": subfolder,
|
||||||
|
"metadata": metadata,
|
||||||
})
|
})
|
||||||
|
|
||||||
# ---- YouTube ----
|
# ---- YouTube ----
|
||||||
@@ -1242,7 +1462,14 @@ class Api:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def youtube_search_download(self, tracks: list) -> dict:
|
def youtube_search_download(self, tracks: list) -> dict:
|
||||||
"""Scarica direttamente gli URL YouTube selezionati (no re-search)."""
|
"""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()
|
cfg = load_config()
|
||||||
out_root = (cfg.get("output_dir") or "").strip()
|
out_root = (cfg.get("output_dir") or "").strip()
|
||||||
if not out_root:
|
if not out_root:
|
||||||
@@ -1251,8 +1478,21 @@ class Api:
|
|||||||
target = self._music_output_dir(out_root, "YouTube")
|
target = self._music_output_dir(out_root, "YouTube")
|
||||||
subfolder = target.name
|
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 = []
|
urls: list = []
|
||||||
titles: list = []
|
titles: list = []
|
||||||
|
metadata: list = []
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
url = (t.get("url") or "").strip()
|
url = (t.get("url") or "").strip()
|
||||||
title = (t.get("title") or "").strip()
|
title = (t.get("title") or "").strip()
|
||||||
@@ -1261,6 +1501,51 @@ class Api:
|
|||||||
urls.append(url)
|
urls.append(url)
|
||||||
titles.append(title)
|
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:
|
if not urls:
|
||||||
return {"ok": False, "error": "Nessun URL valido"}
|
return {"ok": False, "error": "Nessun URL valido"}
|
||||||
|
|
||||||
@@ -1269,4 +1554,162 @@ class Api:
|
|||||||
"titles": titles,
|
"titles": titles,
|
||||||
"output_dir": out_root,
|
"output_dir": out_root,
|
||||||
"subfolder": subfolder,
|
"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})
|
||||||
@@ -226,6 +226,8 @@ def run_pyinstaller():
|
|||||||
"--hidden-import", "webview.platforms.cocoa",
|
"--hidden-import", "webview.platforms.cocoa",
|
||||||
"--hidden-import", "requests",
|
"--hidden-import", "requests",
|
||||||
"--collect-all", "mutagen",
|
"--collect-all", "mutagen",
|
||||||
|
# certifi ha cacert.pem necessario per SSL/HTTPS
|
||||||
|
"--collect-data", "certifi",
|
||||||
# Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs)
|
# Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs)
|
||||||
*add_binaries,
|
*add_binaries,
|
||||||
# Entry point
|
# Entry point
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ def run_pyinstaller():
|
|||||||
"--collect-all", "pythonnet",
|
"--collect-all", "pythonnet",
|
||||||
"--collect-all", "clr_loader",
|
"--collect-all", "clr_loader",
|
||||||
"--collect-all", "mutagen",
|
"--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-data prende anche i file .json/.dll non-Python che
|
||||||
# collect-all potrebbe saltare (Python.Runtime.runtimeconfig.json
|
# collect-all potrebbe saltare (Python.Runtime.runtimeconfig.json
|
||||||
# in particolare).
|
# in particolare).
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class BeatportTrack:
|
|||||||
artists: str # es. "A, B & C" già formattato
|
artists: str # es. "A, B & C" già formattato
|
||||||
duration_sec: int
|
duration_sec: int
|
||||||
beatport_id: int
|
beatport_id: int
|
||||||
|
image_url: str = "" # URL cover art (default vuoto per retrocompatibilità test)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def display(self) -> str:
|
def display(self) -> str:
|
||||||
@@ -150,6 +151,17 @@ def _format_artists(artists_field: object) -> str:
|
|||||||
return ", ".join(names[:-1]) + " & " + names[-1]
|
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:
|
def _parse_tracks(data: dict) -> list:
|
||||||
"""Trasforma i track dict di Beatport in BeatportTrack ordinati per posizione."""
|
"""Trasforma i track dict di Beatport in BeatportTrack ordinati per posizione."""
|
||||||
raw = _find_tracks_results(data)
|
raw = _find_tracks_results(data)
|
||||||
@@ -164,6 +176,7 @@ def _parse_tracks(data: dict) -> list:
|
|||||||
artists=_format_artists(item.get("artists")),
|
artists=_format_artists(item.get("artists")),
|
||||||
duration_sec=length_ms // 1000,
|
duration_sec=length_ms // 1000,
|
||||||
beatport_id=int(item.get("id") or 0),
|
beatport_id=int(item.get("id") or 0),
|
||||||
|
image_url=_extract_image_url(item.get("image")),
|
||||||
)
|
)
|
||||||
except (TypeError, ValueError) as e:
|
except (TypeError, ValueError) as e:
|
||||||
raise BeatportParseError(f"track[{i}] shape inattesa: {e}") from e
|
raise BeatportParseError(f"track[{i}] shape inattesa: {e}") from e
|
||||||
|
|||||||
+3
-1
@@ -5,7 +5,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VERSION = "v1.8.1"
|
VERSION = "v1.9.1"
|
||||||
|
|
||||||
|
|
||||||
APP_NAME = "MusicTools"
|
APP_NAME = "MusicTools"
|
||||||
@@ -72,6 +72,8 @@ DEFAULTS = {
|
|||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
# ---- Beatport ----
|
# ---- Beatport ----
|
||||||
"beatport_last_genre": "melodic-house-techno", # ultimo genere Top 100 caricato
|
"beatport_last_genre": "melodic-house-techno", # ultimo genere Top 100 caricato
|
||||||
|
# ---- Traxsource ----
|
||||||
|
"traxsource_last_genre": "tech-house",
|
||||||
# ---- Music Search (Spotify + YouTube) ----
|
# ---- Music Search (Spotify + YouTube) ----
|
||||||
"spotify_search_last_query": "",
|
"spotify_search_last_query": "",
|
||||||
"spotify_search_artist_mode": False,
|
"spotify_search_artist_mode": False,
|
||||||
|
|||||||
@@ -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())
|
||||||
+48
-2
@@ -121,6 +121,8 @@ def download_playlist(
|
|||||||
bitrate: str = "320K",
|
bitrate: str = "320K",
|
||||||
cookies_path: Optional[str] = None,
|
cookies_path: Optional[str] = None,
|
||||||
progress_callback: Optional[Callable] = None,
|
progress_callback: Optional[Callable] = None,
|
||||||
|
output_filenames: Optional[list] = None,
|
||||||
|
post_download_callback: Optional[Callable] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Scarica tutti i brani dalla lista di tracce.
|
"""Scarica tutti i brani dalla lista di tracce.
|
||||||
|
|
||||||
@@ -130,6 +132,12 @@ def download_playlist(
|
|||||||
bitrate: qualita audio (es. "320K")
|
bitrate: qualita audio (es. "320K")
|
||||||
cookies_path: percorso file cookies (opzionale)
|
cookies_path: percorso file cookies (opzionale)
|
||||||
progress_callback: callback(track_index, total, track_name, status, percent)
|
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()
|
reset_stop()
|
||||||
total = len(tracks)
|
total = len(tracks)
|
||||||
@@ -183,6 +191,15 @@ def download_playlist(
|
|||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback(i, total, query, "downloading", 0)
|
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
|
# Scarica con yt-dlp subprocess
|
||||||
cmd = [
|
cmd = [
|
||||||
ytdlp,
|
ytdlp,
|
||||||
@@ -193,7 +210,7 @@ def download_playlist(
|
|||||||
"--add-metadata",
|
"--add-metadata",
|
||||||
"--no-check-certificates",
|
"--no-check-certificates",
|
||||||
"--newline",
|
"--newline",
|
||||||
"--output", str(Path(output_dir) / "%(title)s.%(ext)s"),
|
"--output", out_template,
|
||||||
video_url,
|
video_url,
|
||||||
]
|
]
|
||||||
ffmpeg_dir = find_ffmpeg_dir()
|
ffmpeg_dir = find_ffmpeg_dir()
|
||||||
@@ -231,6 +248,12 @@ def download_playlist(
|
|||||||
_mark_done(done_file, query)
|
_mark_done(done_file, query)
|
||||||
done_set.add(query)
|
done_set.add(query)
|
||||||
existing_files = _scan_existing_files(out_path)
|
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:
|
if progress_callback:
|
||||||
progress_callback(i, total, query, "done", 100)
|
progress_callback(i, total, query, "done", 100)
|
||||||
else:
|
else:
|
||||||
@@ -406,6 +429,8 @@ def download_urls(
|
|||||||
bitrate: str = "320K",
|
bitrate: str = "320K",
|
||||||
cookies_path: Optional[str] = None,
|
cookies_path: Optional[str] = None,
|
||||||
progress_callback: Optional[Callable] = None,
|
progress_callback: Optional[Callable] = None,
|
||||||
|
output_filenames: Optional[list] = None,
|
||||||
|
post_download_callback: Optional[Callable] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Scarica direttamente da URL YouTube (bypassa search).
|
"""Scarica direttamente da URL YouTube (bypassa search).
|
||||||
|
|
||||||
@@ -420,6 +445,12 @@ def download_urls(
|
|||||||
bitrate: qualita audio (default 320K)
|
bitrate: qualita audio (default 320K)
|
||||||
cookies_path: file cookies opzionale
|
cookies_path: file cookies opzionale
|
||||||
progress_callback: callback(idx, total, title, status, percent)
|
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
|
global _current_process
|
||||||
reset_stop()
|
reset_stop()
|
||||||
@@ -456,6 +487,15 @@ def download_urls(
|
|||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback(i, total, key, "downloading", 0)
|
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 = [
|
cmd = [
|
||||||
ytdlp,
|
ytdlp,
|
||||||
"--extract-audio",
|
"--extract-audio",
|
||||||
@@ -465,7 +505,7 @@ def download_urls(
|
|||||||
"--add-metadata",
|
"--add-metadata",
|
||||||
"--no-check-certificates",
|
"--no-check-certificates",
|
||||||
"--newline",
|
"--newline",
|
||||||
"--output", str(Path(output_dir) / "%(title)s.%(ext)s"),
|
"--output", out_template,
|
||||||
url,
|
url,
|
||||||
]
|
]
|
||||||
ffmpeg_dir = find_ffmpeg_dir()
|
ffmpeg_dir = find_ffmpeg_dir()
|
||||||
@@ -502,6 +542,12 @@ def download_urls(
|
|||||||
_mark_done(done_file, key)
|
_mark_done(done_file, key)
|
||||||
done_set.add(key)
|
done_set.add(key)
|
||||||
existing_files = _scan_existing_files(out_path)
|
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:
|
if progress_callback:
|
||||||
progress_callback(i, total, key, "done", 100)
|
progress_callback(i, total, key, "done", 100)
|
||||||
else:
|
else:
|
||||||
|
|||||||
+69
-5
@@ -308,16 +308,74 @@ def search_tracks(token: str, query: str, limit: int = 50) -> list:
|
|||||||
|
|
||||||
def _track_to_dict(t: dict) -> dict:
|
def _track_to_dict(t: dict) -> dict:
|
||||||
"""Mappa il track object Spotify sul nostro schema uniforme."""
|
"""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 {
|
return {
|
||||||
"id": t.get("id", ""),
|
"id": t.get("id", ""),
|
||||||
"url": t.get("external_urls", {}).get("spotify", ""),
|
"url": t.get("external_urls", {}).get("spotify", ""),
|
||||||
"name": t.get("name", ""),
|
"name": t.get("name", ""),
|
||||||
"artists": ", ".join(a.get("name", "") for a in t.get("artists", [])),
|
"artists": ", ".join(a.get("name", "") for a in artists_arr),
|
||||||
"album": t.get("album", {}).get("name", ""),
|
"album": album.get("name", ""),
|
||||||
"duration_sec": int(t.get("duration_ms", 0)) // 1000,
|
"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:
|
def search_artist_discography(token: str, artist_name: str) -> list:
|
||||||
"""Trova l'artista esatto (o il piu' popolare tra i match) e ritorna
|
"""Trova l'artista esatto (o il piu' popolare tra i match) e ritorna
|
||||||
tutti i suoi brani: top tracks + tracce di ogni album/single.
|
tutti i suoi brani: top tracks + tracce di ogni album/single.
|
||||||
@@ -372,10 +430,13 @@ def search_artist_discography(token: str, artist_name: str) -> list:
|
|||||||
r_alb.raise_for_status()
|
r_alb.raise_for_status()
|
||||||
albums = r_alb.json().get("items", [])
|
albums = r_alb.json().get("items", [])
|
||||||
|
|
||||||
# 4. Per ogni album, tracce (album/track object non ha "album" sub-field, iniettiamola)
|
# 4. Per ogni album, tracce (album/track object non ha "album" sub-field, iniettiamola
|
||||||
|
# includendo anche le images dell'album così _track_to_dict riesce a estrarre la cover)
|
||||||
for alb in albums:
|
for alb in albums:
|
||||||
alb_id = alb.get("id")
|
alb_id = alb.get("id")
|
||||||
alb_name = alb.get("name", "")
|
alb_name = alb.get("name", "")
|
||||||
|
alb_images = alb.get("images", []) or []
|
||||||
|
alb_release_date = alb.get("release_date", "")
|
||||||
if not alb_id:
|
if not alb_id:
|
||||||
continue
|
continue
|
||||||
time.sleep(0.1) # rate limit interno
|
time.sleep(0.1) # rate limit interno
|
||||||
@@ -388,8 +449,11 @@ def search_artist_discography(token: str, artist_name: str) -> list:
|
|||||||
r_at.raise_for_status()
|
r_at.raise_for_status()
|
||||||
for t in r_at.json().get("items", []):
|
for t in r_at.json().get("items", []):
|
||||||
t = dict(t)
|
t = dict(t)
|
||||||
# Album tracks non hanno "album" nested; iniettiamo il nome
|
t.setdefault("album", {
|
||||||
t.setdefault("album", {"name": alb_name})
|
"name": alb_name,
|
||||||
|
"images": alb_images,
|
||||||
|
"release_date": alb_release_date,
|
||||||
|
})
|
||||||
collected.append(_track_to_dict(t))
|
collected.append(_track_to_dict(t))
|
||||||
|
|
||||||
# 5. Dedupe
|
# 5. Dedupe
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
"""Scrittura tag ID3 su file MP3 via mutagen, con cover art da URL."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from mutagen.easyid3 import EasyID3
|
||||||
|
from mutagen.id3 import ID3, APIC
|
||||||
|
from mutagen.mp3 import MP3
|
||||||
|
|
||||||
|
|
||||||
|
SAFE_FILENAME_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename_stem(stem: str, max_len: int = 180) -> str:
|
||||||
|
"""Rimuove caratteri non validi per un filename e limita lunghezza."""
|
||||||
|
s = SAFE_FILENAME_RE.sub("_", stem or "").strip()
|
||||||
|
s = re.sub(r"\s+", " ", s)
|
||||||
|
if len(s) > max_len:
|
||||||
|
s = s[:max_len].rstrip()
|
||||||
|
return s or "untitled"
|
||||||
|
|
||||||
|
|
||||||
|
def build_filename_stem(artist: str, title: str) -> str:
|
||||||
|
"""Compone 'Artista - Titolo' sanitizzato."""
|
||||||
|
artist = (artist or "").strip()
|
||||||
|
title = (title or "").strip()
|
||||||
|
if artist and title:
|
||||||
|
return sanitize_filename_stem(f"{artist} - {title}")
|
||||||
|
return sanitize_filename_stem(title or artist or "untitled")
|
||||||
|
|
||||||
|
|
||||||
|
def _download_cover(url: str) -> Optional[bytes]:
|
||||||
|
"""Scarica bytes cover, ritorna None su qualsiasi errore."""
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=15)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.content
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cover_mime(url: str, content: bytes) -> str:
|
||||||
|
"""Deduci MIME dell'immagine da URL o magic bytes."""
|
||||||
|
u = url.lower()
|
||||||
|
if u.endswith(".png"):
|
||||||
|
return "image/png"
|
||||||
|
if u.endswith((".jpg", ".jpeg")):
|
||||||
|
return "image/jpeg"
|
||||||
|
# magic bytes
|
||||||
|
if content[:8] == b"\x89PNG\r\n\x1a\n":
|
||||||
|
return "image/png"
|
||||||
|
if content[:3] == b"\xff\xd8\xff":
|
||||||
|
return "image/jpeg"
|
||||||
|
return "image/jpeg" # default
|
||||||
|
|
||||||
|
|
||||||
|
def write_tags(filepath: str, metadata: dict) -> None:
|
||||||
|
"""Scrive ID3 tag su file MP3 esistente.
|
||||||
|
|
||||||
|
metadata: dict con chiavi opzionali:
|
||||||
|
title, artist, album, date (YYYY o YYYY-MM-DD), genre,
|
||||||
|
tracknumber, cover_url
|
||||||
|
|
||||||
|
Errori non fatali (file mancante, tag non scrivibili): silenziosi
|
||||||
|
per non bloccare il download principale.
|
||||||
|
"""
|
||||||
|
p = Path(filepath)
|
||||||
|
if not p.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Tag testuali via EasyID3
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
audio = EasyID3(str(p))
|
||||||
|
except Exception:
|
||||||
|
# Se il file non ha header ID3, aggiungine uno vuoto
|
||||||
|
mp3 = MP3(str(p))
|
||||||
|
if mp3.tags is None:
|
||||||
|
mp3.add_tags()
|
||||||
|
mp3.save()
|
||||||
|
audio = EasyID3(str(p))
|
||||||
|
for key in ("title", "artist", "album", "date", "genre", "tracknumber"):
|
||||||
|
val = metadata.get(key)
|
||||||
|
if val:
|
||||||
|
audio[key] = str(val)
|
||||||
|
audio.save(str(p))
|
||||||
|
except Exception:
|
||||||
|
return # ID3 broken, non provo la cover
|
||||||
|
|
||||||
|
# 2. Cover art via ID3 APIC
|
||||||
|
cover_url = (metadata.get("cover_url") or "").strip()
|
||||||
|
if not cover_url:
|
||||||
|
return
|
||||||
|
cover_bytes = _download_cover(cover_url)
|
||||||
|
if not cover_bytes:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
mime = _cover_mime(cover_url, cover_bytes)
|
||||||
|
tags = ID3(str(p))
|
||||||
|
tags.delall("APIC")
|
||||||
|
tags.add(APIC(encoding=3, mime=mime, type=3, desc="Cover", data=cover_bytes))
|
||||||
|
tags.save(str(p))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,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
|
||||||
+39
-9
@@ -13,6 +13,10 @@ from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffprobe, subprocess_fla
|
|||||||
|
|
||||||
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".wav", ".flac"}
|
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".wav", ".flac"}
|
||||||
|
|
||||||
|
# Timeout massimo per singolo download yt-dlp (secondi). Watchdog kill.
|
||||||
|
# Serve a evitare hang su video geo-restricted, YouTube throttle o rete lenta.
|
||||||
|
_DOWNLOAD_TIMEOUT_SEC = 300
|
||||||
|
|
||||||
# Flag globale per interruzione
|
# Flag globale per interruzione
|
||||||
_stop_event = threading.Event()
|
_stop_event = threading.Event()
|
||||||
_current_process: Optional[subprocess.Popen] = None
|
_current_process: Optional[subprocess.Popen] = None
|
||||||
@@ -288,17 +292,43 @@ def upgrade_folder(
|
|||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||||
**subprocess_flags(),
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
|
proc = _current_process
|
||||||
|
|
||||||
for line in _current_process.stdout:
|
# Watchdog: kill del subprocess se supera _DOWNLOAD_TIMEOUT_SEC.
|
||||||
if is_stopped():
|
# Previene hang indefinito su video problematici (geo-block,
|
||||||
_current_process.terminate()
|
# YouTube throttle, rete lenta). Alla scadenza il process viene
|
||||||
return
|
# terminato -> for line esce -> code path "download_error" naturale.
|
||||||
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
|
def _watchdog_kill():
|
||||||
if pct_match and progress_callback:
|
try:
|
||||||
pct = int(float(pct_match.group(1)))
|
if proc.poll() is None:
|
||||||
progress_callback(processed, total, filepath.name, "downloading", current_kbps, pct)
|
proc.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
watchdog = threading.Timer(_DOWNLOAD_TIMEOUT_SEC, _watchdog_kill)
|
||||||
|
watchdog.daemon = True
|
||||||
|
watchdog.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
for line in proc.stdout:
|
||||||
|
if is_stopped():
|
||||||
|
proc.terminate()
|
||||||
|
return
|
||||||
|
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
|
||||||
|
if pct_match and progress_callback:
|
||||||
|
pct = int(float(pct_match.group(1)))
|
||||||
|
progress_callback(processed, total, filepath.name, "downloading", current_kbps, pct)
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
watchdog.cancel()
|
||||||
|
|
||||||
_current_process.wait()
|
|
||||||
with _process_lock:
|
with _process_lock:
|
||||||
_current_process = None
|
_current_process = None
|
||||||
|
|
||||||
|
|||||||
@@ -62,11 +62,13 @@ def search_youtube(query: str, limit: int = 50) -> list:
|
|||||||
continue
|
continue
|
||||||
video_id = e.get("id") or ""
|
video_id = e.get("id") or ""
|
||||||
url = e.get("url") or (f"https://www.youtube.com/watch?v={video_id}" if video_id else "")
|
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({
|
result.append({
|
||||||
"id": video_id,
|
"id": video_id,
|
||||||
"url": url,
|
"url": url,
|
||||||
"title": e.get("title") or "",
|
"title": e.get("title") or "",
|
||||||
"channel": e.get("uploader") or e.get("channel") or "",
|
"channel": e.get("uploader") or e.get("channel") or "",
|
||||||
"duration_sec": int(e.get("duration") or 0),
|
"duration_sec": int(e.get("duration") or 0),
|
||||||
|
"image_url": image_url,
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,256 @@
|
|||||||
|
# Traxsource Charts — Design Spec
|
||||||
|
|
||||||
|
- **Data:** 2026-08-01
|
||||||
|
- **Autore:** LuZa + Claude
|
||||||
|
- **Stato:** Approvato, pronto per implementation plan
|
||||||
|
- **Target release:** MusicTools v1.9.0
|
||||||
|
|
||||||
|
## Obiettivo
|
||||||
|
|
||||||
|
Nuova tab "▲ Traxsource" nell'app che permette di:
|
||||||
|
|
||||||
|
1. Scegliere un genere musicale da un elenco di ~15 generi Traxsource
|
||||||
|
2. Caricare la **Top 100** ufficiale del mese corrente per quel genere
|
||||||
|
3. Anteprima checkbox + download riusando il pipeline esistente (Spotify search → yt-dlp → tag ID3 + cover)
|
||||||
|
|
||||||
|
Sostituisce l'attività manuale di copiare/incollare tracklist Traxsource.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Chart diverse dalla Top 100 (Must Have, Just Added, ecc.)
|
||||||
|
- Ricerca artista/label specifica su Traxsource
|
||||||
|
- Preview audio (Traxsource ha DRM)
|
||||||
|
- Duration tracks (non è nell'HTML della lista; sarebbe una richiesta extra per track)
|
||||||
|
- Login/account Traxsource
|
||||||
|
|
||||||
|
## Ricognizione tecnica (fatta 2026-08-01)
|
||||||
|
|
||||||
|
- **Cloudflare Managed Challenge attivo** su tutte le pagine tranne home e /top. Bypass: session-based con `curl_cffi.Session(impersonate='chrome131')`, prima GET a `/`, poi le pagine sensibili con `Referer: https://www.traxsource.com/`.
|
||||||
|
- **Non è Next.js** → no `__NEXT_DATA__`. HTML server-rendered.
|
||||||
|
- **Struttura URL:**
|
||||||
|
- Genre index: `/genre/<id>/<slug>` — es. `/genre/18/tech-house`. Contiene solo 10 top track più altri widget
|
||||||
|
- Top 100 corrente: `/title/<title_id>/top-100-<genre>-of-<month>-<year>` — es. `/title/2847986/top-100-tech-house-of-july-2026`. Contiene 100 track completi in un unico documento (~394KB)
|
||||||
|
- **Il title_id cambia ogni mese.** Non è hardcodabile. Va scoperto dinamicamente
|
||||||
|
- **Discovery Top 100 URL:** nella pagina `/genre/<id>/<slug>` c'è (almeno un) `<a href="/title/<id>/top-100-<slug>-of-<month>-<year>">`. Estrai col regex `href="(/title/\d+/top-100-[a-z0-9-]+)"`
|
||||||
|
- **Track markup (stabile):**
|
||||||
|
```html
|
||||||
|
<div data-trid="14842066" class="top-item play-trk ptk-14842066">
|
||||||
|
<div class="ttib position">1</div>
|
||||||
|
<div class="image"><img src="https://.../52x52/HASH.jpg" /></div>
|
||||||
|
<div class="ttib info">
|
||||||
|
<a href="/track/14842066/badman-sound-extended-mix" class="com-title">Badman Sound (Extended Mix)</a>
|
||||||
|
<a href="/artist/92248/hannah-wants" class="com-artists">Hannah Wants</a>,
|
||||||
|
<a href="/artist/321517/trace" class="com-artists">Trace</a>
|
||||||
|
<a href="/label/325/nervous" class="com-label">Nervous</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
Class names sono stabili (semantici, non generati da bundler).
|
||||||
|
- **Cover art:** URL contiene la size (`/52x52/`). Sostituendo con `/500x500/` si ottiene la versione grande per tagging ID3.
|
||||||
|
|
||||||
|
## Approccio scelto
|
||||||
|
|
||||||
|
- Session `curl_cffi` con `impersonate='chrome131'`. Cookie CF conservati automaticamente. Preriscaldamento con GET a `/` al primo utilizzo.
|
||||||
|
- Parser: `BeautifulSoup4` con selettori CSS puliti — `div.top-item.play-trk` per righe, `a.com-title` per titolo, `a.com-artists` per artisti, `a.com-label` per label, `div.ttib.position` per rank, `img` per cover URL.
|
||||||
|
- Regex sul titolo per estrarre "mix name" tra parentesi: `Song Title (Extended Mix)` → title="Song Title", mix="Extended Mix".
|
||||||
|
- Cache in-memory 15 min per la lista, come Beatport.
|
||||||
|
- Genere → (numeric_id, slug, display_name) map hardcoded (estratti dalla home /top).
|
||||||
|
|
||||||
|
**Alternative scartate:**
|
||||||
|
- `requests` puro: bloccato da CF, verificato
|
||||||
|
- API interna Traxsource: non esistono API pubbliche gratuite documentate; reverse engineering rischio ban
|
||||||
|
- Playwright: overkill (~100MB dep) quando session + BeautifulSoup basta
|
||||||
|
|
||||||
|
## Architettura
|
||||||
|
|
||||||
|
### Nuovo modulo `core/traxsource.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# Mappa slug → (numeric_id, display_name)
|
||||||
|
# Verificati 2026-08-01 dalla home /top di Traxsource
|
||||||
|
GENRES: dict = {
|
||||||
|
"afro-house": (33, "Afro House"),
|
||||||
|
"afro-latin-brazilian": (23, "Afro / Latin / Brazilian"),
|
||||||
|
"amapiano": (37, "Amapiano"),
|
||||||
|
"bass-club": (25, "Bass / Club"),
|
||||||
|
"breaks-uk-bass": (14, "Breaks / UK Bass"),
|
||||||
|
"classic-house": (12, "Classic House"),
|
||||||
|
"deep-house": (13, "Deep House"),
|
||||||
|
"dj-tools": (16, "DJ Tools"),
|
||||||
|
"downtempo-nu-disco-indie-dance": (22, "Downtempo / Nu Disco / Indie Dance"),
|
||||||
|
"electronica": (5, "Electronica"),
|
||||||
|
"electro-house": (11, "Electro House"),
|
||||||
|
"funky-jackin-groovy": (15, "Funky / Jackin / Groovy"),
|
||||||
|
"garage": (29, "Garage"),
|
||||||
|
"house": (1, "House"),
|
||||||
|
"lounge-chill-out": (10, "Lounge / Chill Out"),
|
||||||
|
"melodic-house-techno-progressive-house": (34, "Melodic House / Techno / Progressive House"),
|
||||||
|
"minimal-deep-tech": (27, "Minimal / Deep Tech"),
|
||||||
|
"organic-house-downtempo": (36, "Organic House / Downtempo"),
|
||||||
|
"progressive-house": (28, "Progressive House"),
|
||||||
|
"r-and-b-hip-hop": (6, "R&B / Hip Hop"),
|
||||||
|
"soulful-house": (24, "Soulful House"),
|
||||||
|
"tech-house": (18, "Tech House"),
|
||||||
|
"techno-peak-time-driving": (26, "Techno (Peak Time / Driving)"),
|
||||||
|
"techno-raw-deep-hypnotic": (32, "Techno (Raw / Deep / Hypnotic)"),
|
||||||
|
"trance": (30, "Trance"),
|
||||||
|
"world-reggae": (21, "World / Reggae"),
|
||||||
|
}
|
||||||
|
# NOTA: la mappa è enumerata in fase di implementazione fetchando /top con curl_cffi
|
||||||
|
# e cercando href="/genre/<id>/<slug>". Vedi Task 1 del plan.
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TraxsourceTrack:
|
||||||
|
position: int
|
||||||
|
title: str
|
||||||
|
mix: str
|
||||||
|
artists: str # "A, B & C" formattato
|
||||||
|
label: str
|
||||||
|
traxsource_id: int
|
||||||
|
slug: str
|
||||||
|
image_url: str = "" # URL thumbnail (per UI)
|
||||||
|
cover_url_large: str = "" # URL cover 500x500 (per tagging)
|
||||||
|
|
||||||
|
|
||||||
|
class TraxsourceError(Exception): pass
|
||||||
|
class TraxsourceUnreachableError(TraxsourceError): pass
|
||||||
|
class TraxsourceParseError(TraxsourceError): pass
|
||||||
|
|
||||||
|
|
||||||
|
def list_genres() -> list:
|
||||||
|
"""Ritorna [{slug, id, name}, ...] ordinato alfabeticamente per name."""
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_top100(slug: str, force_refresh: bool = False) -> list[TraxsourceTrack]:
|
||||||
|
"""Fetches Top 100 corrente per il genere.
|
||||||
|
|
||||||
|
1. Scopre l'URL della playlist Top 100 del mese via GET /genre/<id>/<slug>
|
||||||
|
2. Fetch della playlist /title/<id>/top-100-...
|
||||||
|
3. Parse HTML con BeautifulSoup
|
||||||
|
4. Cache in-memory 15 min
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError se slug non in GENRES
|
||||||
|
TraxsourceUnreachableError su rete/5xx dopo retry
|
||||||
|
TraxsourceParseError su HTML non conforme (title link non trovato, meno di 50 track, ecc.)
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Internals:**
|
||||||
|
- `_session()` singleton — inizializza `curl_cffi.Session(impersonate='chrome131')` e fa GET a `/` una volta per riscaldare CF cookies. Riusata per tutte le fetch successive.
|
||||||
|
- `_discover_top100_url(session, slug) → str` — fetcha `/genre/<id>/<slug>`, regex per il link Top 100
|
||||||
|
- `_parse_tracks(html) → list[TraxsourceTrack]` — BeautifulSoup, itera `div.top-item.play-trk`, estrae campi
|
||||||
|
- `_split_title_mix(full_title) → (title, mix)` — parsa `"Foo (Extended Mix)"` → `("Foo", "Extended Mix")`. Se no parentesi, mix = "".
|
||||||
|
- `_format_artists(anchors) → str` — join `[a.text for a in anchors]` con formattazione "A, B & C"
|
||||||
|
- `_large_cover(url) → str` — sostituisce `/52x52/` con `/500x500/` nel path
|
||||||
|
|
||||||
|
**Retry + timeout:** riusa gli stessi pattern di `core/beatport.py`: 3 tentativi con backoff [1, 3]s, timeout 15s.
|
||||||
|
|
||||||
|
### Nuovi metodi in `api/bridge.py`
|
||||||
|
|
||||||
|
Paralleli ai metodi Beatport (stesso schema):
|
||||||
|
|
||||||
|
- `traxsource_genres() → list[dict]`
|
||||||
|
- `traxsource_fetch_chart(slug, force_refresh) → {ok, tracks} | {ok:false, error, message}` — salva anche `traxsource_last_genre` in config
|
||||||
|
- `traxsource_check_existing(tracks, genre_name) → list[bool]` — check in `<output_dir>/Traxsource_<genre>/`
|
||||||
|
- `traxsource_download_selected(tracks, genre_name) → dict` — costruisce metadata (title/artist/album=label/date=YYYY-MM/genre=display_name/cover_url) e chiama `start_tracks_download` riusando il pipeline con tagging ID3
|
||||||
|
|
||||||
|
**Metadata per il tagger:**
|
||||||
|
- title, artist: dai campi track
|
||||||
|
- album: label Traxsource (best proxy — non c'è "album" per singole tracce)
|
||||||
|
- date: mese/anno corrente (dato che la Top 100 è mensile)
|
||||||
|
- genre: display_name del genere
|
||||||
|
- cover_url: `cover_url_large` (500x500)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
Nuova tab in sidebar dopo YouTube (o Beatport):
|
||||||
|
```html
|
||||||
|
<button class="nav-item" data-view="traxsource" data-feature="audio">
|
||||||
|
<span class="nav-icon">▲</span><span>Traxsource</span>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Section `#view-traxsource` con struttura identica a Beatport (dropdown genere + Carica Top 100 + tabella + toolbar + log). Riusa tutte le classi `.beatport-*` per lo stile.
|
||||||
|
|
||||||
|
Modulo `TraxsourceUI` in `webui/js/app.js` — copia diretta di `BeatportUI` con selettori cambiati (`traxsource-*` invece di `beatport-*`) e chiamate API alle nuove funzioni.
|
||||||
|
|
||||||
|
**Ordinamento colonne** (sortable) e **cover art** già supportati riusando la stessa struttura tabella + modulo helper `_sortPairs`/`_bindSortableHeaders`/`_updateSortArrows` esistenti.
|
||||||
|
|
||||||
|
### Persistenza
|
||||||
|
|
||||||
|
Nuovo campo in `core/config.py::DEFAULTS`:
|
||||||
|
```python
|
||||||
|
"traxsource_last_genre": "tech-house",
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dipendenze
|
||||||
|
|
||||||
|
- `beautifulsoup4` — nuova dep runtime. Aggiungere in `requirements.txt`. Serve `--collect-data bs4` in build_windows.py + build_macos.py? Verificare — bs4 di solito no.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Unit tests `tests/test_traxsource.py` (senza rete, con fixture HTML):**
|
||||||
|
|
||||||
|
Serve una fixture HTML reale della pagina Top 100 (~400KB). Task 1 la genera con `curl_cffi` come fatto per Beatport (`beatport_melodic_top100.html`).
|
||||||
|
|
||||||
|
| Test | Verifica |
|
||||||
|
|---|---|
|
||||||
|
| `test_list_genres_shape` | Lista dict con {slug, id, name}, ordinati |
|
||||||
|
| `test_parse_top100_extracts_100_tracks` | Fixture HTML → 100 track |
|
||||||
|
| `test_parse_positions_sequential` | 1..100 senza buchi |
|
||||||
|
| `test_parse_track_shape` | Ogni track ha title, artists, label, cover URLs non vuoti |
|
||||||
|
| `test_split_title_mix_with_parens` | `"Foo (Extended Mix)"` → `("Foo", "Extended Mix")` |
|
||||||
|
| `test_split_title_mix_no_parens` | `"Foo"` → `("Foo", "")` |
|
||||||
|
| `test_format_artists_multi` | `["A", "B", "C"]` → `"A, B & C"` |
|
||||||
|
| `test_large_cover_url_substitution` | `.../52x52/x.jpg` → `.../500x500/x.jpg` |
|
||||||
|
| `test_discover_top100_url_returns_playlist_link` | HTML genre stub → estrae `/title/N/top-100-...` |
|
||||||
|
| `test_fetch_top100_invalid_slug_raises_value_error` | slug non in GENRES → ValueError early |
|
||||||
|
|
||||||
|
Mock HTTP con `unittest.mock.patch("core.traxsource._session")` (patch della session singleton).
|
||||||
|
|
||||||
|
**Test manuale end-to-end:**
|
||||||
|
1. Avvia app → tab Traxsource
|
||||||
|
2. Selezione "Tech House" → Carica Top 100 → tabella 100 righe in <5s
|
||||||
|
3. Verifica cover art (thumbnail nella tabella)
|
||||||
|
4. Deseleziona 90, tieni 10 → Scarica selezionati
|
||||||
|
5. File in `MUSICA/Traxsource_Tech House/` con tag ID3 completi (title, artist, album=label, genre=Tech House, cover 500x500)
|
||||||
|
6. Ricarica stesso genere → cache hit
|
||||||
|
7. Riavvio → ultimo genere ricordato
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
1. Branch `feat/traxsource-charts`
|
||||||
|
2. Bump `core/config.py::VERSION` → `v1.9.0` (minor: nuova tab visibile)
|
||||||
|
3. Note release `/tmp/notes-v1.9.0.md`
|
||||||
|
4. Merge → tag → CI → server DB update (stesso flusso di sempre)
|
||||||
|
|
||||||
|
## Struttura file impattati
|
||||||
|
|
||||||
|
**Nuovi:**
|
||||||
|
- `core/traxsource.py` (~250 LOC)
|
||||||
|
- `tests/test_traxsource.py` (~200 LOC)
|
||||||
|
- `tests/fixtures/traxsource_tech_house_top100.html` (~400KB)
|
||||||
|
- `scripts/refresh_traxsource_genres.py` (~50 LOC, opzionale)
|
||||||
|
|
||||||
|
**Modificati:**
|
||||||
|
- `requirements.txt` — `beautifulsoup4>=4.12.0`
|
||||||
|
- `build_windows.py` + `build_macos.py` — verificare se serve `--collect-data bs4`
|
||||||
|
- `core/config.py` — VERSION bump + `traxsource_last_genre`
|
||||||
|
- `api/bridge.py` — 4 metodi Api `traxsource_*` (~150 LOC)
|
||||||
|
- `webui/index.html` — nav-item + section (~90 LOC)
|
||||||
|
- `webui/js/app.js` — modulo `TraxsourceUI` (~250 LOC, copia BeatportUI adattata)
|
||||||
|
|
||||||
|
## Considerazioni operative
|
||||||
|
|
||||||
|
- **Fragility scraping:** i class name di Traxsource (`com-title`, `com-artists`, `top-item`, ecc.) sono usati anche nel JS del sito → stabili nel tempo. Rischio: se Traxsource migra a un framework nuovo (React/Next), parser va rifatto.
|
||||||
|
- **Rate limiting:** un solo GET per la genre + un GET per la playlist Top 100 = 2 richieste per caricare la chart. Cache 15 min. Zero rischio ban in uso normale.
|
||||||
|
- **Legal:** stesso perimetro di Beatport — lettura di pagine pubbliche, no ToS violation esplicito.
|
||||||
|
- **Comparabilità con Beatport:** un utente power potrebbe usare entrambe le tab per triangolare le release del mese. UX consistente = curva apprendimento zero.
|
||||||
@@ -4,6 +4,21 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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
|
# Windows: forza il caricamento del runtime pythonnet PRIMA di importare
|
||||||
# webview. Con PyInstaller il lazy-loader fallisce con
|
# webview. Con PyInstaller il lazy-loader fallisce con
|
||||||
# "Failed to resolve Python.Runtime.Loader.Initialize" perche'
|
# "Failed to resolve Python.Runtime.Loader.Initialize" perche'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pywebview>=5.0
|
pywebview>=5.0
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
|
certifi>=2024.0.0
|
||||||
yt-dlp>=2024.0.0
|
yt-dlp>=2024.0.0
|
||||||
mutagen>=1.47.0
|
mutagen>=1.47.0
|
||||||
pyobjc-core>=10.0 ; sys_platform == "darwin"
|
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"
|
pythonnet==3.0.5 ; sys_platform == "win32"
|
||||||
clr-loader==0.2.7.post0 ; sys_platform == "win32"
|
clr-loader==0.2.7.post0 ; sys_platform == "win32"
|
||||||
curl_cffi>=0.9.0
|
curl_cffi>=0.9.0
|
||||||
|
beautifulsoup4>=4.12.0
|
||||||
|
|
||||||
# --- dev only ---
|
# --- dev only ---
|
||||||
pytest>=8.0.0
|
pytest>=8.0.0
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Estrae la lista dei generi Traxsource dalla pagina /top.
|
||||||
|
Uso: python3 scripts/refresh_traxsource_genres.py > /tmp/tx_genres.txt
|
||||||
|
Poi copia manualmente in core/traxsource.py::GENRES."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from curl_cffi import requests
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
s = requests.Session(impersonate="chrome131")
|
||||||
|
s.get("https://www.traxsource.com/", timeout=15)
|
||||||
|
r = s.get("https://www.traxsource.com/top", timeout=15,
|
||||||
|
headers={"Referer": "https://www.traxsource.com/"})
|
||||||
|
if r.status_code != 200:
|
||||||
|
print(f"HTTP {r.status_code}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
# href="/genre/<id>/<slug>"
|
||||||
|
hits = re.findall(r'href="/genre/(\d+)/([a-z0-9-]+)"', r.text)
|
||||||
|
seen = set()
|
||||||
|
for gid, slug in hits:
|
||||||
|
if slug in seen:
|
||||||
|
continue
|
||||||
|
seen.add(slug)
|
||||||
|
# Display name = slug con capitalizzazione euristica (l'utente lo raffina a mano)
|
||||||
|
name = " ".join(w.capitalize() for w in slug.replace("-", " ").split())
|
||||||
|
print(f' "{slug}": ({gid}, "{name}"),')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+2882
File diff suppressed because it is too large.
Load diff
+6332
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,104 @@
|
|||||||
|
"""Test per core.converter — file scanning + stop event.
|
||||||
|
|
||||||
|
I test evitano di invocare ffmpeg reale (che potrebbe non essere in PATH
|
||||||
|
in CI). La funzione di conversione vera e propria e' coperta manualmente.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core import converter
|
||||||
|
|
||||||
|
|
||||||
|
class TestListWavFiles:
|
||||||
|
def test_scans_recursively(self, tmp_path: Path):
|
||||||
|
# Struttura:
|
||||||
|
# root/a.wav
|
||||||
|
# root/b.WAV (case insensitive: glob su POSIX distingue,
|
||||||
|
# quindi controllo esplicito che almeno i .wav
|
||||||
|
# minuscoli siano restituiti)
|
||||||
|
# root/c.mp3
|
||||||
|
# root/sub/d.wav
|
||||||
|
(tmp_path / "a.wav").write_bytes(b"RIFF")
|
||||||
|
(tmp_path / "c.mp3").write_bytes(b"ID3")
|
||||||
|
sub = tmp_path / "sub"
|
||||||
|
sub.mkdir()
|
||||||
|
(sub / "d.wav").write_bytes(b"RIFF")
|
||||||
|
|
||||||
|
got = converter.list_wav_files(str(tmp_path), recursive=True)
|
||||||
|
|
||||||
|
assert len(got) == 2
|
||||||
|
names = {Path(p).name for p in got}
|
||||||
|
assert names == {"a.wav", "d.wav"}
|
||||||
|
# Ordinamento stabile
|
||||||
|
assert got == sorted(got)
|
||||||
|
|
||||||
|
def test_non_recursive_ignores_subfolders(self, tmp_path: Path):
|
||||||
|
(tmp_path / "a.wav").write_bytes(b"RIFF")
|
||||||
|
sub = tmp_path / "sub"
|
||||||
|
sub.mkdir()
|
||||||
|
(sub / "b.wav").write_bytes(b"RIFF")
|
||||||
|
|
||||||
|
got = converter.list_wav_files(str(tmp_path), recursive=False)
|
||||||
|
|
||||||
|
assert len(got) == 1
|
||||||
|
assert Path(got[0]).name == "a.wav"
|
||||||
|
|
||||||
|
def test_empty_dir_returns_empty(self, tmp_path: Path):
|
||||||
|
assert converter.list_wav_files(str(tmp_path)) == []
|
||||||
|
|
||||||
|
def test_missing_dir_returns_empty(self, tmp_path: Path):
|
||||||
|
missing = tmp_path / "does-not-exist"
|
||||||
|
assert converter.list_wav_files(str(missing)) == []
|
||||||
|
|
||||||
|
def test_ignores_non_wav_files(self, tmp_path: Path):
|
||||||
|
(tmp_path / "song.mp3").write_bytes(b"ID3")
|
||||||
|
(tmp_path / "song.flac").write_bytes(b"fLaC")
|
||||||
|
(tmp_path / "notes.txt").write_text("hello")
|
||||||
|
|
||||||
|
assert converter.list_wav_files(str(tmp_path)) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestStopEvent:
|
||||||
|
def setup_method(self):
|
||||||
|
# Ogni test parte con lo stop event pulito.
|
||||||
|
converter.reset_stop()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
converter.reset_stop()
|
||||||
|
|
||||||
|
def test_reset_initially_not_stopped(self):
|
||||||
|
converter.reset_stop()
|
||||||
|
assert converter.is_stopped() is False
|
||||||
|
|
||||||
|
def test_request_stop_sets_flag(self):
|
||||||
|
converter.request_stop()
|
||||||
|
assert converter.is_stopped() is True
|
||||||
|
|
||||||
|
def test_reset_after_stop_clears_flag(self):
|
||||||
|
converter.request_stop()
|
||||||
|
assert converter.is_stopped() is True
|
||||||
|
converter.reset_stop()
|
||||||
|
assert converter.is_stopped() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvertWavToMp3Errors:
|
||||||
|
def test_missing_input_raises(self, tmp_path: Path):
|
||||||
|
missing = tmp_path / "nope.wav"
|
||||||
|
out = tmp_path / "out.mp3"
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
converter.convert_wav_to_mp3(str(missing), str(out))
|
||||||
|
|
||||||
|
|
||||||
|
class TestVbrQualityMap:
|
||||||
|
def test_all_bitrates_have_mapping(self):
|
||||||
|
for br in (128, 192, 256, 320):
|
||||||
|
assert br in converter._VBR_QUALITY
|
||||||
|
|
||||||
|
def test_higher_bitrate_lower_or_equal_quality_number(self):
|
||||||
|
# -q:a: piu' basso = migliore. Coerenza monotona sulle chiavi.
|
||||||
|
vals = [converter._VBR_QUALITY[br] for br in (128, 192, 256, 320)]
|
||||||
|
assert vals == sorted(vals, reverse=True)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Test per core.tagger — filename sanitization + write_tags safety net."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from core import tagger
|
||||||
|
|
||||||
|
|
||||||
|
class TestSanitizeFilenameStem:
|
||||||
|
def test_removes_bad_chars(self):
|
||||||
|
# Tutti i char vietati su Windows/macOS: < > : " / \ | ? *
|
||||||
|
got = tagger.sanitize_filename_stem('bad<name>:"/\\|?*test')
|
||||||
|
# Nessuno dei char proibiti sopravvive
|
||||||
|
for ch in '<>:"/\\|?*':
|
||||||
|
assert ch not in got
|
||||||
|
# E ho ottenuto qualcosa di non vuoto
|
||||||
|
assert got
|
||||||
|
|
||||||
|
def test_collapses_whitespace(self):
|
||||||
|
assert tagger.sanitize_filename_stem("a b c") == "a b c"
|
||||||
|
|
||||||
|
def test_truncates_long_stem(self):
|
||||||
|
s = "x" * 500
|
||||||
|
got = tagger.sanitize_filename_stem(s, max_len=180)
|
||||||
|
assert len(got) <= 180
|
||||||
|
|
||||||
|
def test_empty_returns_untitled(self):
|
||||||
|
assert tagger.sanitize_filename_stem("") == "untitled"
|
||||||
|
assert tagger.sanitize_filename_stem(" ") == "untitled"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildFilenameStem:
|
||||||
|
def test_format_artist_dash_title(self):
|
||||||
|
assert tagger.build_filename_stem("Kapuchon", "Hot Sauce") == "Kapuchon - Hot Sauce"
|
||||||
|
|
||||||
|
def test_multiple_artists_ok(self):
|
||||||
|
got = tagger.build_filename_stem("Kapuchon, Miss Monique & GLZ", "Hot Sauce (Extended)")
|
||||||
|
assert got == "Kapuchon, Miss Monique & GLZ - Hot Sauce (Extended)"
|
||||||
|
|
||||||
|
def test_missing_artist_uses_title_only(self):
|
||||||
|
assert tagger.build_filename_stem("", "Only Title") == "Only Title"
|
||||||
|
|
||||||
|
def test_missing_title_uses_artist_only(self):
|
||||||
|
assert tagger.build_filename_stem("Only Artist", "") == "Only Artist"
|
||||||
|
|
||||||
|
def test_sanitizes_bad_chars(self):
|
||||||
|
got = tagger.build_filename_stem("A/B", "C:D")
|
||||||
|
# Nessuno slash o colon residuo
|
||||||
|
assert "/" not in got
|
||||||
|
assert ":" not in got
|
||||||
|
|
||||||
|
|
||||||
|
class TestWriteTagsMissingFile:
|
||||||
|
def test_missing_file_is_noop(self):
|
||||||
|
# Non deve sollevare alcuna eccezione se il file non esiste
|
||||||
|
tagger.write_tags("/nonexistent/path/to/file.mp3", {
|
||||||
|
"title": "x", "artist": "y", "cover_url": "http://example.com/x.jpg",
|
||||||
|
})
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""Test per core.traxsource."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core import traxsource
|
||||||
|
|
||||||
|
|
||||||
|
class TestListGenres:
|
||||||
|
def test_returns_list_of_dicts(self):
|
||||||
|
result = traxsource.list_genres()
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) >= 10
|
||||||
|
for g in result:
|
||||||
|
assert set(g.keys()) == {"slug", "id", "name"}
|
||||||
|
assert isinstance(g["slug"], str) and g["slug"]
|
||||||
|
assert isinstance(g["id"], int) and g["id"] > 0
|
||||||
|
assert isinstance(g["name"], str) and g["name"]
|
||||||
|
|
||||||
|
def test_sorted_alphabetically_by_name(self):
|
||||||
|
result = traxsource.list_genres()
|
||||||
|
names = [g["name"] for g in result]
|
||||||
|
assert names == sorted(names, key=str.casefold)
|
||||||
|
|
||||||
|
def test_tech_house_present(self):
|
||||||
|
result = traxsource.list_genres()
|
||||||
|
slugs = [g["slug"] for g in result]
|
||||||
|
assert "tech-house" in slugs
|
||||||
|
|
||||||
|
|
||||||
|
class TestTraxsourceTrack:
|
||||||
|
def test_frozen(self):
|
||||||
|
t = traxsource.TraxsourceTrack(
|
||||||
|
position=1, title="X", mix="Y", artists="A", label="L",
|
||||||
|
traxsource_id=1, slug="x",
|
||||||
|
)
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
t.title = "Z"
|
||||||
|
|
||||||
|
def test_defaults(self):
|
||||||
|
t = traxsource.TraxsourceTrack(
|
||||||
|
position=1, title="X", mix="", artists="A", label="",
|
||||||
|
traxsource_id=1, slug="x",
|
||||||
|
)
|
||||||
|
assert t.image_url == ""
|
||||||
|
assert t.cover_url_large == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestSplitTitleMix:
|
||||||
|
def test_with_parens(self):
|
||||||
|
assert traxsource._split_title_mix("Foo (Extended Mix)") == ("Foo", "Extended Mix")
|
||||||
|
|
||||||
|
def test_without_parens(self):
|
||||||
|
assert traxsource._split_title_mix("Foo") == ("Foo", "")
|
||||||
|
|
||||||
|
def test_multiple_parens_takes_last(self):
|
||||||
|
# es. "Foo (feat. Bar) (Original Mix)" -> mix = "Original Mix"
|
||||||
|
assert traxsource._split_title_mix("Foo (feat. Bar) (Original Mix)") == ("Foo (feat. Bar)", "Original Mix")
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
assert traxsource._split_title_mix("") == ("", "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatArtists:
|
||||||
|
def test_single(self):
|
||||||
|
assert traxsource._format_artists(["Kapuchon"]) == "Kapuchon"
|
||||||
|
|
||||||
|
def test_two(self):
|
||||||
|
assert traxsource._format_artists(["A", "B"]) == "A & B"
|
||||||
|
|
||||||
|
def test_three(self):
|
||||||
|
assert traxsource._format_artists(["A", "B", "C"]) == "A, B & C"
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
assert traxsource._format_artists([]) == ""
|
||||||
|
|
||||||
|
def test_strips_whitespace(self):
|
||||||
|
assert traxsource._format_artists([" A ", " B "]) == "A & B"
|
||||||
|
|
||||||
|
|
||||||
|
class TestLargeCover:
|
||||||
|
def test_substitutes_52x52_with_500x500(self):
|
||||||
|
url = "https://www.traxsource.com/scripts/image.php/52x52/abc.jpg"
|
||||||
|
assert traxsource._large_cover(url) == "https://www.traxsource.com/scripts/image.php/500x500/abc.jpg"
|
||||||
|
|
||||||
|
def test_no_change_when_no_pattern(self):
|
||||||
|
assert traxsource._large_cover("https://example.com/x.jpg") == "https://example.com/x.jpg"
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
assert traxsource._large_cover("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestExceptions:
|
||||||
|
def test_exceptions_are_subclasses(self):
|
||||||
|
assert issubclass(traxsource.TraxsourceUnreachableError, traxsource.TraxsourceError)
|
||||||
|
assert issubclass(traxsource.TraxsourceParseError, traxsource.TraxsourceError)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscoverTop100Url:
|
||||||
|
def test_extracts_link_from_genre_page(self, fixtures_dir):
|
||||||
|
html = (fixtures_dir / "traxsource_tech_house_genre.html").read_text()
|
||||||
|
url = traxsource._discover_top100_url(html)
|
||||||
|
assert url.startswith("/title/")
|
||||||
|
assert "top-100" in url
|
||||||
|
|
||||||
|
def test_raises_when_no_link(self):
|
||||||
|
with pytest.raises(traxsource.TraxsourceParseError, match="Top 100"):
|
||||||
|
traxsource._discover_top100_url("<html>nulla</html>")
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseTracks:
|
||||||
|
def test_parses_100_tracks(self, fixtures_dir):
|
||||||
|
html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
|
||||||
|
tracks = traxsource._parse_tracks(html)
|
||||||
|
assert len(tracks) == 100
|
||||||
|
|
||||||
|
def test_positions_sequential_1_to_100(self, fixtures_dir):
|
||||||
|
html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
|
||||||
|
tracks = traxsource._parse_tracks(html)
|
||||||
|
positions = [t.position for t in tracks]
|
||||||
|
assert positions == list(range(1, 101))
|
||||||
|
|
||||||
|
def test_track_shape(self, fixtures_dir):
|
||||||
|
html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
|
||||||
|
tracks = traxsource._parse_tracks(html)
|
||||||
|
first = tracks[0]
|
||||||
|
assert first.title
|
||||||
|
assert first.artists
|
||||||
|
assert first.traxsource_id > 0
|
||||||
|
assert first.slug
|
||||||
|
assert first.image_url.startswith("https://")
|
||||||
|
assert first.cover_url_large.startswith("https://")
|
||||||
|
assert "500x500" in first.cover_url_large
|
||||||
|
|
||||||
|
def test_raises_when_no_tracks(self):
|
||||||
|
with pytest.raises(traxsource.TraxsourceParseError, match="track"):
|
||||||
|
traxsource._parse_tracks("<html>vuoto</html>")
|
||||||
|
|
||||||
|
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_response(text: str, status_code: int = 200) -> MagicMock:
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.text = text
|
||||||
|
resp.status_code = status_code
|
||||||
|
|
||||||
|
def _raise():
|
||||||
|
if status_code >= 400:
|
||||||
|
raise Exception(f"HTTP {status_code}")
|
||||||
|
resp.raise_for_status = _raise
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchTop100:
|
||||||
|
@pytest.fixture
|
||||||
|
def genre_html(self, fixtures_dir):
|
||||||
|
return (fixtures_dir / "traxsource_tech_house_genre.html").read_text()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def top100_html(self, fixtures_dir):
|
||||||
|
return (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
|
||||||
|
|
||||||
|
def test_success_returns_100_tracks(self, genre_html, top100_html):
|
||||||
|
traxsource._cache.clear()
|
||||||
|
mock_sess = MagicMock()
|
||||||
|
mock_sess.get.side_effect = [
|
||||||
|
_mock_response(genre_html, 200),
|
||||||
|
_mock_response(top100_html, 200),
|
||||||
|
]
|
||||||
|
with patch("core.traxsource._session", return_value=mock_sess):
|
||||||
|
tracks = traxsource.fetch_top100("tech-house")
|
||||||
|
assert len(tracks) == 100
|
||||||
|
|
||||||
|
def test_invalid_slug_raises_value_error(self):
|
||||||
|
with pytest.raises(ValueError, match="slug"):
|
||||||
|
traxsource.fetch_top100("not-a-genre")
|
||||||
|
|
||||||
|
def test_5xx_retries_and_raises_unreachable(self):
|
||||||
|
traxsource._cache.clear()
|
||||||
|
mock_sess = MagicMock()
|
||||||
|
mock_sess.get.return_value = _mock_response("", 503)
|
||||||
|
with patch("core.traxsource._session", return_value=mock_sess), \
|
||||||
|
patch("core.traxsource.time.sleep"):
|
||||||
|
with pytest.raises(traxsource.TraxsourceUnreachableError):
|
||||||
|
traxsource.fetch_top100("tech-house")
|
||||||
|
# 3 tentativi
|
||||||
|
assert mock_sess.get.call_count == 3
|
||||||
|
|
||||||
|
def test_cache_hit_within_ttl(self, genre_html, top100_html):
|
||||||
|
traxsource._cache.clear()
|
||||||
|
mock_sess = MagicMock()
|
||||||
|
mock_sess.get.side_effect = [
|
||||||
|
_mock_response(genre_html, 200),
|
||||||
|
_mock_response(top100_html, 200),
|
||||||
|
]
|
||||||
|
with patch("core.traxsource._session", return_value=mock_sess):
|
||||||
|
traxsource.fetch_top100("tech-house")
|
||||||
|
traxsource.fetch_top100("tech-house")
|
||||||
|
# 2 chiamate al primo fetch (genre + top100), 0 al secondo
|
||||||
|
assert mock_sess.get.call_count == 2
|
||||||
|
|
||||||
|
def test_force_refresh_bypasses_cache(self, genre_html, top100_html):
|
||||||
|
traxsource._cache.clear()
|
||||||
|
mock_sess = MagicMock()
|
||||||
|
# 4 risposte (2 fetch x 2 richieste ciascuno)
|
||||||
|
mock_sess.get.side_effect = [
|
||||||
|
_mock_response(genre_html, 200),
|
||||||
|
_mock_response(top100_html, 200),
|
||||||
|
_mock_response(genre_html, 200),
|
||||||
|
_mock_response(top100_html, 200),
|
||||||
|
]
|
||||||
|
with patch("core.traxsource._session", return_value=mock_sess):
|
||||||
|
traxsource.fetch_top100("tech-house")
|
||||||
|
traxsource.fetch_top100("tech-house", force_refresh=True)
|
||||||
|
assert mock_sess.get.call_count == 4
|
||||||
+55
-25
@@ -49,6 +49,16 @@
|
|||||||
--text-2: #B3B3B3;
|
--text-2: #B3B3B3;
|
||||||
--text-3: #727272;
|
--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-sm: 8px;
|
||||||
--r-md: 12px;
|
--r-md: 12px;
|
||||||
--r-lg: 14px;
|
--r-lg: 14px;
|
||||||
@@ -84,6 +94,15 @@ html[data-theme="light"] {
|
|||||||
--rec-red-dim: #FECACA;
|
--rec-red-dim: #FECACA;
|
||||||
--indigo-dim: #C7D2FE;
|
--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: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||||
--shadow-lg: 0 16px 40px rgba(15, 23, 42, 0.1);
|
--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 {
|
.nav-item:hover {
|
||||||
background: #1A1A1A;
|
background: var(--bg-card-2);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active {
|
.nav-item.active {
|
||||||
background: #1F1F1F;
|
background: var(--bg-card-2);
|
||||||
color: var(--green);
|
color: var(--green-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
@@ -214,7 +233,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footer-by {
|
.footer-by {
|
||||||
color: var(--green);
|
color: var(--green-text);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
letter-spacing: 0.4px;
|
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-sep { color: var(--text-3); margin: 0 4px; font-weight: 400; }
|
||||||
.quota-plan {
|
.quota-plan {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--green);
|
color: var(--green-text);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -314,7 +333,9 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
|||||||
HERO
|
HERO
|
||||||
=============================== */
|
=============================== */
|
||||||
.hero {
|
.hero {
|
||||||
position: relative;
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
border-radius: var(--r-lg);
|
border-radius: var(--r-lg);
|
||||||
padding: 28px 32px;
|
padding: 28px 32px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
@@ -469,7 +490,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
|||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
background: rgba(29, 185, 84, 0.15);
|
background: rgba(29, 185, 84, 0.15);
|
||||||
color: var(--green);
|
color: var(--green-text);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -495,8 +516,8 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
|||||||
.input::placeholder { color: var(--text-3); }
|
.input::placeholder { color: var(--text-3); }
|
||||||
|
|
||||||
.input:focus {
|
.input:focus {
|
||||||
background: #333;
|
background: var(--bg-input-hover);
|
||||||
border-color: rgba(29, 185, 84, 0.4);
|
border-color: var(--green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-sm { max-width: 200px; }
|
.input-sm { max-width: 200px; }
|
||||||
@@ -663,8 +684,8 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.percent.green { color: var(--green); }
|
.percent.green { color: var(--green-text); }
|
||||||
.percent.blue { color: var(--blue); }
|
.percent.blue { color: var(--blue-text); }
|
||||||
.percent.pink { color: var(--pink-hover); }
|
.percent.pink { color: var(--pink-hover); }
|
||||||
|
|
||||||
.progress {
|
.progress {
|
||||||
@@ -711,10 +732,10 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
line-height: 1.6;
|
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-err { color: var(--red); }
|
||||||
.log .l-info { color: var(--blue); }
|
.log .l-info { color: var(--blue-text); }
|
||||||
.log .l-warn { color: #FDCB6E; }
|
.log .l-warn { color: var(--amber-text); }
|
||||||
.log .l-muted { color: var(--text-3); }
|
.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 strong { color: var(--text); font-weight: 700; }
|
||||||
|
|
||||||
.hint code {
|
.hint code {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: var(--bg-input);
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||||
@@ -838,7 +859,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
INFO ROW (Upgrade tab)
|
INFO ROW (Upgrade tab)
|
||||||
=============================== */
|
=============================== */
|
||||||
.info-row {
|
.info-row {
|
||||||
color: var(--green);
|
color: var(--green-text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
@@ -862,7 +883,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
min-height: 18px;
|
min-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-status.ok { color: var(--green); }
|
.update-status.ok { color: var(--green-text); }
|
||||||
.update-status.warn { color: #FDCB6E; }
|
.update-status.warn { color: #FDCB6E; }
|
||||||
.update-status.err { color: var(--red); }
|
.update-status.err { color: var(--red); }
|
||||||
|
|
||||||
@@ -893,7 +914,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.save-status {
|
.save-status {
|
||||||
color: var(--green);
|
color: var(--green-text);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
opacity: 0;
|
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-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)
|
SCROLLBARS (WebKit)
|
||||||
@@ -1011,11 +1032,11 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #404040;
|
background: var(--text-3);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border: 2px solid var(--bg);
|
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); }
|
.log::-webkit-scrollbar-thumb { border-color: var(--bg-card-2); }
|
||||||
.main::-webkit-scrollbar-thumb { border-color: var(--bg); }
|
.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.loading { color: var(--text-3); font-style: italic; }
|
||||||
.beatport-status.error { color: var(--red); font-weight: 600; }
|
.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 {
|
.beatport-output-info {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
@@ -1592,7 +1613,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.beatport-output-info.warn {
|
.beatport-output-info.warn {
|
||||||
color: var(--amber-hover);
|
color: var(--amber-text);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1635,7 +1656,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.beatport-table tbody tr:hover {
|
.beatport-table tbody tr:hover {
|
||||||
background: rgba(99, 102, 241, 0.06);
|
background: var(--bg-input);
|
||||||
}
|
}
|
||||||
|
|
||||||
.beatport-table tbody tr.already-downloaded td {
|
.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 {
|
.beatport-table tbody tr.already-downloaded .col-state {
|
||||||
color: var(--green);
|
color: var(--green-text);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.beatport-table .col-check { width: 36px; }
|
.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-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-dur { width: 70px; color: var(--text-2); font-variant-numeric: tabular-nums; text-align: right; }
|
||||||
.beatport-table .col-state { width: 130px; font-size: 12px; }
|
.beatport-table .col-state { width: 130px; font-size: 12px; }
|
||||||
|
|||||||
+167
-13
@@ -33,6 +33,10 @@
|
|||||||
<span class="nav-icon">🎧</span>
|
<span class="nav-icon">🎧</span>
|
||||||
<span>Beatport</span>
|
<span>Beatport</span>
|
||||||
</button>
|
</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">
|
<button class="nav-item" data-view="spotify" data-feature="audio">
|
||||||
<span class="nav-icon">🟢</span>
|
<span class="nav-icon">🟢</span>
|
||||||
<span>Spotify</span>
|
<span>Spotify</span>
|
||||||
@@ -41,6 +45,10 @@
|
|||||||
<span class="nav-icon">▶</span>
|
<span class="nav-icon">▶</span>
|
||||||
<span>YouTube</span>
|
<span>YouTube</span>
|
||||||
</button>
|
</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">
|
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
|
||||||
<span class="nav-icon">⚡</span>
|
<span class="nav-icon">⚡</span>
|
||||||
<span>Upgrade</span>
|
<span>Upgrade</span>
|
||||||
@@ -891,10 +899,12 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="col-check"><input type="checkbox" id="beatport-select-all" /></th>
|
<th class="col-check"><input type="checkbox" id="beatport-select-all" /></th>
|
||||||
<th class="col-pos">#</th>
|
<th class="col-cover"></th>
|
||||||
<th>Artista</th>
|
<th class="col-pos sortable" data-sort="position">#</th>
|
||||||
<th>Titolo (Mix)</th>
|
<th class="sortable" data-sort="artists">Artista</th>
|
||||||
<th class="col-dur">Durata</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>
|
<th class="col-state">Stato</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -918,6 +928,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 ===== -->
|
<!-- ===== VIEW: SPOTIFY SEARCH ===== -->
|
||||||
<section class="view" id="view-spotify">
|
<section class="view" id="view-spotify">
|
||||||
<header class="hero hero-indigo">
|
<header class="hero hero-indigo">
|
||||||
@@ -951,11 +1019,13 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="col-check"><input type="checkbox" id="spotify-select-all" checked /></th>
|
<th class="col-check"><input type="checkbox" id="spotify-select-all" checked /></th>
|
||||||
<th class="col-pos">#</th>
|
<th class="col-cover"></th>
|
||||||
<th>Artista</th>
|
<th class="col-pos sortable" data-sort="position">#</th>
|
||||||
<th>Titolo</th>
|
<th class="sortable" data-sort="artists">Artista</th>
|
||||||
<th>Album</th>
|
<th class="sortable" data-sort="name">Titolo</th>
|
||||||
<th class="col-dur">Durata</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>
|
<th class="col-state"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -1008,10 +1078,12 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="col-check"><input type="checkbox" id="youtube-select-all" checked /></th>
|
<th class="col-check"><input type="checkbox" id="youtube-select-all" checked /></th>
|
||||||
<th class="col-pos">#</th>
|
<th class="col-cover"></th>
|
||||||
<th>Titolo video</th>
|
<th class="col-pos sortable" data-sort="position">#</th>
|
||||||
<th>Canale</th>
|
<th class="sortable" data-sort="title">Titolo video</th>
|
||||||
<th class="col-dur">Durata</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>
|
<th class="col-state"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -1035,6 +1107,88 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+720
-18
@@ -399,6 +399,8 @@ const logEls = {
|
|||||||
video: () => $("#videoLog"),
|
video: () => $("#videoLog"),
|
||||||
spotify: () => $("#spotify-log"),
|
spotify: () => $("#spotify-log"),
|
||||||
youtube: () => $("#youtube-log"),
|
youtube: () => $("#youtube-log"),
|
||||||
|
convert: () => $("#convert-log"),
|
||||||
|
traxsource: () => $("#traxsource-log"),
|
||||||
};
|
};
|
||||||
|
|
||||||
function classifyLog(msg) {
|
function classifyLog(msg) {
|
||||||
@@ -510,6 +512,12 @@ async function init() {
|
|||||||
// Spotify + YouTube search tabs
|
// Spotify + YouTube search tabs
|
||||||
await SpotifyUI.init();
|
await SpotifyUI.init();
|
||||||
await YoutubeUI.init();
|
await YoutubeUI.init();
|
||||||
|
|
||||||
|
// Traxsource charts tab
|
||||||
|
await TraxsourceUI.init();
|
||||||
|
|
||||||
|
// WAV -> MP3 converter tab
|
||||||
|
await ConvertUI.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRecDevices() {
|
async function refreshRecDevices() {
|
||||||
@@ -1179,6 +1187,61 @@ $("#quotaUpgradeBtn")?.addEventListener("click", async () => {
|
|||||||
try { await window.pywebview.api.open_purchase_page(); } catch (_e) {}
|
try { await window.pywebview.api.open_purchase_page(); } catch (_e) {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Helper di ordinamento condivisi tra BeatportUI, SpotifyUI, YoutubeUI
|
||||||
|
// ============================================================
|
||||||
|
function _sortPairs(tracks, existing, col, dir) {
|
||||||
|
const pairs = tracks.map((t, i) => ({ t, e: existing[i] || false, origIdx: i }));
|
||||||
|
const dirMul = dir === "desc" ? -1 : 1;
|
||||||
|
pairs.sort((a, b) => {
|
||||||
|
let av = a.t[col], bv = b.t[col];
|
||||||
|
if (col === "position") {
|
||||||
|
// Per Beatport t.position esiste (rank ufficiale); per Spotify/YouTube
|
||||||
|
// torniamo l'ordine originale della risposta API.
|
||||||
|
av = (typeof av === "number") ? av : a.origIdx;
|
||||||
|
bv = (typeof bv === "number") ? bv : b.origIdx;
|
||||||
|
}
|
||||||
|
if (typeof av === "number" && typeof bv === "number") {
|
||||||
|
return (av - bv) * dirMul;
|
||||||
|
}
|
||||||
|
av = String(av == null ? "" : av).toLowerCase();
|
||||||
|
bv = String(bv == null ? "" : bv).toLowerCase();
|
||||||
|
// Empty strings sempre in fondo indipendentemente da direzione
|
||||||
|
if (av === "" && bv !== "") return 1;
|
||||||
|
if (bv === "" && av !== "") return -1;
|
||||||
|
if (av < bv) return -1 * dirMul;
|
||||||
|
if (av > bv) return 1 * dirMul;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
tracks: pairs.map(p => p.t),
|
||||||
|
existing: pairs.map(p => p.e),
|
||||||
|
origIndexes: pairs.map(p => p.origIdx),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _bindSortableHeaders(tableSelector, onSort) {
|
||||||
|
document.querySelectorAll(`${tableSelector} th.sortable`).forEach(th => {
|
||||||
|
th.style.cursor = "pointer";
|
||||||
|
th.style.userSelect = "none";
|
||||||
|
th.addEventListener("click", () => onSort(th.dataset.sort));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateSortArrows(tableSelector, sortCol, sortDir) {
|
||||||
|
document.querySelectorAll(`${tableSelector} th.sortable`).forEach(th => {
|
||||||
|
const key = th.dataset.sort;
|
||||||
|
// Usa un <span class="sort-arrow"> per non toccare il testo principale
|
||||||
|
let arrow = th.querySelector(".sort-arrow");
|
||||||
|
if (!arrow) {
|
||||||
|
arrow = document.createElement("span");
|
||||||
|
arrow.className = "sort-arrow";
|
||||||
|
th.appendChild(arrow);
|
||||||
|
}
|
||||||
|
arrow.textContent = key === sortCol ? (sortDir === "asc" ? " ▲" : " ▼") : "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// BEATPORT tab — carica Top 100 per genere, seleziona tracce, scarica
|
// BEATPORT tab — carica Top 100 per genere, seleziona tracce, scarica
|
||||||
// (il download riusa start_tracks_download, quindi passa dagli stessi
|
// (il download riusa start_tracks_download, quindi passa dagli stessi
|
||||||
@@ -1191,6 +1254,8 @@ const BeatportUI = (function () {
|
|||||||
let currentGenreName = "";
|
let currentGenreName = "";
|
||||||
let currentGenreSlug = "";
|
let currentGenreSlug = "";
|
||||||
let downloading = false;
|
let downloading = false;
|
||||||
|
let sortCol = null;
|
||||||
|
let sortDir = "asc";
|
||||||
|
|
||||||
function fmtDur(sec) {
|
function fmtDur(sec) {
|
||||||
const n = Number(sec) || 0;
|
const n = Number(sec) || 0;
|
||||||
@@ -1283,23 +1348,48 @@ const BeatportUI = (function () {
|
|||||||
const tbody = table.querySelector("tbody");
|
const tbody = table.querySelector("tbody");
|
||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
|
|
||||||
currentTracks.forEach((t, i) => {
|
let displayTracks = currentTracks;
|
||||||
|
let displayExisting = existing;
|
||||||
|
let origIndexes = currentTracks.map((_, i) => i);
|
||||||
|
if (sortCol) {
|
||||||
|
const s = _sortPairs(currentTracks, existing, sortCol, sortDir);
|
||||||
|
displayTracks = s.tracks;
|
||||||
|
displayExisting = s.existing;
|
||||||
|
origIndexes = s.origIndexes;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayTracks.forEach((t, i) => {
|
||||||
|
const origIdx = origIndexes[i];
|
||||||
|
const alreadyDl = displayExisting[i];
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
if (existing[i]) tr.classList.add("already-downloaded");
|
if (alreadyDl) tr.classList.add("already-downloaded");
|
||||||
|
|
||||||
const tdCheck = document.createElement("td");
|
const tdCheck = document.createElement("td");
|
||||||
tdCheck.className = "col-check";
|
tdCheck.className = "col-check";
|
||||||
const cb = document.createElement("input");
|
const cb = document.createElement("input");
|
||||||
cb.type = "checkbox";
|
cb.type = "checkbox";
|
||||||
cb.dataset.idx = String(i);
|
cb.dataset.idx = String(origIdx);
|
||||||
cb.checked = !existing[i];
|
cb.checked = !alreadyDl;
|
||||||
cb.addEventListener("change", updateSelectionCount);
|
cb.addEventListener("change", updateSelectionCount);
|
||||||
tdCheck.appendChild(cb);
|
tdCheck.appendChild(cb);
|
||||||
tr.appendChild(tdCheck);
|
tr.appendChild(tdCheck);
|
||||||
|
|
||||||
|
const tdCover = document.createElement("td");
|
||||||
|
tdCover.className = "col-cover";
|
||||||
|
if (t.image_url) {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = t.image_url;
|
||||||
|
img.alt = "";
|
||||||
|
img.loading = "lazy";
|
||||||
|
img.referrerPolicy = "no-referrer";
|
||||||
|
img.onerror = () => { img.style.visibility = "hidden"; };
|
||||||
|
tdCover.appendChild(img);
|
||||||
|
}
|
||||||
|
tr.appendChild(tdCover);
|
||||||
|
|
||||||
const tdPos = document.createElement("td");
|
const tdPos = document.createElement("td");
|
||||||
tdPos.className = "col-pos";
|
tdPos.className = "col-pos";
|
||||||
tdPos.textContent = String(t.position || (i + 1));
|
tdPos.textContent = String(t.position || (origIdx + 1));
|
||||||
tr.appendChild(tdPos);
|
tr.appendChild(tdPos);
|
||||||
|
|
||||||
const tdArtist = document.createElement("td");
|
const tdArtist = document.createElement("td");
|
||||||
@@ -1315,20 +1405,36 @@ const BeatportUI = (function () {
|
|||||||
tdDur.textContent = fmtDur(t.duration_sec);
|
tdDur.textContent = fmtDur(t.duration_sec);
|
||||||
tr.appendChild(tdDur);
|
tr.appendChild(tdDur);
|
||||||
|
|
||||||
|
const tdDate = document.createElement("td");
|
||||||
|
tdDate.className = "col-date";
|
||||||
|
tdDate.textContent = t.release_date || "";
|
||||||
|
tr.appendChild(tdDate);
|
||||||
|
|
||||||
const tdState = document.createElement("td");
|
const tdState = document.createElement("td");
|
||||||
tdState.className = "col-state";
|
tdState.className = "col-state";
|
||||||
if (existing[i]) tdState.textContent = "✓ già scaricato";
|
if (alreadyDl) tdState.textContent = "✓ già scaricato";
|
||||||
tr.appendChild(tdState);
|
tr.appendChild(tdState);
|
||||||
|
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_updateSortArrows("#beatport-table", sortCol, sortDir);
|
||||||
table.hidden = false;
|
table.hidden = false;
|
||||||
$("#beatport-toolbar").hidden = false;
|
$("#beatport-toolbar").hidden = false;
|
||||||
updateOutputInfo();
|
updateOutputInfo();
|
||||||
updateSelectionCount();
|
updateSelectionCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSort(col) {
|
||||||
|
if (sortCol === col) {
|
||||||
|
sortDir = sortDir === "asc" ? "desc" : "asc";
|
||||||
|
} else {
|
||||||
|
sortCol = col;
|
||||||
|
sortDir = "asc";
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
function updateSelectionCount() {
|
function updateSelectionCount() {
|
||||||
const boxes = $$("#beatport-table tbody input[type=checkbox]");
|
const boxes = $$("#beatport-table tbody input[type=checkbox]");
|
||||||
const total = boxes.length;
|
const total = boxes.length;
|
||||||
@@ -1445,6 +1551,7 @@ const BeatportUI = (function () {
|
|||||||
$("#beatport-download-btn").addEventListener("click", startDownload);
|
$("#beatport-download-btn").addEventListener("click", startDownload);
|
||||||
$("#beatport-stop-btn").addEventListener("click", stopDownload);
|
$("#beatport-stop-btn").addEventListener("click", stopDownload);
|
||||||
$("#beatport-select-all").addEventListener("change", (e) => toggleAll(e.target.checked));
|
$("#beatport-select-all").addEventListener("change", (e) => toggleAll(e.target.checked));
|
||||||
|
_bindSortableHeaders("#beatport-table", handleSort);
|
||||||
|
|
||||||
// Aggancia agli event listener esistenti del canale "download" senza rompere
|
// Aggancia agli event listener esistenti del canale "download" senza rompere
|
||||||
// il comportamento della tab Scarica (wrapping additivo).
|
// il comportamento della tab Scarica (wrapping additivo).
|
||||||
@@ -1475,7 +1582,7 @@ const BeatportUI = (function () {
|
|||||||
const SpotifyUI = (() => {
|
const SpotifyUI = (() => {
|
||||||
// NOTE: local module state — named `mstate` to NOT shadow the outer
|
// NOTE: local module state — named `mstate` to NOT shadow the outer
|
||||||
// module-level `state` (which holds `state.config`).
|
// module-level `state` (which holds `state.config`).
|
||||||
const mstate = { tracks: [], existing: [], downloading: false };
|
const mstate = { tracks: [], existing: [], downloading: false, sortCol: null, sortDir: "asc" };
|
||||||
|
|
||||||
function _escape(s) {
|
function _escape(s) {
|
||||||
const d = document.createElement("div");
|
const d = document.createElement("div");
|
||||||
@@ -1513,27 +1620,55 @@ const SpotifyUI = (() => {
|
|||||||
const tbody = $("#spotify-tbody");
|
const tbody = $("#spotify-tbody");
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
mstate.tracks.forEach((t, i) => {
|
|
||||||
const already = !!mstate.existing[i];
|
let displayTracks = mstate.tracks;
|
||||||
|
let displayExisting = mstate.existing;
|
||||||
|
let origIndexes = mstate.tracks.map((_, i) => i);
|
||||||
|
if (mstate.sortCol) {
|
||||||
|
const s = _sortPairs(mstate.tracks, mstate.existing, mstate.sortCol, mstate.sortDir);
|
||||||
|
displayTracks = s.tracks;
|
||||||
|
displayExisting = s.existing;
|
||||||
|
origIndexes = s.origIndexes;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayTracks.forEach((t, i) => {
|
||||||
|
const origIdx = origIndexes[i];
|
||||||
|
const already = !!displayExisting[i];
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.dataset.idx = String(i);
|
tr.dataset.idx = String(origIdx);
|
||||||
if (already) tr.classList.add("already-downloaded");
|
if (already) tr.classList.add("already-downloaded");
|
||||||
|
const coverImg = t.image_url
|
||||||
|
? `<img src="${_escape(t.image_url)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"/>`
|
||||||
|
: "";
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td class="col-check"><input type="checkbox" data-idx="${i}" ${already ? "" : "checked"} /></td>
|
<td class="col-check"><input type="checkbox" data-idx="${origIdx}" ${already ? "" : "checked"} /></td>
|
||||||
<td class="col-pos">${i + 1}</td>
|
<td class="col-cover">${coverImg}</td>
|
||||||
|
<td class="col-pos">${origIdx + 1}</td>
|
||||||
<td>${_escape(t.artists)}</td>
|
<td>${_escape(t.artists)}</td>
|
||||||
<td>${_escape(t.name)}</td>
|
<td>${_escape(t.name)}</td>
|
||||||
<td>${_escape(t.album)}</td>
|
<td>${_escape(t.album)}</td>
|
||||||
<td class="col-dur">${_fmtDuration(t.duration_sec)}</td>
|
<td class="col-dur">${_fmtDuration(t.duration_sec)}</td>
|
||||||
|
<td class="col-date">${_escape(t.release_date || "")}</td>
|
||||||
<td class="col-state">${already ? "✓ già scaricato" : ""}</td>
|
<td class="col-state">${already ? "✓ già scaricato" : ""}</td>
|
||||||
`;
|
`;
|
||||||
const cb = tr.querySelector("input[type=checkbox]");
|
const cb = tr.querySelector("input[type=checkbox]");
|
||||||
if (cb) cb.addEventListener("change", updateSelectionCount);
|
if (cb) cb.addEventListener("change", updateSelectionCount);
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
_updateSortArrows("#spotify-table", mstate.sortCol, mstate.sortDir);
|
||||||
updateSelectionCount();
|
updateSelectionCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSort(col) {
|
||||||
|
if (mstate.sortCol === col) {
|
||||||
|
mstate.sortDir = mstate.sortDir === "asc" ? "desc" : "asc";
|
||||||
|
} else {
|
||||||
|
mstate.sortCol = col;
|
||||||
|
mstate.sortDir = "asc";
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
function setStatus(text, kind) {
|
function setStatus(text, kind) {
|
||||||
const el = $("#spotify-status");
|
const el = $("#spotify-status");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -1687,6 +1822,7 @@ const SpotifyUI = (() => {
|
|||||||
});
|
});
|
||||||
$("#spotify-download-btn").addEventListener("click", startDownload);
|
$("#spotify-download-btn").addEventListener("click", startDownload);
|
||||||
$("#spotify-stop-btn").addEventListener("click", stopDownload);
|
$("#spotify-stop-btn").addEventListener("click", stopDownload);
|
||||||
|
_bindSortableHeaders("#spotify-table", handleSort);
|
||||||
|
|
||||||
// Wrap bridge handlers additivamente
|
// Wrap bridge handlers additivamente
|
||||||
const origLog = bridgeHandlers["log"];
|
const origLog = bridgeHandlers["log"];
|
||||||
@@ -1710,7 +1846,7 @@ const SpotifyUI = (() => {
|
|||||||
// YouTube search
|
// YouTube search
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
const YoutubeUI = (() => {
|
const YoutubeUI = (() => {
|
||||||
const mstate = { tracks: [], existing: [], downloading: false };
|
const mstate = { tracks: [], existing: [], downloading: false, sortCol: null, sortDir: "asc" };
|
||||||
|
|
||||||
function _escape(s) {
|
function _escape(s) {
|
||||||
const d = document.createElement("div");
|
const d = document.createElement("div");
|
||||||
@@ -1747,26 +1883,54 @@ const YoutubeUI = (() => {
|
|||||||
const tbody = $("#youtube-tbody");
|
const tbody = $("#youtube-tbody");
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
mstate.tracks.forEach((t, i) => {
|
|
||||||
const already = !!mstate.existing[i];
|
let displayTracks = mstate.tracks;
|
||||||
|
let displayExisting = mstate.existing;
|
||||||
|
let origIndexes = mstate.tracks.map((_, i) => i);
|
||||||
|
if (mstate.sortCol) {
|
||||||
|
const s = _sortPairs(mstate.tracks, mstate.existing, mstate.sortCol, mstate.sortDir);
|
||||||
|
displayTracks = s.tracks;
|
||||||
|
displayExisting = s.existing;
|
||||||
|
origIndexes = s.origIndexes;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayTracks.forEach((t, i) => {
|
||||||
|
const origIdx = origIndexes[i];
|
||||||
|
const already = !!displayExisting[i];
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.dataset.idx = String(i);
|
tr.dataset.idx = String(origIdx);
|
||||||
if (already) tr.classList.add("already-downloaded");
|
if (already) tr.classList.add("already-downloaded");
|
||||||
|
const coverImg = t.image_url
|
||||||
|
? `<img src="${_escape(t.image_url)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"/>`
|
||||||
|
: "";
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td class="col-check"><input type="checkbox" data-idx="${i}" ${already ? "" : "checked"} /></td>
|
<td class="col-check"><input type="checkbox" data-idx="${origIdx}" ${already ? "" : "checked"} /></td>
|
||||||
<td class="col-pos">${i + 1}</td>
|
<td class="col-cover">${coverImg}</td>
|
||||||
|
<td class="col-pos">${origIdx + 1}</td>
|
||||||
<td>${_escape(t.title)}</td>
|
<td>${_escape(t.title)}</td>
|
||||||
<td>${_escape(t.channel)}</td>
|
<td>${_escape(t.channel)}</td>
|
||||||
<td class="col-dur">${_fmtDuration(t.duration_sec)}</td>
|
<td class="col-dur">${_fmtDuration(t.duration_sec)}</td>
|
||||||
|
<td class="col-date">${_escape(t.release_date || "")}</td>
|
||||||
<td class="col-state">${already ? "✓ già scaricato" : ""}</td>
|
<td class="col-state">${already ? "✓ già scaricato" : ""}</td>
|
||||||
`;
|
`;
|
||||||
const cb = tr.querySelector("input[type=checkbox]");
|
const cb = tr.querySelector("input[type=checkbox]");
|
||||||
if (cb) cb.addEventListener("change", updateSelectionCount);
|
if (cb) cb.addEventListener("change", updateSelectionCount);
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
_updateSortArrows("#youtube-table", mstate.sortCol, mstate.sortDir);
|
||||||
updateSelectionCount();
|
updateSelectionCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSort(col) {
|
||||||
|
if (mstate.sortCol === col) {
|
||||||
|
mstate.sortDir = mstate.sortDir === "asc" ? "desc" : "asc";
|
||||||
|
} else {
|
||||||
|
mstate.sortCol = col;
|
||||||
|
mstate.sortDir = "asc";
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
function setStatus(text, kind) {
|
function setStatus(text, kind) {
|
||||||
const el = $("#youtube-status");
|
const el = $("#youtube-status");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -1909,6 +2073,7 @@ const YoutubeUI = (() => {
|
|||||||
});
|
});
|
||||||
$("#youtube-download-btn").addEventListener("click", startDownload);
|
$("#youtube-download-btn").addEventListener("click", startDownload);
|
||||||
$("#youtube-stop-btn").addEventListener("click", stopDownload);
|
$("#youtube-stop-btn").addEventListener("click", stopDownload);
|
||||||
|
_bindSortableHeaders("#youtube-table", handleSort);
|
||||||
|
|
||||||
const origLog = bridgeHandlers["log"];
|
const origLog = bridgeHandlers["log"];
|
||||||
bridgeHandlers["log"] = (payload) => {
|
bridgeHandlers["log"] = (payload) => {
|
||||||
@@ -1927,6 +2092,543 @@ const YoutubeUI = (() => {
|
|||||||
return { init };
|
return { init };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// TRAXSOURCE tab — carica Top 100 per genere, seleziona tracce, scarica
|
||||||
|
// Parallelo a BeatportUI (stessi canali "download:progress" /
|
||||||
|
// "download:done" / log view="download"). Colonne: cover, #, artista,
|
||||||
|
// titolo (mix), label, stato. No Durata / Data (non nel markup Traxsource).
|
||||||
|
// ============================================================
|
||||||
|
const TraxsourceUI = (function () {
|
||||||
|
let genres = [];
|
||||||
|
let currentTracks = [];
|
||||||
|
let existing = [];
|
||||||
|
let currentGenreName = "";
|
||||||
|
let currentGenreSlug = "";
|
||||||
|
let downloading = false;
|
||||||
|
let sortCol = null;
|
||||||
|
let sortDir = "asc";
|
||||||
|
|
||||||
|
function safeGenreFolder(name) {
|
||||||
|
return String(name || "").replace(/[\/\\]/g, "_").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateSelect() {
|
||||||
|
const sel = $("#traxsource-genre");
|
||||||
|
if (!sel) return;
|
||||||
|
sel.innerHTML = "";
|
||||||
|
for (const g of genres) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = g.slug;
|
||||||
|
opt.textContent = g.name;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOutputInfo() {
|
||||||
|
const info = $("#traxsource-output-info");
|
||||||
|
if (!info) return;
|
||||||
|
const root = (state.config && state.config.output_dir) || "";
|
||||||
|
if (!root) {
|
||||||
|
info.textContent = "⚠ Imposta la cartella output in Impostazioni prima di scaricare";
|
||||||
|
info.className = "beatport-output-info warn";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const folder = safeGenreFolder(currentGenreName);
|
||||||
|
info.textContent = folder
|
||||||
|
? `Destinazione: ${root}/Traxsource_${folder}`
|
||||||
|
: `Destinazione: ${root}/Traxsource`;
|
||||||
|
info.className = "beatport-output-info";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text, kind) {
|
||||||
|
const el = $("#traxsource-status");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text || "";
|
||||||
|
el.className = "beatport-status" + (kind ? " " + kind : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChart(forceRefresh) {
|
||||||
|
const sel = $("#traxsource-genre");
|
||||||
|
const slug = sel && sel.value;
|
||||||
|
if (!slug) return;
|
||||||
|
const genreName = (sel.selectedOptions[0] && sel.selectedOptions[0].textContent) || slug;
|
||||||
|
currentGenreSlug = slug;
|
||||||
|
currentGenreName = genreName;
|
||||||
|
|
||||||
|
setStatus(forceRefresh ? "Ricarico Top 100 (bypass cache)…" : "Caricamento Top 100…", "loading");
|
||||||
|
$("#traxsource-table").hidden = true;
|
||||||
|
$("#traxsource-toolbar").hidden = true;
|
||||||
|
$("#traxsource-table").querySelector("tbody").innerHTML = "";
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await window.pywebview.api.traxsource_fetch_chart(slug, !!forceRefresh);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("Errore: " + ((e && e.message) || e), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
const code = res && res.error;
|
||||||
|
let msg = (res && res.message) || "Errore sconosciuto";
|
||||||
|
if (code === "invalid_genre") msg = "Genere non valido";
|
||||||
|
else if (code === "unreachable") msg = "Traxsource non raggiungibile — riprova più tardi";
|
||||||
|
else if (code === "parse") msg = "Impossibile leggere i dati (schema pagina cambiato?)";
|
||||||
|
setStatus(msg, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTracks = res.tracks || [];
|
||||||
|
try {
|
||||||
|
existing = await window.pywebview.api.traxsource_check_existing(currentTracks, currentGenreName);
|
||||||
|
} catch (_e) {
|
||||||
|
existing = currentTracks.map(() => false);
|
||||||
|
}
|
||||||
|
setStatus(`${currentTracks.length} brani caricati`, "ok");
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const table = $("#traxsource-table");
|
||||||
|
const tbody = table.querySelector("tbody");
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
|
||||||
|
let displayTracks = currentTracks;
|
||||||
|
let displayExisting = existing;
|
||||||
|
let origIndexes = currentTracks.map((_, i) => i);
|
||||||
|
if (sortCol) {
|
||||||
|
const s = _sortPairs(currentTracks, existing, sortCol, sortDir);
|
||||||
|
displayTracks = s.tracks;
|
||||||
|
displayExisting = s.existing;
|
||||||
|
origIndexes = s.origIndexes;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayTracks.forEach((t, i) => {
|
||||||
|
const origIdx = origIndexes[i];
|
||||||
|
const alreadyDl = displayExisting[i];
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
if (alreadyDl) tr.classList.add("already-downloaded");
|
||||||
|
|
||||||
|
const tdCheck = document.createElement("td");
|
||||||
|
tdCheck.className = "col-check";
|
||||||
|
const cb = document.createElement("input");
|
||||||
|
cb.type = "checkbox";
|
||||||
|
cb.dataset.idx = String(origIdx);
|
||||||
|
cb.checked = !alreadyDl;
|
||||||
|
cb.addEventListener("change", updateSelectionCount);
|
||||||
|
tdCheck.appendChild(cb);
|
||||||
|
tr.appendChild(tdCheck);
|
||||||
|
|
||||||
|
const tdCover = document.createElement("td");
|
||||||
|
tdCover.className = "col-cover";
|
||||||
|
if (t.image_url) {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = t.image_url;
|
||||||
|
img.alt = "";
|
||||||
|
img.loading = "lazy";
|
||||||
|
img.referrerPolicy = "no-referrer";
|
||||||
|
img.onerror = () => { img.style.visibility = "hidden"; };
|
||||||
|
tdCover.appendChild(img);
|
||||||
|
}
|
||||||
|
tr.appendChild(tdCover);
|
||||||
|
|
||||||
|
const tdPos = document.createElement("td");
|
||||||
|
tdPos.className = "col-pos";
|
||||||
|
tdPos.textContent = String(t.position || (origIdx + 1));
|
||||||
|
tr.appendChild(tdPos);
|
||||||
|
|
||||||
|
const tdArtist = document.createElement("td");
|
||||||
|
tdArtist.textContent = t.artists || "";
|
||||||
|
tr.appendChild(tdArtist);
|
||||||
|
|
||||||
|
const tdTitle = document.createElement("td");
|
||||||
|
tdTitle.textContent = t.mix ? `${t.title} (${t.mix})` : (t.title || "");
|
||||||
|
tr.appendChild(tdTitle);
|
||||||
|
|
||||||
|
const tdLabel = document.createElement("td");
|
||||||
|
tdLabel.textContent = t.label || "";
|
||||||
|
tr.appendChild(tdLabel);
|
||||||
|
|
||||||
|
const tdState = document.createElement("td");
|
||||||
|
tdState.className = "col-state";
|
||||||
|
if (alreadyDl) tdState.textContent = "✓ già scaricato";
|
||||||
|
tr.appendChild(tdState);
|
||||||
|
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
_updateSortArrows("#traxsource-table", sortCol, sortDir);
|
||||||
|
table.hidden = false;
|
||||||
|
$("#traxsource-toolbar").hidden = false;
|
||||||
|
updateOutputInfo();
|
||||||
|
updateSelectionCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSort(col) {
|
||||||
|
if (sortCol === col) {
|
||||||
|
sortDir = sortDir === "asc" ? "desc" : "asc";
|
||||||
|
} else {
|
||||||
|
sortCol = col;
|
||||||
|
sortDir = "asc";
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectionCount() {
|
||||||
|
const boxes = $$("#traxsource-table tbody input[type=checkbox]");
|
||||||
|
const total = boxes.length;
|
||||||
|
const checked = boxes.filter((b) => b.checked).length;
|
||||||
|
$("#traxsource-selected-count").textContent = `${checked}/${total} selezionati`;
|
||||||
|
const dlBtn = $("#traxsource-download-btn");
|
||||||
|
if (dlBtn) dlBtn.disabled = downloading || checked === 0;
|
||||||
|
|
||||||
|
const master = $("#traxsource-select-all");
|
||||||
|
if (master) {
|
||||||
|
if (total === 0) { master.checked = false; master.indeterminate = false; }
|
||||||
|
else if (checked === 0) { master.checked = false; master.indeterminate = false; }
|
||||||
|
else if (checked === total) { master.checked = true; master.indeterminate = false; }
|
||||||
|
else { master.checked = false; master.indeterminate = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(checked) {
|
||||||
|
$$("#traxsource-table tbody input[type=checkbox]").forEach((b) => { b.checked = checked; });
|
||||||
|
updateSelectionCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendTraxsourceLog(msg) {
|
||||||
|
const el = $("#traxsource-log");
|
||||||
|
if (!el) return;
|
||||||
|
const cls = classifyLog(msg);
|
||||||
|
const line = document.createElement("div");
|
||||||
|
if (cls) line.className = cls;
|
||||||
|
line.textContent = msg;
|
||||||
|
el.appendChild(line);
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDownload() {
|
||||||
|
const boxes = $$("#traxsource-table tbody input[type=checkbox]");
|
||||||
|
const selected = [];
|
||||||
|
boxes.forEach((b) => {
|
||||||
|
if (b.checked) {
|
||||||
|
const idx = parseInt(b.dataset.idx, 10);
|
||||||
|
if (!isNaN(idx) && currentTracks[idx]) selected.push(currentTracks[idx]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!selected.length) {
|
||||||
|
toast("Seleziona almeno un brano", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.config || !state.config.output_dir) {
|
||||||
|
toast("Imposta la cartella output in Impostazioni", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloading = true;
|
||||||
|
$("#traxsource-download-btn").disabled = true;
|
||||||
|
$("#traxsource-stop-btn").hidden = false;
|
||||||
|
$("#traxsource-stop-btn").disabled = false;
|
||||||
|
$("#traxsource-log").innerHTML = "";
|
||||||
|
appendTraxsourceLog(`[INFO] Avvio download di ${selected.length} brani da ${currentGenreName}…`);
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await window.pywebview.api.traxsource_download_selected(selected, currentGenreName);
|
||||||
|
} catch (e) {
|
||||||
|
appendTraxsourceLog("[ERRORE] " + ((e && e.message) || e));
|
||||||
|
finishDownload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
const errMsg = (res && (res.error || res.message)) || "Impossibile avviare il download";
|
||||||
|
appendTraxsourceLog("[ERRORE] " + errMsg);
|
||||||
|
if (!handleGateBlock(res || {})) toast(errMsg, "error");
|
||||||
|
finishDownload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopDownload() {
|
||||||
|
try {
|
||||||
|
await window.pywebview.api.stop_download();
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishDownload() {
|
||||||
|
downloading = false;
|
||||||
|
$("#traxsource-stop-btn").hidden = true;
|
||||||
|
$("#traxsource-stop-btn").disabled = true;
|
||||||
|
updateSelectionCount();
|
||||||
|
if (currentTracks.length) {
|
||||||
|
window.pywebview.api.traxsource_check_existing(currentTracks, currentGenreName)
|
||||||
|
.then((res) => { existing = res || existing; renderTable(); })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
genres = await window.pywebview.api.traxsource_genres();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Traxsource: impossibile caricare i generi", e);
|
||||||
|
genres = [];
|
||||||
|
}
|
||||||
|
populateSelect();
|
||||||
|
|
||||||
|
const last = (state.config && state.config.traxsource_last_genre) || "";
|
||||||
|
const sel = $("#traxsource-genre");
|
||||||
|
if (sel && last && genres.some((g) => g.slug === last)) {
|
||||||
|
sel.value = last;
|
||||||
|
}
|
||||||
|
updateOutputInfo();
|
||||||
|
|
||||||
|
$("#traxsource-load-btn").addEventListener("click", (e) => loadChart(e.shiftKey));
|
||||||
|
$("#traxsource-download-btn").addEventListener("click", startDownload);
|
||||||
|
$("#traxsource-stop-btn").addEventListener("click", stopDownload);
|
||||||
|
$("#traxsource-select-all").addEventListener("change", (e) => toggleAll(e.target.checked));
|
||||||
|
_bindSortableHeaders("#traxsource-table", handleSort);
|
||||||
|
|
||||||
|
// Wrapping additivo sui bridgeHandlers (canale "download" condiviso)
|
||||||
|
const origProgress = bridgeHandlers["download:progress"];
|
||||||
|
bridgeHandlers["download:progress"] = (p) => {
|
||||||
|
if (origProgress) origProgress(p);
|
||||||
|
};
|
||||||
|
const origDone = bridgeHandlers["download:done"];
|
||||||
|
bridgeHandlers["download:done"] = (p) => {
|
||||||
|
if (origDone) origDone(p);
|
||||||
|
if (downloading) finishDownload();
|
||||||
|
};
|
||||||
|
const origLog = bridgeHandlers["log"];
|
||||||
|
bridgeHandlers["log"] = ({ view, msg }) => {
|
||||||
|
if (origLog) origLog({ view, msg });
|
||||||
|
if (downloading && view === "download") appendTraxsourceLog(msg);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init };
|
||||||
|
})();
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Converti (WAV -> MP3)
|
||||||
|
// =====================================================================
|
||||||
|
const ConvertUI = (() => {
|
||||||
|
const mstate = { files: [], selected: new Set(), running: false, outputDir: "" };
|
||||||
|
|
||||||
|
function _escape(s) {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.textContent = s == null ? "" : String(s);
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const tbody = $("#convert-tbody");
|
||||||
|
if (!tbody) return;
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
mstate.files.forEach((f, i) => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.dataset.idx = String(i);
|
||||||
|
const checked = mstate.selected.has(i) ? "checked" : "";
|
||||||
|
const baseName = f.split(/[\/\\]/).pop();
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="col-check"><input type="checkbox" data-idx="${i}" ${checked} /></td>
|
||||||
|
<td class="col-pos">${i + 1}</td>
|
||||||
|
<td title="${_escape(f)}">${_escape(baseName)}</td>
|
||||||
|
<td class="col-state" data-idx="${i}"></td>
|
||||||
|
`;
|
||||||
|
tr.querySelector("input[type=checkbox]").addEventListener("change", (e) => {
|
||||||
|
if (e.target.checked) mstate.selected.add(i);
|
||||||
|
else mstate.selected.delete(i);
|
||||||
|
updateSelectionCount();
|
||||||
|
});
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
$("#convert-table-wrap").hidden = mstate.files.length === 0;
|
||||||
|
updateSelectionCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectionCount() {
|
||||||
|
const total = mstate.files.length;
|
||||||
|
const sel = mstate.selected.size;
|
||||||
|
$("#convert-selected-count").textContent = `${sel}/${total} selezionati`;
|
||||||
|
$("#convert-run-btn").disabled = mstate.running || sel === 0;
|
||||||
|
const sa = $("#convert-select-all");
|
||||||
|
if (sa) {
|
||||||
|
sa.indeterminate = sel > 0 && sel < total;
|
||||||
|
sa.checked = sel === total && total > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendFiles(newFiles) {
|
||||||
|
// Dedup + append
|
||||||
|
const existing = new Set(mstate.files);
|
||||||
|
for (const f of newFiles) {
|
||||||
|
if (!existing.has(f)) {
|
||||||
|
mstate.files.push(f);
|
||||||
|
mstate.selected.add(mstate.files.length - 1);
|
||||||
|
existing.add(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text, kind) {
|
||||||
|
const el = $("#convert-status");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text || "";
|
||||||
|
el.className = "beatport-status" + (kind ? " " + kind : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickFiles() {
|
||||||
|
const files = await window.pywebview.api.convert_pick_wav_files();
|
||||||
|
if (Array.isArray(files) && files.length) {
|
||||||
|
appendFiles(files);
|
||||||
|
setStatus("", "");
|
||||||
|
} else {
|
||||||
|
setStatus("Nessun file .wav selezionato", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickFolder() {
|
||||||
|
setStatus("Scansione cartella...", "loading");
|
||||||
|
const files = await window.pywebview.api.convert_pick_wav_folder(true);
|
||||||
|
if (Array.isArray(files) && files.length) {
|
||||||
|
appendFiles(files);
|
||||||
|
setStatus(`Trovati ${files.length} file .wav`, "ok");
|
||||||
|
} else {
|
||||||
|
setStatus("Nessun file .wav trovato nella cartella", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFiles() {
|
||||||
|
mstate.files = [];
|
||||||
|
mstate.selected.clear();
|
||||||
|
renderTable();
|
||||||
|
setStatus("", "");
|
||||||
|
$("#convert-log").innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browseOutput() {
|
||||||
|
const p = await window.pywebview.api.browse_directory();
|
||||||
|
if (p && typeof p === "string") {
|
||||||
|
mstate.outputDir = p;
|
||||||
|
$("#convert-out-path").textContent = p;
|
||||||
|
$("#convert-out-custom").checked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendConvertLog(msg) {
|
||||||
|
const el = $("#convert-log");
|
||||||
|
if (!el) return;
|
||||||
|
const cls = classifyLog(msg);
|
||||||
|
const line = document.createElement("div");
|
||||||
|
if (cls) line.className = cls;
|
||||||
|
line.textContent = msg;
|
||||||
|
el.appendChild(line);
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStateCell(idx, text, cls) {
|
||||||
|
const td = document.querySelector(`#convert-tbody td.col-state[data-idx="${idx}"]`);
|
||||||
|
if (!td) return;
|
||||||
|
td.textContent = text;
|
||||||
|
td.style.color = cls === "err" ? "var(--red)" : (cls === "ok" ? "var(--green-text)" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startConversion() {
|
||||||
|
const files = [...mstate.selected].sort((a, b) => a - b).map((i) => mstate.files[i]);
|
||||||
|
if (!files.length) return;
|
||||||
|
const bitrate = parseInt($("#convert-bitrate").value, 10);
|
||||||
|
const vbr = $("#convert-vbr").checked;
|
||||||
|
const useCustom = $("#convert-out-custom").checked;
|
||||||
|
const output_dir = useCustom ? (mstate.outputDir || "") : "";
|
||||||
|
if (useCustom && !output_dir) {
|
||||||
|
setStatus("Scegli una cartella di destinazione", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mstate.running = true;
|
||||||
|
$("#convert-run-btn").disabled = true;
|
||||||
|
$("#convert-stop-btn").hidden = false;
|
||||||
|
$("#convert-log").innerHTML = "";
|
||||||
|
// Reset state cells
|
||||||
|
$$("#convert-tbody td.col-state").forEach((td) => {
|
||||||
|
td.textContent = "";
|
||||||
|
td.style.color = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await window.pywebview.api.convert_start({ files, bitrate, vbr, output_dir });
|
||||||
|
} catch (e) {
|
||||||
|
appendConvertLog("[ERRORE] " + ((e && e.message) || e));
|
||||||
|
finishConversion();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
const errMsg = (res && res.error) || "Impossibile avviare";
|
||||||
|
appendConvertLog("[ERRORE] " + errMsg);
|
||||||
|
if (!handleGateBlock(res || {})) toast(errMsg, "error");
|
||||||
|
finishConversion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopConversion() {
|
||||||
|
try { await window.pywebview.api.convert_stop(); } catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishConversion() {
|
||||||
|
mstate.running = false;
|
||||||
|
$("#convert-run-btn").disabled = mstate.selected.size === 0;
|
||||||
|
$("#convert-stop-btn").hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
// Bind eventi
|
||||||
|
$("#convert-pick-files").addEventListener("click", pickFiles);
|
||||||
|
$("#convert-pick-folder").addEventListener("click", pickFolder);
|
||||||
|
$("#convert-clear").addEventListener("click", clearFiles);
|
||||||
|
$("#convert-out-browse").addEventListener("click", browseOutput);
|
||||||
|
$("#convert-out-custom").addEventListener("change", () => {
|
||||||
|
$("#convert-out-browse").disabled = false;
|
||||||
|
});
|
||||||
|
$("#convert-out-same").addEventListener("change", () => {
|
||||||
|
$("#convert-out-browse").disabled = true;
|
||||||
|
});
|
||||||
|
$("#convert-select-all").addEventListener("change", (e) => {
|
||||||
|
mstate.selected.clear();
|
||||||
|
if (e.target.checked) {
|
||||||
|
for (let i = 0; i < mstate.files.length; i++) mstate.selected.add(i);
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
$("#convert-run-btn").addEventListener("click", startConversion);
|
||||||
|
$("#convert-stop-btn").addEventListener("click", stopConversion);
|
||||||
|
|
||||||
|
// Bridge handlers per il canale "convert"
|
||||||
|
const origProgress = bridgeHandlers["convert:progress"];
|
||||||
|
bridgeHandlers["convert:progress"] = (p) => {
|
||||||
|
if (origProgress) origProgress(p);
|
||||||
|
if (!p) return;
|
||||||
|
if (p.status === "done") updateStateCell(p.idx, "✓ fatto", "ok");
|
||||||
|
else if (p.status === "skipped") updateStateCell(p.idx, "già esiste", "");
|
||||||
|
else if (p.status === "stopped") updateStateCell(p.idx, "interrotto", "");
|
||||||
|
else if (typeof p.status === "string" && p.status.startsWith("error")) updateStateCell(p.idx, "✗ errore", "err");
|
||||||
|
else if (p.status === "converting") updateStateCell(p.idx, `${p.pct || 0}%`, "");
|
||||||
|
};
|
||||||
|
const origDone = bridgeHandlers["convert:done"];
|
||||||
|
bridgeHandlers["convert:done"] = (p) => {
|
||||||
|
if (origDone) origDone(p);
|
||||||
|
if (mstate.running) {
|
||||||
|
appendConvertLog("[INFO] Conversione terminata.");
|
||||||
|
finishConversion();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Il canale "log" e' gia gestito da logEls.convert (aggiunto sopra), quindi
|
||||||
|
// non serve wrap qui: appendLog(view=convert, msg) targeta #convert-log.
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init };
|
||||||
|
})();
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Boot
|
// Boot
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in new issue
Block a user