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