Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1437e8e24 | ||
|
|
acbd9c0b1f | ||
|
|
f226c3fa8e | ||
|
|
85f7ec0993 | ||
|
|
9dbdd74b2f | ||
|
|
8c73651312 | ||
|
|
1fa49fabc4 | ||
|
|
83c47df29c | ||
|
|
9ea14eb81c | ||
|
|
91cfc173d4 | ||
|
|
67e48e54f7 | ||
|
|
0949df031a | ||
|
|
2771fa8075 | ||
|
|
252d76b907 | ||
|
|
58d4896ffe | ||
|
|
91aa1bd6a3 |
No files matched your search
+265
-1
@@ -12,8 +12,10 @@ from dataclasses import asdict
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
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
|
from core import beatport, spotify_client
|
||||||
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,
|
||||||
@@ -544,6 +546,40 @@ class Api:
|
|||||||
self._download_thread.start()
|
self._download_thread.start()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
def start_urls_download(self, payload: dict) -> dict:
|
||||||
|
"""Analogo a start_tracks_download ma accetta URL YouTube gia noti
|
||||||
|
(bypass search). Usato dal flow del tab 'YouTube Search'."""
|
||||||
|
if self._any_job_running():
|
||||||
|
return {"ok": False, "error": "Un download gia in corso"}
|
||||||
|
|
||||||
|
urls = payload.get("urls") or []
|
||||||
|
titles = payload.get("titles") or []
|
||||||
|
output_dir = (payload.get("output_dir") or "").strip()
|
||||||
|
subfolder = (payload.get("subfolder") or "").strip()
|
||||||
|
if not urls:
|
||||||
|
return {"ok": False, "error": "Nessuna URL fornita"}
|
||||||
|
if len(urls) != len(titles):
|
||||||
|
return {"ok": False, "error": "urls e titles devono avere stessa lunghezza"}
|
||||||
|
if not output_dir:
|
||||||
|
return {"ok": False, "error": "Cartella output non impostata"}
|
||||||
|
|
||||||
|
if subfolder:
|
||||||
|
safe = subfolder.replace("/", "_").replace("\\", "_").strip()
|
||||||
|
if safe:
|
||||||
|
output_dir = os.path.join(output_dir, safe)
|
||||||
|
|
||||||
|
gate = self._gate("audio")
|
||||||
|
if gate:
|
||||||
|
return gate
|
||||||
|
|
||||||
|
self._download_thread = threading.Thread(
|
||||||
|
target=self._urls_worker,
|
||||||
|
args=(list(urls), list(titles), output_dir),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._download_thread.start()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
def stop_download(self) -> dict:
|
def stop_download(self) -> dict:
|
||||||
request_download_stop()
|
request_download_stop()
|
||||||
self._log("download", "[INFO] Interruzione richiesta...")
|
self._log("download", "[INFO] Interruzione richiesta...")
|
||||||
@@ -601,6 +637,53 @@ class Api:
|
|||||||
download_playlist(tracks, output_dir, bitrate, cookies_path, progress_cb)
|
download_playlist(tracks, output_dir, bitrate, cookies_path, progress_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:
|
||||||
|
from core.downloader import download_urls
|
||||||
|
reset_download_stop()
|
||||||
|
cfg = load_config()
|
||||||
|
bitrate = cfg.get("bitrate", "320K")
|
||||||
|
cookies_path = cfg.get("cookies_path", "")
|
||||||
|
view = "download"
|
||||||
|
|
||||||
|
self._log(view, f"[INFO] URL list: {len(urls)} da scaricare da YouTube")
|
||||||
|
self._log(view, f"[INFO] Destinazione: {output_dir}")
|
||||||
|
|
||||||
|
_last = [0.0]
|
||||||
|
_THROTTLE = 0.10
|
||||||
|
|
||||||
|
def progress_cb(idx, total, title, status, pct):
|
||||||
|
if status == "downloading":
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - _last[0] < _THROTTLE:
|
||||||
|
return
|
||||||
|
_last[0] = now
|
||||||
|
|
||||||
|
payload_evt = {
|
||||||
|
"idx": idx, "total": total, "track": title,
|
||||||
|
"status": status, "pct": pct,
|
||||||
|
"url_idx": 0, "url_total": 1,
|
||||||
|
}
|
||||||
|
if status == "skipped":
|
||||||
|
self._log(view, f"[SKIP] {title} (gia scaricato)")
|
||||||
|
payload_evt["overall"] = min((idx + 1) / total, 1.0) if total else 1
|
||||||
|
elif status == "downloading":
|
||||||
|
payload_evt["overall"] = min((idx / total) + (pct / 100 / total), 1.0) if total else 0
|
||||||
|
elif status == "done":
|
||||||
|
self._log(view, f"[OK] {title}")
|
||||||
|
payload_evt["overall"] = min((idx + 1) / total, 1.0) if total else 1
|
||||||
|
elif status == "stopped":
|
||||||
|
self._log(view, "[INFO] Download interrotto.")
|
||||||
|
elif status == "completed":
|
||||||
|
payload_evt["overall"] = 1.0
|
||||||
|
self._log(view, "[INFO] Download completato!")
|
||||||
|
elif status.startswith("error"):
|
||||||
|
self._log(view, f"[ERRORE] {title}: {status}")
|
||||||
|
|
||||||
|
self._emit("download:progress", payload_evt)
|
||||||
|
|
||||||
|
download_urls(urls, titles, output_dir, bitrate, cookies_path, progress_cb)
|
||||||
|
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:
|
||||||
reset_download_stop()
|
reset_download_stop()
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
@@ -1006,3 +1089,184 @@ class Api:
|
|||||||
"output_dir": out_root,
|
"output_dir": out_root,
|
||||||
"subfolder": subfolder,
|
"subfolder": subfolder,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Music Search — Spotify + YouTube
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
def _music_output_dir(self, out_root: str, source: str) -> Path:
|
||||||
|
"""Cartella target per Spotify/YouTube search. Coerente col pattern
|
||||||
|
di start_tracks_download (subfolder singolo, no nested)."""
|
||||||
|
safe = source.replace("/", "_").replace("\\", "_").strip()
|
||||||
|
return Path(out_root) / safe
|
||||||
|
|
||||||
|
# ---- Spotify ----
|
||||||
|
|
||||||
|
def spotify_search(self, query: str, artist_mode: bool = False) -> dict:
|
||||||
|
"""Cerca su Spotify. Free-form (limit 50) o artist-mode (discografia)."""
|
||||||
|
query = (query or "").strip()
|
||||||
|
if not query:
|
||||||
|
return {"ok": False, "error": "empty_query", "message": "Query vuota"}
|
||||||
|
|
||||||
|
# Salva stato
|
||||||
|
try:
|
||||||
|
cfg = load_config()
|
||||||
|
cfg["spotify_search_last_query"] = query
|
||||||
|
cfg["spotify_search_artist_mode"] = bool(artist_mode)
|
||||||
|
save_config(cfg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
cid = (cfg.get("client_id") or "").strip()
|
||||||
|
secret = (cfg.get("client_secret") or "").strip()
|
||||||
|
if not cid or not secret:
|
||||||
|
return {"ok": False, "error": "no_creds", "message": "Credenziali Spotify mancanti"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = spotify_client.get_access_token(cid, secret)
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": "auth", "message": str(e)}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if artist_mode:
|
||||||
|
tracks = spotify_client.search_artist_discography(token, query)
|
||||||
|
else:
|
||||||
|
tracks = spotify_client.search_tracks(token, query, limit=50)
|
||||||
|
except ValueError as e:
|
||||||
|
return {"ok": False, "error": "artist_not_found", "message": str(e)}
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
code = e.response.status_code if e.response is not None else 0
|
||||||
|
if code == 429:
|
||||||
|
return {"ok": False, "error": "rate_limit", "message": "Spotify limitante - attendi qualche secondo"}
|
||||||
|
return {"ok": False, "error": "server", "message": f"Spotify HTTP {code}"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": "unknown", "message": str(e)}
|
||||||
|
|
||||||
|
return {"ok": True, "tracks": tracks}
|
||||||
|
|
||||||
|
def spotify_check_existing(self, tracks: list) -> list:
|
||||||
|
"""True per ogni track già presente in output_dir/Spotify/."""
|
||||||
|
cfg = load_config()
|
||||||
|
out_root = (cfg.get("output_dir") or "").strip()
|
||||||
|
if not out_root:
|
||||||
|
return [False] * len(tracks)
|
||||||
|
out_dir = self._music_output_dir(out_root, "Spotify")
|
||||||
|
if not out_dir.exists():
|
||||||
|
return [False] * len(tracks)
|
||||||
|
existing_stems = [p.stem.lower() for p in out_dir.glob("*.mp3")]
|
||||||
|
result = []
|
||||||
|
for t in tracks:
|
||||||
|
title = (t.get("name") or "").lower().strip()
|
||||||
|
artists = (t.get("artists") or "")
|
||||||
|
first_artist = artists.split(",")[0].strip().lower()
|
||||||
|
if not title or not first_artist:
|
||||||
|
result.append(False)
|
||||||
|
continue
|
||||||
|
result.append(any((title in stem and first_artist in stem) for stem in existing_stems))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def spotify_search_download(self, tracks: list) -> dict:
|
||||||
|
"""Scarica i track Spotify selezionati (name+artist → YouTube search)."""
|
||||||
|
cfg = load_config()
|
||||||
|
out_root = (cfg.get("output_dir") or "").strip()
|
||||||
|
if not out_root:
|
||||||
|
return {"ok": False, "error": "Cartella output non impostata"}
|
||||||
|
|
||||||
|
target = self._music_output_dir(out_root, "Spotify")
|
||||||
|
subfolder = target.name
|
||||||
|
|
||||||
|
converted = []
|
||||||
|
for t in tracks:
|
||||||
|
title = (t.get("name") or "").strip()
|
||||||
|
artists = (t.get("artists") or "").strip()
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
converted.append({"name": title, "artist": artists})
|
||||||
|
|
||||||
|
if not converted:
|
||||||
|
return {"ok": False, "error": "Nessun brano valido"}
|
||||||
|
|
||||||
|
return self.start_tracks_download({
|
||||||
|
"tracks": converted,
|
||||||
|
"output_dir": out_root,
|
||||||
|
"subfolder": subfolder,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ---- YouTube ----
|
||||||
|
|
||||||
|
def youtube_search(self, query: str) -> dict:
|
||||||
|
"""Cerca 50 risultati su YouTube via yt-dlp."""
|
||||||
|
from core import youtube_search as yts
|
||||||
|
|
||||||
|
query = (query or "").strip()
|
||||||
|
if not query:
|
||||||
|
return {"ok": False, "error": "empty_query", "message": "Query vuota"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg = load_config()
|
||||||
|
cfg["youtube_search_last_query"] = query
|
||||||
|
save_config(cfg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = yts.search_youtube(query, limit=50)
|
||||||
|
except RuntimeError as e:
|
||||||
|
return {"ok": False, "error": "ytdlp", "message": str(e)}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": "unknown", "message": str(e)}
|
||||||
|
|
||||||
|
return {"ok": True, "tracks": results}
|
||||||
|
|
||||||
|
def youtube_check_existing(self, tracks: list) -> list:
|
||||||
|
"""True per ogni track già presente in output_dir/YouTube/. Match sul titolo video."""
|
||||||
|
cfg = load_config()
|
||||||
|
out_root = (cfg.get("output_dir") or "").strip()
|
||||||
|
if not out_root:
|
||||||
|
return [False] * len(tracks)
|
||||||
|
out_dir = self._music_output_dir(out_root, "YouTube")
|
||||||
|
if not out_dir.exists():
|
||||||
|
return [False] * len(tracks)
|
||||||
|
existing_stems = [p.stem.lower() for p in out_dir.glob("*.mp3")]
|
||||||
|
result = []
|
||||||
|
for t in tracks:
|
||||||
|
title = (t.get("title") or "").lower().strip()
|
||||||
|
if not title:
|
||||||
|
result.append(False)
|
||||||
|
continue
|
||||||
|
result.append(any(
|
||||||
|
(title in stem or stem in title)
|
||||||
|
for stem in existing_stems
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def youtube_search_download(self, tracks: list) -> dict:
|
||||||
|
"""Scarica direttamente gli URL YouTube selezionati (no re-search)."""
|
||||||
|
cfg = load_config()
|
||||||
|
out_root = (cfg.get("output_dir") or "").strip()
|
||||||
|
if not out_root:
|
||||||
|
return {"ok": False, "error": "Cartella output non impostata"}
|
||||||
|
|
||||||
|
target = self._music_output_dir(out_root, "YouTube")
|
||||||
|
subfolder = target.name
|
||||||
|
|
||||||
|
urls: list = []
|
||||||
|
titles: list = []
|
||||||
|
for t in tracks:
|
||||||
|
url = (t.get("url") or "").strip()
|
||||||
|
title = (t.get("title") or "").strip()
|
||||||
|
if not url or not title:
|
||||||
|
continue
|
||||||
|
urls.append(url)
|
||||||
|
titles.append(title)
|
||||||
|
|
||||||
|
if not urls:
|
||||||
|
return {"ok": False, "error": "Nessun URL valido"}
|
||||||
|
|
||||||
|
return self.start_urls_download({
|
||||||
|
"urls": urls,
|
||||||
|
"titles": titles,
|
||||||
|
"output_dir": out_root,
|
||||||
|
"subfolder": subfolder,
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
+5
-1
@@ -5,7 +5,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VERSION = "v1.8.0"
|
VERSION = "v1.8.3"
|
||||||
|
|
||||||
|
|
||||||
APP_NAME = "MusicTools"
|
APP_NAME = "MusicTools"
|
||||||
@@ -72,6 +72,10 @@ 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
|
||||||
|
# ---- Music Search (Spotify + YouTube) ----
|
||||||
|
"spotify_search_last_query": "",
|
||||||
|
"spotify_search_artist_mode": False,
|
||||||
|
"youtube_search_last_query": "",
|
||||||
# ---- Licenza ----
|
# ---- Licenza ----
|
||||||
"license_key": "", # chiave fornita all'utente via email
|
"license_key": "", # chiave fornita all'utente via email
|
||||||
"license_email": "", # email associata all'acquisto
|
"license_email": "", # email associata all'acquisto
|
||||||
|
|||||||
@@ -399,6 +399,125 @@ def download_direct_url(
|
|||||||
progress_callback(total, total, "", "completed", 100)
|
progress_callback(total, total, "", "completed", 100)
|
||||||
|
|
||||||
|
|
||||||
|
def download_urls(
|
||||||
|
urls: list,
|
||||||
|
titles: list,
|
||||||
|
output_dir: str,
|
||||||
|
bitrate: str = "320K",
|
||||||
|
cookies_path: Optional[str] = None,
|
||||||
|
progress_callback: Optional[Callable] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Scarica direttamente da URL YouTube (bypassa search).
|
||||||
|
|
||||||
|
Usa gli stessi flag yt-dlp di download_playlist. La differenza e che
|
||||||
|
salta lo step di ricerca perche l'URL e gia noto (usato dal flow del
|
||||||
|
tab "YouTube Search" dopo che l'utente ha selezionato i risultati).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
urls: lista URL YouTube (parallela a titles)
|
||||||
|
titles: lista titoli video (per logging/dedupe)
|
||||||
|
output_dir: cartella destinazione
|
||||||
|
bitrate: qualita audio (default 320K)
|
||||||
|
cookies_path: file cookies opzionale
|
||||||
|
progress_callback: callback(idx, total, title, status, percent)
|
||||||
|
"""
|
||||||
|
global _current_process
|
||||||
|
reset_stop()
|
||||||
|
total = len(urls)
|
||||||
|
out_path = Path(output_dir)
|
||||||
|
out_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
done_file = out_path / ".downloaded_tracks"
|
||||||
|
done_set = _load_done_set(done_file)
|
||||||
|
existing_files = _scan_existing_files(out_path)
|
||||||
|
ytdlp = find_ytdlp()
|
||||||
|
|
||||||
|
for i, (url, title) in enumerate(zip(urls, titles)):
|
||||||
|
if is_stopped():
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, "", "stopped", 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
key = title or url
|
||||||
|
|
||||||
|
# Salta se gia scaricato (file di tracking)
|
||||||
|
if key in done_set:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, key, "skipped", 100)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Salta se esiste gia un file corrispondente nella cartella
|
||||||
|
if title and _file_exists_for_query(title, existing_files):
|
||||||
|
_mark_done(done_file, key)
|
||||||
|
done_set.add(key)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, key, "skipped", 100)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, key, "downloading", 0)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
ytdlp,
|
||||||
|
"--extract-audio",
|
||||||
|
"--audio-format", "mp3",
|
||||||
|
"--audio-quality", bitrate.rstrip("Kk"),
|
||||||
|
"--embed-thumbnail",
|
||||||
|
"--add-metadata",
|
||||||
|
"--no-check-certificates",
|
||||||
|
"--newline",
|
||||||
|
"--output", str(Path(output_dir) / "%(title)s.%(ext)s"),
|
||||||
|
url,
|
||||||
|
]
|
||||||
|
ffmpeg_dir = find_ffmpeg_dir()
|
||||||
|
if ffmpeg_dir:
|
||||||
|
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||||
|
if cookies_path and Path(cookies_path).exists():
|
||||||
|
cmd.extend(["--cookies", cookies_path])
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _process_lock:
|
||||||
|
_current_process = subprocess.Popen(
|
||||||
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||||
|
**subprocess_flags(),
|
||||||
|
)
|
||||||
|
|
||||||
|
for line in _current_process.stdout:
|
||||||
|
if is_stopped():
|
||||||
|
_current_process.terminate()
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, key, "stopped", 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
|
||||||
|
if pct_match and progress_callback:
|
||||||
|
pct = int(float(pct_match.group(1)))
|
||||||
|
progress_callback(i, total, key, "downloading", pct)
|
||||||
|
|
||||||
|
return_code = _current_process.wait()
|
||||||
|
|
||||||
|
with _process_lock:
|
||||||
|
_current_process = None
|
||||||
|
|
||||||
|
if return_code == 0:
|
||||||
|
_mark_done(done_file, key)
|
||||||
|
done_set.add(key)
|
||||||
|
existing_files = _scan_existing_files(out_path)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, key, "done", 100)
|
||||||
|
else:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, key, f"error: yt-dlp exit {return_code}", 0)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
with _process_lock:
|
||||||
|
_current_process = None
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, key, f"error: {e}", 0)
|
||||||
|
|
||||||
|
if progress_callback and not is_stopped():
|
||||||
|
progress_callback(total, total, "", "completed", 100)
|
||||||
|
|
||||||
|
|
||||||
def _video_format(quality: str) -> str:
|
def _video_format(quality: str) -> str:
|
||||||
"""Costruisce la format string yt-dlp per la qualita richiesta."""
|
"""Costruisce la format string yt-dlp per la qualita richiesta."""
|
||||||
if not quality or quality == "best":
|
if not quality or quality == "best":
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
@@ -280,3 +282,131 @@ def search_track(token: str, query: str):
|
|||||||
"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 t.get("artists", [])),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def search_tracks(token: str, query: str, limit: int = 50) -> list:
|
||||||
|
"""Cerca brani su Spotify e ritorna una lista di dict.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: access token Spotify
|
||||||
|
query: query libera (titolo, artista, misto)
|
||||||
|
limit: max risultati (Spotify cap = 50)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict] con {id, url, name, artists, album, duration_sec}. Vuota se nessun match.
|
||||||
|
"""
|
||||||
|
resp = requests.get(
|
||||||
|
"https://api.spotify.com/v1/search",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
params={"q": query, "type": "track", "limit": min(limit, 50)},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
items = resp.json().get("tracks", {}).get("items", [])
|
||||||
|
return [_track_to_dict(t) for t in items]
|
||||||
|
|
||||||
|
|
||||||
|
def _track_to_dict(t: dict) -> dict:
|
||||||
|
"""Mappa il track object Spotify sul nostro schema uniforme."""
|
||||||
|
images = t.get("album", {}).get("images", []) or []
|
||||||
|
# Spotify torna 3 taglie ordinate large->small. Prendo la più piccola (~64px)
|
||||||
|
# se disponibile, altrimenti la prima che c'è.
|
||||||
|
image_url = ""
|
||||||
|
if images:
|
||||||
|
image_url = images[-1].get("url", "") or images[0].get("url", "")
|
||||||
|
return {
|
||||||
|
"id": t.get("id", ""),
|
||||||
|
"url": t.get("external_urls", {}).get("spotify", ""),
|
||||||
|
"name": t.get("name", ""),
|
||||||
|
"artists": ", ".join(a.get("name", "") for a in t.get("artists", [])),
|
||||||
|
"album": t.get("album", {}).get("name", ""),
|
||||||
|
"duration_sec": int(t.get("duration_ms", 0)) // 1000,
|
||||||
|
"image_url": image_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def search_artist_discography(token: str, artist_name: str) -> list:
|
||||||
|
"""Trova l'artista esatto (o il piu' popolare tra i match) e ritorna
|
||||||
|
tutti i suoi brani: top tracks + tracce di ogni album/single.
|
||||||
|
Deduplica per (name.lower().strip(), first_artist.lower().strip()).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: se nessun artista trovato per il nome dato.
|
||||||
|
"""
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
# 1. Cerca artista
|
||||||
|
resp = requests.get(
|
||||||
|
"https://api.spotify.com/v1/search",
|
||||||
|
headers=headers,
|
||||||
|
params={"q": artist_name, "type": "artist", "limit": 5},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
candidates = resp.json().get("artists", {}).get("items", [])
|
||||||
|
if not candidates:
|
||||||
|
raise ValueError(f"Artista '{artist_name}' non trovato")
|
||||||
|
|
||||||
|
# Match esatto (case-insensitive) se possibile, altrimenti piu' popolare
|
||||||
|
query_lower = artist_name.lower().strip()
|
||||||
|
exact = [c for c in candidates if c.get("name", "").lower().strip() == query_lower]
|
||||||
|
if exact:
|
||||||
|
artist = exact[0]
|
||||||
|
else:
|
||||||
|
artist = max(candidates, key=lambda c: c.get("popularity", 0))
|
||||||
|
artist_id = artist["id"]
|
||||||
|
|
||||||
|
collected: list = []
|
||||||
|
|
||||||
|
# 2. Top tracks
|
||||||
|
r_top = requests.get(
|
||||||
|
f"https://api.spotify.com/v1/artists/{artist_id}/top-tracks",
|
||||||
|
headers=headers,
|
||||||
|
params={"market": "IT"},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
r_top.raise_for_status()
|
||||||
|
for t in r_top.json().get("tracks", []):
|
||||||
|
collected.append(_track_to_dict(t))
|
||||||
|
|
||||||
|
# 3. Albums (album + single)
|
||||||
|
r_alb = requests.get(
|
||||||
|
f"https://api.spotify.com/v1/artists/{artist_id}/albums",
|
||||||
|
headers=headers,
|
||||||
|
params={"include_groups": "album,single", "limit": 50, "market": "IT"},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
r_alb.raise_for_status()
|
||||||
|
albums = r_alb.json().get("items", [])
|
||||||
|
|
||||||
|
# 4. Per ogni album, tracce (album/track object non ha "album" sub-field, iniettiamola
|
||||||
|
# includendo anche le images dell'album così _track_to_dict riesce a estrarre la cover)
|
||||||
|
for alb in albums:
|
||||||
|
alb_id = alb.get("id")
|
||||||
|
alb_name = alb.get("name", "")
|
||||||
|
alb_images = alb.get("images", []) or []
|
||||||
|
if not alb_id:
|
||||||
|
continue
|
||||||
|
time.sleep(0.1) # rate limit interno
|
||||||
|
r_at = requests.get(
|
||||||
|
f"https://api.spotify.com/v1/albums/{alb_id}/tracks",
|
||||||
|
headers=headers,
|
||||||
|
params={"limit": 50},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
r_at.raise_for_status()
|
||||||
|
for t in r_at.json().get("items", []):
|
||||||
|
t = dict(t)
|
||||||
|
t.setdefault("album", {"name": alb_name, "images": alb_images})
|
||||||
|
collected.append(_track_to_dict(t))
|
||||||
|
|
||||||
|
# 5. Dedupe
|
||||||
|
seen: set = set()
|
||||||
|
unique: list = []
|
||||||
|
for t in collected:
|
||||||
|
key = (t["name"].lower().strip(), t["artists"].split(",")[0].lower().strip())
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
unique.append(t)
|
||||||
|
return unique
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Ricerca su YouTube via yt-dlp (subprocess, flat playlist metadata)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from core.paths import find_ytdlp, subprocess_flags
|
||||||
|
|
||||||
|
|
||||||
|
_TIMEOUT_SEC = 30
|
||||||
|
|
||||||
|
|
||||||
|
def search_youtube(query: str, limit: int = 50) -> list:
|
||||||
|
"""Cerca su YouTube (ytsearchN:query) e ritorna metadata "flat" (senza scaricare).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: testo di ricerca libero
|
||||||
|
limit: numero massimo di risultati (default 50)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict] con {id, url, title, channel, duration_sec}. Vuota se nessun match.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: se yt-dlp non disponibile, timeout, o exit non-zero.
|
||||||
|
"""
|
||||||
|
ytdlp = find_ytdlp()
|
||||||
|
if not ytdlp:
|
||||||
|
raise RuntimeError("yt-dlp non trovato nel bundle / PATH")
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
ytdlp,
|
||||||
|
"--flat-playlist",
|
||||||
|
"--dump-single-json",
|
||||||
|
"--no-warnings",
|
||||||
|
f"ytsearch{limit}:{query}",
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=_TIMEOUT_SEC,
|
||||||
|
**subprocess_flags(),
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
raise RuntimeError(f"Timeout ricerca YouTube ({_TIMEOUT_SEC}s)") from e
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
tail = (proc.stderr or "").strip().splitlines()[-3:]
|
||||||
|
raise RuntimeError(f"yt-dlp error: {' | '.join(tail) or 'unknown'}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(proc.stdout)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise RuntimeError(f"yt-dlp output non-JSON: {e}") from e
|
||||||
|
|
||||||
|
entries = data.get("entries") or []
|
||||||
|
result: list = []
|
||||||
|
for e in entries:
|
||||||
|
if not isinstance(e, dict):
|
||||||
|
continue
|
||||||
|
video_id = e.get("id") or ""
|
||||||
|
url = e.get("url") or (f"https://www.youtube.com/watch?v={video_id}" if video_id else "")
|
||||||
|
image_url = f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg" if video_id else ""
|
||||||
|
result.append({
|
||||||
|
"id": video_id,
|
||||||
|
"url": url,
|
||||||
|
"title": e.get("title") or "",
|
||||||
|
"channel": e.get("uploader") or e.get("channel") or "",
|
||||||
|
"duration_sec": int(e.get("duration") or 0),
|
||||||
|
"image_url": image_url,
|
||||||
|
})
|
||||||
|
return result
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,260 @@
|
|||||||
|
# Music Search (Spotify + YouTube) — Design Spec
|
||||||
|
|
||||||
|
- **Data:** 2026-07-14
|
||||||
|
- **Autore:** LuZa + Claude
|
||||||
|
- **Stato:** Approvato, pronto per implementation plan
|
||||||
|
- **Target release:** MusicTools v1.8.1
|
||||||
|
|
||||||
|
## Obiettivo
|
||||||
|
|
||||||
|
Aggiungere due tab distinte all'app che permettano di **cercare brani/artisti/parole libere** e scaricarli:
|
||||||
|
|
||||||
|
1. **Tab 🟢 Spotify** — ricerca via Spotify API (canonica, metadata pulita). Toggle "Solo artista" per ottenere tutta la discografia di un artista invece della ricerca libera.
|
||||||
|
2. **Tab ▶ YouTube** — ricerca diretta via `yt-dlp ytsearch:` (grezza, senza distinzione artista/album, ma copre mix DJ, unreleased, bootleg, live, video-only content).
|
||||||
|
|
||||||
|
Il download in entrambi i casi riusa la pipeline esistente (`start_tracks_download` in `api/bridge.py`) con licenza gate `_gate("audio")`.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Un'unica tab "Cerca" con radio Spotify/YouTube (scartato: UX molto diversa per le due sorgenti)
|
||||||
|
- Ricerca combinata Spotify+YouTube in un'unica lista (nessuna deduplica cross-source affidabile)
|
||||||
|
- Playlist temporanee salvabili
|
||||||
|
- Preview audio
|
||||||
|
- Filtri per genere/anno/BPM (non richiesto, YAGNI)
|
||||||
|
- Ricerca album, artisti, playlist su Spotify (limitata a track)
|
||||||
|
- Autocomplete / suggestions come digiti
|
||||||
|
|
||||||
|
## Approcci scelti
|
||||||
|
|
||||||
|
### Spotify
|
||||||
|
|
||||||
|
- Free-form search: `GET /v1/search?q=<query>&type=track&limit=50`
|
||||||
|
- Ritorna fino a 50 track ordinati per rilevanza/popolarità (default Spotify)
|
||||||
|
- Query può essere qualsiasi cosa: titolo, artista, "artista - titolo", parola singola
|
||||||
|
- Artist-mode (toggle attivo):
|
||||||
|
1. `GET /v1/search?q=<query>&type=artist&limit=5` → prendi il match con `name.lower() == query.lower()` altrimenti il più popolare
|
||||||
|
2. `GET /v1/artists/{id}/top-tracks?market=IT` → ~10 top track
|
||||||
|
3. `GET /v1/artists/{id}/albums?include_groups=album,single&limit=50&market=IT` → lista album
|
||||||
|
4. Per ogni album (max 50): `GET /v1/albums/{id}/tracks?limit=50` → tracce
|
||||||
|
5. Deduplica su `(name.lower().strip() + '|' + first_artist.lower().strip())`
|
||||||
|
6. Rate limit interno: `time.sleep(0.1)` tra chiamate `/albums/{id}/tracks`
|
||||||
|
7. Total: ~100-500 track per artista prolifico
|
||||||
|
|
||||||
|
**Vantaggi:** metadata pulita, download pipeline già rodata (Spotify→YouTube via yt-dlp).
|
||||||
|
|
||||||
|
### YouTube
|
||||||
|
|
||||||
|
- `yt-dlp --flat-playlist --dump-single-json "ytsearch50:<query>"` → JSON con `entries[]`
|
||||||
|
- Per ogni entry: `{title, uploader/channel, duration, id, url}`
|
||||||
|
- Nessuna modalità artista (YouTube search non ha channel-exact disambiguation affidabile)
|
||||||
|
|
||||||
|
**Vantaggi:** trova ciò che Spotify non ha (mix, unreleased, bootleg, live).
|
||||||
|
|
||||||
|
## Architettura
|
||||||
|
|
||||||
|
### Nuovi moduli / estensioni
|
||||||
|
|
||||||
|
#### `core/spotify_client.py` (estensione)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def search_tracks(token: str, query: str, limit: int = 50) -> list:
|
||||||
|
"""Ricerca free-form. Ritorna list[dict] con {id, url, name, artists, album, duration_sec}."""
|
||||||
|
|
||||||
|
def search_artist_discography(token: str, artist_name: str) -> list:
|
||||||
|
"""Trova l'artista esatto e ritorna tutti i suoi brani (top tracks + tracce da album/singles).
|
||||||
|
Deduplica per (title, first_artist) normalizzato. Solleva ValueError se nessun artista trovato."""
|
||||||
|
```
|
||||||
|
|
||||||
|
Nota: la funzione `search_track` (singolare) aggiunta in Task 7 di Beatport resta com'è per retro-compatibilità. La nuova `search_tracks` (plurale, con limit) è quella usata da questa feature.
|
||||||
|
|
||||||
|
#### `core/youtube_search.py` (nuovo)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def search_youtube(query: str, limit: int = 50) -> list:
|
||||||
|
"""Cerca su YouTube via `yt-dlp ytsearchN:query`.
|
||||||
|
Ritorna list[dict] con {id, url, title, channel, duration_sec}."""
|
||||||
|
```
|
||||||
|
|
||||||
|
Riusa `find_ytdlp()` e `subprocess_flags()` da `core/paths.py`. Timeout subprocess 30s.
|
||||||
|
|
||||||
|
#### `api/bridge.py` — nuovi metodi
|
||||||
|
|
||||||
|
- `spotify_search(query: str, artist_mode: bool) → dict` — salva query+toggle in config, poi chiama la funzione giusta e ritorna `{ok, tracks}` o `{ok:false, error, message}`
|
||||||
|
- `spotify_search_download(tracks: list) → dict` — wrapper del pattern Beatport: converte in `[{name, artist}]` e chiama `start_tracks_download` con `subfolder="Spotify"`
|
||||||
|
- `youtube_search(query: str) → dict` — salva query in config, chiama `core.youtube_search`, ritorna `{ok, tracks}`
|
||||||
|
- `youtube_search_download(tracks: list) → dict` — chiama nuovo helper `start_urls_download` (see below) con URL YouTube e `subfolder="YouTube"`
|
||||||
|
|
||||||
|
#### `api/bridge.py` — nuovo helper (se non c'è già)
|
||||||
|
|
||||||
|
- `start_urls_download(payload: dict) → dict` — analogo a `start_tracks_download` ma accetta `{urls: [str], output_dir, subfolder}` e chiama `download_playlist_from_urls(urls, ...)` in `core/downloader.py`. Se non esiste una funzione equivalente in `downloader.py`, va aggiunta.
|
||||||
|
|
||||||
|
**Verifica in implementazione:** controlla se `download_playlist(tracks, ...)` può ricevere tracks contenenti solo `url` (bypass search); in tal caso riusa quella. Altrimenti aggiungi il nuovo path.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- 2 nuove tab in sidebar (`data-view="spotify"` e `data-view="youtube"`), entrambe con `data-feature="audio"` (stesso license gate del Beatport)
|
||||||
|
- 2 nuove section `<section id="view-spotify">` e `<section id="view-youtube">` in `webui/index.html`
|
||||||
|
- Modulo JS `SpotifyUI` e `YoutubeUI` in `webui/js/app.js`, entrambi con pattern init/loadResults/renderTable/updateSelection/startDownload (parallelo a `BeatportUI`)
|
||||||
|
- Stili in `webui/css/style.css` — riusa `.beatport-table` come base, aggiunge varianti dove serve
|
||||||
|
|
||||||
|
### Persistenza
|
||||||
|
|
||||||
|
3 nuovi campi in `core/config.py::DEFAULTS`:
|
||||||
|
- `spotify_search_last_query: str = ""`
|
||||||
|
- `spotify_search_artist_mode: bool = False`
|
||||||
|
- `youtube_search_last_query: str = ""`
|
||||||
|
|
||||||
|
Salvati dal backend a ogni ricerca (analogo a `beatport_last_genre`).
|
||||||
|
|
||||||
|
### Cartelle output
|
||||||
|
|
||||||
|
- `{output_dir}/Spotify/` (piatta, nessun sub-folder per query)
|
||||||
|
- `{output_dir}/YouTube/` (piatta)
|
||||||
|
|
||||||
|
Sanitizzazione slash coerente con il pattern di `start_tracks_download` (subfolder singolo, no nested).
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
### Spotify search
|
||||||
|
|
||||||
|
```
|
||||||
|
[JS] User digita query, opzionale toggle "Solo artista", click Cerca
|
||||||
|
↓
|
||||||
|
[JS] api.spotify_search(query, artist_mode)
|
||||||
|
↓
|
||||||
|
[PY] api/bridge.py::spotify_search():
|
||||||
|
├─ Salva {spotify_search_last_query, spotify_search_artist_mode} in config
|
||||||
|
├─ Verifica creds Spotify (client_id, client_secret) → altrimenti {ok:false, error:"no_creds"}
|
||||||
|
├─ Get token (spotify_client.get_access_token)
|
||||||
|
├─ Se artist_mode:
|
||||||
|
│ └─ spotify_client.search_artist_discography(token, query)
|
||||||
|
├─ Altrimenti:
|
||||||
|
│ └─ spotify_client.search_tracks(token, query, limit=50)
|
||||||
|
└─ Ritorna {ok:True, tracks: [...]}
|
||||||
|
↓
|
||||||
|
[JS] Riceve lista, chiama api.spotify_check_existing() per pre-deselezionare i già-scaricati
|
||||||
|
↓
|
||||||
|
[JS] Renderizza tabella (colonne: check, #, artista, titolo, album, durata, stato)
|
||||||
|
↓
|
||||||
|
[JS] User seleziona, click "Scarica selezionati"
|
||||||
|
↓
|
||||||
|
[JS] api.spotify_search_download(tracks_selected)
|
||||||
|
↓
|
||||||
|
[PY] converte in [{name, artist}] e chiama self.start_tracks_download({..., subfolder: "Spotify"})
|
||||||
|
↓
|
||||||
|
[EXISTING] pipeline yt-dlp, log/progress su canale "download"
|
||||||
|
```
|
||||||
|
|
||||||
|
### YouTube search
|
||||||
|
|
||||||
|
```
|
||||||
|
[JS] User digita query, click Cerca
|
||||||
|
↓
|
||||||
|
[JS] api.youtube_search(query)
|
||||||
|
↓
|
||||||
|
[PY] api/bridge.py::youtube_search():
|
||||||
|
├─ Salva youtube_search_last_query in config
|
||||||
|
├─ core.youtube_search.search_youtube(query, limit=50)
|
||||||
|
│ └─ subprocess: yt-dlp --flat-playlist --dump-single-json "ytsearch50:<query>"
|
||||||
|
└─ Ritorna {ok:True, tracks: [{id, url, title, channel, duration_sec}, ...]}
|
||||||
|
↓
|
||||||
|
[JS] Renderizza tabella (colonne: check, #, titolo video, canale, durata, stato)
|
||||||
|
↓
|
||||||
|
[JS] User seleziona, click "Scarica selezionati"
|
||||||
|
↓
|
||||||
|
[JS] api.youtube_search_download(tracks_selected) # passa URL, non {name,artist}
|
||||||
|
↓
|
||||||
|
[PY] start_urls_download({urls, subfolder: "YouTube"})
|
||||||
|
↓
|
||||||
|
[EXISTING] pipeline yt-dlp diretto sugli URL
|
||||||
|
```
|
||||||
|
|
||||||
|
## Matrice errori & recovery
|
||||||
|
|
||||||
|
| Errore | Dove | Comportamento |
|
||||||
|
|---|---|---|
|
||||||
|
| Query vuota | UI JS | Bottone "Cerca" disabilitato, nessuna richiesta |
|
||||||
|
| Creds Spotify mancanti | spotify_search | `{ok:false, error:"no_creds"}` → banner giallo con link Impostazioni |
|
||||||
|
| Spotify 401 (token scaduto/invalid) | get_access_token | Refresh token nel handler; se persiste, banner rosso |
|
||||||
|
| Spotify 429 rate limit | search endpoint | `{ok:false, error:"rate_limit", message:"Attendi qualche secondo"}` → banner arancione, retry manuale |
|
||||||
|
| Spotify 5xx | search endpoint | `{ok:false, error:"server", message:...}` → banner rosso, retry |
|
||||||
|
| Nessun risultato Spotify | search endpoint | `{ok:true, tracks:[]}` → messaggio grigio "Nessun brano trovato per '<query>'" |
|
||||||
|
| Artist-mode senza match esatto | search_artist_discography | Solleva ValueError → `{ok:false, error:"artist_not_found", message:"Artista '<q>' non trovato — disattiva toggle per ricerca libera"}` |
|
||||||
|
| yt-dlp non trovato | search_youtube | RuntimeError → banner rosso "yt-dlp non installato" |
|
||||||
|
| yt-dlp timeout (rete lenta) | search_youtube | subprocess.TimeoutExpired → banner rosso "Timeout ricerca YouTube" |
|
||||||
|
| yt-dlp errore generico | search_youtube | Non-zero exit → banner rosso con stderr tail |
|
||||||
|
| Nessun risultato YouTube | search_youtube | `{ok:true, tracks:[]}` → messaggio grigio |
|
||||||
|
| User preme Stop durante download | download pipeline | Comportamento invariato (esistente) |
|
||||||
|
|
||||||
|
## Licenza / piani
|
||||||
|
|
||||||
|
Entrambe le tab sono audio download → gate `_gate("audio")` automatico via `start_tracks_download` / `start_urls_download`. Il `daily_limit` del piano si applica. Nessuna nuova feature-flag.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit tests
|
||||||
|
|
||||||
|
**`tests/test_spotify_client.py` (estensione):**
|
||||||
|
- `test_search_tracks_returns_list_up_to_limit` — mock response con 50 items, verifica list length
|
||||||
|
- `test_search_tracks_maps_fields_correctly` — sample dict → verifica keys {id, url, name, artists, album, duration_sec}
|
||||||
|
- `test_search_tracks_empty_query_returns_empty` — mock 0 items
|
||||||
|
- `test_search_artist_discography_exact_match_wins` — mock search artist con 3 candidati diversi, verifica quello esatto (case-insensitive)
|
||||||
|
- `test_search_artist_discography_top_tracks_and_albums_combined` — mock 3 endpoint, verifica deduplica
|
||||||
|
- `test_search_artist_discography_raises_when_no_match` — mock search artist con 0 risultati → ValueError
|
||||||
|
- `test_search_artist_discography_deduplicates` — mock top-tracks e album-tracks con overlap → verifica no duplicati
|
||||||
|
|
||||||
|
**`tests/test_youtube_search.py` (nuovo):**
|
||||||
|
- `test_search_youtube_parses_entries` — mock `subprocess.run` con JSON stub (5 entries), verifica list mapping
|
||||||
|
- `test_search_youtube_empty_result` — mock JSON senza entries → []
|
||||||
|
- `test_search_youtube_ytdlp_not_found` — mock `find_ytdlp` che ritorna None → RuntimeError
|
||||||
|
- `test_search_youtube_timeout` — mock subprocess.TimeoutExpired → RuntimeError
|
||||||
|
|
||||||
|
### Test manuale end-to-end
|
||||||
|
|
||||||
|
**Tab Spotify:**
|
||||||
|
1. Query semplice: "Solomun" senza toggle → 50 risultati, ordinati per rilevanza
|
||||||
|
2. Query composta: "Kapuchon Hot Sauce" → 5-10 risultati pertinenti
|
||||||
|
3. Artist mode: "Solomun" con toggle attivo → 100-300 risultati (top tracks + tutti album)
|
||||||
|
4. Artist mode con nome inesistente: "sadgjhkasdg" → banner "Artista non trovato"
|
||||||
|
5. Download di 3 brani → file in `MUSICA/Spotify/`
|
||||||
|
6. Ricerca ripetuta → "già scaricato" mostrato correttamente
|
||||||
|
7. Riavvio app → ultima query + toggle ricordati
|
||||||
|
|
||||||
|
**Tab YouTube:**
|
||||||
|
1. Query: "Kapuchon Hot Sauce" → 50 risultati con canali diversi
|
||||||
|
2. Query set DJ: "Solomun Cocoricò 2024" → set lunghi (60+ min) in lista
|
||||||
|
3. Download di 2 brani → file in `MUSICA/YouTube/`
|
||||||
|
4. Query vuota → bottone disabilitato
|
||||||
|
5. Riavvio → ultima query ricordata
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
1. Branch `feat/music-search`
|
||||||
|
2. Bump `core/config.py::VERSION` → `v1.8.1`
|
||||||
|
3. Note release `/tmp/notes-v1.8.1.md`:
|
||||||
|
> **Nuove tab Spotify e YouTube 🔎** — cerca brani per titolo o artista su Spotify (con toggle "solo artista" per scaricare tutta la discografia), oppure cerca su YouTube per trovare mix DJ, unreleased, bootleg e brani non presenti su Spotify.
|
||||||
|
4. Commit + tag `v1.8.1` → CI + release notes background task
|
||||||
|
5. Update DB `releases` tabella sul server (macos + windows rows)
|
||||||
|
|
||||||
|
Zero cambi server-side backend, zero migration.
|
||||||
|
|
||||||
|
## Struttura file impattati
|
||||||
|
|
||||||
|
**Nuovi:**
|
||||||
|
- `core/youtube_search.py`
|
||||||
|
- `tests/test_youtube_search.py`
|
||||||
|
|
||||||
|
**Modificati:**
|
||||||
|
- `core/spotify_client.py` — 2 nuove funzioni pubbliche (`search_tracks`, `search_artist_discography`)
|
||||||
|
- `core/downloader.py` — se serve, aggiunta `download_playlist_from_urls()` per il flusso YouTube
|
||||||
|
- `core/config.py` — VERSION bump + 3 nuovi campi in DEFAULTS
|
||||||
|
- `api/bridge.py` — 6 nuovi metodi Api: `spotify_search`, `spotify_check_existing`, `spotify_search_download`, `youtube_search`, `youtube_check_existing`, `youtube_search_download` + eventuale helper `start_urls_download`
|
||||||
|
- `tests/test_spotify_client.py` — 7 nuovi test
|
||||||
|
- `webui/index.html` — 2 nav-item + 2 sezioni view
|
||||||
|
- `webui/js/app.js` — moduli `SpotifyUI` e `YoutubeUI` (~200 righe cadauno, parallelo a `BeatportUI`)
|
||||||
|
- `webui/css/style.css` — piccole estensioni (o riuso classi Beatport)
|
||||||
|
- `requirements.txt` — nessuna nuova dep
|
||||||
|
|
||||||
|
## Riduzione della duplicazione
|
||||||
|
|
||||||
|
Se durante l'implementazione emerge che i 3 pannelli (Beatport, Spotify, YouTube) hanno logica JS quasi identica (renderTable con checkbox, updateSelectionCount, startDownload wrapper), **valuta** l'estrazione di un `SelectableTracksTable` component in `webui/js/app.js`. Se l'astrazione è chiara e riduce codice significativamente, fallo. Se costringe a hooks/callbacks tortuosi per gestire differenze di colonne, lascia stare — 3 istanze non sono tante e YAGNI.
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import responses
|
import responses
|
||||||
|
|
||||||
@@ -68,3 +70,264 @@ class TestSearchTrack:
|
|||||||
assert params["q"] == "Kapuchon Hot Sauce"
|
assert params["q"] == "Kapuchon Hot Sauce"
|
||||||
assert params["type"] == "track"
|
assert params["type"] == "track"
|
||||||
assert params["limit"] == "1"
|
assert params["limit"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchTracks:
|
||||||
|
@responses.activate
|
||||||
|
def test_returns_list_of_tracks(self):
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/search",
|
||||||
|
json={
|
||||||
|
"tracks": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": f"id{i}",
|
||||||
|
"name": f"Track {i}",
|
||||||
|
"artists": [{"name": "Solomun"}],
|
||||||
|
"album": {"name": "Album X"},
|
||||||
|
"duration_ms": 300000 + i * 1000,
|
||||||
|
"external_urls": {"spotify": f"https://open.spotify.com/track/id{i}"},
|
||||||
|
}
|
||||||
|
for i in range(50)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
result = spotify_client.search_tracks("t", "solomun", limit=50)
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 50
|
||||||
|
first = result[0]
|
||||||
|
assert first["id"] == "id0"
|
||||||
|
assert first["name"] == "Track 0"
|
||||||
|
assert first["artists"] == "Solomun"
|
||||||
|
assert first["album"] == "Album X"
|
||||||
|
assert first["duration_sec"] == 300
|
||||||
|
assert first["url"] == "https://open.spotify.com/track/id0"
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_multiple_artists_joined_with_comma(self):
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/search",
|
||||||
|
json={"tracks": {"items": [{
|
||||||
|
"id": "x", "name": "n",
|
||||||
|
"artists": [{"name": "A"}, {"name": "B"}, {"name": "C"}],
|
||||||
|
"album": {"name": "Alb"},
|
||||||
|
"duration_ms": 60000,
|
||||||
|
"external_urls": {"spotify": "u"},
|
||||||
|
}]}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
result = spotify_client.search_tracks("t", "q", limit=1)
|
||||||
|
assert result[0]["artists"] == "A, B, C"
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_empty_query_returns_empty_list(self):
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/search",
|
||||||
|
json={"tracks": {"items": []}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
result = spotify_client.search_tracks("t", "no-match", limit=50)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_query_params_include_limit(self):
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/search",
|
||||||
|
json={"tracks": {"items": []}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
spotify_client.search_tracks("t", "q", limit=25)
|
||||||
|
params = responses.calls[0].request.params
|
||||||
|
assert params["q"] == "q"
|
||||||
|
assert params["type"] == "track"
|
||||||
|
assert params["limit"] == "25"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchArtistDiscography:
|
||||||
|
@responses.activate
|
||||||
|
def test_exact_name_match_beats_popular(self):
|
||||||
|
# 3 candidati: uno esatto (case-insensitive), altri popolari
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/search",
|
||||||
|
json={"artists": {"items": [
|
||||||
|
{"id": "pop", "name": "Solomun Tribute", "popularity": 90},
|
||||||
|
{"id": "exact", "name": "SOLOMUN", "popularity": 60},
|
||||||
|
{"id": "unrelated", "name": "Other", "popularity": 70},
|
||||||
|
]}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
# Mock top-tracks vuoto per non allungare il test
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/artists/exact/top-tracks",
|
||||||
|
json={"tracks": []},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
# Mock albums vuoto
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/artists/exact/albums",
|
||||||
|
json={"items": []},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
result = spotify_client.search_artist_discography("t", "Solomun")
|
||||||
|
assert result == []
|
||||||
|
# Verifica che sia stato chiamato l'artista "exact", non "pop"
|
||||||
|
top_tracks_calls = [c for c in responses.calls if "/top-tracks" in c.request.url]
|
||||||
|
assert len(top_tracks_calls) == 1
|
||||||
|
assert "/exact/top-tracks" in top_tracks_calls[0].request.url
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_falls_back_to_most_popular_if_no_exact_match(self):
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/search",
|
||||||
|
json={"artists": {"items": [
|
||||||
|
{"id": "a1", "name": "Solomun Fanpage", "popularity": 30},
|
||||||
|
{"id": "a2", "name": "Solomun Live", "popularity": 80},
|
||||||
|
]}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/artists/a2/top-tracks",
|
||||||
|
json={"tracks": []},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/artists/a2/albums",
|
||||||
|
json={"items": []},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
spotify_client.search_artist_discography("t", "solomun")
|
||||||
|
top_tracks_calls = [c for c in responses.calls if "/top-tracks" in c.request.url]
|
||||||
|
assert "/a2/top-tracks" in top_tracks_calls[0].request.url
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_raises_when_no_artist_found(self):
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/search",
|
||||||
|
json={"artists": {"items": []}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="Artista"):
|
||||||
|
spotify_client.search_artist_discography("t", "asdgjhkasdgj")
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_combines_top_tracks_and_album_tracks(self):
|
||||||
|
# 1 artista esatto
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/search",
|
||||||
|
json={"artists": {"items": [{"id": "artX", "name": "artX", "popularity": 50}]}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
# 3 top-tracks
|
||||||
|
top_tracks_data = {"tracks": [
|
||||||
|
{
|
||||||
|
"id": f"t{i}", "name": f"Top{i}",
|
||||||
|
"artists": [{"name": "artX"}],
|
||||||
|
"album": {"name": "AlbTop"},
|
||||||
|
"duration_ms": 200000,
|
||||||
|
"external_urls": {"spotify": f"u{i}"},
|
||||||
|
} for i in range(3)
|
||||||
|
]}
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/artists/artX/top-tracks",
|
||||||
|
json=top_tracks_data,
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
# 2 album
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/artists/artX/albums",
|
||||||
|
json={"items": [
|
||||||
|
{"id": "alb1", "name": "Album 1"},
|
||||||
|
{"id": "alb2", "name": "Album 2"},
|
||||||
|
]},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
# album 1: 2 tracce
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/albums/alb1/tracks",
|
||||||
|
json={"items": [
|
||||||
|
{
|
||||||
|
"id": f"a1t{i}", "name": f"Alb1Track{i}",
|
||||||
|
"artists": [{"name": "artX"}],
|
||||||
|
"duration_ms": 180000,
|
||||||
|
"external_urls": {"spotify": f"a1u{i}"},
|
||||||
|
} for i in range(2)
|
||||||
|
]},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
# album 2: 1 traccia
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/albums/alb2/tracks",
|
||||||
|
json={"items": [
|
||||||
|
{
|
||||||
|
"id": "a2t0", "name": "Alb2Track0",
|
||||||
|
"artists": [{"name": "artX"}],
|
||||||
|
"duration_ms": 240000,
|
||||||
|
"external_urls": {"spotify": "a2u0"},
|
||||||
|
}
|
||||||
|
]},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
with patch("core.spotify_client.time.sleep"): # no wait
|
||||||
|
result = spotify_client.search_artist_discography("t", "artX")
|
||||||
|
# 3 top + 2 alb1 + 1 alb2 = 6
|
||||||
|
assert len(result) == 6
|
||||||
|
titles = {r["name"] for r in result}
|
||||||
|
assert "Top0" in titles
|
||||||
|
assert "Alb1Track0" in titles
|
||||||
|
assert "Alb2Track0" in titles
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_dedupe_across_top_and_album(self):
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/search",
|
||||||
|
json={"artists": {"items": [{"id": "aX", "name": "aX", "popularity": 50}]}},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
# Stesso track name+artist in top-tracks e in album (id diverso)
|
||||||
|
common = {
|
||||||
|
"name": "Same Song",
|
||||||
|
"artists": [{"name": "aX"}],
|
||||||
|
"album": {"name": "OG Album"},
|
||||||
|
"duration_ms": 200000,
|
||||||
|
"external_urls": {"spotify": "u"},
|
||||||
|
}
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/artists/aX/top-tracks",
|
||||||
|
json={"tracks": [{**common, "id": "top-id"}]},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/artists/aX/albums",
|
||||||
|
json={"items": [{"id": "alb", "name": "OG"}]},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://api.spotify.com/v1/albums/alb/tracks",
|
||||||
|
json={"items": [{**common, "id": "alb-id"}]},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
with patch("core.spotify_client.time.sleep"):
|
||||||
|
result = spotify_client.search_artist_discography("t", "aX")
|
||||||
|
assert len(result) == 1
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Test per core.youtube_search."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core import youtube_search
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_ytdlp_result(entries: list) -> MagicMock:
|
||||||
|
"""Mock subprocess.CompletedProcess con JSON stub."""
|
||||||
|
result = MagicMock()
|
||||||
|
result.returncode = 0
|
||||||
|
result.stdout = json.dumps({"entries": entries})
|
||||||
|
result.stderr = ""
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchYoutube:
|
||||||
|
def test_parses_entries(self):
|
||||||
|
entries = [
|
||||||
|
{
|
||||||
|
"id": "abc123",
|
||||||
|
"url": "https://www.youtube.com/watch?v=abc123",
|
||||||
|
"title": "Kapuchon - Hot Sauce (Official Video)",
|
||||||
|
"uploader": "Kapuchon Official",
|
||||||
|
"duration": 336,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "def456",
|
||||||
|
"url": "https://www.youtube.com/watch?v=def456",
|
||||||
|
"title": "Solomun @ Cocoricò 2024",
|
||||||
|
"uploader": "Cocoricò",
|
||||||
|
"duration": 3600,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||||
|
patch("core.youtube_search.subprocess.run", return_value=_mock_ytdlp_result(entries)):
|
||||||
|
result = youtube_search.search_youtube("kapuchon hot sauce", limit=50)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0]["title"] == "Kapuchon - Hot Sauce (Official Video)"
|
||||||
|
assert result[0]["channel"] == "Kapuchon Official"
|
||||||
|
assert result[0]["duration_sec"] == 336
|
||||||
|
assert result[0]["url"] == "https://www.youtube.com/watch?v=abc123"
|
||||||
|
|
||||||
|
def test_empty_entries_returns_empty(self):
|
||||||
|
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||||
|
patch("core.youtube_search.subprocess.run", return_value=_mock_ytdlp_result([])):
|
||||||
|
result = youtube_search.search_youtube("no-match", limit=50)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_raises_when_ytdlp_missing(self):
|
||||||
|
with patch("core.youtube_search.find_ytdlp", return_value=None):
|
||||||
|
with pytest.raises(RuntimeError, match="yt-dlp"):
|
||||||
|
youtube_search.search_youtube("q", limit=50)
|
||||||
|
|
||||||
|
def test_command_uses_ytsearch_with_limit(self):
|
||||||
|
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||||
|
patch("core.youtube_search.subprocess.run", return_value=_mock_ytdlp_result([])) as mock_run:
|
||||||
|
youtube_search.search_youtube("solomun", limit=30)
|
||||||
|
args = mock_run.call_args.args[0]
|
||||||
|
assert args[0] == "/fake/yt-dlp"
|
||||||
|
search_arg = [a for a in args if a.startswith("ytsearch")]
|
||||||
|
assert search_arg == ["ytsearch30:solomun"]
|
||||||
|
|
||||||
|
def test_timeout_raises_runtime_error(self):
|
||||||
|
import subprocess as sp
|
||||||
|
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||||
|
patch("core.youtube_search.subprocess.run", side_effect=sp.TimeoutExpired("yt-dlp", 30)):
|
||||||
|
with pytest.raises(RuntimeError, match="[Tt]imeout"):
|
||||||
|
youtube_search.search_youtube("q", limit=50)
|
||||||
|
|
||||||
|
def test_nonzero_exit_raises(self):
|
||||||
|
bad = MagicMock()
|
||||||
|
bad.returncode = 1
|
||||||
|
bad.stdout = ""
|
||||||
|
bad.stderr = "some yt-dlp error"
|
||||||
|
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||||
|
patch("core.youtube_search.subprocess.run", return_value=bad):
|
||||||
|
with pytest.raises(RuntimeError, match="yt-dlp"):
|
||||||
|
youtube_search.search_youtube("q", limit=50)
|
||||||
+55
-25
@@ -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; }
|
||||||
|
|||||||
@@ -33,6 +33,14 @@
|
|||||||
<span class="nav-icon">🎧</span>
|
<span class="nav-icon">🎧</span>
|
||||||
<span>Beatport</span>
|
<span>Beatport</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-item" data-view="spotify" data-feature="audio">
|
||||||
|
<span class="nav-icon">🟢</span>
|
||||||
|
<span>Spotify</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-item" data-view="youtube" data-feature="audio">
|
||||||
|
<span class="nav-icon">▶</span>
|
||||||
|
<span>YouTube</span>
|
||||||
|
</button>
|
||||||
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
|
<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>
|
||||||
@@ -883,6 +891,7 @@
|
|||||||
<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-cover"></th>
|
||||||
<th class="col-pos">#</th>
|
<th class="col-pos">#</th>
|
||||||
<th>Artista</th>
|
<th>Artista</th>
|
||||||
<th>Titolo (Mix)</th>
|
<th>Titolo (Mix)</th>
|
||||||
@@ -910,6 +919,125 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== VIEW: SPOTIFY SEARCH ===== -->
|
||||||
|
<section class="view" id="view-spotify">
|
||||||
|
<header class="hero hero-indigo">
|
||||||
|
<div class="hero-content">
|
||||||
|
<div class="hero-eyebrow indigo">RICERCA SPOTIFY</div>
|
||||||
|
<h1 class="hero-title">Cerca brani su Spotify</h1>
|
||||||
|
<p class="hero-subtitle">Digita un artista, un titolo, o entrambi. Attiva "Solo artista" per scaricare tutta la discografia.</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-deco">🟢</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<h2 class="section-label">Ricerca</h2>
|
||||||
|
<div class="card">
|
||||||
|
<div class="beatport-header">
|
||||||
|
<input type="text" id="spotify-query" class="input pill" placeholder="Es: Solomun, Hot Sauce, Kapuchon Hot Sauce…" style="flex:1; min-width:260px;" />
|
||||||
|
<label style="display:flex; gap:6px; align-items:center; color:var(--text-2); font-size:13px;">
|
||||||
|
<input type="checkbox" id="spotify-artist-mode" />
|
||||||
|
<span>Solo artista (discografia)</span>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-primary pill" id="spotify-search-btn">
|
||||||
|
<span class="ico">🔎</span> Cerca
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="spotify-status" class="beatport-status"></div>
|
||||||
|
<div id="spotify-output-info" class="beatport-output-info"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card beatport-table-card" id="spotify-table-wrap" hidden>
|
||||||
|
<div class="beatport-table-wrap">
|
||||||
|
<table class="beatport-table" id="spotify-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-check"><input type="checkbox" id="spotify-select-all" checked /></th>
|
||||||
|
<th class="col-cover"></th>
|
||||||
|
<th class="col-pos">#</th>
|
||||||
|
<th>Artista</th>
|
||||||
|
<th>Titolo</th>
|
||||||
|
<th>Album</th>
|
||||||
|
<th class="col-dur">Durata</th>
|
||||||
|
<th class="col-state"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="spotify-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-toolbar" id="spotify-toolbar">
|
||||||
|
<span class="counter" id="spotify-selected-count">0/0 selezionati</span>
|
||||||
|
<button class="btn btn-primary pill" id="spotify-download-btn" disabled>
|
||||||
|
<span class="ico">▶</span> Scarica selezionati
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger pill" id="spotify-stop-btn" hidden>
|
||||||
|
<span class="ico">◼</span> Interrompi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-label">Log</h2>
|
||||||
|
<div class="card log-card">
|
||||||
|
<div class="log" id="spotify-log"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== VIEW: YOUTUBE SEARCH ===== -->
|
||||||
|
<section class="view" id="view-youtube">
|
||||||
|
<header class="hero hero-indigo">
|
||||||
|
<div class="hero-content">
|
||||||
|
<div class="hero-eyebrow indigo">RICERCA YOUTUBE</div>
|
||||||
|
<h1 class="hero-title">Cerca video su YouTube</h1>
|
||||||
|
<p class="hero-subtitle">Perfetto per mix DJ, unreleased, bootleg e brani non presenti su Spotify.</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-deco">▶</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<h2 class="section-label">Ricerca</h2>
|
||||||
|
<div class="card">
|
||||||
|
<div class="beatport-header">
|
||||||
|
<input type="text" id="youtube-query" class="input pill" placeholder="Es: Solomun Cocoricò 2024, Kapuchon Hot Sauce remix…" style="flex:1; min-width:260px;" />
|
||||||
|
<button class="btn btn-primary pill" id="youtube-search-btn">
|
||||||
|
<span class="ico">🔎</span> Cerca
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="youtube-status" class="beatport-status"></div>
|
||||||
|
<div id="youtube-output-info" class="beatport-output-info"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card beatport-table-card" id="youtube-table-wrap" hidden>
|
||||||
|
<div class="beatport-table-wrap">
|
||||||
|
<table class="beatport-table" id="youtube-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-check"><input type="checkbox" id="youtube-select-all" checked /></th>
|
||||||
|
<th class="col-cover"></th>
|
||||||
|
<th class="col-pos">#</th>
|
||||||
|
<th>Titolo video</th>
|
||||||
|
<th>Canale</th>
|
||||||
|
<th class="col-dur">Durata</th>
|
||||||
|
<th class="col-state"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="youtube-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-toolbar" id="youtube-toolbar">
|
||||||
|
<span class="counter" id="youtube-selected-count">0/0 selezionati</span>
|
||||||
|
<button class="btn btn-primary pill" id="youtube-download-btn" disabled>
|
||||||
|
<span class="ico">▶</span> Scarica selezionati
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger pill" id="youtube-stop-btn" hidden>
|
||||||
|
<span class="ico">◼</span> Interrompi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-label">Log</h2>
|
||||||
|
<div class="card log-card">
|
||||||
|
<div class="log" id="youtube-log"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+485
@@ -397,6 +397,8 @@ const logEls = {
|
|||||||
download: () => $("#dlLog"),
|
download: () => $("#dlLog"),
|
||||||
upgrade: () => $("#upLog"),
|
upgrade: () => $("#upLog"),
|
||||||
video: () => $("#videoLog"),
|
video: () => $("#videoLog"),
|
||||||
|
spotify: () => $("#spotify-log"),
|
||||||
|
youtube: () => $("#youtube-log"),
|
||||||
};
|
};
|
||||||
|
|
||||||
function classifyLog(msg) {
|
function classifyLog(msg) {
|
||||||
@@ -504,6 +506,10 @@ async function init() {
|
|||||||
|
|
||||||
// Beatport tab — popola generi, ripristina ultimo genere, aggancia handler
|
// Beatport tab — popola generi, ripristina ultimo genere, aggancia handler
|
||||||
await BeatportUI.init();
|
await BeatportUI.init();
|
||||||
|
|
||||||
|
// Spotify + YouTube search tabs
|
||||||
|
await SpotifyUI.init();
|
||||||
|
await YoutubeUI.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRecDevices() {
|
async function refreshRecDevices() {
|
||||||
@@ -1291,6 +1297,19 @@ const BeatportUI = (function () {
|
|||||||
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 || (i + 1));
|
||||||
@@ -1463,6 +1482,472 @@ const BeatportUI = (function () {
|
|||||||
return { init };
|
return { init };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Spotify search
|
||||||
|
// =====================================================================
|
||||||
|
const SpotifyUI = (() => {
|
||||||
|
// NOTE: local module state — named `mstate` to NOT shadow the outer
|
||||||
|
// module-level `state` (which holds `state.config`).
|
||||||
|
const mstate = { tracks: [], existing: [], downloading: false };
|
||||||
|
|
||||||
|
function _escape(s) {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.textContent = s == null ? "" : String(s);
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fmtDuration(sec) {
|
||||||
|
const n = Math.max(0, Math.floor(Number(sec) || 0));
|
||||||
|
const m = Math.floor(n / 60);
|
||||||
|
const s = n % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectionCount() {
|
||||||
|
const boxes = $$("#spotify-tbody input[type=checkbox]");
|
||||||
|
const total = boxes.length;
|
||||||
|
const selected = boxes.filter((b) => b.checked).length;
|
||||||
|
$("#spotify-selected-count").textContent = `${selected}/${total} selezionati`;
|
||||||
|
const btn = $("#spotify-download-btn");
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = mstate.downloading || selected === 0;
|
||||||
|
btn.innerHTML = `<span class="ico">▶</span> Scarica selezionati${selected ? ` (${selected})` : ""}`;
|
||||||
|
}
|
||||||
|
const sa = $("#spotify-select-all");
|
||||||
|
if (sa) {
|
||||||
|
if (total === 0) { sa.checked = false; sa.indeterminate = false; }
|
||||||
|
else if (selected === 0) { sa.checked = false; sa.indeterminate = false; }
|
||||||
|
else if (selected === total) { sa.checked = true; sa.indeterminate = false; }
|
||||||
|
else { sa.checked = false; sa.indeterminate = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const tbody = $("#spotify-tbody");
|
||||||
|
if (!tbody) return;
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
mstate.tracks.forEach((t, i) => {
|
||||||
|
const already = !!mstate.existing[i];
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.dataset.idx = String(i);
|
||||||
|
if (already) tr.classList.add("already-downloaded");
|
||||||
|
const coverImg = t.image_url
|
||||||
|
? `<img src="${_escape(t.image_url)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"/>`
|
||||||
|
: "";
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="col-check"><input type="checkbox" data-idx="${i}" ${already ? "" : "checked"} /></td>
|
||||||
|
<td class="col-cover">${coverImg}</td>
|
||||||
|
<td class="col-pos">${i + 1}</td>
|
||||||
|
<td>${_escape(t.artists)}</td>
|
||||||
|
<td>${_escape(t.name)}</td>
|
||||||
|
<td>${_escape(t.album)}</td>
|
||||||
|
<td class="col-dur">${_fmtDuration(t.duration_sec)}</td>
|
||||||
|
<td class="col-state">${already ? "✓ già scaricato" : ""}</td>
|
||||||
|
`;
|
||||||
|
const cb = tr.querySelector("input[type=checkbox]");
|
||||||
|
if (cb) cb.addEventListener("change", updateSelectionCount);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
updateSelectionCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text, kind) {
|
||||||
|
const el = $("#spotify-status");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text || "";
|
||||||
|
el.className = "beatport-status" + (kind ? " " + kind : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOutputInfo() {
|
||||||
|
const info = $("#spotify-output-info");
|
||||||
|
if (!info) return;
|
||||||
|
const root = (state.config && state.config.output_dir) || "";
|
||||||
|
if (!root) {
|
||||||
|
info.textContent = "⚠ Imposta la cartella output in Impostazioni prima di scaricare";
|
||||||
|
info.className = "beatport-output-info warn";
|
||||||
|
} else {
|
||||||
|
info.textContent = `Destinazione: ${root}/Spotify`;
|
||||||
|
info.className = "beatport-output-info";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSearch() {
|
||||||
|
const q = ($("#spotify-query").value || "").trim();
|
||||||
|
if (!q) return;
|
||||||
|
const artistMode = $("#spotify-artist-mode").checked;
|
||||||
|
const wrap = $("#spotify-table-wrap");
|
||||||
|
|
||||||
|
setStatus("Ricerca in corso…", "loading");
|
||||||
|
if (wrap) wrap.hidden = true;
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await window.pywebview.api.spotify_search(q, artistMode);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("Errore: " + ((e && e.message) || e), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
const errKey = res && res.error;
|
||||||
|
const messages = {
|
||||||
|
empty_query: "Inserisci una query di ricerca.",
|
||||||
|
no_creds: "Credenziali Spotify mancanti. Vai su Impostazioni.",
|
||||||
|
auth: "Impossibile autenticarsi con Spotify. Verifica le credenziali.",
|
||||||
|
};
|
||||||
|
setStatus(messages[errKey] || (res && res.message) || "Errore sconosciuto", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mstate.tracks = res.tracks || [];
|
||||||
|
if (mstate.tracks.length === 0) {
|
||||||
|
setStatus(`Nessun brano trovato per "${q}".`, "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
mstate.existing = await window.pywebview.api.spotify_check_existing(mstate.tracks);
|
||||||
|
} catch (_e) {
|
||||||
|
mstate.existing = mstate.tracks.map(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(`${mstate.tracks.length} risultati`, "ok");
|
||||||
|
updateOutputInfo();
|
||||||
|
renderTable();
|
||||||
|
if (wrap) wrap.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSpotifyLog(msg) {
|
||||||
|
const el = $("#spotify-log");
|
||||||
|
if (!el) return;
|
||||||
|
const cls = classifyLog(msg);
|
||||||
|
const line = document.createElement("div");
|
||||||
|
if (cls) line.className = cls;
|
||||||
|
line.textContent = msg;
|
||||||
|
el.appendChild(line);
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDownload() {
|
||||||
|
const boxes = $$("#spotify-tbody input[type=checkbox]");
|
||||||
|
const selected = [];
|
||||||
|
boxes.forEach((b) => {
|
||||||
|
if (b.checked) {
|
||||||
|
const idx = parseInt(b.dataset.idx, 10);
|
||||||
|
if (!isNaN(idx) && mstate.tracks[idx]) selected.push(mstate.tracks[idx]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!selected.length) {
|
||||||
|
toast("Seleziona almeno un brano", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.config || !state.config.output_dir) {
|
||||||
|
toast("Imposta la cartella output in Impostazioni", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mstate.downloading = true;
|
||||||
|
$("#spotify-download-btn").disabled = true;
|
||||||
|
$("#spotify-stop-btn").hidden = false;
|
||||||
|
$("#spotify-stop-btn").disabled = false;
|
||||||
|
$("#spotify-log").innerHTML = "";
|
||||||
|
appendSpotifyLog(`[INFO] Avvio download di ${selected.length} brani…`);
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await window.pywebview.api.spotify_search_download(selected);
|
||||||
|
} catch (e) {
|
||||||
|
appendSpotifyLog("[ERRORE] " + ((e && e.message) || e));
|
||||||
|
finishDownload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
const errMsg = (res && (res.error || res.message)) || "Impossibile avviare il download";
|
||||||
|
appendSpotifyLog("[ERRORE] " + errMsg);
|
||||||
|
if (!handleGateBlock(res || {})) toast(errMsg, "error");
|
||||||
|
finishDownload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopDownload() {
|
||||||
|
try { await window.pywebview.api.stop_download(); } catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishDownload() {
|
||||||
|
mstate.downloading = false;
|
||||||
|
$("#spotify-stop-btn").hidden = true;
|
||||||
|
$("#spotify-stop-btn").disabled = true;
|
||||||
|
updateSelectionCount();
|
||||||
|
if (mstate.tracks.length) {
|
||||||
|
window.pywebview.api.spotify_check_existing(mstate.tracks)
|
||||||
|
.then((r) => { mstate.existing = r || mstate.existing; renderTable(); })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
// Pre-compila da config
|
||||||
|
try {
|
||||||
|
const q = (state.config && state.config.spotify_search_last_query) || "";
|
||||||
|
const am = !!(state.config && state.config.spotify_search_artist_mode);
|
||||||
|
const qi = $("#spotify-query");
|
||||||
|
if (qi && q) qi.value = q;
|
||||||
|
const ac = $("#spotify-artist-mode");
|
||||||
|
if (ac) ac.checked = am;
|
||||||
|
} catch (_e) { /* ignora */ }
|
||||||
|
|
||||||
|
updateOutputInfo();
|
||||||
|
|
||||||
|
$("#spotify-search-btn").addEventListener("click", doSearch);
|
||||||
|
$("#spotify-query").addEventListener("keydown", (e) => { if (e.key === "Enter") doSearch(); });
|
||||||
|
$("#spotify-select-all").addEventListener("change", (e) => {
|
||||||
|
$$("#spotify-tbody input[type=checkbox]").forEach((b) => { b.checked = e.target.checked; });
|
||||||
|
updateSelectionCount();
|
||||||
|
});
|
||||||
|
$("#spotify-download-btn").addEventListener("click", startDownload);
|
||||||
|
$("#spotify-stop-btn").addEventListener("click", stopDownload);
|
||||||
|
|
||||||
|
// Wrap bridge handlers additivamente
|
||||||
|
const origLog = bridgeHandlers["log"];
|
||||||
|
bridgeHandlers["log"] = (payload) => {
|
||||||
|
if (origLog) origLog(payload);
|
||||||
|
if (mstate.downloading && payload && payload.view === "download") {
|
||||||
|
appendSpotifyLog(payload.msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const origDone = bridgeHandlers["download:done"];
|
||||||
|
bridgeHandlers["download:done"] = (payload) => {
|
||||||
|
if (origDone) origDone(payload);
|
||||||
|
if (mstate.downloading) finishDownload();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init };
|
||||||
|
})();
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// YouTube search
|
||||||
|
// =====================================================================
|
||||||
|
const YoutubeUI = (() => {
|
||||||
|
const mstate = { tracks: [], existing: [], downloading: false };
|
||||||
|
|
||||||
|
function _escape(s) {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.textContent = s == null ? "" : String(s);
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
function _fmtDuration(sec) {
|
||||||
|
const n = Math.max(0, Math.floor(Number(sec) || 0));
|
||||||
|
const m = Math.floor(n / 60);
|
||||||
|
const s = n % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectionCount() {
|
||||||
|
const boxes = $$("#youtube-tbody input[type=checkbox]");
|
||||||
|
const total = boxes.length;
|
||||||
|
const selected = boxes.filter((b) => b.checked).length;
|
||||||
|
$("#youtube-selected-count").textContent = `${selected}/${total} selezionati`;
|
||||||
|
const btn = $("#youtube-download-btn");
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = mstate.downloading || selected === 0;
|
||||||
|
btn.innerHTML = `<span class="ico">▶</span> Scarica selezionati${selected ? ` (${selected})` : ""}`;
|
||||||
|
}
|
||||||
|
const sa = $("#youtube-select-all");
|
||||||
|
if (sa) {
|
||||||
|
if (total === 0) { sa.checked = false; sa.indeterminate = false; }
|
||||||
|
else if (selected === 0) { sa.checked = false; sa.indeterminate = false; }
|
||||||
|
else if (selected === total) { sa.checked = true; sa.indeterminate = false; }
|
||||||
|
else { sa.checked = false; sa.indeterminate = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const tbody = $("#youtube-tbody");
|
||||||
|
if (!tbody) return;
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
mstate.tracks.forEach((t, i) => {
|
||||||
|
const already = !!mstate.existing[i];
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.dataset.idx = String(i);
|
||||||
|
if (already) tr.classList.add("already-downloaded");
|
||||||
|
const coverImg = t.image_url
|
||||||
|
? `<img src="${_escape(t.image_url)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"/>`
|
||||||
|
: "";
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="col-check"><input type="checkbox" data-idx="${i}" ${already ? "" : "checked"} /></td>
|
||||||
|
<td class="col-cover">${coverImg}</td>
|
||||||
|
<td class="col-pos">${i + 1}</td>
|
||||||
|
<td>${_escape(t.title)}</td>
|
||||||
|
<td>${_escape(t.channel)}</td>
|
||||||
|
<td class="col-dur">${_fmtDuration(t.duration_sec)}</td>
|
||||||
|
<td class="col-state">${already ? "✓ già scaricato" : ""}</td>
|
||||||
|
`;
|
||||||
|
const cb = tr.querySelector("input[type=checkbox]");
|
||||||
|
if (cb) cb.addEventListener("change", updateSelectionCount);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
updateSelectionCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text, kind) {
|
||||||
|
const el = $("#youtube-status");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text || "";
|
||||||
|
el.className = "beatport-status" + (kind ? " " + kind : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOutputInfo() {
|
||||||
|
const info = $("#youtube-output-info");
|
||||||
|
if (!info) return;
|
||||||
|
const root = (state.config && state.config.output_dir) || "";
|
||||||
|
if (!root) {
|
||||||
|
info.textContent = "⚠ Imposta la cartella output in Impostazioni prima di scaricare";
|
||||||
|
info.className = "beatport-output-info warn";
|
||||||
|
} else {
|
||||||
|
info.textContent = `Destinazione: ${root}/YouTube`;
|
||||||
|
info.className = "beatport-output-info";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSearch() {
|
||||||
|
const q = ($("#youtube-query").value || "").trim();
|
||||||
|
if (!q) return;
|
||||||
|
const wrap = $("#youtube-table-wrap");
|
||||||
|
|
||||||
|
setStatus("Ricerca su YouTube in corso…", "loading");
|
||||||
|
if (wrap) wrap.hidden = true;
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await window.pywebview.api.youtube_search(q);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("Errore: " + ((e && e.message) || e), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
setStatus((res && res.message) || "Errore sconosciuto", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mstate.tracks = res.tracks || [];
|
||||||
|
if (mstate.tracks.length === 0) {
|
||||||
|
setStatus(`Nessun video trovato per "${q}".`, "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
mstate.existing = await window.pywebview.api.youtube_check_existing(mstate.tracks);
|
||||||
|
} catch (_e) {
|
||||||
|
mstate.existing = mstate.tracks.map(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(`${mstate.tracks.length} risultati`, "ok");
|
||||||
|
updateOutputInfo();
|
||||||
|
renderTable();
|
||||||
|
if (wrap) wrap.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendYoutubeLog(msg) {
|
||||||
|
const el = $("#youtube-log");
|
||||||
|
if (!el) return;
|
||||||
|
const cls = classifyLog(msg);
|
||||||
|
const line = document.createElement("div");
|
||||||
|
if (cls) line.className = cls;
|
||||||
|
line.textContent = msg;
|
||||||
|
el.appendChild(line);
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDownload() {
|
||||||
|
const boxes = $$("#youtube-tbody input[type=checkbox]");
|
||||||
|
const selected = [];
|
||||||
|
boxes.forEach((b) => {
|
||||||
|
if (b.checked) {
|
||||||
|
const idx = parseInt(b.dataset.idx, 10);
|
||||||
|
if (!isNaN(idx) && mstate.tracks[idx]) selected.push(mstate.tracks[idx]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!selected.length) {
|
||||||
|
toast("Seleziona almeno un video", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.config || !state.config.output_dir) {
|
||||||
|
toast("Imposta la cartella output in Impostazioni", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mstate.downloading = true;
|
||||||
|
$("#youtube-download-btn").disabled = true;
|
||||||
|
$("#youtube-stop-btn").hidden = false;
|
||||||
|
$("#youtube-stop-btn").disabled = false;
|
||||||
|
$("#youtube-log").innerHTML = "";
|
||||||
|
appendYoutubeLog(`[INFO] Avvio download di ${selected.length} video…`);
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await window.pywebview.api.youtube_search_download(selected);
|
||||||
|
} catch (e) {
|
||||||
|
appendYoutubeLog("[ERRORE] " + ((e && e.message) || e));
|
||||||
|
finishDownload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
const errMsg = (res && (res.error || res.message)) || "Impossibile avviare il download";
|
||||||
|
appendYoutubeLog("[ERRORE] " + errMsg);
|
||||||
|
if (!handleGateBlock(res || {})) toast(errMsg, "error");
|
||||||
|
finishDownload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopDownload() {
|
||||||
|
try { await window.pywebview.api.stop_download(); } catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishDownload() {
|
||||||
|
mstate.downloading = false;
|
||||||
|
$("#youtube-stop-btn").hidden = true;
|
||||||
|
$("#youtube-stop-btn").disabled = true;
|
||||||
|
updateSelectionCount();
|
||||||
|
if (mstate.tracks.length) {
|
||||||
|
window.pywebview.api.youtube_check_existing(mstate.tracks)
|
||||||
|
.then((r) => { mstate.existing = r || mstate.existing; renderTable(); })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const q = (state.config && state.config.youtube_search_last_query) || "";
|
||||||
|
const qi = $("#youtube-query");
|
||||||
|
if (qi && q) qi.value = q;
|
||||||
|
} catch (_e) { /* ignora */ }
|
||||||
|
|
||||||
|
updateOutputInfo();
|
||||||
|
|
||||||
|
$("#youtube-search-btn").addEventListener("click", doSearch);
|
||||||
|
$("#youtube-query").addEventListener("keydown", (e) => { if (e.key === "Enter") doSearch(); });
|
||||||
|
$("#youtube-select-all").addEventListener("change", (e) => {
|
||||||
|
$$("#youtube-tbody input[type=checkbox]").forEach((b) => { b.checked = e.target.checked; });
|
||||||
|
updateSelectionCount();
|
||||||
|
});
|
||||||
|
$("#youtube-download-btn").addEventListener("click", startDownload);
|
||||||
|
$("#youtube-stop-btn").addEventListener("click", stopDownload);
|
||||||
|
|
||||||
|
const origLog = bridgeHandlers["log"];
|
||||||
|
bridgeHandlers["log"] = (payload) => {
|
||||||
|
if (origLog) origLog(payload);
|
||||||
|
if (mstate.downloading && payload && payload.view === "download") {
|
||||||
|
appendYoutubeLog(payload.msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const origDone = bridgeHandlers["download:done"];
|
||||||
|
bridgeHandlers["download:done"] = (payload) => {
|
||||||
|
if (origDone) origDone(payload);
|
||||||
|
if (mstate.downloading) finishDownload();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init };
|
||||||
|
})();
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Boot
|
// Boot
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in new issue
Block a user