Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e10466d93b | ||
|
|
f0a29f7ca7 | ||
|
|
4e1af7b8f6 | ||
|
|
d8723f9408 | ||
|
|
eb20a034e6 | ||
|
|
cdd380d62f |
No files matched your search
+71
-3
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -110,6 +111,9 @@ class Api:
|
|||||||
self._download_thread: Optional[threading.Thread] = None
|
self._download_thread: Optional[threading.Thread] = None
|
||||||
self._upgrade_thread: Optional[threading.Thread] = None
|
self._upgrade_thread: Optional[threading.Thread] = None
|
||||||
self._video_thread: Optional[threading.Thread] = None
|
self._video_thread: Optional[threading.Thread] = None
|
||||||
|
# Coda usata dal resolve_callback per attendere la scelta utente
|
||||||
|
# sul modal "match locali multipli" della tab Upgrade.
|
||||||
|
self._upgrade_resolve_q: "queue.Queue[dict]" = queue.Queue(1)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
@@ -882,6 +886,7 @@ class Api:
|
|||||||
return {"ok": False, "error": "Upgrade gia in corso"}
|
return {"ok": False, "error": "Upgrade gia in corso"}
|
||||||
|
|
||||||
directory = (payload.get("directory") or "").strip()
|
directory = (payload.get("directory") or "").strip()
|
||||||
|
archive_dir = (payload.get("archive_dir") or "").strip() or None
|
||||||
recursive = bool(payload.get("recursive", False))
|
recursive = bool(payload.get("recursive", False))
|
||||||
try:
|
try:
|
||||||
threshold = int(payload.get("threshold", 310))
|
threshold = int(payload.get("threshold", 310))
|
||||||
@@ -898,9 +903,16 @@ class Api:
|
|||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
cookies_path = cfg.get("cookies_path", "")
|
cookies_path = cfg.get("cookies_path", "")
|
||||||
|
|
||||||
|
# Drain la queue di risoluzione da eventuali run precedenti (safety)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
self._upgrade_resolve_q.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
self._upgrade_thread = threading.Thread(
|
self._upgrade_thread = threading.Thread(
|
||||||
target=self._upgrade_worker,
|
target=self._upgrade_worker,
|
||||||
args=(directory, threshold, cookies_path, recursive),
|
args=(directory, threshold, cookies_path, recursive, archive_dir),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
self._upgrade_thread.start()
|
self._upgrade_thread.start()
|
||||||
@@ -908,10 +920,36 @@ class Api:
|
|||||||
|
|
||||||
def stop_upgrade(self) -> dict:
|
def stop_upgrade(self) -> dict:
|
||||||
request_upgrade_stop()
|
request_upgrade_stop()
|
||||||
|
# Sblocca eventuale resolve_callback in attesa: inviamo scelta 'skip'
|
||||||
|
# cosi il worker esce pulito invece di restare bloccato in q.get().
|
||||||
|
try:
|
||||||
|
self._upgrade_resolve_q.put_nowait({"action": "skip"})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
self._log("upgrade", "[INFO] Interruzione richiesta...")
|
self._log("upgrade", "[INFO] Interruzione richiesta...")
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
def _upgrade_worker(self, directory, threshold, cookies_path, recursive):
|
def upgrade_resolve_candidates(self, choice: dict) -> dict:
|
||||||
|
"""Riceve la scelta utente dal modal 'match locali multipli' e la
|
||||||
|
deposita nella queue attesa dal resolve_callback del worker.
|
||||||
|
|
||||||
|
`choice`: {'action': 'use_local'|'use_youtube'|'skip', 'path': str?}
|
||||||
|
"""
|
||||||
|
if not isinstance(choice, dict):
|
||||||
|
choice = {"action": "skip"}
|
||||||
|
try:
|
||||||
|
# Drain (nel caso rarissimo di doppio put) e poi metti la nuova
|
||||||
|
try:
|
||||||
|
self._upgrade_resolve_q.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
self._upgrade_resolve_q.put_nowait(choice)
|
||||||
|
return {"ok": True}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
def _upgrade_worker(self, directory, threshold, cookies_path, recursive,
|
||||||
|
archive_dir: Optional[str] = None):
|
||||||
view = "upgrade"
|
view = "upgrade"
|
||||||
|
|
||||||
def progress_cb(idx, total, filename, status, old_kbps, new_kbps):
|
def progress_cb(idx, total, filename, status, old_kbps, new_kbps):
|
||||||
@@ -938,6 +976,19 @@ class Api:
|
|||||||
diff = new_kbps - old_kbps
|
diff = new_kbps - old_kbps
|
||||||
diff_str = f" (+{diff}kbps)" if diff > 0 else ""
|
diff_str = f" (+{diff}kbps)" if diff > 0 else ""
|
||||||
self._log(view, f"[UPGRADE] {filename}: {old_kbps} -> {new_kbps}kbps{diff_str}")
|
self._log(view, f"[UPGRADE] {filename}: {old_kbps} -> {new_kbps}kbps{diff_str}")
|
||||||
|
elif status == "local_copy":
|
||||||
|
self._log(view, f"[LOCALE] {filename}: uso match dall'archivio")
|
||||||
|
elif status == "local_upgraded":
|
||||||
|
diff = new_kbps - old_kbps
|
||||||
|
diff_str = f" (+{diff}kbps)" if diff > 0 else ""
|
||||||
|
self._log(view, f"[LOCALE OK] {filename}: {old_kbps} -> {new_kbps}kbps{diff_str}")
|
||||||
|
elif status == "skipped_by_user":
|
||||||
|
self._log(view, f"[SKIP] {filename} (saltato manualmente)")
|
||||||
|
elif status == "resolve_wait":
|
||||||
|
self._log(view, f"[SCELTA] {filename}: match multipli, in attesa scelta utente")
|
||||||
|
elif status == "scan_archive":
|
||||||
|
# `new_kbps` in questo caso trasporta il count delle chiavi
|
||||||
|
self._log(view, f"[ARCHIVIO] Indicizzati {new_kbps} gruppi audio")
|
||||||
elif status == "download_error":
|
elif status == "download_error":
|
||||||
self._log(view, f"[ERRORE] {filename}: download fallito")
|
self._log(view, f"[ERRORE] {filename}: download fallito")
|
||||||
elif status == "stopped":
|
elif status == "stopped":
|
||||||
@@ -948,7 +999,24 @@ class Api:
|
|||||||
|
|
||||||
self._emit("upgrade:progress", payload_evt)
|
self._emit("upgrade:progress", payload_evt)
|
||||||
|
|
||||||
upgrade_folder(directory, threshold, cookies_path, recursive, progress_cb)
|
def resolve_cb(filename: str, candidates: list) -> dict:
|
||||||
|
"""Bloccante: emette evento e attende scelta utente via queue."""
|
||||||
|
self._emit("upgrade:candidates_needed", {
|
||||||
|
"file": filename,
|
||||||
|
"candidates": candidates,
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
# Timeout 10 min — se l'utente sparisce, salta il brano
|
||||||
|
choice = self._upgrade_resolve_q.get(timeout=600)
|
||||||
|
except queue.Empty:
|
||||||
|
choice = {"action": "skip"}
|
||||||
|
return choice
|
||||||
|
|
||||||
|
upgrade_folder(
|
||||||
|
directory, threshold, cookies_path, recursive, progress_cb,
|
||||||
|
archive_dir=archive_dir,
|
||||||
|
resolve_callback=resolve_cb,
|
||||||
|
)
|
||||||
self._emit("upgrade:done", {"ok": True})
|
self._emit("upgrade: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.9.0"
|
VERSION = "v1.9.3"
|
||||||
|
|
||||||
|
|
||||||
APP_NAME = "MusicTools"
|
APP_NAME = "MusicTools"
|
||||||
|
|||||||
+332
-12
@@ -3,16 +3,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import queue
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffprobe, subprocess_flags
|
from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffmpeg, find_ffprobe, subprocess_flags
|
||||||
|
|
||||||
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".wav", ".flac"}
|
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".wav", ".flac"}
|
||||||
|
|
||||||
|
# Soglia minima Jaccard per considerare un file dell'archivio come candidato.
|
||||||
|
_ARCHIVE_MIN_SIMILARITY = 0.5
|
||||||
|
|
||||||
|
# Token di lunghezza inferiore a questa vengono scartati (troppo generici).
|
||||||
|
_MIN_TOKEN_LEN = 3
|
||||||
|
|
||||||
|
# Timeout massimo per singolo download yt-dlp (secondi). Watchdog kill.
|
||||||
|
# Serve a evitare hang su video geo-restricted, YouTube throttle o rete lenta.
|
||||||
|
_DOWNLOAD_TIMEOUT_SEC = 300
|
||||||
|
|
||||||
# Flag globale per interruzione
|
# Flag globale per interruzione
|
||||||
_stop_event = threading.Event()
|
_stop_event = threading.Event()
|
||||||
_current_process: Optional[subprocess.Popen] = None
|
_current_process: Optional[subprocess.Popen] = None
|
||||||
@@ -70,6 +82,140 @@ def get_bitrate(filepath: str | Path) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_stem(stem: str) -> set:
|
||||||
|
"""Ritorna il set di token normalizzati per un nome file (senza extension).
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
1. Lowercase
|
||||||
|
2. Sostituisce caratteri non alfanumerici con spazi (mantiene split naturale)
|
||||||
|
3. Split su whitespace
|
||||||
|
4. Filtra token di lunghezza < _MIN_TOKEN_LEN (troppo generici)
|
||||||
|
"""
|
||||||
|
if not stem:
|
||||||
|
return set()
|
||||||
|
lowered = stem.lower()
|
||||||
|
# Manteniamo spazi ma sostituiamo tutto il resto (che non è alfanumerico) con spazi
|
||||||
|
cleaned = re.sub(r"[^a-z0-9\s]+", " ", lowered)
|
||||||
|
tokens = cleaned.split()
|
||||||
|
return {t for t in tokens if len(t) >= _MIN_TOKEN_LEN}
|
||||||
|
|
||||||
|
|
||||||
|
def _tokens_to_key(tokens: set) -> str:
|
||||||
|
"""Chiave stabile per un set di token (usata come chiave del dict d'indice)."""
|
||||||
|
return "|".join(sorted(tokens))
|
||||||
|
|
||||||
|
|
||||||
|
def _key_to_tokens(key: str) -> set:
|
||||||
|
"""Inverso di _tokens_to_key."""
|
||||||
|
if not key:
|
||||||
|
return set()
|
||||||
|
return set(key.split("|"))
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_archive(archive_dir: str) -> dict:
|
||||||
|
"""Indicizza recursivamente un archivio di file audio.
|
||||||
|
|
||||||
|
Ritorna dict {token_key: [Path, ...]}. La scansione è case-insensitive
|
||||||
|
sulle estensioni: `.MP3`, `.Mp3`, `.mp3` sono tutti riconosciuti.
|
||||||
|
"""
|
||||||
|
base = Path(archive_dir)
|
||||||
|
index: dict = {}
|
||||||
|
if not base.exists() or not base.is_dir():
|
||||||
|
return index
|
||||||
|
for f in base.rglob("*"):
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
if f.suffix.lower() not in AUDIO_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
tokens = _normalize_stem(f.stem)
|
||||||
|
if not tokens:
|
||||||
|
continue
|
||||||
|
key = _tokens_to_key(tokens)
|
||||||
|
index.setdefault(key, []).append(f)
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _find_candidates(
|
||||||
|
target_stem: str,
|
||||||
|
index: dict,
|
||||||
|
min_similarity: float = _ARCHIVE_MIN_SIMILARITY,
|
||||||
|
) -> list:
|
||||||
|
"""Cerca candidati nell'indice tramite Jaccard sui token del nome.
|
||||||
|
|
||||||
|
Ritorna lista di tuple `(path, similarity, bitrate)` ordinata per
|
||||||
|
bitrate DESC, poi similarity DESC.
|
||||||
|
"""
|
||||||
|
target_tokens = _normalize_stem(target_stem)
|
||||||
|
if not target_tokens:
|
||||||
|
return []
|
||||||
|
|
||||||
|
results: list = []
|
||||||
|
for key, paths in index.items():
|
||||||
|
entry_tokens = _key_to_tokens(key)
|
||||||
|
if not entry_tokens:
|
||||||
|
continue
|
||||||
|
common = len(target_tokens & entry_tokens)
|
||||||
|
if common == 0:
|
||||||
|
continue
|
||||||
|
total = len(target_tokens | entry_tokens)
|
||||||
|
if total == 0:
|
||||||
|
continue
|
||||||
|
sim = common / total
|
||||||
|
if sim < min_similarity:
|
||||||
|
continue
|
||||||
|
for p in paths:
|
||||||
|
try:
|
||||||
|
br = get_bitrate(p)
|
||||||
|
except Exception:
|
||||||
|
br = 0
|
||||||
|
results.append((p, sim, br))
|
||||||
|
|
||||||
|
# Ordina: bitrate DESC, poi similarity DESC (stabile)
|
||||||
|
results.sort(key=lambda t: (-t[2], -t[1]))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_or_convert_to_mp3(
|
||||||
|
src: Path,
|
||||||
|
dst: Path,
|
||||||
|
temp_dir: Path,
|
||||||
|
) -> bool:
|
||||||
|
"""Copia (o converte se necessario) `src` in `dst` come MP3.
|
||||||
|
|
||||||
|
- Se src è già .mp3 → copia diretta con shutil.copy2
|
||||||
|
- Altrimenti → converti con ffmpeg a 320k CBR
|
||||||
|
Ritorna True in caso di successo.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if src.suffix.lower() == ".mp3":
|
||||||
|
shutil.copy2(str(src), str(dst))
|
||||||
|
return dst.exists()
|
||||||
|
# Convert non-mp3 -> mp3 320k
|
||||||
|
ffmpeg_bin = find_ffmpeg() or "ffmpeg"
|
||||||
|
temp_out = temp_dir / f"_archive_convert_{dst.stem}.mp3"
|
||||||
|
# -y per sovrascrivere se residuo di run precedente
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
ffmpeg_bin, "-y",
|
||||||
|
"-i", str(src),
|
||||||
|
"-vn", # scarta eventuali stream video/cover art (le riproviamo dopo)
|
||||||
|
"-c:a", "libmp3lame",
|
||||||
|
"-b:a", "320k",
|
||||||
|
"-id3v2_version", "3",
|
||||||
|
str(temp_out),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=300,
|
||||||
|
**subprocess_flags(),
|
||||||
|
)
|
||||||
|
if not temp_out.exists():
|
||||||
|
return False
|
||||||
|
temp_out.replace(dst)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _load_done_set(done_file: Path) -> set[str]:
|
def _load_done_set(done_file: Path) -> set[str]:
|
||||||
if done_file.exists():
|
if done_file.exists():
|
||||||
return set(done_file.read_text(encoding="utf-8").splitlines())
|
return set(done_file.read_text(encoding="utf-8").splitlines())
|
||||||
@@ -180,8 +326,21 @@ def upgrade_folder(
|
|||||||
cookies_path: Optional[str] = None,
|
cookies_path: Optional[str] = None,
|
||||||
recursive: bool = False,
|
recursive: bool = False,
|
||||||
progress_callback: Optional[Callable] = None,
|
progress_callback: Optional[Callable] = None,
|
||||||
|
archive_dir: Optional[str] = None,
|
||||||
|
resolve_callback: Optional[Callable] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Logica principale di upgrade qualita."""
|
"""Logica principale di upgrade qualita.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
archive_dir: se presente, prima di scaricare da YouTube l'app cerca
|
||||||
|
una versione HQ del brano in questa cartella (recursive). Se ne
|
||||||
|
trova una la copia (o converte in mp3 320k) mantenendo il nome
|
||||||
|
originale.
|
||||||
|
resolve_callback: chiamato in caso di match multiplo nell'archivio.
|
||||||
|
Riceve una lista di dict {path, bitrate, size, similarity} e deve
|
||||||
|
ritornare bloccante un dict {'action': 'use_local'|'use_youtube'|
|
||||||
|
'skip', 'path': Optional[str]}.
|
||||||
|
"""
|
||||||
reset_stop()
|
reset_stop()
|
||||||
ytdlp = find_ytdlp()
|
ytdlp = find_ytdlp()
|
||||||
|
|
||||||
@@ -195,7 +354,7 @@ def upgrade_folder(
|
|||||||
else:
|
else:
|
||||||
folders = [Path(directory)]
|
folders = [Path(directory)]
|
||||||
|
|
||||||
all_items: list[tuple[Path, Path]] = []
|
all_items: list = []
|
||||||
for folder in folders:
|
for folder in folders:
|
||||||
for ext in AUDIO_EXTENSIONS:
|
for ext in AUDIO_EXTENSIONS:
|
||||||
for f in sorted(folder.glob(f"*{ext}")):
|
for f in sorted(folder.glob(f"*{ext}")):
|
||||||
@@ -207,6 +366,16 @@ def upgrade_folder(
|
|||||||
progress_callback(0, 0, "", "no_files", 0, 0)
|
progress_callback(0, 0, "", "no_files", 0, 0)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Pre-scan dell'archivio (una volta sola). Se archive_dir è None si salta.
|
||||||
|
archive_index: dict = {}
|
||||||
|
if archive_dir:
|
||||||
|
try:
|
||||||
|
archive_index = _scan_archive(archive_dir)
|
||||||
|
except Exception:
|
||||||
|
archive_index = {}
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(0, total, "", "scan_archive", 0, len(archive_index))
|
||||||
|
|
||||||
processed = 0
|
processed = 0
|
||||||
|
|
||||||
for filepath, folder in all_items:
|
for filepath, folder in all_items:
|
||||||
@@ -230,6 +399,95 @@ def upgrade_folder(
|
|||||||
|
|
||||||
current_kbps = get_bitrate(filepath)
|
current_kbps = get_bitrate(filepath)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 1) ARCHIVE LOOKUP (se archive_dir presente)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
if archive_index:
|
||||||
|
try:
|
||||||
|
candidates = _find_candidates(filename, archive_index)
|
||||||
|
except Exception:
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
selected_path: Optional[Path] = None
|
||||||
|
user_chose_youtube = False
|
||||||
|
user_chose_skip = False
|
||||||
|
|
||||||
|
if len(candidates) == 1:
|
||||||
|
selected_path = candidates[0][0]
|
||||||
|
elif len(candidates) >= 2 and resolve_callback is not None:
|
||||||
|
cand_payload = []
|
||||||
|
for cp, csim, cbr in candidates:
|
||||||
|
try:
|
||||||
|
csize = cp.stat().st_size
|
||||||
|
except Exception:
|
||||||
|
csize = 0
|
||||||
|
cand_payload.append({
|
||||||
|
"path": str(cp),
|
||||||
|
"bitrate": cbr,
|
||||||
|
"size": csize,
|
||||||
|
"similarity": csim,
|
||||||
|
})
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(processed, total, filepath.name,
|
||||||
|
"resolve_wait", current_kbps, 0)
|
||||||
|
try:
|
||||||
|
choice = resolve_callback(filepath.name, cand_payload) or {}
|
||||||
|
except Exception:
|
||||||
|
choice = {}
|
||||||
|
action = (choice.get("action") or "").strip()
|
||||||
|
if action == "use_local":
|
||||||
|
chosen = (choice.get("path") or "").strip()
|
||||||
|
if chosen:
|
||||||
|
cp = Path(chosen)
|
||||||
|
if cp.exists():
|
||||||
|
selected_path = cp
|
||||||
|
elif action == "use_youtube":
|
||||||
|
user_chose_youtube = True
|
||||||
|
elif action == "skip":
|
||||||
|
user_chose_skip = True
|
||||||
|
else:
|
||||||
|
# Risposta invalida: fallback su YouTube per non bloccare
|
||||||
|
user_chose_youtube = True
|
||||||
|
# len(candidates) >= 2 senza callback: fallback YouTube
|
||||||
|
# len(candidates) == 0: fallback YouTube
|
||||||
|
|
||||||
|
if user_chose_skip:
|
||||||
|
_mark_done(done_file, filename)
|
||||||
|
processed += 1
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(processed, total, filepath.name,
|
||||||
|
"skipped_by_user", current_kbps, 0)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if selected_path is not None and not user_chose_youtube:
|
||||||
|
# Copia/converte il candidato locale come .mp3 con nome originale
|
||||||
|
dst = filepath.parent / f"{filename}.mp3"
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(processed, total, filepath.name,
|
||||||
|
"local_copy", current_kbps, 0)
|
||||||
|
# Rimuovi originale solo se ha estensione diversa (altrimenti
|
||||||
|
# verrà sovrascritto dalla copia)
|
||||||
|
try:
|
||||||
|
if filepath.exists() and filepath.resolve() != dst.resolve():
|
||||||
|
filepath.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ok = _copy_or_convert_to_mp3(selected_path, dst, temp_dir)
|
||||||
|
if ok:
|
||||||
|
new_kbps = get_bitrate(dst)
|
||||||
|
_mark_done(done_file, filename)
|
||||||
|
_cleanup_temp(temp_dir)
|
||||||
|
processed += 1
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(processed, total, filepath.name,
|
||||||
|
"local_upgraded", current_kbps, new_kbps)
|
||||||
|
continue
|
||||||
|
# Copia fallita: fallback YouTube (non marchiamo done)
|
||||||
|
_cleanup_temp(temp_dir)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 2) YOUTUBE FALLBACK (comportamento originale)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback(processed, total, filepath.name, "searching", current_kbps, 0)
|
progress_callback(processed, total, filepath.name, "searching", current_kbps, 0)
|
||||||
|
|
||||||
@@ -288,17 +546,79 @@ def upgrade_folder(
|
|||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||||
**subprocess_flags(),
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
|
proc = _current_process
|
||||||
|
|
||||||
for line in _current_process.stdout:
|
# Watchdog: kill del subprocess se supera _DOWNLOAD_TIMEOUT_SEC.
|
||||||
if is_stopped():
|
# Previene hang indefinito su video problematici (geo-block,
|
||||||
_current_process.terminate()
|
# YouTube throttle, rete lenta).
|
||||||
return
|
def _watchdog_kill():
|
||||||
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
|
try:
|
||||||
if pct_match and progress_callback:
|
if proc.poll() is None:
|
||||||
pct = int(float(pct_match.group(1)))
|
proc.kill() # SIGKILL — SIGTERM può lasciare ffmpeg orfano che tiene aperto il pipe
|
||||||
progress_callback(processed, total, filepath.name, "downloading", current_kbps, pct)
|
except Exception:
|
||||||
|
pass
|
||||||
|
watchdog = threading.Timer(_DOWNLOAD_TIMEOUT_SEC, _watchdog_kill)
|
||||||
|
watchdog.daemon = True
|
||||||
|
watchdog.start()
|
||||||
|
|
||||||
|
# Lettore stdout in thread separato + queue: il main loop polla
|
||||||
|
# con timeout invece di bloccare su `for line in proc.stdout`.
|
||||||
|
# Cosi is_stopped() e proc.poll() vengono controllati periodicamente
|
||||||
|
# -> il bottone Stop risponde in <1s anche se il subprocess ha
|
||||||
|
# figli orfani (ffmpeg) che tengono aperto il pipe.
|
||||||
|
output_q: queue.Queue = queue.Queue()
|
||||||
|
|
||||||
|
def _reader():
|
||||||
|
try:
|
||||||
|
for line in proc.stdout:
|
||||||
|
output_q.put(line)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
output_q.put(None) # sentinel: pipe closed
|
||||||
|
|
||||||
|
reader = threading.Thread(target=_reader, daemon=True)
|
||||||
|
reader.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if is_stopped():
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
line = output_q.get(timeout=0.5)
|
||||||
|
except queue.Empty:
|
||||||
|
# Se il subprocess e' morto e il pipe non produce piu' output
|
||||||
|
# (child orfano), esci comunque.
|
||||||
|
if proc.poll() is not None and output_q.empty():
|
||||||
|
# Aspetta ancora un attimo per drenare
|
||||||
|
try:
|
||||||
|
line = output_q.get(timeout=1.0)
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if line is None:
|
||||||
|
break
|
||||||
|
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
|
||||||
|
if pct_match and progress_callback:
|
||||||
|
pct = int(float(pct_match.group(1)))
|
||||||
|
progress_callback(processed, total, filepath.name, "downloading", current_kbps, pct)
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
watchdog.cancel()
|
||||||
|
|
||||||
_current_process.wait()
|
|
||||||
with _process_lock:
|
with _process_lock:
|
||||||
_current_process = None
|
_current_process = None
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Test unitari per le funzioni di archive-lookup in core.upgrader.
|
||||||
|
|
||||||
|
Focalizzati su _normalize_stem, _scan_archive, _find_candidates.
|
||||||
|
Non testiamo il flow end-to-end di upgrade_folder (richiederebbe mock
|
||||||
|
di subprocess/yt-dlp/ffmpeg — out of scope, coperto da smoke test manuale).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core import upgrader
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# _normalize_stem
|
||||||
|
# ============================================================
|
||||||
|
class TestNormalizeStem:
|
||||||
|
def test_lowercase_and_dedup(self):
|
||||||
|
# "Hot" e "hot" → un solo token; token duplicati collassano nel set
|
||||||
|
got = upgrader._normalize_stem("Hot Sauce hot sauce")
|
||||||
|
assert got == {"hot", "sauce"}
|
||||||
|
|
||||||
|
def test_filters_short_tokens(self):
|
||||||
|
# Token di lunghezza < 3 vengono scartati (di, il, a, b, cd...)
|
||||||
|
got = upgrader._normalize_stem("A B cd Boombox")
|
||||||
|
assert "a" not in got
|
||||||
|
assert "b" not in got
|
||||||
|
assert "cd" not in got
|
||||||
|
assert "boombox" in got
|
||||||
|
|
||||||
|
def test_removes_punctuation(self):
|
||||||
|
# Trattini, virgole, parentesi, apostrofi → tutti sostituiti con spazi
|
||||||
|
got = upgrader._normalize_stem("Artist - Title (Extended Mix)")
|
||||||
|
assert got == {"artist", "title", "extended", "mix"}
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
assert upgrader._normalize_stem("") == set()
|
||||||
|
assert upgrader._normalize_stem(" ") == set()
|
||||||
|
|
||||||
|
def test_only_short_tokens_returns_empty(self):
|
||||||
|
assert upgrader._normalize_stem("a b c d") == set()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# _scan_archive
|
||||||
|
# ============================================================
|
||||||
|
class TestScanArchive:
|
||||||
|
def test_recursive_scan(self, tmp_path: Path):
|
||||||
|
# Crea albero: top-level + sub/ + sub/sub2/
|
||||||
|
(tmp_path / "Artist - Song.mp3").touch()
|
||||||
|
(tmp_path / "sub").mkdir()
|
||||||
|
(tmp_path / "sub" / "Second Track.mp3").touch()
|
||||||
|
(tmp_path / "sub" / "sub2").mkdir()
|
||||||
|
(tmp_path / "sub" / "sub2" / "Deep One.m4a").touch()
|
||||||
|
# File non-audio devono essere ignorati
|
||||||
|
(tmp_path / "readme.txt").touch()
|
||||||
|
|
||||||
|
index = upgrader._scan_archive(str(tmp_path))
|
||||||
|
# 3 entries totali (una per ogni file audio, ognuna con token distinti)
|
||||||
|
assert len(index) == 3
|
||||||
|
# Verifica che le path siano riferite ai file corretti
|
||||||
|
all_paths = [p for paths in index.values() for p in paths]
|
||||||
|
names = sorted(p.name for p in all_paths)
|
||||||
|
assert names == ["Artist - Song.mp3", "Deep One.m4a", "Second Track.mp3"]
|
||||||
|
|
||||||
|
def test_case_insensitive_extensions(self, tmp_path: Path):
|
||||||
|
(tmp_path / "one.MP3").touch()
|
||||||
|
(tmp_path / "two.Mp3").touch()
|
||||||
|
(tmp_path / "three.WAV").touch()
|
||||||
|
(tmp_path / "four.FLAC").touch()
|
||||||
|
(tmp_path / "five.txt").touch() # non-audio: ignorato
|
||||||
|
index = upgrader._scan_archive(str(tmp_path))
|
||||||
|
all_paths = [p for paths in index.values() for p in paths]
|
||||||
|
assert len(all_paths) == 4 # 4 file audio, 1 skippato
|
||||||
|
|
||||||
|
def test_duplicates_appended(self, tmp_path: Path):
|
||||||
|
# Due file con stesso token set → stessa key, due path
|
||||||
|
(tmp_path / "sub1").mkdir()
|
||||||
|
(tmp_path / "sub2").mkdir()
|
||||||
|
(tmp_path / "sub1" / "Hot Sauce.mp3").touch()
|
||||||
|
(tmp_path / "sub2" / "Hot Sauce.mp3").touch()
|
||||||
|
index = upgrader._scan_archive(str(tmp_path))
|
||||||
|
assert len(index) == 1
|
||||||
|
# Una sola chiave, con 2 path
|
||||||
|
paths = list(index.values())[0]
|
||||||
|
assert len(paths) == 2
|
||||||
|
|
||||||
|
def test_nonexistent_dir_returns_empty(self, tmp_path: Path):
|
||||||
|
assert upgrader._scan_archive(str(tmp_path / "nope")) == {}
|
||||||
|
|
||||||
|
def test_empty_stems_skipped(self, tmp_path: Path):
|
||||||
|
# File il cui stem produce zero token utili (solo caratteri corti) → skip
|
||||||
|
(tmp_path / "a.mp3").touch()
|
||||||
|
(tmp_path / "Real Track Name.mp3").touch()
|
||||||
|
index = upgrader._scan_archive(str(tmp_path))
|
||||||
|
# Solo "Real Track Name.mp3" ha token >=3 chars
|
||||||
|
all_paths = [p for paths in index.values() for p in paths]
|
||||||
|
assert len(all_paths) == 1
|
||||||
|
assert all_paths[0].name == "Real Track Name.mp3"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# _find_candidates
|
||||||
|
# ============================================================
|
||||||
|
class TestFindCandidates:
|
||||||
|
def _fake_index(self, tmp_path: Path, entries: list) -> dict:
|
||||||
|
"""Helper: crea file (touch) e ritorna un index manuale."""
|
||||||
|
index: dict = {}
|
||||||
|
for name in entries:
|
||||||
|
p = tmp_path / name
|
||||||
|
p.touch()
|
||||||
|
tokens = upgrader._normalize_stem(p.stem)
|
||||||
|
key = upgrader._tokens_to_key(tokens)
|
||||||
|
index.setdefault(key, []).append(p)
|
||||||
|
return index
|
||||||
|
|
||||||
|
def test_ranking_bitrate_desc_then_similarity_desc(self, tmp_path: Path):
|
||||||
|
# 3 candidati: variamo bitrate + similarity per verificare l'ordine
|
||||||
|
# a: sim alta (0.75), bitrate basso (128)
|
||||||
|
# b: sim media (0.5), bitrate alto (320)
|
||||||
|
# c: sim alta (0.75), bitrate medio (192)
|
||||||
|
# Ordine atteso: b(320) > c(192, sim 0.75) > a(128)
|
||||||
|
idx = self._fake_index(tmp_path, [
|
||||||
|
"Artist - Hot Sauce.mp3", # a: 3 token comuni su 4 = 0.75
|
||||||
|
"Different Song Boombox.mp3", # b: 1 su 5 = 0.2 (troppo bassa, filtrato)
|
||||||
|
"Artist Hot Sauce Extended.mp3", # c: 3 su 4 = 0.75
|
||||||
|
])
|
||||||
|
# Target: "Artist Hot Sauce" → tokens = {artist, hot, sauce}
|
||||||
|
target = "Artist Hot Sauce"
|
||||||
|
|
||||||
|
# Mock bitrate: mappa nome → kbps
|
||||||
|
def fake_bitrate(p):
|
||||||
|
n = Path(p).name
|
||||||
|
if n == "Artist - Hot Sauce.mp3": return 128
|
||||||
|
if n == "Different Song Boombox.mp3": return 320
|
||||||
|
if n == "Artist Hot Sauce Extended.mp3": return 192
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with patch.object(upgrader, "get_bitrate", side_effect=fake_bitrate):
|
||||||
|
results = upgrader._find_candidates(target, idx, min_similarity=0.3)
|
||||||
|
|
||||||
|
# "b" viene filtrato (sim 1/5 = 0.2 < 0.3 min); restano a e c
|
||||||
|
assert len(results) == 2
|
||||||
|
# c (bitrate 192) prima di a (bitrate 128)
|
||||||
|
assert results[0][0].name == "Artist Hot Sauce Extended.mp3"
|
||||||
|
assert results[1][0].name == "Artist - Hot Sauce.mp3"
|
||||||
|
|
||||||
|
def test_below_threshold_filtered(self, tmp_path: Path):
|
||||||
|
# Un solo candidato con similarity ~ 1/5 = 0.2 → sotto default 0.5 → escluso
|
||||||
|
idx = self._fake_index(tmp_path, [
|
||||||
|
"Foo Bar Baz Qux Extra.mp3",
|
||||||
|
])
|
||||||
|
with patch.object(upgrader, "get_bitrate", return_value=320):
|
||||||
|
results = upgrader._find_candidates("Hot Sauce", idx) # solo "hot"/"sauce"
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
def test_exact_match_returned(self, tmp_path: Path):
|
||||||
|
idx = self._fake_index(tmp_path, [
|
||||||
|
"Hot Sauce Extended.mp3",
|
||||||
|
])
|
||||||
|
with patch.object(upgrader, "get_bitrate", return_value=320):
|
||||||
|
results = upgrader._find_candidates("Hot Sauce Extended", idx)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0][1] == 1.0 # perfect Jaccard
|
||||||
|
assert results[0][2] == 320
|
||||||
@@ -1708,3 +1708,70 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
.hint-inline { margin-left: 0; }
|
.hint-inline { margin-left: 0; }
|
||||||
.beatport-table .col-state { width: 110px; }
|
.beatport-table .col-state { width: 110px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===============================
|
||||||
|
UPGRADE — picker modal (match locale)
|
||||||
|
=============================== */
|
||||||
|
.modal-body.upgrade-picker-body {
|
||||||
|
/* Override del default .modal-body (mono + pre-wrap): qui è UI, non log */
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: normal;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.modal-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.upgrade-picker-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 380px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.upgrade-picker-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md, 10px);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease;
|
||||||
|
background: var(--bg-input, transparent);
|
||||||
|
}
|
||||||
|
.upgrade-picker-row:hover { border-color: var(--text-2); }
|
||||||
|
.upgrade-picker-row.selected {
|
||||||
|
border-color: var(--blue, #3b82f6);
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
}
|
||||||
|
.upgrade-picker-row input[type="radio"] {
|
||||||
|
margin-top: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.upgrade-picker-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.upgrade-picker-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.upgrade-picker-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
margin-top: 3px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.upgrade-picker-path {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-top: 2px;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
@@ -272,6 +272,19 @@
|
|||||||
<label class="field-label">Soglia HQ (kbps)</label>
|
<label class="field-label">Soglia HQ (kbps)</label>
|
||||||
<input type="number" id="upThreshold" class="input pill input-sm" value="310" />
|
<input type="number" id="upThreshold" class="input pill input-sm" value="310" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">Cartella archivio (opzionale)</label>
|
||||||
|
<div class="row">
|
||||||
|
<div class="path-display empty" id="upArchivePathDisplay">Nessuna cartella</div>
|
||||||
|
<button class="btn btn-ghost pill btn-sm" id="upArchiveBrowseBtn">📂 Sfoglia</button>
|
||||||
|
<button class="btn btn-ghost pill btn-sm" id="upArchiveClearBtn" hidden>Rimuovi</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
<span class="hint-ico">ⓘ</span>
|
||||||
|
<div>Se selezionata, l'app cerca versioni HQ del brano nella tua libreria locale prima di scaricare da YouTube. In caso di match multipli scegli tu quale usare.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card actions">
|
<div class="card actions">
|
||||||
@@ -1262,6 +1275,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ============== MODAL: Match locale Upgrade ============== -->
|
||||||
|
<div class="modal-backdrop" id="upgradePickerModal" hidden>
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>Match locali trovati</h2>
|
||||||
|
<button class="modal-close" id="upgradePickerCloseBtn">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body upgrade-picker-body">
|
||||||
|
<div class="modal-sub" id="upgradePickerFilename"></div>
|
||||||
|
<div id="upgradePickerList" class="upgrade-picker-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button class="btn btn-primary pill" id="upgradePickerUseBtn" disabled>Usa selezionato</button>
|
||||||
|
<button class="btn btn-ghost pill" id="upgradePickerYoutubeBtn">Cerca su YouTube</button>
|
||||||
|
<button class="btn btn-danger pill" id="upgradePickerSkipBtn">Salta</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ============== TOAST ============== -->
|
<!-- ============== TOAST ============== -->
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
|||||||
+131
@@ -12,6 +12,8 @@ const state = {
|
|||||||
loaded: null, // { kind: "urls"|"tracks", urls?, tracks?, count }
|
loaded: null, // { kind: "urls"|"tracks", urls?, tracks?, count }
|
||||||
dlOutputDir: "",
|
dlOutputDir: "",
|
||||||
upDir: "",
|
upDir: "",
|
||||||
|
upArchiveDir: "",
|
||||||
|
upPicker: { file: "", candidates: [], selectedIdx: -1 },
|
||||||
videoOutputDir: "",
|
videoOutputDir: "",
|
||||||
showSecret: false,
|
showSecret: false,
|
||||||
downloading: false,
|
downloading: false,
|
||||||
@@ -310,8 +312,13 @@ const bridgeHandlers = {
|
|||||||
state.upgrading = false;
|
state.upgrading = false;
|
||||||
$("#upgradeBtn").disabled = false;
|
$("#upgradeBtn").disabled = false;
|
||||||
$("#stopUpgradeBtn").disabled = true;
|
$("#stopUpgradeBtn").disabled = true;
|
||||||
|
// Chiudi eventuale modal picker rimasto aperto
|
||||||
|
const modal = $("#upgradePickerModal");
|
||||||
|
if (modal && !modal.hidden) modal.hidden = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"upgrade:candidates_needed": (p) => openUpgradePicker(p),
|
||||||
|
|
||||||
"video:progress": (p) => {
|
"video:progress": (p) => {
|
||||||
if (typeof p.overall === "number") {
|
if (typeof p.overall === "number") {
|
||||||
const pct = Math.round(p.overall * 100);
|
const pct = Math.round(p.overall * 100);
|
||||||
@@ -714,6 +721,26 @@ $("#upBrowseBtn").addEventListener("click", async () => {
|
|||||||
|
|
||||||
$("#upRecursive").addEventListener("change", refreshUpgradeScan);
|
$("#upRecursive").addEventListener("change", refreshUpgradeScan);
|
||||||
|
|
||||||
|
// --- Cartella archivio (opzionale) ---
|
||||||
|
$("#upArchiveBrowseBtn").addEventListener("click", async () => {
|
||||||
|
const path = await window.pywebview.api.browse_directory();
|
||||||
|
if (path) {
|
||||||
|
state.upArchiveDir = path;
|
||||||
|
const disp = $("#upArchivePathDisplay");
|
||||||
|
disp.textContent = path;
|
||||||
|
disp.classList.remove("empty");
|
||||||
|
$("#upArchiveClearBtn").hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#upArchiveClearBtn").addEventListener("click", () => {
|
||||||
|
state.upArchiveDir = "";
|
||||||
|
const disp = $("#upArchivePathDisplay");
|
||||||
|
disp.textContent = "Nessuna cartella";
|
||||||
|
disp.classList.add("empty");
|
||||||
|
$("#upArchiveClearBtn").hidden = true;
|
||||||
|
});
|
||||||
|
|
||||||
async function refreshUpgradeScan() {
|
async function refreshUpgradeScan() {
|
||||||
if (!state.upDir) return;
|
if (!state.upDir) return;
|
||||||
const res = await window.pywebview.api.scan_audio_folder(state.upDir, $("#upRecursive").checked);
|
const res = await window.pywebview.api.scan_audio_folder(state.upDir, $("#upRecursive").checked);
|
||||||
@@ -748,6 +775,7 @@ $("#upgradeBtn").addEventListener("click", async () => {
|
|||||||
directory: state.upDir,
|
directory: state.upDir,
|
||||||
recursive: $("#upRecursive").checked,
|
recursive: $("#upRecursive").checked,
|
||||||
threshold: parseInt($("#upThreshold").value, 10) || 310,
|
threshold: parseInt($("#upThreshold").value, 10) || 310,
|
||||||
|
archive_dir: state.upArchiveDir || "",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
|
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
|
||||||
@@ -760,6 +788,109 @@ $("#stopUpgradeBtn").addEventListener("click", async () => {
|
|||||||
$("#stopUpgradeBtn").disabled = true;
|
$("#stopUpgradeBtn").disabled = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Modal picker "Match locali multipli" ---
|
||||||
|
function _fmtBytes(n) {
|
||||||
|
if (!n || n < 0) return "—";
|
||||||
|
if (n < 1024) return n + " B";
|
||||||
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
||||||
|
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB";
|
||||||
|
return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _basename(p) {
|
||||||
|
if (!p) return "";
|
||||||
|
const parts = String(p).split(/[/\\]/);
|
||||||
|
return parts[parts.length - 1] || p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _escapeHtml(s) {
|
||||||
|
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUpgradePicker(payload) {
|
||||||
|
const modal = $("#upgradePickerModal");
|
||||||
|
if (!modal) return;
|
||||||
|
const file = (payload && payload.file) || "";
|
||||||
|
const candidates = (payload && payload.candidates) || [];
|
||||||
|
state.upPicker = { file, candidates, selectedIdx: -1 };
|
||||||
|
|
||||||
|
$("#upgradePickerFilename").textContent = "Brano: " + file;
|
||||||
|
const list = $("#upgradePickerList");
|
||||||
|
list.innerHTML = "";
|
||||||
|
candidates.forEach((c, i) => {
|
||||||
|
const row = document.createElement("label");
|
||||||
|
row.className = "upgrade-picker-row";
|
||||||
|
row.dataset.idx = String(i);
|
||||||
|
const simPct = Math.round((c.similarity || 0) * 100);
|
||||||
|
const kbps = c.bitrate ? `${c.bitrate}k` : "—";
|
||||||
|
row.innerHTML = `
|
||||||
|
<input type="radio" name="upgradePickerRadio" value="${i}" />
|
||||||
|
<div class="upgrade-picker-info">
|
||||||
|
<div class="upgrade-picker-title">${_escapeHtml(_basename(c.path))}</div>
|
||||||
|
<div class="upgrade-picker-meta">${_escapeHtml(kbps)} · ${_escapeHtml(_fmtBytes(c.size))} · similarity ${simPct}%</div>
|
||||||
|
<div class="upgrade-picker-path">${_escapeHtml(c.path || "")}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
row.addEventListener("click", () => {
|
||||||
|
state.upPicker.selectedIdx = i;
|
||||||
|
$("#upgradePickerUseBtn").disabled = false;
|
||||||
|
$$("#upgradePickerList .upgrade-picker-row").forEach((r) =>
|
||||||
|
r.classList.toggle("selected", r === row)
|
||||||
|
);
|
||||||
|
const radio = row.querySelector("input[type=radio]");
|
||||||
|
if (radio) radio.checked = true;
|
||||||
|
});
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
$("#upgradePickerUseBtn").disabled = true;
|
||||||
|
modal.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUpgradePicker() {
|
||||||
|
const modal = $("#upgradePickerModal");
|
||||||
|
if (modal) modal.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _sendUpgradeChoice(choice) {
|
||||||
|
try {
|
||||||
|
await window.pywebview.api.upgrade_resolve_candidates(choice);
|
||||||
|
} catch (_e) { /* backend può essere già terminato: ignora */ }
|
||||||
|
closeUpgradePicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#upgradePickerUseBtn")?.addEventListener("click", () => {
|
||||||
|
const idx = state.upPicker.selectedIdx;
|
||||||
|
if (idx < 0 || idx >= state.upPicker.candidates.length) return;
|
||||||
|
const path = state.upPicker.candidates[idx].path;
|
||||||
|
_sendUpgradeChoice({ action: "use_local", path });
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#upgradePickerYoutubeBtn")?.addEventListener("click", () => {
|
||||||
|
_sendUpgradeChoice({ action: "use_youtube" });
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#upgradePickerSkipBtn")?.addEventListener("click", () => {
|
||||||
|
_sendUpgradeChoice({ action: "skip" });
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#upgradePickerCloseBtn")?.addEventListener("click", () => {
|
||||||
|
_sendUpgradeChoice({ action: "skip" });
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#upgradePickerModal")?.addEventListener("click", (e) => {
|
||||||
|
// Click sul backdrop (non sulla card) → skip
|
||||||
|
if (e.target === e.currentTarget) _sendUpgradeChoice({ action: "skip" });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
const modal = $("#upgradePickerModal");
|
||||||
|
if (modal && !modal.hidden) _sendUpgradeChoice({ action: "skip" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// RECORDER tab
|
// RECORDER tab
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in new issue
Block a user