Compare commits

..
16 Commits
Author SHA1 Message Date
luciano 72e700c85d config: bump VERSION v1.8.6 2026-07-21 17:51:22 +02:00
luciano a774906814 ui: ConvertUI (file picker, opzioni, batch conversione) 2026-07-21 17:48:31 +02:00
luciano 9f41f9d302 ui: markup tab Converti 2026-07-21 17:47:16 +02:00
luciano b0cd046375 bridge: 4 metodi Api per convertitore WAV -> MP3 2026-07-21 17:44:59 +02:00
luciano 5fc9f8ac2b converter: modulo ffmpeg WAV -> MP3 con VBR/CBR + stop 2026-07-21 17:44:36 +02:00
luciano c4e6d6e322 fix: bundle certifi CA per SSL su Windows/macOS + bump v1.8.5
- requirements.txt: certifi come dep esplicita
- main.py: setta SSL_CERT_FILE/REQUESTS_CA_BUNDLE su certifi.where() al boot
- build_windows.py + build_macos.py: --collect-data certifi nel PyInstaller

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

No files matched your search

+349 -10
View File
@@ -10,7 +10,7 @@ import time
import webbrowser import webbrowser
from dataclasses import asdict from dataclasses import asdict
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Callable, Optional
import requests import requests
@@ -32,6 +32,7 @@ from core.spotify_client import (
SpotifyAuthRequired, SpotifyAuthRequired,
) )
from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS
from core import tagger
from core.recorder import ( from core.recorder import (
list_input_devices, list_input_devices,
start_recording, start_recording,
@@ -516,13 +517,20 @@ class Api:
def start_tracks_download(self, payload: dict) -> dict: def start_tracks_download(self, payload: dict) -> dict:
"""Avvia il download di una tracklist gia parsata """Avvia il download di una tracklist gia parsata
(lista di {name, artist}). Se 'subfolder' e presente, (lista di {name, artist}). Se 'subfolder' e presente,
scarica in output_dir/subfolder.""" scarica in output_dir/subfolder.
`payload['metadata']` opzionale: lista parallela a `tracks` con dict
per il tagging ID3 (title, artist, album, date, genre, tracknumber,
cover_url). Se presente, i file MP3 vengono nominati come
"Artista - Titolo.mp3" e taggati post-download.
"""
if self._any_job_running(): if self._any_job_running():
return {"ok": False, "error": "Un download gia in corso"} return {"ok": False, "error": "Un download gia in corso"}
tracks = payload.get("tracks") or [] tracks = payload.get("tracks") or []
output_dir = (payload.get("output_dir") or "").strip() output_dir = (payload.get("output_dir") or "").strip()
subfolder = (payload.get("subfolder") or "").strip() subfolder = (payload.get("subfolder") or "").strip()
metadata_list = payload.get("metadata") or None
if not tracks: if not tracks:
return {"ok": False, "error": "Nessuna traccia fornita"} return {"ok": False, "error": "Nessuna traccia fornita"}
if not output_dir: if not output_dir:
@@ -541,6 +549,7 @@ class Api:
self._download_thread = threading.Thread( self._download_thread = threading.Thread(
target=self._tracks_worker, target=self._tracks_worker,
args=(list(tracks), output_dir), args=(list(tracks), output_dir),
kwargs={"metadata_list": list(metadata_list) if metadata_list else None},
daemon=True, daemon=True,
) )
self._download_thread.start() self._download_thread.start()
@@ -548,7 +557,13 @@ class Api:
def start_urls_download(self, payload: dict) -> dict: def start_urls_download(self, payload: dict) -> dict:
"""Analogo a start_tracks_download ma accetta URL YouTube gia noti """Analogo a start_tracks_download ma accetta URL YouTube gia noti
(bypass search). Usato dal flow del tab 'YouTube Search'.""" (bypass search). Usato dal flow del tab 'YouTube Search'.
`payload['metadata']` opzionale: lista parallela a `urls` con dict
per il tagging ID3 (title, artist, album, date, genre, tracknumber,
cover_url). Se presente, i file MP3 vengono nominati come
"Artista - Titolo.mp3" e taggati post-download.
"""
if self._any_job_running(): if self._any_job_running():
return {"ok": False, "error": "Un download gia in corso"} return {"ok": False, "error": "Un download gia in corso"}
@@ -556,6 +571,7 @@ class Api:
titles = payload.get("titles") or [] titles = payload.get("titles") or []
output_dir = (payload.get("output_dir") or "").strip() output_dir = (payload.get("output_dir") or "").strip()
subfolder = (payload.get("subfolder") or "").strip() subfolder = (payload.get("subfolder") or "").strip()
metadata_list = payload.get("metadata") or None
if not urls: if not urls:
return {"ok": False, "error": "Nessuna URL fornita"} return {"ok": False, "error": "Nessuna URL fornita"}
if len(urls) != len(titles): if len(urls) != len(titles):
@@ -575,6 +591,7 @@ class Api:
self._download_thread = threading.Thread( self._download_thread = threading.Thread(
target=self._urls_worker, target=self._urls_worker,
args=(list(urls), list(titles), output_dir), args=(list(urls), list(titles), output_dir),
kwargs={"metadata_list": list(metadata_list) if metadata_list else None},
daemon=True, daemon=True,
) )
self._download_thread.start() self._download_thread.start()
@@ -585,7 +602,8 @@ class Api:
self._log("download", "[INFO] Interruzione richiesta...") self._log("download", "[INFO] Interruzione richiesta...")
return {"ok": True} return {"ok": True}
def _tracks_worker(self, tracks: list, output_dir: str) -> None: def _tracks_worker(self, tracks: list, output_dir: str,
metadata_list: Optional[list] = None) -> None:
reset_download_stop() reset_download_stop()
cfg = load_config() cfg = load_config()
bitrate = cfg.get("bitrate", "320K") bitrate = cfg.get("bitrate", "320K")
@@ -595,6 +613,31 @@ class Api:
self._log(view, f"[INFO] Tracklist: {len(tracks)} brani da cercare su YouTube") self._log(view, f"[INFO] Tracklist: {len(tracks)} brani da cercare su YouTube")
self._log(view, f"[INFO] Destinazione: {output_dir}") self._log(view, f"[INFO] Destinazione: {output_dir}")
# Pipeline tagging: se metadata_list fornito, calcola i filename
# ("Artista - Titolo") e prepara callback per scrivere ID3 tag
# + cover art dopo ogni download riuscito.
output_filenames: Optional[list] = None
post_cb: Optional[Callable] = None
if metadata_list:
output_filenames = []
for md in metadata_list:
md = md or {}
stem = tagger.build_filename_stem(
md.get("artist", ""),
md.get("title", ""),
)
output_filenames.append(stem)
def post_cb(idx: int, filepath: str,
_md_list=metadata_list) -> None:
if idx < 0 or idx >= len(_md_list):
return
md = _md_list[idx] or {}
try:
tagger.write_tags(filepath, md)
except Exception:
pass # tagging best-effort, non blocca il download
_last = [0.0] _last = [0.0]
_THROTTLE = 0.10 _THROTTLE = 0.10
@@ -634,10 +677,15 @@ class Api:
self._emit("download:progress", payload_evt) self._emit("download:progress", payload_evt)
download_playlist(tracks, output_dir, bitrate, cookies_path, progress_cb) download_playlist(
tracks, output_dir, bitrate, cookies_path, progress_cb,
output_filenames=output_filenames,
post_download_callback=post_cb,
)
self._emit("download:done", {"ok": True}) self._emit("download:done", {"ok": True})
def _urls_worker(self, urls: list, titles: list, output_dir: str) -> None: def _urls_worker(self, urls: list, titles: list, output_dir: str,
metadata_list: Optional[list] = None) -> None:
from core.downloader import download_urls from core.downloader import download_urls
reset_download_stop() reset_download_stop()
cfg = load_config() cfg = load_config()
@@ -648,6 +696,29 @@ class Api:
self._log(view, f"[INFO] URL list: {len(urls)} da scaricare da YouTube") self._log(view, f"[INFO] URL list: {len(urls)} da scaricare da YouTube")
self._log(view, f"[INFO] Destinazione: {output_dir}") self._log(view, f"[INFO] Destinazione: {output_dir}")
# Stessa pipeline tagging di _tracks_worker
output_filenames: Optional[list] = None
post_cb: Optional[Callable] = None
if metadata_list:
output_filenames = []
for md in metadata_list:
md = md or {}
stem = tagger.build_filename_stem(
md.get("artist", ""),
md.get("title", ""),
)
output_filenames.append(stem)
def post_cb(idx: int, filepath: str,
_md_list=metadata_list) -> None:
if idx < 0 or idx >= len(_md_list):
return
md = _md_list[idx] or {}
try:
tagger.write_tags(filepath, md)
except Exception:
pass # tagging best-effort, non blocca il download
_last = [0.0] _last = [0.0]
_THROTTLE = 0.10 _THROTTLE = 0.10
@@ -681,7 +752,11 @@ class Api:
self._emit("download:progress", payload_evt) self._emit("download:progress", payload_evt)
download_urls(urls, titles, output_dir, bitrate, cookies_path, progress_cb) download_urls(
urls, titles, output_dir, bitrate, cookies_path, progress_cb,
output_filenames=output_filenames,
post_download_callback=post_cb,
)
self._emit("download:done", {"ok": True}) self._emit("download:done", {"ok": True})
def _download_worker(self, urls: list, output_dir: str) -> None: def _download_worker(self, urls: list, output_dir: str) -> None:
@@ -1167,7 +1242,13 @@ class Api:
return result return result
def spotify_search_download(self, tracks: list) -> dict: def spotify_search_download(self, tracks: list) -> dict:
"""Scarica i track Spotify selezionati (name+artist → YouTube search).""" """Scarica i track Spotify selezionati (name+artist → YouTube search).
Costruisce anche `metadata` (una entry per track) per il tagging ID3
+ cover art post-download. Se le creds Spotify sono presenti,
arricchisce con i generi dell'artista (cache locale per artist_id
per evitare N+1 chiamate).
"""
cfg = load_config() cfg = load_config()
out_root = (cfg.get("output_dir") or "").strip() out_root = (cfg.get("output_dir") or "").strip()
if not out_root: if not out_root:
@@ -1176,7 +1257,20 @@ class Api:
target = self._music_output_dir(out_root, "Spotify") target = self._music_output_dir(out_root, "Spotify")
subfolder = target.name subfolder = target.name
converted = [] # Tentativo di token Spotify per fetch generi (best-effort, opzionale)
cid = (cfg.get("client_id") or "").strip()
secret = (cfg.get("client_secret") or "").strip()
token: Optional[str] = None
if cid and secret:
try:
token = spotify_client.get_access_token(cid, secret)
except Exception:
token = None
genre_cache: dict = {} # artist_id -> [genres]
converted: list = []
metadata: list = []
for t in tracks: for t in tracks:
title = (t.get("name") or "").strip() title = (t.get("name") or "").strip()
artists = (t.get("artists") or "").strip() artists = (t.get("artists") or "").strip()
@@ -1184,6 +1278,27 @@ class Api:
continue continue
converted.append({"name": title, "artist": artists}) converted.append({"name": title, "artist": artists})
# Genere: primo genere dell'artista (se disponibile e token OK)
genre = ""
artist_id = (t.get("artist_id") or "").strip()
if token and artist_id:
if artist_id not in genre_cache:
genre_cache[artist_id] = spotify_client.get_artist_genres(token, artist_id)
genres = genre_cache.get(artist_id) or []
if genres:
genre = genres[0]
tn = t.get("track_number") or 0
metadata.append({
"title": title,
"artist": artists,
"album": (t.get("album") or "").strip(),
"date": (t.get("release_date") or "").strip(),
"genre": genre,
"tracknumber": str(tn) if tn else "",
"cover_url": (t.get("cover_url_large") or t.get("image_url") or "").strip(),
})
if not converted: if not converted:
return {"ok": False, "error": "Nessun brano valido"} return {"ok": False, "error": "Nessun brano valido"}
@@ -1191,6 +1306,7 @@ class Api:
"tracks": converted, "tracks": converted,
"output_dir": out_root, "output_dir": out_root,
"subfolder": subfolder, "subfolder": subfolder,
"metadata": metadata,
}) })
# ---- YouTube ---- # ---- YouTube ----
@@ -1242,7 +1358,14 @@ class Api:
return result return result
def youtube_search_download(self, tracks: list) -> dict: def youtube_search_download(self, tracks: list) -> dict:
"""Scarica direttamente gli URL YouTube selezionati (no re-search).""" """Scarica direttamente gli URL YouTube selezionati (no re-search).
Per ogni video prova a dedurre i metadati:
1) Se le creds Spotify sono presenti, cerca il titolo su Spotify
e usa i tag Spotify se c'è un match (best-effort).
2) Altrimenti fallback: parsing "Artista - Titolo" dal titolo del
video; uploader come fallback per l'album.
"""
cfg = load_config() cfg = load_config()
out_root = (cfg.get("output_dir") or "").strip() out_root = (cfg.get("output_dir") or "").strip()
if not out_root: if not out_root:
@@ -1251,8 +1374,21 @@ class Api:
target = self._music_output_dir(out_root, "YouTube") target = self._music_output_dir(out_root, "YouTube")
subfolder = target.name subfolder = target.name
# Token Spotify opzionale per enrichment
cid = (cfg.get("client_id") or "").strip()
secret = (cfg.get("client_secret") or "").strip()
token: Optional[str] = None
if cid and secret:
try:
token = spotify_client.get_access_token(cid, secret)
except Exception:
token = None
genre_cache: dict = {} # artist_id -> [genres]
urls: list = [] urls: list = []
titles: list = [] titles: list = []
metadata: list = []
for t in tracks: for t in tracks:
url = (t.get("url") or "").strip() url = (t.get("url") or "").strip()
title = (t.get("title") or "").strip() title = (t.get("title") or "").strip()
@@ -1261,6 +1397,51 @@ class Api:
urls.append(url) urls.append(url)
titles.append(title) titles.append(title)
md: dict = {}
# 1) Enrichment via Spotify search (se token)
if token:
sp = spotify_client.enrich_from_youtube_title(token, title)
if sp:
# Genere: opzionale
genre = ""
artist_id = (sp.get("artist_id") or "").strip()
if artist_id:
if artist_id not in genre_cache:
genre_cache[artist_id] = spotify_client.get_artist_genres(token, artist_id)
genres = genre_cache.get(artist_id) or []
if genres:
genre = genres[0]
tn = sp.get("track_number") or 0
md = {
"title": (sp.get("name") or "").strip(),
"artist": (sp.get("artists") or "").strip(),
"album": (sp.get("album") or "").strip(),
"date": (sp.get("release_date") or "").strip(),
"genre": genre,
"tracknumber": str(tn) if tn else "",
"cover_url": (sp.get("cover_url_large") or sp.get("image_url") or "").strip(),
}
# 2) Fallback: parsing del titolo YouTube
if not md.get("title") or not md.get("artist"):
parsed = _parse_track_line(title)
if parsed:
fb_artist = parsed["artist"]
fb_title = parsed["name"]
else:
fb_artist = ""
fb_title = title
md = {
"title": fb_title,
"artist": fb_artist,
"album": (t.get("channel") or "").strip(),
"date": (t.get("release_date") or "").strip(),
"genre": "",
"tracknumber": "",
"cover_url": (t.get("image_url") or "").strip(),
}
metadata.append(md)
if not urls: if not urls:
return {"ok": False, "error": "Nessun URL valido"} return {"ok": False, "error": "Nessun URL valido"}
@@ -1269,4 +1450,162 @@ class Api:
"titles": titles, "titles": titles,
"output_dir": out_root, "output_dir": out_root,
"subfolder": subfolder, "subfolder": subfolder,
"metadata": metadata,
}) })
# ================================================================
# WAV -> MP3 converter
# ================================================================
def convert_pick_wav_files(self) -> list:
"""Apre file picker multi-selezione per .wav. Ritorna list di path."""
if not self.window:
return []
try:
filetypes = ("WAV files (*.wav)",)
result = self.window.create_file_dialog(
self._open_dialog_type(),
allow_multiple=True,
file_types=filetypes,
)
except Exception:
return []
if not result:
return []
return [str(p) for p in result if str(p).lower().endswith(".wav")]
def convert_pick_wav_folder(self, recursive: bool = True) -> list:
"""Apre folder picker, scan .wav (opzionalmente ricorsivo)."""
from core import converter
if not self.window:
return []
try:
result = self.window.create_file_dialog(self._folder_dialog_type())
except Exception:
return []
if not result:
return []
folder = str(result[0]) if isinstance(result, (list, tuple)) else str(result)
return converter.list_wav_files(folder, recursive=recursive)
def convert_start(self, payload: dict) -> dict:
"""Avvia conversione batch. Payload:
{files: [str], bitrate: int, vbr: bool, output_dir: str | None}
Se output_dir e vuoto/None, il .mp3 va accanto al .wav.
"""
if self._any_job_running():
return {"ok": False, "error": "Un'operazione e' gia in corso"}
files = payload.get("files") or []
if not files:
return {"ok": False, "error": "Nessun file selezionato"}
try:
bitrate = int(payload.get("bitrate", 320))
except (TypeError, ValueError):
bitrate = 320
if bitrate not in (128, 192, 256, 320):
return {"ok": False, "error": "Bitrate non valido"}
vbr = bool(payload.get("vbr", False))
output_dir = (payload.get("output_dir") or "").strip() or None
gate = self._gate("audio")
if gate:
return gate
self._download_thread = threading.Thread(
target=self._convert_worker,
args=(list(files), bitrate, vbr, output_dir),
daemon=True,
)
self._download_thread.start()
return {"ok": True}
def convert_stop(self) -> dict:
from core import converter as conv
conv.request_stop()
self._log("convert", "[INFO] Interruzione richiesta...")
return {"ok": True}
def _convert_worker(self, files: list, bitrate: int, vbr: bool,
output_dir: Optional[str]) -> None:
from core import converter as conv
conv.reset_stop()
view = "convert"
total = len(files)
mode = "VBR" if vbr else "CBR"
self._log(view, f"[INFO] Conversione di {total} file (MP3 {bitrate}k {mode})")
if output_dir:
self._log(view, f"[INFO] Destinazione: {output_dir}")
else:
self._log(view, "[INFO] Destinazione: accanto al file originale")
_last = [0.0]
def make_progress_cb(idx: int, filename: str):
def cb(pct: int):
import time as _t
if pct == -1:
# interruzione
self._emit("convert:progress", {
"idx": idx, "total": total, "file": filename,
"status": "stopped", "pct": 0,
})
return
# throttle
now = _t.monotonic()
if pct < 100 and now - _last[0] < 0.10:
return
_last[0] = now
self._emit("convert:progress", {
"idx": idx, "total": total, "file": filename,
"status": "converting" if pct < 100 else "done",
"pct": pct,
"overall": min(((idx + pct / 100) / total), 1.0) if total else 0,
})
return cb
for i, src_path in enumerate(files):
if conv.is_stopped():
self._log(view, "[INFO] Conversione interrotta.")
break
src = Path(src_path)
if not src.exists():
self._log(view, f"[ERRORE] File mancante: {src.name}")
continue
dst_dir = Path(output_dir) if output_dir else src.parent
dst = dst_dir / (src.stem + ".mp3")
# Skip se il .mp3 esiste gia (protezione contro overwrite)
if dst.exists():
self._log(view, f"[SKIP] {dst.name} esiste gia'")
self._emit("convert:progress", {
"idx": i, "total": total, "file": src.name,
"status": "skipped", "pct": 100,
"overall": min(((i + 1) / total), 1.0) if total else 1,
})
continue
self._log(view, f"[CONVERTI] {src.name}")
self._emit("convert:progress", {
"idx": i, "total": total, "file": src.name,
"status": "converting", "pct": 0,
})
try:
conv.convert_wav_to_mp3(
str(src), str(dst),
bitrate=bitrate, vbr=vbr,
progress_callback=make_progress_cb(i, src.name),
)
self._log(view, f"[OK] {dst.name}")
except Exception as e:
self._log(view, f"[ERRORE] {src.name}: {e}")
self._emit("convert:progress", {
"idx": i, "total": total, "file": src.name,
"status": f"error: {e}", "pct": 0,
})
self._emit("convert:done", {"ok": True})
+2
View File
@@ -226,6 +226,8 @@ def run_pyinstaller():
"--hidden-import", "webview.platforms.cocoa", "--hidden-import", "webview.platforms.cocoa",
"--hidden-import", "requests", "--hidden-import", "requests",
"--collect-all", "mutagen", "--collect-all", "mutagen",
# certifi ha cacert.pem necessario per SSL/HTTPS
"--collect-data", "certifi",
# Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs) # Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs)
*add_binaries, *add_binaries,
# Entry point # Entry point
+2
View File
@@ -133,6 +133,8 @@ def run_pyinstaller():
"--collect-all", "pythonnet", "--collect-all", "pythonnet",
"--collect-all", "clr_loader", "--collect-all", "clr_loader",
"--collect-all", "mutagen", "--collect-all", "mutagen",
# certifi ha il file dati cacert.pem necessario per SSL/HTTPS
"--collect-data", "certifi",
# collect-data prende anche i file .json/.dll non-Python che # collect-data prende anche i file .json/.dll non-Python che
# collect-all potrebbe saltare (Python.Runtime.runtimeconfig.json # collect-all potrebbe saltare (Python.Runtime.runtimeconfig.json
# in particolare). # in particolare).
+13
View File
@@ -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
+1 -1
View File
@@ -5,7 +5,7 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
VERSION = "v1.8.1" VERSION = "v1.8.6"
APP_NAME = "MusicTools" APP_NAME = "MusicTools"
+176
View File
@@ -0,0 +1,176 @@
"""Conversione WAV -> MP3 via ffmpeg subprocess."""
from __future__ import annotations
import re
import subprocess
import threading
from pathlib import Path
from typing import Callable, Optional
from core.paths import find_ffmpeg_dir, subprocess_flags
_stop_event = threading.Event()
_current_process: Optional[subprocess.Popen] = None
_process_lock = threading.Lock()
def request_stop() -> None:
_stop_event.set()
with _process_lock:
if _current_process and _current_process.poll() is None:
_current_process.terminate()
def reset_stop() -> None:
_stop_event.clear()
def is_stopped() -> bool:
return _stop_event.is_set()
# VBR quality mapping da bitrate label a -q:a (libmp3lame). Piu basso = migliore qualita.
# Vedi https://trac.ffmpeg.org/wiki/Encode/MP3
_VBR_QUALITY = {
128: 5,
192: 2,
256: 0,
320: 0, # V0 e' ~245k medio; per >V0 c'e' solo CBR 320
}
def _find_ffmpeg() -> str:
"""Trova il binario ffmpeg dentro bundle_bin o PATH.
Solleva RuntimeError se non trovato.
"""
ffmpeg_dir = find_ffmpeg_dir()
if ffmpeg_dir:
for name in ("ffmpeg", "ffmpeg.exe"):
p = Path(ffmpeg_dir) / name
if p.exists():
return str(p)
# Fallback PATH
import shutil
ff = shutil.which("ffmpeg")
if ff:
return ff
raise RuntimeError("ffmpeg non trovato nel bundle o nel PATH")
def convert_wav_to_mp3(
input_path: str,
output_path: str,
bitrate: int = 320,
vbr: bool = False,
progress_callback: Optional[Callable] = None,
) -> None:
"""Converte un file WAV in MP3.
Args:
input_path: file .wav sorgente
output_path: file .mp3 destinazione
bitrate: 128 / 192 / 256 / 320
vbr: True per Variable Bit Rate (-q:a), False per Constant (-b:a)
progress_callback: opzionale, chiamato con (percent 0..100)
Raises:
RuntimeError: se ffmpeg non trovato o exit non-zero.
FileNotFoundError: se input_path non esiste.
"""
src = Path(input_path)
if not src.exists():
raise FileNotFoundError(f"File sorgente non trovato: {input_path}")
dst = Path(output_path)
dst.parent.mkdir(parents=True, exist_ok=True)
ffmpeg = _find_ffmpeg()
cmd = [ffmpeg, "-y", "-i", str(src)]
if vbr:
quality = _VBR_QUALITY.get(bitrate, 2)
cmd.extend(["-c:a", "libmp3lame", "-q:a", str(quality)])
else:
cmd.extend(["-c:a", "libmp3lame", "-b:a", f"{bitrate}k"])
cmd.extend([
"-map_metadata", "0", # copia metadata WAV se presenti
"-id3v2_version", "3",
"-progress", "pipe:2", # progress su stderr in formato key=value
str(dst),
])
# Prova a ottenere la durata totale per calcolare % — leggendo lo stream INFO
duration_us = _probe_duration_us(ffmpeg, str(src))
global _current_process
with _process_lock:
_current_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
**subprocess_flags(),
)
proc = _current_process
try:
for line in proc.stderr:
if is_stopped():
proc.terminate()
if progress_callback:
progress_callback(-1) # segnale di interruzione
return
if not progress_callback or not duration_us:
continue
# ffmpeg -progress emette "out_time_us=<microsecondi>"
m = re.match(r"out_time_us=(\d+)", line.strip())
if m:
elapsed = int(m.group(1))
pct = min(100, int(elapsed * 100 / duration_us))
progress_callback(pct)
rc = proc.wait()
if rc != 0:
raise RuntimeError(f"ffmpeg exit {rc}")
if progress_callback:
progress_callback(100)
finally:
with _process_lock:
_current_process = None
def _probe_duration_us(ffmpeg: str, input_path: str) -> int:
"""Estrae la durata in microsecondi via ffmpeg -f null. Ritorna 0 se non riesce."""
try:
# ffprobe potrebbe non essere sempre bundlato; usa ffmpeg
result = subprocess.run(
[ffmpeg, "-i", input_path, "-f", "null", "-"],
capture_output=True,
text=True,
timeout=10,
**subprocess_flags(),
)
# Cerca "Duration: HH:MM:SS.ms"
m = re.search(r"Duration:\s+(\d+):(\d+):(\d+)\.(\d+)", result.stderr)
if m:
h, mi, s, ms = map(int, m.groups())
total_sec = h * 3600 + mi * 60 + s + ms / 100
return int(total_sec * 1_000_000)
except Exception:
pass
return 0
def list_wav_files(directory: str, recursive: bool = True) -> list:
"""Scan cartella per file .wav. Ritorna list di path stringa ordinati."""
root = Path(directory)
if not root.is_dir():
return []
pattern = "**/*.wav" if recursive else "*.wav"
return sorted(str(p) for p in root.glob(pattern) if p.is_file())
+48 -2
View File
@@ -121,6 +121,8 @@ def download_playlist(
bitrate: str = "320K", bitrate: str = "320K",
cookies_path: Optional[str] = None, cookies_path: Optional[str] = None,
progress_callback: Optional[Callable] = None, progress_callback: Optional[Callable] = None,
output_filenames: Optional[list] = None,
post_download_callback: Optional[Callable] = None,
) -> None: ) -> None:
"""Scarica tutti i brani dalla lista di tracce. """Scarica tutti i brani dalla lista di tracce.
@@ -130,6 +132,12 @@ def download_playlist(
bitrate: qualita audio (es. "320K") bitrate: qualita audio (es. "320K")
cookies_path: percorso file cookies (opzionale) cookies_path: percorso file cookies (opzionale)
progress_callback: callback(track_index, total, track_name, status, percent) progress_callback: callback(track_index, total, track_name, status, percent)
output_filenames: opzionale, parallelo a `tracks`. Se presente e non
vuoto per l'indice i, yt-dlp scrive `<output_filenames[i]>.mp3`
invece di usare il titolo YouTube. Passa gia' sanitizzato.
post_download_callback: opzionale, chiamato come `(i, filepath)`
dopo yt-dlp exit 0 con il path effettivo del file .mp3 salvato.
Usato per tagging ID3 post-download.
""" """
reset_stop() reset_stop()
total = len(tracks) total = len(tracks)
@@ -183,6 +191,15 @@ def download_playlist(
if progress_callback: if progress_callback:
progress_callback(i, total, query, "downloading", 0) progress_callback(i, total, query, "downloading", 0)
# Nome file custom (per tagging: "Artista - Titolo") oppure titolo YouTube grezzo
custom_stem = ""
if output_filenames and i < len(output_filenames):
custom_stem = (output_filenames[i] or "").strip()
if custom_stem:
out_template = str(Path(output_dir) / f"{custom_stem}.%(ext)s")
else:
out_template = str(Path(output_dir) / "%(title)s.%(ext)s")
# Scarica con yt-dlp subprocess # Scarica con yt-dlp subprocess
cmd = [ cmd = [
ytdlp, ytdlp,
@@ -193,7 +210,7 @@ def download_playlist(
"--add-metadata", "--add-metadata",
"--no-check-certificates", "--no-check-certificates",
"--newline", "--newline",
"--output", str(Path(output_dir) / "%(title)s.%(ext)s"), "--output", out_template,
video_url, video_url,
] ]
ffmpeg_dir = find_ffmpeg_dir() ffmpeg_dir = find_ffmpeg_dir()
@@ -231,6 +248,12 @@ def download_playlist(
_mark_done(done_file, query) _mark_done(done_file, query)
done_set.add(query) done_set.add(query)
existing_files = _scan_existing_files(out_path) existing_files = _scan_existing_files(out_path)
if post_download_callback and custom_stem:
mp3_path = Path(output_dir) / f"{custom_stem}.mp3"
try:
post_download_callback(i, str(mp3_path))
except Exception:
pass
if progress_callback: if progress_callback:
progress_callback(i, total, query, "done", 100) progress_callback(i, total, query, "done", 100)
else: else:
@@ -406,6 +429,8 @@ def download_urls(
bitrate: str = "320K", bitrate: str = "320K",
cookies_path: Optional[str] = None, cookies_path: Optional[str] = None,
progress_callback: Optional[Callable] = None, progress_callback: Optional[Callable] = None,
output_filenames: Optional[list] = None,
post_download_callback: Optional[Callable] = None,
) -> None: ) -> None:
"""Scarica direttamente da URL YouTube (bypassa search). """Scarica direttamente da URL YouTube (bypassa search).
@@ -420,6 +445,12 @@ def download_urls(
bitrate: qualita audio (default 320K) bitrate: qualita audio (default 320K)
cookies_path: file cookies opzionale cookies_path: file cookies opzionale
progress_callback: callback(idx, total, title, status, percent) progress_callback: callback(idx, total, title, status, percent)
output_filenames: opzionale, parallelo a `urls`. Se presente e non
vuoto per l'indice i, yt-dlp scrive `<output_filenames[i]>.mp3`
invece di usare il titolo YouTube. Passa gia' sanitizzato.
post_download_callback: opzionale, chiamato come `(i, filepath)`
dopo yt-dlp exit 0 con il path effettivo del file .mp3 salvato.
Usato per tagging ID3 post-download.
""" """
global _current_process global _current_process
reset_stop() reset_stop()
@@ -456,6 +487,15 @@ def download_urls(
if progress_callback: if progress_callback:
progress_callback(i, total, key, "downloading", 0) progress_callback(i, total, key, "downloading", 0)
# Nome file custom (per tagging: "Artista - Titolo") oppure titolo YouTube grezzo
custom_stem = ""
if output_filenames and i < len(output_filenames):
custom_stem = (output_filenames[i] or "").strip()
if custom_stem:
out_template = str(Path(output_dir) / f"{custom_stem}.%(ext)s")
else:
out_template = str(Path(output_dir) / "%(title)s.%(ext)s")
cmd = [ cmd = [
ytdlp, ytdlp,
"--extract-audio", "--extract-audio",
@@ -465,7 +505,7 @@ def download_urls(
"--add-metadata", "--add-metadata",
"--no-check-certificates", "--no-check-certificates",
"--newline", "--newline",
"--output", str(Path(output_dir) / "%(title)s.%(ext)s"), "--output", out_template,
url, url,
] ]
ffmpeg_dir = find_ffmpeg_dir() ffmpeg_dir = find_ffmpeg_dir()
@@ -502,6 +542,12 @@ def download_urls(
_mark_done(done_file, key) _mark_done(done_file, key)
done_set.add(key) done_set.add(key)
existing_files = _scan_existing_files(out_path) existing_files = _scan_existing_files(out_path)
if post_download_callback and custom_stem:
mp3_path = Path(output_dir) / f"{custom_stem}.mp3"
try:
post_download_callback(i, str(mp3_path))
except Exception:
pass
if progress_callback: if progress_callback:
progress_callback(i, total, key, "done", 100) progress_callback(i, total, key, "done", 100)
else: else:
+69 -5
View File
@@ -308,16 +308,74 @@ def search_tracks(token: str, query: str, limit: int = 50) -> list:
def _track_to_dict(t: dict) -> dict: def _track_to_dict(t: dict) -> dict:
"""Mappa il track object Spotify sul nostro schema uniforme.""" """Mappa il track object Spotify sul nostro schema uniforme."""
album = t.get("album", {}) or {}
images = album.get("images", []) or []
# Spotify torna 3 taglie ordinate large->small. Prendo la più piccola (~64px)
# per la thumbnail UI, la più grande (~640px) per il tagging cover.
image_url = "" # ~64px per UI thumbnail
cover_url_large = "" # ~640px per tagging ID3
if images:
image_url = images[-1].get("url", "") or images[0].get("url", "")
cover_url_large = images[0].get("url", "") or image_url
# release_date può essere YYYY, YYYY-MM o YYYY-MM-DD in base a release_date_precision
release_date = str(album.get("release_date") or "").strip()
# track_number (opzionale: presente su brani da album, assente su top-tracks flat)
tracknumber = t.get("track_number")
# artist_id (primo artista) per fetchare i generi ID3
artists_arr = t.get("artists", []) or []
artist_id = artists_arr[0].get("id", "") if artists_arr else ""
return { return {
"id": t.get("id", ""), "id": t.get("id", ""),
"url": t.get("external_urls", {}).get("spotify", ""), "url": t.get("external_urls", {}).get("spotify", ""),
"name": t.get("name", ""), "name": t.get("name", ""),
"artists": ", ".join(a.get("name", "") for a in t.get("artists", [])), "artists": ", ".join(a.get("name", "") for a in artists_arr),
"album": t.get("album", {}).get("name", ""), "album": album.get("name", ""),
"duration_sec": int(t.get("duration_ms", 0)) // 1000, "duration_sec": int(t.get("duration_ms", 0)) // 1000,
"image_url": image_url,
"cover_url_large": cover_url_large,
"release_date": release_date,
"track_number": int(tracknumber) if tracknumber else 0,
"artist_id": artist_id,
} }
def get_artist_genres(token: str, artist_id: str) -> list:
"""Ritorna la lista di generi dell'artista (può essere vuota).
Silenzia gli errori HTTP: usato per arricchimento best-effort dei tag
ID3, non deve mai bloccare il download.
"""
if not artist_id:
return []
try:
resp = requests.get(
f"https://api.spotify.com/v1/artists/{artist_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
if resp.status_code != 200:
return []
return list(resp.json().get("genres") or [])
except Exception:
return []
def enrich_from_youtube_title(token: str, video_title: str) -> dict:
"""Cerca su Spotify il primo match per il titolo di un video YouTube.
Ritorna un dict con la stessa shape di _track_to_dict, oppure {} se
nessun match / errore. Best-effort per il tagging dei download YouTube.
"""
query = (video_title or "").strip()
if not query:
return {}
try:
results = search_tracks(token, query, limit=1)
except Exception:
return {}
return results[0] if results else {}
def search_artist_discography(token: str, artist_name: str) -> list: def search_artist_discography(token: str, artist_name: str) -> list:
"""Trova l'artista esatto (o il piu' popolare tra i match) e ritorna """Trova l'artista esatto (o il piu' popolare tra i match) e ritorna
tutti i suoi brani: top tracks + tracce di ogni album/single. tutti i suoi brani: top tracks + tracce di ogni album/single.
@@ -372,10 +430,13 @@ def search_artist_discography(token: str, artist_name: str) -> list:
r_alb.raise_for_status() r_alb.raise_for_status()
albums = r_alb.json().get("items", []) albums = r_alb.json().get("items", [])
# 4. Per ogni album, tracce (album/track object non ha "album" sub-field, iniettiamola) # 4. Per ogni album, tracce (album/track object non ha "album" sub-field, iniettiamola
# includendo anche le images dell'album così _track_to_dict riesce a estrarre la cover)
for alb in albums: for alb in albums:
alb_id = alb.get("id") alb_id = alb.get("id")
alb_name = alb.get("name", "") alb_name = alb.get("name", "")
alb_images = alb.get("images", []) or []
alb_release_date = alb.get("release_date", "")
if not alb_id: if not alb_id:
continue continue
time.sleep(0.1) # rate limit interno time.sleep(0.1) # rate limit interno
@@ -388,8 +449,11 @@ def search_artist_discography(token: str, artist_name: str) -> list:
r_at.raise_for_status() r_at.raise_for_status()
for t in r_at.json().get("items", []): for t in r_at.json().get("items", []):
t = dict(t) t = dict(t)
# Album tracks non hanno "album" nested; iniettiamo il nome t.setdefault("album", {
t.setdefault("album", {"name": alb_name}) "name": alb_name,
"images": alb_images,
"release_date": alb_release_date,
})
collected.append(_track_to_dict(t)) collected.append(_track_to_dict(t))
# 5. Dedupe # 5. Dedupe
+109
View File
@@ -0,0 +1,109 @@
"""Scrittura tag ID3 su file MP3 via mutagen, con cover art da URL."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Optional
import requests
from mutagen.easyid3 import EasyID3
from mutagen.id3 import ID3, APIC
from mutagen.mp3 import MP3
SAFE_FILENAME_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
def sanitize_filename_stem(stem: str, max_len: int = 180) -> str:
"""Rimuove caratteri non validi per un filename e limita lunghezza."""
s = SAFE_FILENAME_RE.sub("_", stem or "").strip()
s = re.sub(r"\s+", " ", s)
if len(s) > max_len:
s = s[:max_len].rstrip()
return s or "untitled"
def build_filename_stem(artist: str, title: str) -> str:
"""Compone 'Artista - Titolo' sanitizzato."""
artist = (artist or "").strip()
title = (title or "").strip()
if artist and title:
return sanitize_filename_stem(f"{artist} - {title}")
return sanitize_filename_stem(title or artist or "untitled")
def _download_cover(url: str) -> Optional[bytes]:
"""Scarica bytes cover, ritorna None su qualsiasi errore."""
try:
resp = requests.get(url, timeout=15)
if resp.status_code == 200:
return resp.content
except Exception:
pass
return None
def _cover_mime(url: str, content: bytes) -> str:
"""Deduci MIME dell'immagine da URL o magic bytes."""
u = url.lower()
if u.endswith(".png"):
return "image/png"
if u.endswith((".jpg", ".jpeg")):
return "image/jpeg"
# magic bytes
if content[:8] == b"\x89PNG\r\n\x1a\n":
return "image/png"
if content[:3] == b"\xff\xd8\xff":
return "image/jpeg"
return "image/jpeg" # default
def write_tags(filepath: str, metadata: dict) -> None:
"""Scrive ID3 tag su file MP3 esistente.
metadata: dict con chiavi opzionali:
title, artist, album, date (YYYY o YYYY-MM-DD), genre,
tracknumber, cover_url
Errori non fatali (file mancante, tag non scrivibili): silenziosi
per non bloccare il download principale.
"""
p = Path(filepath)
if not p.exists():
return
# 1. Tag testuali via EasyID3
try:
try:
audio = EasyID3(str(p))
except Exception:
# Se il file non ha header ID3, aggiungine uno vuoto
mp3 = MP3(str(p))
if mp3.tags is None:
mp3.add_tags()
mp3.save()
audio = EasyID3(str(p))
for key in ("title", "artist", "album", "date", "genre", "tracknumber"):
val = metadata.get(key)
if val:
audio[key] = str(val)
audio.save(str(p))
except Exception:
return # ID3 broken, non provo la cover
# 2. Cover art via ID3 APIC
cover_url = (metadata.get("cover_url") or "").strip()
if not cover_url:
return
cover_bytes = _download_cover(cover_url)
if not cover_bytes:
return
try:
mime = _cover_mime(cover_url, cover_bytes)
tags = ID3(str(p))
tags.delall("APIC")
tags.add(APIC(encoding=3, mime=mime, type=3, desc="Cover", data=cover_bytes))
tags.save(str(p))
except Exception:
pass
+2
View File
@@ -62,11 +62,13 @@ def search_youtube(query: str, limit: int = 50) -> list:
continue continue
video_id = e.get("id") or "" video_id = e.get("id") or ""
url = e.get("url") or (f"https://www.youtube.com/watch?v={video_id}" if video_id else "") url = e.get("url") or (f"https://www.youtube.com/watch?v={video_id}" if video_id else "")
image_url = f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg" if video_id else ""
result.append({ result.append({
"id": video_id, "id": video_id,
"url": url, "url": url,
"title": e.get("title") or "", "title": e.get("title") or "",
"channel": e.get("uploader") or e.get("channel") or "", "channel": e.get("uploader") or e.get("channel") or "",
"duration_sec": int(e.get("duration") or 0), "duration_sec": int(e.get("duration") or 0),
"image_url": image_url,
}) })
return result return result
+15
View File
@@ -4,6 +4,21 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
# ============================================================
# Fix SSL CA bundle: quando l'app e' frozen (PyInstaller) i moduli
# ssl e requests non trovano nessun CA bundle di default -> tutte
# le chiamate HTTPS falliscono con CERTIFICATE_VERIFY_FAILED.
# Puntiamo esplicitamente al bundle di certifi (che il build script
# include via `--collect-data certifi`).
# ============================================================
try:
import certifi as _certifi
_ca_bundle = _certifi.where()
os.environ.setdefault("SSL_CERT_FILE", _ca_bundle)
os.environ.setdefault("REQUESTS_CA_BUNDLE", _ca_bundle)
except Exception as _e:
print(f"[bootstrap] certifi setup failed: {_e}")
# Windows: forza il caricamento del runtime pythonnet PRIMA di importare # Windows: forza il caricamento del runtime pythonnet PRIMA di importare
# webview. Con PyInstaller il lazy-loader fallisce con # webview. Con PyInstaller il lazy-loader fallisce con
# "Failed to resolve Python.Runtime.Loader.Initialize" perche' # "Failed to resolve Python.Runtime.Loader.Initialize" perche'
+1
View File
@@ -1,5 +1,6 @@
pywebview>=5.0 pywebview>=5.0
requests>=2.31.0 requests>=2.31.0
certifi>=2024.0.0
yt-dlp>=2024.0.0 yt-dlp>=2024.0.0
mutagen>=1.47.0 mutagen>=1.47.0
pyobjc-core>=10.0 ; sys_platform == "darwin" pyobjc-core>=10.0 ; sys_platform == "darwin"
+104
View File
@@ -0,0 +1,104 @@
"""Test per core.converter — file scanning + stop event.
I test evitano di invocare ffmpeg reale (che potrebbe non essere in PATH
in CI). La funzione di conversione vera e propria e' coperta manualmente.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from core import converter
class TestListWavFiles:
def test_scans_recursively(self, tmp_path: Path):
# Struttura:
# root/a.wav
# root/b.WAV (case insensitive: glob su POSIX distingue,
# quindi controllo esplicito che almeno i .wav
# minuscoli siano restituiti)
# root/c.mp3
# root/sub/d.wav
(tmp_path / "a.wav").write_bytes(b"RIFF")
(tmp_path / "c.mp3").write_bytes(b"ID3")
sub = tmp_path / "sub"
sub.mkdir()
(sub / "d.wav").write_bytes(b"RIFF")
got = converter.list_wav_files(str(tmp_path), recursive=True)
assert len(got) == 2
names = {Path(p).name for p in got}
assert names == {"a.wav", "d.wav"}
# Ordinamento stabile
assert got == sorted(got)
def test_non_recursive_ignores_subfolders(self, tmp_path: Path):
(tmp_path / "a.wav").write_bytes(b"RIFF")
sub = tmp_path / "sub"
sub.mkdir()
(sub / "b.wav").write_bytes(b"RIFF")
got = converter.list_wav_files(str(tmp_path), recursive=False)
assert len(got) == 1
assert Path(got[0]).name == "a.wav"
def test_empty_dir_returns_empty(self, tmp_path: Path):
assert converter.list_wav_files(str(tmp_path)) == []
def test_missing_dir_returns_empty(self, tmp_path: Path):
missing = tmp_path / "does-not-exist"
assert converter.list_wav_files(str(missing)) == []
def test_ignores_non_wav_files(self, tmp_path: Path):
(tmp_path / "song.mp3").write_bytes(b"ID3")
(tmp_path / "song.flac").write_bytes(b"fLaC")
(tmp_path / "notes.txt").write_text("hello")
assert converter.list_wav_files(str(tmp_path)) == []
class TestStopEvent:
def setup_method(self):
# Ogni test parte con lo stop event pulito.
converter.reset_stop()
def teardown_method(self):
converter.reset_stop()
def test_reset_initially_not_stopped(self):
converter.reset_stop()
assert converter.is_stopped() is False
def test_request_stop_sets_flag(self):
converter.request_stop()
assert converter.is_stopped() is True
def test_reset_after_stop_clears_flag(self):
converter.request_stop()
assert converter.is_stopped() is True
converter.reset_stop()
assert converter.is_stopped() is False
class TestConvertWavToMp3Errors:
def test_missing_input_raises(self, tmp_path: Path):
missing = tmp_path / "nope.wav"
out = tmp_path / "out.mp3"
with pytest.raises(FileNotFoundError):
converter.convert_wav_to_mp3(str(missing), str(out))
class TestVbrQualityMap:
def test_all_bitrates_have_mapping(self):
for br in (128, 192, 256, 320):
assert br in converter._VBR_QUALITY
def test_higher_bitrate_lower_or_equal_quality_number(self):
# -q:a: piu' basso = migliore. Coerenza monotona sulle chiavi.
vals = [converter._VBR_QUALITY[br] for br in (128, 192, 256, 320)]
assert vals == sorted(vals, reverse=True)
+57
View File
@@ -0,0 +1,57 @@
"""Test per core.tagger — filename sanitization + write_tags safety net."""
from __future__ import annotations
from core import tagger
class TestSanitizeFilenameStem:
def test_removes_bad_chars(self):
# Tutti i char vietati su Windows/macOS: < > : " / \ | ? *
got = tagger.sanitize_filename_stem('bad<name>:"/\\|?*test')
# Nessuno dei char proibiti sopravvive
for ch in '<>:"/\\|?*':
assert ch not in got
# E ho ottenuto qualcosa di non vuoto
assert got
def test_collapses_whitespace(self):
assert tagger.sanitize_filename_stem("a b c") == "a b c"
def test_truncates_long_stem(self):
s = "x" * 500
got = tagger.sanitize_filename_stem(s, max_len=180)
assert len(got) <= 180
def test_empty_returns_untitled(self):
assert tagger.sanitize_filename_stem("") == "untitled"
assert tagger.sanitize_filename_stem(" ") == "untitled"
class TestBuildFilenameStem:
def test_format_artist_dash_title(self):
assert tagger.build_filename_stem("Kapuchon", "Hot Sauce") == "Kapuchon - Hot Sauce"
def test_multiple_artists_ok(self):
got = tagger.build_filename_stem("Kapuchon, Miss Monique & GLZ", "Hot Sauce (Extended)")
assert got == "Kapuchon, Miss Monique & GLZ - Hot Sauce (Extended)"
def test_missing_artist_uses_title_only(self):
assert tagger.build_filename_stem("", "Only Title") == "Only Title"
def test_missing_title_uses_artist_only(self):
assert tagger.build_filename_stem("Only Artist", "") == "Only Artist"
def test_sanitizes_bad_chars(self):
got = tagger.build_filename_stem("A/B", "C:D")
# Nessuno slash o colon residuo
assert "/" not in got
assert ":" not in got
class TestWriteTagsMissingFile:
def test_missing_file_is_noop(self):
# Non deve sollevare alcuna eccezione se il file non esiste
tagger.write_tags("/nonexistent/path/to/file.mp3", {
"title": "x", "artist": "y", "cover_url": "http://example.com/x.jpg",
})
+55 -25
View File
@@ -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; }
+89
View File
@@ -41,6 +41,10 @@
<span class="nav-icon">▶</span> <span class="nav-icon">▶</span>
<span>YouTube</span> <span>YouTube</span>
</button> </button>
<button class="nav-item" data-view="convert" data-feature="audio">
<span class="nav-icon">🔄</span>
<span>Converti</span>
</button>
<button class="nav-item" data-view="upgrade" data-feature="upgrade"> <button class="nav-item" data-view="upgrade" data-feature="upgrade">
<span class="nav-icon">⚡</span> <span class="nav-icon">⚡</span>
<span>Upgrade</span> <span>Upgrade</span>
@@ -891,6 +895,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>
@@ -951,6 +956,7 @@
<thead> <thead>
<tr> <tr>
<th class="col-check"><input type="checkbox" id="spotify-select-all" checked /></th> <th class="col-check"><input type="checkbox" id="spotify-select-all" checked /></th>
<th class="col-cover"></th>
<th class="col-pos">#</th> <th class="col-pos">#</th>
<th>Artista</th> <th>Artista</th>
<th>Titolo</th> <th>Titolo</th>
@@ -1008,6 +1014,7 @@
<thead> <thead>
<tr> <tr>
<th class="col-check"><input type="checkbox" id="youtube-select-all" checked /></th> <th class="col-check"><input type="checkbox" id="youtube-select-all" checked /></th>
<th class="col-cover"></th>
<th class="col-pos">#</th> <th class="col-pos">#</th>
<th>Titolo video</th> <th>Titolo video</th>
<th>Canale</th> <th>Canale</th>
@@ -1035,6 +1042,88 @@
</div> </div>
</section> </section>
<!-- ===== VIEW: CONVERTI (WAV -> MP3) ===== -->
<section class="view" id="view-convert">
<header class="hero hero-purple">
<div class="hero-content">
<div class="hero-eyebrow purple">WAV → MP3</div>
<h1 class="hero-title">Converti file WAV in MP3</h1>
<p class="hero-subtitle">Bitrate configurabile, CBR o VBR, output accanto al file o in cartella custom.</p>
</div>
<div class="hero-deco">🔄</div>
</header>
<h2 class="section-label">Sorgente</h2>
<div class="card">
<div class="beatport-header">
<button id="convert-pick-files" class="btn btn-primary pill">📄 Scegli file .wav</button>
<button id="convert-pick-folder" class="btn btn-ghost pill">📁 Scegli cartella</button>
<button id="convert-clear" class="btn btn-ghost pill">Svuota</button>
</div>
<div class="beatport-header" style="margin-top:12px;gap:16px;">
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
Bitrate:
<select id="convert-bitrate" class="input pill input-sm">
<option value="128">128 kbps</option>
<option value="192">192 kbps</option>
<option value="256">256 kbps</option>
<option value="320" selected>320 kbps</option>
</select>
</label>
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
<input type="checkbox" id="convert-vbr" />
<span>VBR (qualità variabile)</span>
</label>
</div>
<div class="beatport-header" style="margin-top:12px;gap:12px;">
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
<input type="radio" name="convert-out" id="convert-out-same" checked />
<span>Accanto al file originale</span>
</label>
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
<input type="radio" name="convert-out" id="convert-out-custom" />
<span>Cartella custom:</span>
</label>
<button id="convert-out-browse" class="btn btn-ghost pill btn-sm" disabled>Sfoglia…</button>
<span id="convert-out-path" class="beatport-output-info" style="margin:0;"></span>
</div>
<div id="convert-status" class="beatport-status"></div>
</div>
<div class="card beatport-table-card" id="convert-table-wrap" hidden>
<div class="beatport-table-wrap">
<table class="beatport-table" id="convert-table">
<thead>
<tr>
<th class="col-check"><input type="checkbox" id="convert-select-all" checked /></th>
<th class="col-pos">#</th>
<th>File</th>
<th class="col-state">Stato</th>
</tr>
</thead>
<tbody id="convert-tbody"></tbody>
</table>
</div>
<div class="beatport-toolbar" id="convert-toolbar">
<span class="counter" id="convert-selected-count">0/0 selezionati</span>
<button id="convert-run-btn" class="btn btn-primary pill" disabled>
<span class="ico">▶</span> Converti selezionati
</button>
<button id="convert-stop-btn" class="btn btn-danger pill" hidden>
<span class="ico">◼</span> Interrompi
</button>
</div>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="convert-log"></div>
</div>
</section>
</main> </main>
</div> </div>
+247
View File
@@ -399,6 +399,7 @@ const logEls = {
video: () => $("#videoLog"), video: () => $("#videoLog"),
spotify: () => $("#spotify-log"), spotify: () => $("#spotify-log"),
youtube: () => $("#youtube-log"), youtube: () => $("#youtube-log"),
convert: () => $("#convert-log"),
}; };
function classifyLog(msg) { function classifyLog(msg) {
@@ -510,6 +511,9 @@ async function init() {
// Spotify + YouTube search tabs // Spotify + YouTube search tabs
await SpotifyUI.init(); await SpotifyUI.init();
await YoutubeUI.init(); await YoutubeUI.init();
// WAV -> MP3 converter tab
await ConvertUI.init();
} }
async function refreshRecDevices() { async function refreshRecDevices() {
@@ -1297,6 +1301,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));
@@ -1518,8 +1535,12 @@ const SpotifyUI = (() => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.dataset.idx = String(i); tr.dataset.idx = String(i);
if (already) tr.classList.add("already-downloaded"); if (already) tr.classList.add("already-downloaded");
const coverImg = t.image_url
? `<img src="${_escape(t.image_url)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"/>`
: "";
tr.innerHTML = ` tr.innerHTML = `
<td class="col-check"><input type="checkbox" data-idx="${i}" ${already ? "" : "checked"} /></td> <td class="col-check"><input type="checkbox" data-idx="${i}" ${already ? "" : "checked"} /></td>
<td class="col-cover">${coverImg}</td>
<td class="col-pos">${i + 1}</td> <td class="col-pos">${i + 1}</td>
<td>${_escape(t.artists)}</td> <td>${_escape(t.artists)}</td>
<td>${_escape(t.name)}</td> <td>${_escape(t.name)}</td>
@@ -1752,8 +1773,12 @@ const YoutubeUI = (() => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.dataset.idx = String(i); tr.dataset.idx = String(i);
if (already) tr.classList.add("already-downloaded"); if (already) tr.classList.add("already-downloaded");
const coverImg = t.image_url
? `<img src="${_escape(t.image_url)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"/>`
: "";
tr.innerHTML = ` tr.innerHTML = `
<td class="col-check"><input type="checkbox" data-idx="${i}" ${already ? "" : "checked"} /></td> <td class="col-check"><input type="checkbox" data-idx="${i}" ${already ? "" : "checked"} /></td>
<td class="col-cover">${coverImg}</td>
<td class="col-pos">${i + 1}</td> <td class="col-pos">${i + 1}</td>
<td>${_escape(t.title)}</td> <td>${_escape(t.title)}</td>
<td>${_escape(t.channel)}</td> <td>${_escape(t.channel)}</td>
@@ -1927,6 +1952,228 @@ const YoutubeUI = (() => {
return { init }; return { init };
})(); })();
// =====================================================================
// Converti (WAV -> MP3)
// =====================================================================
const ConvertUI = (() => {
const mstate = { files: [], selected: new Set(), running: false, outputDir: "" };
function _escape(s) {
const d = document.createElement("div");
d.textContent = s == null ? "" : String(s);
return d.innerHTML;
}
function renderTable() {
const tbody = $("#convert-tbody");
if (!tbody) return;
tbody.innerHTML = "";
mstate.files.forEach((f, i) => {
const tr = document.createElement("tr");
tr.dataset.idx = String(i);
const checked = mstate.selected.has(i) ? "checked" : "";
const baseName = f.split(/[\/\\]/).pop();
tr.innerHTML = `
<td class="col-check"><input type="checkbox" data-idx="${i}" ${checked} /></td>
<td class="col-pos">${i + 1}</td>
<td title="${_escape(f)}">${_escape(baseName)}</td>
<td class="col-state" data-idx="${i}"></td>
`;
tr.querySelector("input[type=checkbox]").addEventListener("change", (e) => {
if (e.target.checked) mstate.selected.add(i);
else mstate.selected.delete(i);
updateSelectionCount();
});
tbody.appendChild(tr);
});
$("#convert-table-wrap").hidden = mstate.files.length === 0;
updateSelectionCount();
}
function updateSelectionCount() {
const total = mstate.files.length;
const sel = mstate.selected.size;
$("#convert-selected-count").textContent = `${sel}/${total} selezionati`;
$("#convert-run-btn").disabled = mstate.running || sel === 0;
const sa = $("#convert-select-all");
if (sa) {
sa.indeterminate = sel > 0 && sel < total;
sa.checked = sel === total && total > 0;
}
}
function appendFiles(newFiles) {
// Dedup + append
const existing = new Set(mstate.files);
for (const f of newFiles) {
if (!existing.has(f)) {
mstate.files.push(f);
mstate.selected.add(mstate.files.length - 1);
existing.add(f);
}
}
renderTable();
}
function setStatus(text, kind) {
const el = $("#convert-status");
if (!el) return;
el.textContent = text || "";
el.className = "beatport-status" + (kind ? " " + kind : "");
}
async function pickFiles() {
const files = await window.pywebview.api.convert_pick_wav_files();
if (Array.isArray(files) && files.length) {
appendFiles(files);
setStatus("", "");
} else {
setStatus("Nessun file .wav selezionato", "");
}
}
async function pickFolder() {
setStatus("Scansione cartella...", "loading");
const files = await window.pywebview.api.convert_pick_wav_folder(true);
if (Array.isArray(files) && files.length) {
appendFiles(files);
setStatus(`Trovati ${files.length} file .wav`, "ok");
} else {
setStatus("Nessun file .wav trovato nella cartella", "");
}
}
function clearFiles() {
mstate.files = [];
mstate.selected.clear();
renderTable();
setStatus("", "");
$("#convert-log").innerHTML = "";
}
async function browseOutput() {
const p = await window.pywebview.api.browse_directory();
if (p && typeof p === "string") {
mstate.outputDir = p;
$("#convert-out-path").textContent = p;
$("#convert-out-custom").checked = true;
}
}
function appendConvertLog(msg) {
const el = $("#convert-log");
if (!el) return;
const cls = classifyLog(msg);
const line = document.createElement("div");
if (cls) line.className = cls;
line.textContent = msg;
el.appendChild(line);
el.scrollTop = el.scrollHeight;
}
function updateStateCell(idx, text, cls) {
const td = document.querySelector(`#convert-tbody td.col-state[data-idx="${idx}"]`);
if (!td) return;
td.textContent = text;
td.style.color = cls === "err" ? "var(--red)" : (cls === "ok" ? "var(--green-text)" : "");
}
async function startConversion() {
const files = [...mstate.selected].sort((a, b) => a - b).map((i) => mstate.files[i]);
if (!files.length) return;
const bitrate = parseInt($("#convert-bitrate").value, 10);
const vbr = $("#convert-vbr").checked;
const useCustom = $("#convert-out-custom").checked;
const output_dir = useCustom ? (mstate.outputDir || "") : "";
if (useCustom && !output_dir) {
setStatus("Scegli una cartella di destinazione", "error");
return;
}
mstate.running = true;
$("#convert-run-btn").disabled = true;
$("#convert-stop-btn").hidden = false;
$("#convert-log").innerHTML = "";
// Reset state cells
$$("#convert-tbody td.col-state").forEach((td) => {
td.textContent = "";
td.style.color = "";
});
let res;
try {
res = await window.pywebview.api.convert_start({ files, bitrate, vbr, output_dir });
} catch (e) {
appendConvertLog("[ERRORE] " + ((e && e.message) || e));
finishConversion();
return;
}
if (!res || !res.ok) {
const errMsg = (res && res.error) || "Impossibile avviare";
appendConvertLog("[ERRORE] " + errMsg);
if (!handleGateBlock(res || {})) toast(errMsg, "error");
finishConversion();
}
}
async function stopConversion() {
try { await window.pywebview.api.convert_stop(); } catch (e) { console.error(e); }
}
function finishConversion() {
mstate.running = false;
$("#convert-run-btn").disabled = mstate.selected.size === 0;
$("#convert-stop-btn").hidden = true;
}
async function init() {
// Bind eventi
$("#convert-pick-files").addEventListener("click", pickFiles);
$("#convert-pick-folder").addEventListener("click", pickFolder);
$("#convert-clear").addEventListener("click", clearFiles);
$("#convert-out-browse").addEventListener("click", browseOutput);
$("#convert-out-custom").addEventListener("change", () => {
$("#convert-out-browse").disabled = false;
});
$("#convert-out-same").addEventListener("change", () => {
$("#convert-out-browse").disabled = true;
});
$("#convert-select-all").addEventListener("change", (e) => {
mstate.selected.clear();
if (e.target.checked) {
for (let i = 0; i < mstate.files.length; i++) mstate.selected.add(i);
}
renderTable();
});
$("#convert-run-btn").addEventListener("click", startConversion);
$("#convert-stop-btn").addEventListener("click", stopConversion);
// Bridge handlers per il canale "convert"
const origProgress = bridgeHandlers["convert:progress"];
bridgeHandlers["convert:progress"] = (p) => {
if (origProgress) origProgress(p);
if (!p) return;
if (p.status === "done") updateStateCell(p.idx, "✓ fatto", "ok");
else if (p.status === "skipped") updateStateCell(p.idx, "già esiste", "");
else if (p.status === "stopped") updateStateCell(p.idx, "interrotto", "");
else if (typeof p.status === "string" && p.status.startsWith("error")) updateStateCell(p.idx, "✗ errore", "err");
else if (p.status === "converting") updateStateCell(p.idx, `${p.pct || 0}%`, "");
};
const origDone = bridgeHandlers["convert:done"];
bridgeHandlers["convert:done"] = (p) => {
if (origDone) origDone(p);
if (mstate.running) {
appendConvertLog("[INFO] Conversione terminata.");
finishConversion();
}
};
// Il canale "log" e' gia gestito da logEls.convert (aggiunto sopra), quindi
// non serve wrap qui: appendLog(view=convert, msg) targeta #convert-log.
}
return { init };
})();
// ============================================================ // ============================================================
// Boot // Boot
// ============================================================ // ============================================================