Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72e700c85d | ||
|
|
a774906814 | ||
|
|
9f41f9d302 | ||
|
|
b0cd046375 | ||
|
|
5fc9f8ac2b |
No files matched your search
+157
@@ -1452,3 +1452,160 @@ class Api:
|
|||||||
"subfolder": subfolder,
|
"subfolder": subfolder,
|
||||||
"metadata": metadata,
|
"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})
|
||||||
+1
-1
@@ -5,7 +5,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VERSION = "v1.8.5"
|
VERSION = "v1.8.6"
|
||||||
|
|
||||||
|
|
||||||
APP_NAME = "MusicTools"
|
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())
|
||||||
@@ -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)
|
||||||
@@ -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>
|
||||||
@@ -1038,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>
|
||||||
|
|
||||||
|
|||||||
+226
@@ -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() {
|
||||||
@@ -1948,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
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in new issue
Block a user