Compare commits

...
40 Commits
Author SHA1 Message Date
luciano 39a117f08b feat: cookies-from-browser per bypass HTTP 403 YouTube (v1.10.1)
Alcuni video YouTube restituiscono 403 anche con yt-dlp aggiornato.
Fix: nuova opzione Impostazioni "Cookies da browser" con dropdown
(chrome/safari/firefox/edge/brave). Priorita': file cookies.txt
esistente -> --cookies-from-browser <name> -> nessuno.

Bonus:
- core/paths.py::_bundle_dirs() ora include <project>/bundle_bin/
  anche in dev, cosi' python main.py usa lo stesso yt-dlp del build
  invece di Homebrew (spesso obsoleto)
- Helper _cookie_args() in downloader.py per non ripetere la logica
  su tutti i punti dove serve --cookies
2026-08-19 15:30:46 +02:00
luciano e7d11d0235 feat: Dedup enhancements (streaming, retry, filename mode, scroll, auto-select)
Iterazioni sul feature Dedup (v1.10.0) da subito-post-scaffolding:
- compute_fingerprint espone il vero errore invece del generico None,
  worker propaga l'err_msg via progress_callback esteso
- Retry automatico: -length 30 su "invalid data / decoding frame",
  -length 60 + -algorithm 1 su "fingerprint vuoto"
- Nuovo metodo "filename" (Jaccard sui token nome file) con algoritmo
  incrementale O(N·K) — utile per file corrotti o per anteprima veloce
- Streaming groups: sia fingerprint che filename emettono `dedup:group`
  appena un gruppo raggiunge >=2 file; JS accumula in Map per update
  in-place. Progress bar avanza in tempo reale
- Fix shape bug che dava "NaN duplicati" nella summary line
- .dedup-groups-scroll: max-height 55vh + overflow-y auto per non
  perdere l'header/footer scrollando molti gruppi
- Bottone "Seleziona tutti i consigliati" nel footer: ripristina la
  selezione di default (tutti tranne il TIENI marcati per cancellazione)
- .gitignore: dedup_cache.db (SQLite locale per macchina utente)
2026-08-02 13:59:39 +02:00
luciano 00ff5a6220 ui: DedupUI + stili gruppi duplicati
- DedupUI: state locale + folder picker + start/stop scan +
  render gruppi (checkbox multi-select), file col bitrate max
  auto-preselezionato come "TIENI" (verde, checkbox disabled),
  audio preview riusa _makePreviewBtn dal picker Upgrade.
- Listener bridge dedup:progress / dedup:done, ripristino
  ultima cartella + recursive da config al mount.
- CSS: .dedup-group-card, .dedup-file-row, .dedup-keep (verde),
  .dedup-keep-badge, .dedup-footer-bar sticky.
2026-08-02 13:23:04 +02:00
luciano e90e38edc8 ui: markup tab Dedup 2026-08-02 13:22:52 +02:00
luciano f18dd375e8 bridge: metodi Api per tab Dedup + bump v1.10.0
- Api.dedup_pick_folder / dedup_start_scan / dedup_stop_scan
  / dedup_move_to_trash con worker thread + eventi
  dedup:progress e dedup:done.
- core.config: dedup_last_folder, dedup_recursive defaults;
  VERSION -> v1.10.0 (minor: nuova tab Dedup).
2026-08-02 13:22:40 +02:00
luciano fb13b0f70e dedup: modulo audio fingerprinting via fpcalc + cache SQLite
Nuovo modulo core.dedup per rilevare brani audio duplicati usando
Chromaprint (fpcalc): scan cartella (opzionalmente ricorsivo),
fingerprint acustico via fpcalc -json, cache SQLite in
_get_config_dir()/dedup_cache.db per non ricalcolare al re-scan,
raggruppamento per fingerprint identico + ordering per bitrate DESC.
move_to_trash() usa send2trash (reversibile via Finder/Explorer).

- core/paths.py: find_fpcalc() con fallback dev su project bundle_bin
- core/dedup.py: scan_folder, compute_fingerprint, move_to_trash
- tests/test_dedup.py: 9 unit test (mock fpcalc + send2trash)
- requirements.txt: send2trash>=1.8.0
- build_macos.py: download fpcalc universal binary da GitHub releases
- build_windows.py: download fpcalc.exe da GitHub releases
2026-08-02 13:22:24 +02:00
luciano e09ee1382b fix: typo _find_ffmpeg_dir in update_cover_only (bump v1.9.5)
Il typo (underscore prefix) causava NameError nel thread upgrade
quando un file era già HQ (soglia superata) e serviva solo aggiornare
la copertina. Il thread moriva silenziosamente e l'utente vedeva
l'app 'bloccata' senza feedback.
2026-08-02 13:02:26 +02:00
luciano e7fde842f2 feat: upgrade archivio con auto-pick, audio preview, cancel-all (v1.9.4)
Tab Upgrade:
- Checkbox "Scelta automatica" per skippare il modal quando ci sono
  multi-match (usa bitrate DESC)
- Anteprima audio nel picker: play button sul source + ogni candidato
  via bridge read_audio_data_url (WKWebView blocca file://)
- Bottone "Annulla tutto" nel picker → interrompe l'intera coda
- Fix mtime: shutil.copy (non copy2) così il file upgradato ha
  data odierna nel Finder
- Fix stop audio → non triggera più toast di errore
- Modal picker più largo (620-780px) con footer wrap

Extra: campo release_date estratto anche in Beatport (publish_date)
e YouTube (upload_date) — completato scaffold sort colonna Data
2026-08-02 12:56:19 +02:00
luciano e10466d93b ui: UpgradeUI archive_dir + candidates picker + bump v1.9.3
Frontend Upgrade:
- state.upArchiveDir + handler Sfoglia/Rimuovi per la seconda path-display
- start_upgrade payload include archive_dir
- bridge handler 'upgrade:candidates_needed' apre openUpgradePicker() con
  lista candidati (basename + bitrate + size + similarity + path)
- 3 azioni utente: Usa selezionato / Cerca YouTube / Salta; ESC e click
  su backdrop = Salta. Chiusura implicita quando upgrade:done.
- CSS minimale (.upgrade-picker-*) accodato dopo le sezioni esistenti.

Version bump v1.9.2 -> v1.9.3.
2026-08-02 11:59:59 +02:00
luciano f0a29f7ca7 ui: campo Cartella archivio + modal picker per Upgrade
Nella tab Upgrade aggiunta seconda coppia path-display + Sfoglia/Rimuovi
per la cartella archivio opzionale (con hint esplicativo).

In fondo al body: nuovo modal upgradePickerModal con lista candidati e
3 bottoni (Usa selezionato / Cerca YouTube / Salta). Riusa le classi
esistenti .modal-backdrop / .modal / .modal-head / .modal-body / .modal-foot.
2026-08-02 11:56:48 +02:00
luciano 4e1af7b8f6 bridge: upgrade_resolve_candidates + evento upgrade:candidates_needed
start_upgrade accetta 'archive_dir' nel payload e lo inoltra a
upgrade_folder. Il worker passa un resolve_callback che emette
'upgrade:candidates_needed' e blocca su una queue.Queue(1) attendendo
la scelta utente dal modal (timeout 10 min → skip).

Il metodo upgrade_resolve_candidates(choice) è chiamato dalla UI e
deposita la scelta nella queue. stop_upgrade sblocca eventuale attesa
inviando un 'skip' automatico. Log dedicati per gli status nuovi
(local_copy, local_upgraded, skipped_by_user, resolve_wait, scan_archive).
2026-08-02 11:56:10 +02:00
luciano d8723f9408 upgrader: _scan_archive + _find_candidates + resolve_callback per match locale
Nuovo flusso: se archive_dir è passato, upgrade_folder cerca versioni HQ
del brano nell'archivio locale prima di scaricare da YouTube. Match via
Jaccard sui token del filename (soglia default 0.5). Se 1 candidato:
copia/converte diretto in mp3 320k. Se >=2 e resolve_callback presente:
chiama la callback bloccante (per la UI) con lista candidati + bitrate
+ similarity, poi rispetta la scelta (use_local / use_youtube / skip).
Fallback su YouTube se nessun match o archive_dir=None.

Test unitari sui 3 helper (_normalize_stem, _scan_archive, _find_candidates).
2026-08-02 11:54:58 +02:00
luciano eb20a034e6 fix: stop button e hang su ffmpeg orfano in Upgrade (bump v1.9.2)
Il worker Python era bloccato in `for line in proc.stdout` che non
ritornava mai quando il subprocess yt-dlp veniva killed ma lasciava
figli ffmpeg orfani con pipe stdout ancora aperto. Effetti:
1) Il bottone Stop non fermava l'upgrade
2) Anche il watchdog kill non liberava il thread

Fix:
- Lettura stdout in thread separato + queue.Queue
- Main loop polla ogni 500ms con timeout: controlla is_stopped() e
  proc.poll() -> Stop risponde entro 1s
- Watchdog usa kill() (SIGKILL) invece di terminate() (SIGTERM) per
  garantire cleanup child orfani
2026-08-02 11:37:40 +02:00
luciano cdd380d62f fix: watchdog su download upgrader (bump v1.9.1)
Prima: se yt-dlp si bloccava nel download di un video (geo-restricted,
YouTube throttle, rete lenta), il for loop su stdout attendeva
indefinitamente e la tab Upgrade si fermava dopo 2-3 brani.

Ora: threading.Timer da 5 min uccide il subprocess se in scaduta.
Al kill il for esce -> codice trova temp_dir vuota -> marca il brano
"download_error" e prosegue col successivo.
2026-08-02 10:55:47 +02:00
luciano 2689f686ec Merge feat/traxsource-charts: MusicTools v1.9.0
Nuova tab Traxsource per caricare la Top 100 mensile per genere.
23 generi, bypass Cloudflare via curl_cffi session, HTML scraping con
BeautifulSoup, cache 15min. Riusa pipeline download + tag ID3.

29 nuovi test unitari, 90/90 test verdi totali.
2026-08-01 12:15:36 +02:00
luciano f8e2ee66bd ui: TraxsourceUI (init, loadChart, render, download) 2026-08-01 12:10:28 +02:00
luciano a07f6c34ca ui: markup tab Traxsource 2026-08-01 12:08:56 +02:00
luciano 6c473eaec9 bridge: 4 metodi Api Traxsource (parallelo Beatport, metadata album=label) 2026-08-01 12:07:56 +02:00
luciano 16c8b8ee67 config: v1.9.0 + traxsource_last_genre nei DEFAULTS 2026-08-01 12:05:11 +02:00
luciano 88e59ad37f traxsource: fetch_top100 con session curl_cffi + retry + cache 15min 2026-08-01 12:01:00 +02:00
luciano 784b56682f traxsource: _discover_top100_url + _parse_tracks con BeautifulSoup 2026-08-01 11:59:55 +02:00
luciano e1e629f8a2 traxsource: helper _split_title_mix + _format_artists + _large_cover + eccezioni 2026-08-01 11:57:42 +02:00
luciano a95912f78c traxsource: GENRES + TraxsourceTrack + list_genres 2026-08-01 11:55:30 +02:00
luciano 7feda99720 traxsource: fixture HTML + bs4 dep + script refresh generi 2026-08-01 11:54:55 +02:00
luciano 191c245fc1 plan: implementation Traxsource charts v1.9.0 2026-08-01 11:50:44 +02:00
luciano 98235625ba spec: feature Traxsource charts (v1.9.0 target) 2026-08-01 11:47:32 +02:00
luciano 72e700c85d config: bump VERSION v1.8.6 2026-07-21 17:51:22 +02:00
luciano a774906814 ui: ConvertUI (file picker, opzioni, batch conversione) 2026-07-21 17:48:31 +02:00
luciano 9f41f9d302 ui: markup tab Converti 2026-07-21 17:47:16 +02:00
luciano b0cd046375 bridge: 4 metodi Api per convertitore WAV -> MP3 2026-07-21 17:44:59 +02:00
luciano 5fc9f8ac2b converter: modulo ffmpeg WAV -> MP3 con VBR/CBR + stop 2026-07-21 17:44:36 +02:00
luciano c4e6d6e322 fix: bundle certifi CA per SSL su Windows/macOS + bump v1.8.5
- requirements.txt: certifi come dep esplicita
- main.py: setta SSL_CERT_FILE/REQUESTS_CA_BUNDLE su certifi.where() al boot
- build_windows.py + build_macos.py: --collect-data certifi nel PyInstaller

Bugfix: senza certifi bundlato, le chiamate HTTPS dal build Windows
falliscono con CERTIFICATE_VERIFY_FAILED (attivazione licenza impossibile).
2026-07-17 16:26:16 +02:00
luciano c7dfe95890 config: bump VERSION v1.8.4 2026-07-15 13:04:06 +02:00
luciano 25b646d754 bridge: metadata pipeline per tag ID3 su download search 2026-07-15 13:00:19 +02:00
luciano 73b4480890 downloader: param output_filenames + post_download_callback 2026-07-15 12:56:56 +02:00
luciano d53b8f20ed spotify: get_artist_genres + enrich_from_youtube_title + cover_url_large 2026-07-15 12:54:59 +02:00
luciano 0f7cd24703 tagger: modulo mutagen per ID3 tag + cover art da URL 2026-07-15 12:54:26 +02:00
luciano f1437e8e24 config: bump VERSION v1.8.3 2026-07-15 11:46:50 +02:00
luciano acbd9c0b1f ui: hero sticky in cima al pannello scrollabile di ogni tab 2026-07-15 11:45:43 +02:00
luciano f226c3fa8e ui: cover art in Beatport, Spotify e YouTube search
- core/beatport.py: BeatportTrack.image_url estratto da image.dynamic_uri (95x95)
- core/spotify_client.py: _track_to_dict include image_url (Spotify torna 3 taglie,
  prendo la ~64px). search_artist_discography inietta album.images anche negli
  album-tracks per la cover
- core/youtube_search.py: image_url = i.ytimg.com/vi/<id>/mqdefault.jpg
- webui/index.html: nuova <th class="col-cover"></th> nelle 3 tabelle
- webui/js/app.js: 3 renderTable inseriscono <td class="col-cover"><img>
  con loading=lazy, referrerpolicy=no-referrer e onerror fallback
- webui/css/style.css: .beatport-table .col-cover img { 40x40, border-radius 4px,
  object-fit: cover, bg fallback var(--bg-input) }
2026-07-15 11:45:28 +02:00
30 changed files with 15594 additions and 81 deletions

No files matched your search

+4
View File
@@ -49,3 +49,7 @@ server/node_modules/
server/.wrangler/
server/.dev.vars
server/dist/
# Dedup local cache (SQLite fingerprint cache, per macchina dell'utente)
dedup_cache.db
dedup_cache.db-journal
+707 -14
View File
@@ -4,18 +4,19 @@ from __future__ import annotations
import json
import os
import queue
import re
import threading
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
from core.config import load_config, save_config, VERSION, LICENSE_API_URL
from core import beatport, spotify_client
from core import beatport, spotify_client, traxsource
from core import license as license_mod
from core.downloader import (
download_playlist,
@@ -32,6 +33,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,
@@ -44,6 +46,7 @@ from core.upgrader import (
request_stop as request_upgrade_stop,
count_files_info,
)
from core import dedup
SPOTIFY_GUIDE_TEXT = """\
@@ -109,6 +112,10 @@ class Api:
self._download_thread: Optional[threading.Thread] = None
self._upgrade_thread: Optional[threading.Thread] = None
self._video_thread: Optional[threading.Thread] = None
self._dedup_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
@@ -163,6 +170,7 @@ class Api:
"bitrate": payload.get("bitrate", "320K"),
"hq_threshold": threshold,
"cookies_path": (payload.get("cookies_path") or "").strip(),
"cookies_browser": (payload.get("cookies_browser") or "").strip().lower(),
"output_dir": (payload.get("output_dir") or "").strip(),
"theme": payload.get("theme", "dark"),
})
@@ -516,13 +524,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 +556,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 +564,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 +578,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 +598,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 +609,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 +620,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 +684,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 +703,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 +759,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:
@@ -807,6 +889,8 @@ class Api:
return {"ok": False, "error": "Upgrade gia in corso"}
directory = (payload.get("directory") or "").strip()
archive_dir = (payload.get("archive_dir") or "").strip() or None
archive_auto_pick = bool(payload.get("archive_auto_pick", False))
recursive = bool(payload.get("recursive", False))
try:
threshold = int(payload.get("threshold", 310))
@@ -823,9 +907,16 @@ class Api:
cfg = load_config()
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(
target=self._upgrade_worker,
args=(directory, threshold, cookies_path, recursive),
args=(directory, threshold, cookies_path, recursive, archive_dir, archive_auto_pick),
daemon=True,
)
self._upgrade_thread.start()
@@ -833,10 +924,63 @@ class Api:
def stop_upgrade(self) -> dict:
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...")
return {"ok": True}
def _upgrade_worker(self, directory, threshold, cookies_path, recursive):
def read_audio_data_url(self, path: str) -> dict:
"""Legge un file audio locale e lo ritorna come data URL base64
per il preview HTML5 nel modal upgrade. WKWebView (macOS pywebview)
blocca `file://` da HTML servito via file://, quindi passiamo dal
bridge. Limite: 60MB per non esplodere la memoria JS."""
import base64 as _b64
MAX = 60 * 1024 * 1024
try:
p = Path(path or "")
if not p.exists() or not p.is_file():
return {"ok": False, "error": "File non trovato"}
size = p.stat().st_size
if size > MAX:
return {"ok": False, "error": f"File troppo grande ({size // 1024 // 1024}MB, max 60MB)"}
ext = p.suffix.lower().lstrip(".")
mime_map = {
"mp3": "audio/mpeg", "m4a": "audio/mp4", "aac": "audio/aac",
"wav": "audio/wav", "flac": "audio/flac", "ogg": "audio/ogg",
"opus": "audio/opus", "webm": "audio/webm",
}
mime = mime_map.get(ext, "audio/mpeg")
b64 = _b64.b64encode(p.read_bytes()).decode("ascii")
return {"ok": True, "data_url": f"data:{mime};base64,{b64}"}
except Exception as e:
return {"ok": False, "error": str(e)}
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,
archive_auto_pick: bool = False):
view = "upgrade"
def progress_cb(idx, total, filename, status, old_kbps, new_kbps):
@@ -863,6 +1007,19 @@ class Api:
diff = new_kbps - old_kbps
diff_str = f" (+{diff}kbps)" if diff > 0 else ""
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":
self._log(view, f"[ERRORE] {filename}: download fallito")
elif status == "stopped":
@@ -873,7 +1030,35 @@ class Api:
self._emit("upgrade:progress", payload_evt)
upgrade_folder(directory, threshold, cookies_path, recursive, progress_cb)
def resolve_cb(source_path: str, candidates: list) -> dict:
"""Bloccante: emette evento e attende scelta utente via queue.
Se archive_auto_pick=True, salta il modal e sceglie il primo
candidato (già ordinato per bitrate DESC nel core).
`source_path` è il path assoluto del file sorgente (per il preview
audio nel modal)."""
src_name = Path(source_path).name if source_path else ""
if archive_auto_pick and candidates:
top = candidates[0]
path = top.get("path") if isinstance(top, dict) else str(top)
self._log(view, f"[AUTO] {src_name}: match multipli, uso {Path(path).name}")
return {"action": "use_local", "path": path}
self._emit("upgrade:candidates_needed", {
"file": src_name,
"source_path": source_path,
"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})
# ------------------------------------------------------------------
@@ -1090,6 +1275,110 @@ class Api:
"subfolder": subfolder,
})
# ================================================================
# Traxsource charts
# ================================================================
def _traxsource_output_dir(self, out_root: str, genre_name: str) -> Path:
"""Cartella dove finiscono i file Traxsource. Coerente con la
sanitizzazione di `start_tracks_download` (slash -> underscore)."""
safe_genre = genre_name.replace("/", "_").replace("\\", "_").strip()
subfolder = f"Traxsource_{safe_genre}" if safe_genre else "Traxsource"
return Path(out_root) / subfolder
def traxsource_genres(self) -> list:
"""Lista dei generi Traxsource per il dropdown UI."""
return traxsource.list_genres()
def traxsource_fetch_chart(self, slug: str, force_refresh: bool = False) -> dict:
"""Fetches la Top 100 corrente per il genere. Salva l'ultimo genere in config."""
try:
cfg = load_config()
cfg["traxsource_last_genre"] = slug
save_config(cfg)
except Exception:
pass
try:
tracks = traxsource.fetch_top100(slug, force_refresh=force_refresh)
except ValueError as e:
return {"ok": False, "error": "invalid_genre", "message": str(e)}
except traxsource.TraxsourceUnreachableError as e:
return {"ok": False, "error": "unreachable", "message": str(e)}
except traxsource.TraxsourceParseError as e:
return {"ok": False, "error": "parse", "message": str(e)}
return {"ok": True, "tracks": [asdict(t) for t in tracks]}
def traxsource_check_existing(self, tracks: list, genre_name: str) -> list:
"""True per ogni track già presente nella cartella output Traxsource.
Match euristico: filename (senza extension) deve contenere sia il titolo
che il primo artista (case-insensitive)."""
cfg = load_config()
out_root = (cfg.get("output_dir") or "").strip()
if not out_root:
return [False] * len(tracks)
out_dir = self._traxsource_output_dir(out_root, genre_name)
if not out_dir.exists():
return [False] * len(tracks)
existing_stems = [p.stem.lower() for p in out_dir.glob("*.mp3")]
result = []
for t in tracks:
title = (t.get("title") or "").lower().strip()
artists = (t.get("artists") or "")
first_artist = artists.split(",")[0].split("&")[0].strip().lower()
if not title or not first_artist:
result.append(False)
continue
result.append(any(
(title in stem and first_artist in stem)
for stem in existing_stems
))
return result
def traxsource_download_selected(self, tracks: list, genre_name: str) -> dict:
"""Converte i TraxsourceTrack in tracklist compatibile con
start_tracks_download + costruisce metadata paralleli per il tagger ID3
(album=label Traxsource, date=YYYY-MM corrente, genre=nome genere)."""
cfg = load_config()
out_root = (cfg.get("output_dir") or "").strip()
if not out_root:
return {"ok": False, "error": "Cartella output non impostata"}
target = self._traxsource_output_dir(out_root, genre_name)
subfolder = target.name # es. "Traxsource_Tech House"
import datetime as _dt
current_month = _dt.date.today().strftime("%Y-%m")
converted: list = []
metadata: list = []
for t in tracks:
title = (t.get("title") or "").strip()
artists = (t.get("artists") or "").strip()
if not title:
continue
converted.append({"name": title, "artist": artists})
metadata.append({
"title": title,
"artist": artists,
"album": (t.get("label") or "").strip() or f"Traxsource Top 100 {current_month}",
"date": current_month,
"genre": genre_name,
"cover_url": (t.get("cover_url_large") or t.get("image_url") or "").strip(),
})
if not converted:
return {"ok": False, "error": "Nessun brano valido"}
return self.start_tracks_download({
"tracks": converted,
"output_dir": out_root,
"subfolder": subfolder,
"metadata": metadata,
})
# ================================================================
# Music Search — Spotify + YouTube
# ================================================================
@@ -1167,7 +1456,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 +1471,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 +1492,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 +1520,7 @@ class Api:
"tracks": converted,
"output_dir": out_root,
"subfolder": subfolder,
"metadata": metadata,
})
# ---- YouTube ----
@@ -1242,7 +1572,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 +1588,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 +1611,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 +1664,302 @@ 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})
# ================================================================
# DEDUP — audio duplicati via Chromaprint fingerprinting
# ================================================================
def dedup_pick_folder(self) -> str:
"""Folder picker per la cartella da scansionare."""
return self.browse_directory()
def dedup_start_scan(self, payload: dict) -> dict:
"""Avvia worker di scansione. Payload: {directory, recursive}.
Salva `dedup_last_folder` e `dedup_recursive` in config per la
prossima apertura del tab.
"""
if self._dedup_thread and self._dedup_thread.is_alive():
return {"ok": False, "error": "Scansione dedup gia in corso"}
directory = (payload.get("directory") or "").strip()
recursive = bool(payload.get("recursive", True))
method = (payload.get("method") or "fingerprint").strip()
if method not in ("fingerprint", "filename"):
method = "fingerprint"
if not directory:
return {"ok": False, "error": "Cartella non impostata"}
if not os.path.isdir(directory):
return {"ok": False, "error": "Cartella non trovata"}
# Persist last folder/recursive/method
try:
cfg = load_config()
cfg["dedup_last_folder"] = directory
cfg["dedup_recursive"] = recursive
cfg["dedup_method"] = method
save_config(cfg)
except Exception:
pass
self._dedup_thread = threading.Thread(
target=self._dedup_worker,
args=(directory, recursive, method),
daemon=True,
)
self._dedup_thread.start()
return {"ok": True}
def dedup_stop_scan(self) -> dict:
dedup.request_stop()
self._log("dedup", "[INFO] Interruzione richiesta...")
return {"ok": True}
def dedup_move_to_trash(self, paths: list) -> dict:
"""Sposta i file in cestino via send2trash. Non consuma quota."""
if not isinstance(paths, list):
return {"ok": False, "error": "paths deve essere una lista"}
# Sanitize: solo str non vuote
clean = [str(p).strip() for p in paths if p and str(p).strip()]
if not clean:
return {"ok": False, "error": "Nessun file da cancellare"}
result = dedup.move_to_trash(clean)
moved_n = len(result.get("moved", []))
failed_n = len(result.get("failed", []))
if moved_n:
self._log("dedup", f"[OK] {moved_n} file spostati nel cestino")
for f in result.get("failed", []):
self._log("dedup",
f"[ERRORE] {f.get('path')}: {f.get('error')}")
return {"ok": True, "moved": result.get("moved", []),
"failed": result.get("failed", []),
"moved_count": moved_n, "failed_count": failed_n}
def _dedup_worker(self, directory: str, recursive: bool,
method: str = "fingerprint") -> None:
"""Esegue la scansione in background e emette progress/done."""
view = "dedup"
dedup.reset_stop()
method_label = "audio fingerprint" if method == "fingerprint" else "nome file"
self._log(view, f"[INFO] Scansione: {directory} (metodo: {method_label}, recursive={recursive})")
_last = [0.0]
_THROTTLE = 0.05
def progress_cb(idx: int, total: int, filename: str, status: str, err_msg: str = "") -> None:
# Throttle solo eventi 'computing'/'cached' (che possono essere migliaia)
if status in ("computing", "cached"):
now = time.monotonic()
if now - _last[0] < _THROTTLE and idx != total:
return
_last[0] = now
payload_evt = {
"idx": idx, "total": total,
"filename": filename, "status": status,
}
if err_msg:
payload_evt["error_msg"] = err_msg
if total > 0:
payload_evt["overall"] = min(idx / total, 1.0)
if status == "completed":
payload_evt["overall"] = 1.0
self._log(view, f"[INFO] Scansione completata ({total} file).")
elif status == "stopped":
self._log(view, "[INFO] Scansione interrotta.")
elif status == "error" and filename:
detail = f": {err_msg}" if err_msg else ""
self._log(view, f"[ERRORE] {filename}{detail}")
self._emit("dedup:progress", payload_evt)
def group_cb(group: dict) -> None:
"""Emit streaming: appena un gruppo raggiunge/aggiorna >=2 file."""
try:
self._emit("dedup:group", group)
except Exception:
pass
try:
groups = dedup.scan_folder(directory, recursive=recursive,
progress_callback=progress_cb,
method=method,
group_callback=group_cb)
except Exception as e:
self._log(view, f"[ERRORE] {e}")
self._emit("dedup:done", {"ok": False, "error": str(e),
"groups": []})
return
n_groups = len(groups)
n_dupes = sum(max(0, len(g) - 1) for g in groups)
total_bytes = sum(sum(int(e.get("size") or 0) for e in g[1:])
for g in groups)
self._log(view,
f"[INFO] Gruppi: {n_groups} — duplicati: {n_dupes} — "
f"spazio recuperabile: ~{total_bytes // 1024 // 1024} MB")
self._emit("dedup:done", {
"ok": True,
"groups": groups,
"n_groups": n_groups,
"n_dupes": n_dupes,
"reclaimable_bytes": total_bytes,
})
+45
View File
@@ -64,6 +64,48 @@ def download_ytdlp():
return dest
# =========================================================================
# 1b. Scarica fpcalc (Chromaprint) — usato dal tab Dedup
# =========================================================================
def download_fpcalc():
"""Scarica il binario universal Chromaprint fpcalc per macOS."""
dest = BUNDLE_DIR / "fpcalc"
if dest.exists():
log(f"fpcalc gia presente: {dest}")
return dest
url = ("https://github.com/acoustid/chromaprint/releases/download/"
"v1.5.1/chromaprint-fpcalc-1.5.1-macos-universal.tar.gz")
log(f"Scarico fpcalc da {url} ...")
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
tmp_tar = BUNDLE_DIR / "_fpcalc.tar.gz"
subprocess.run(["curl", "-L", "-o", str(tmp_tar), url], check=True)
# Estrae ovunque nella cartella, poi trova fpcalc e lo sposta al posto
tmp_extract = BUNDLE_DIR / "_fpcalc_extract"
if tmp_extract.exists():
shutil.rmtree(tmp_extract)
tmp_extract.mkdir()
subprocess.run(["tar", "-xzf", str(tmp_tar), "-C", str(tmp_extract)],
check=True)
# Trova fpcalc nel folder estratto
found = None
for p in tmp_extract.rglob("fpcalc"):
if p.is_file():
found = p
break
if not found:
shutil.rmtree(tmp_extract, ignore_errors=True)
tmp_tar.unlink(missing_ok=True)
raise RuntimeError("fpcalc non trovato nell'archivio")
shutil.copy2(found, dest)
dest.chmod(dest.stat().st_mode | stat.S_IEXEC)
shutil.rmtree(tmp_extract, ignore_errors=True)
tmp_tar.unlink(missing_ok=True)
log(f"fpcalc scaricato: {dest}")
return dest
# =========================================================================
# 2. Raccogli ffmpeg/ffprobe + dylib
# =========================================================================
@@ -226,6 +268,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
@@ -509,6 +553,7 @@ def main():
shutil.rmtree(BUNDLE_DIR)
download_ytdlp()
download_fpcalc()
bundle_ffmpeg()
run_pyinstaller()
+32
View File
@@ -31,6 +31,8 @@ BUNDLE_DIR = ROOT / "bundle_bin"
YTDLP_URL = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
FFMPEG_URL = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
FPCALC_URL = ("https://github.com/acoustid/chromaprint/releases/download/"
"v1.5.1/chromaprint-fpcalc-1.5.1-windows-x86_64.zip")
def log(msg):
@@ -87,6 +89,33 @@ def download_ffmpeg():
sys.exit(1)
# =========================================================================
# 2b. Scarica fpcalc.exe (Chromaprint) — usato dal tab Dedup
# =========================================================================
def download_fpcalc():
dest = BUNDLE_DIR / "fpcalc.exe"
if dest.exists():
log("fpcalc.exe gia presente")
return
log(f"Scarico fpcalc.exe ...")
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
response = urllib.request.urlopen(FPCALC_URL)
zip_data = io.BytesIO(response.read())
with zipfile.ZipFile(zip_data) as zf:
for member in zf.namelist():
basename = Path(member).name
if basename == "fpcalc.exe":
data = zf.read(member)
dest.write_bytes(data)
log(f" Estratto: fpcalc.exe ({len(data) // 1024} KB)")
if not dest.exists():
print("ERRORE: fpcalc.exe non trovato nello zip!")
sys.exit(1)
# =========================================================================
# 3. PyInstaller
# =========================================================================
@@ -133,6 +162,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).
@@ -185,6 +216,7 @@ def main():
download_ytdlp()
download_ffmpeg()
download_fpcalc()
run_pyinstaller()
print("\n" + "=" * 50)
+15
View File
@@ -23,6 +23,8 @@ 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)
release_date: str = "" # ISO YYYY-MM-DD, vuoto se mancante
@property
def display(self) -> str:
@@ -150,6 +152,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 +177,8 @@ 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")),
release_date=str(item.get("publish_date") or item.get("new_release_date") or "").strip(),
)
except (TypeError, ValueError) as e:
raise BeatportParseError(f"track[{i}] shape inattesa: {e}") from e
+8 -1
View File
@@ -5,7 +5,7 @@ import os
import sys
from pathlib import Path
VERSION = "v1.8.2"
VERSION = "v1.10.1"
APP_NAME = "MusicTools"
@@ -68,14 +68,21 @@ DEFAULTS = {
"bitrate": "320K",
"hq_threshold": 310,
"cookies_path": str(_project_dir / "cookies.txt"),
"cookies_browser": "", # "" | chrome | safari | firefox | edge | brave
"output_dir": str(_project_dir / "MUSICA"),
"theme": "dark",
# ---- Beatport ----
"beatport_last_genre": "melodic-house-techno", # ultimo genere Top 100 caricato
# ---- Traxsource ----
"traxsource_last_genre": "tech-house",
# ---- Music Search (Spotify + YouTube) ----
"spotify_search_last_query": "",
"spotify_search_artist_mode": False,
"youtube_search_last_query": "",
# ---- Dedup (audio duplicati via Chromaprint) ----
"dedup_last_folder": "",
"dedup_recursive": True,
"dedup_method": "fingerprint", # "fingerprint" | "filename"
# ---- Licenza ----
"license_key": "", # chiave fornita all'utente via email
"license_email": "", # email associata all'acquisto
+176
View File
@@ -0,0 +1,176 @@
"""Conversione WAV -> MP3 via ffmpeg subprocess."""
from __future__ import annotations
import re
import subprocess
import threading
from pathlib import Path
from typing import Callable, Optional
from core.paths import find_ffmpeg_dir, subprocess_flags
_stop_event = threading.Event()
_current_process: Optional[subprocess.Popen] = None
_process_lock = threading.Lock()
def request_stop() -> None:
_stop_event.set()
with _process_lock:
if _current_process and _current_process.poll() is None:
_current_process.terminate()
def reset_stop() -> None:
_stop_event.clear()
def is_stopped() -> bool:
return _stop_event.is_set()
# VBR quality mapping da bitrate label a -q:a (libmp3lame). Piu basso = migliore qualita.
# Vedi https://trac.ffmpeg.org/wiki/Encode/MP3
_VBR_QUALITY = {
128: 5,
192: 2,
256: 0,
320: 0, # V0 e' ~245k medio; per >V0 c'e' solo CBR 320
}
def _find_ffmpeg() -> str:
"""Trova il binario ffmpeg dentro bundle_bin o PATH.
Solleva RuntimeError se non trovato.
"""
ffmpeg_dir = find_ffmpeg_dir()
if ffmpeg_dir:
for name in ("ffmpeg", "ffmpeg.exe"):
p = Path(ffmpeg_dir) / name
if p.exists():
return str(p)
# Fallback PATH
import shutil
ff = shutil.which("ffmpeg")
if ff:
return ff
raise RuntimeError("ffmpeg non trovato nel bundle o nel PATH")
def convert_wav_to_mp3(
input_path: str,
output_path: str,
bitrate: int = 320,
vbr: bool = False,
progress_callback: Optional[Callable] = None,
) -> None:
"""Converte un file WAV in MP3.
Args:
input_path: file .wav sorgente
output_path: file .mp3 destinazione
bitrate: 128 / 192 / 256 / 320
vbr: True per Variable Bit Rate (-q:a), False per Constant (-b:a)
progress_callback: opzionale, chiamato con (percent 0..100)
Raises:
RuntimeError: se ffmpeg non trovato o exit non-zero.
FileNotFoundError: se input_path non esiste.
"""
src = Path(input_path)
if not src.exists():
raise FileNotFoundError(f"File sorgente non trovato: {input_path}")
dst = Path(output_path)
dst.parent.mkdir(parents=True, exist_ok=True)
ffmpeg = _find_ffmpeg()
cmd = [ffmpeg, "-y", "-i", str(src)]
if vbr:
quality = _VBR_QUALITY.get(bitrate, 2)
cmd.extend(["-c:a", "libmp3lame", "-q:a", str(quality)])
else:
cmd.extend(["-c:a", "libmp3lame", "-b:a", f"{bitrate}k"])
cmd.extend([
"-map_metadata", "0", # copia metadata WAV se presenti
"-id3v2_version", "3",
"-progress", "pipe:2", # progress su stderr in formato key=value
str(dst),
])
# Prova a ottenere la durata totale per calcolare % — leggendo lo stream INFO
duration_us = _probe_duration_us(ffmpeg, str(src))
global _current_process
with _process_lock:
_current_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
**subprocess_flags(),
)
proc = _current_process
try:
for line in proc.stderr:
if is_stopped():
proc.terminate()
if progress_callback:
progress_callback(-1) # segnale di interruzione
return
if not progress_callback or not duration_us:
continue
# ffmpeg -progress emette "out_time_us=<microsecondi>"
m = re.match(r"out_time_us=(\d+)", line.strip())
if m:
elapsed = int(m.group(1))
pct = min(100, int(elapsed * 100 / duration_us))
progress_callback(pct)
rc = proc.wait()
if rc != 0:
raise RuntimeError(f"ffmpeg exit {rc}")
if progress_callback:
progress_callback(100)
finally:
with _process_lock:
_current_process = None
def _probe_duration_us(ffmpeg: str, input_path: str) -> int:
"""Estrae la durata in microsecondi via ffmpeg -f null. Ritorna 0 se non riesce."""
try:
# ffprobe potrebbe non essere sempre bundlato; usa ffmpeg
result = subprocess.run(
[ffmpeg, "-i", input_path, "-f", "null", "-"],
capture_output=True,
text=True,
timeout=10,
**subprocess_flags(),
)
# Cerca "Duration: HH:MM:SS.ms"
m = re.search(r"Duration:\s+(\d+):(\d+):(\d+)\.(\d+)", result.stderr)
if m:
h, mi, s, ms = map(int, m.groups())
total_sec = h * 3600 + mi * 60 + s + ms / 100
return int(total_sec * 1_000_000)
except Exception:
pass
return 0
def list_wav_files(directory: str, recursive: bool = True) -> list:
"""Scan cartella per file .wav. Ritorna list di path stringa ordinati."""
root = Path(directory)
if not root.is_dir():
return []
pattern = "**/*.wav" if recursive else "*.wav"
return sorted(str(p) for p in root.glob(pattern) if p.is_file())
+518
View File
@@ -0,0 +1,518 @@
"""Deduplicator audio via Chromaprint fingerprinting + SQLite cache.
Pipeline:
1. Scansiona la cartella (opzionalmente ricorsivo) filtrando per
estensioni audio (AUDIO_EXTENSIONS di core.upgrader).
2. Per ogni file calcola il fingerprint Chromaprint (`fpcalc -json`).
Il valore viene messo in cache SQLite: al re-scan, se
(size, mtime) coincide col record, riusiamo il fingerprint senza
rilanciare fpcalc.
3. Raggruppa i file per fingerprint identico (>= 2 file). Per ogni
gruppo, i file vengono ordinati per bitrate DESC (tie-break: size
DESC): il primo e' quello "da tenere", gli altri i duplicati.
4. `move_to_trash` invia i path selezionati al cestino di sistema
tramite send2trash (reversibile via Finder/Explorer).
Progress callback firma:
(processed: int, total: int, filename: str, status: str)
Status validi: 'scanning' | 'computing' | 'cached' | 'error' | 'stopped'
| 'completed'.
"""
from __future__ import annotations
import json
import sqlite3
import subprocess
import threading
from pathlib import Path
from typing import Callable, Optional
from core.paths import find_fpcalc, subprocess_flags
from core.upgrader import AUDIO_EXTENSIONS, get_bitrate
# Timeout massimo per una singola invocazione fpcalc.
_FPCALC_TIMEOUT_SEC = 30
# ------------------------------------------------------------------
# Stop / interrupt
# ------------------------------------------------------------------
_stop_event = threading.Event()
def request_stop() -> None:
"""Segnala al worker di interrompere la scansione al prossimo file."""
_stop_event.set()
def reset_stop() -> None:
"""Azzera il flag di stop prima di iniziare una nuova scansione."""
_stop_event.clear()
def is_stopped() -> bool:
return _stop_event.is_set()
# ------------------------------------------------------------------
# Cache SQLite
# ------------------------------------------------------------------
def _cache_db_path() -> Path:
"""Path del DB di cache dei fingerprint.
Riusa `_get_config_dir` di core.config cosi' finisce nella stessa
cartella di config.json (~/Library/Application Support/MusicTools/
su macOS, %APPDATA%/MusicTools/ su Windows, project root in dev).
"""
from core.config import _get_config_dir
return _get_config_dir() / "dedup_cache.db"
def _init_db(conn: sqlite3.Connection) -> None:
"""Crea (idempotente) lo schema della cache."""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS files (
path TEXT PRIMARY KEY,
size INTEGER NOT NULL,
mtime REAL NOT NULL,
duration REAL,
fingerprint TEXT,
bitrate INTEGER
)
"""
)
conn.commit()
def _open_cache(db_path: Optional[Path] = None) -> sqlite3.Connection:
"""Apre (creando se serve) la connessione alla cache."""
p = db_path or _cache_db_path()
p.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(p))
_init_db(conn)
return conn
def _cache_get(conn: sqlite3.Connection, path: str,
size: int, mtime: float) -> Optional[dict]:
"""Ritorna il record se (size, mtime) invariato, altrimenti None."""
cur = conn.execute(
"SELECT size, mtime, duration, fingerprint, bitrate FROM files WHERE path = ?",
(path,),
)
row = cur.fetchone()
if not row:
return None
csize, cmtime, dur, fp, br = row
# Tolleranza minima sul mtime (float precision su alcuni FS)
if csize != size or abs(float(cmtime) - float(mtime)) > 0.001:
return None
if not fp:
return None
return {
"size": int(csize),
"mtime": float(cmtime),
"duration": float(dur) if dur is not None else 0.0,
"fingerprint": str(fp),
"bitrate": int(br) if br is not None else 0,
}
def _cache_put(conn: sqlite3.Connection, path: str, size: int, mtime: float,
duration: float, fingerprint: str, bitrate: int) -> None:
"""Upsert (SQLite ha ON CONFLICT REPLACE via INSERT OR REPLACE)."""
conn.execute(
"INSERT OR REPLACE INTO files (path, size, mtime, duration, fingerprint, bitrate)"
" VALUES (?, ?, ?, ?, ?, ?)",
(path, int(size), float(mtime), float(duration or 0),
str(fingerprint or ""), int(bitrate or 0)),
)
conn.commit()
# ------------------------------------------------------------------
# fpcalc
# ------------------------------------------------------------------
def _run_fpcalc(fpcalc: str, path: str, length: Optional[int] = None) -> dict:
"""Esegue fpcalc una volta. Ritorna {duration, fingerprint} su successo
o {_error: str} su fallimento."""
cmd = [fpcalc, "-json"]
if length is not None:
cmd += ["-length", str(length)]
cmd.append(str(path))
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=_FPCALC_TIMEOUT_SEC,
**subprocess_flags(),
)
except subprocess.TimeoutExpired:
return {"_error": f"timeout {_FPCALC_TIMEOUT_SEC}s"}
except (OSError, ValueError) as e:
return {"_error": f"subprocess: {e}"}
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip().splitlines()
msg = err[-1] if err else f"exit {proc.returncode}"
return {"_error": msg[:200]}
try:
data = json.loads(proc.stdout or "{}")
except (json.JSONDecodeError, ValueError) as e:
return {"_error": f"JSON malformato: {e}"}
fp = data.get("fingerprint")
if not fp:
return {"_error": "fingerprint vuoto (audio troppo corto?)"}
try:
dur = float(data.get("duration") or 0)
except (TypeError, ValueError):
dur = 0.0
return {"duration": dur, "fingerprint": str(fp)}
def compute_fingerprint(fpcalc: str, path: str) -> Optional[dict]:
"""Chiama fpcalc e ritorna {duration, fingerprint} o {_error}.
Se il primo tentativo (full length) fallisce con "Invalid data" o simili
(frame audio corrotti che libav rifiuta), riprova con `-length 30`.
Molti file danneggiati hanno i frame corrotti nella parte finale e
limitando la scansione ai primi 30s si riesce a estrarre comunque
un fingerprint affidabile (30s bastano per l'unicità Chromaprint).
"""
if not fpcalc:
return {"_error": "fpcalc non trovato nel bundle"}
res = _run_fpcalc(fpcalc, path)
if "fingerprint" in res:
return res
err_msg = res.get("_error", "").lower()
# Retry 1: frame audio corrotti → riduci finestra a 30s
corrupt_signals = ("invalid data", "decoding audio frame",
"error while decoding", "invalid frame")
if any(sig in err_msg for sig in corrupt_signals):
res2 = _run_fpcalc(fpcalc, path, length=30)
if "fingerprint" in res2:
res2["_partial"] = True # 30s soltanto
return res2
# Retry 2: fingerprint vuoto → prova con finestra piu' lunga (60s)
# nel caso l'intro sia silenzio/muto (chromaprint richiede audio "reale")
if "vuoto" in err_msg or "empty" in err_msg:
res2 = _run_fpcalc(fpcalc, path, length=60)
if "fingerprint" in res2:
res2["_partial"] = True
return res2
# Ancora vuoto → prova algoritmo differente (chromaprint algo 1)
# tramite subprocess diretto perche' _run_fpcalc non lo supporta
try:
proc = subprocess.run(
[fpcalc, "-json", "-length", "60", "-algorithm", "1", str(path)],
capture_output=True, text=True,
timeout=_FPCALC_TIMEOUT_SEC,
**subprocess_flags(),
)
if proc.returncode == 0:
data = json.loads(proc.stdout or "{}")
fp = data.get("fingerprint")
if fp:
return {
"duration": float(data.get("duration") or 0),
"fingerprint": str(fp),
"_partial": True,
}
except Exception:
pass
return res
# ------------------------------------------------------------------
# Scan
# ------------------------------------------------------------------
def _iter_audio_files(directory: str, recursive: bool) -> list[Path]:
"""Elenca tutti i file audio (estensione case-insensitive)."""
base = Path(directory)
if not base.exists() or not base.is_dir():
return []
files: list[Path] = []
if recursive:
for f in base.rglob("*"):
if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS:
files.append(f)
else:
for f in base.iterdir():
if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS:
files.append(f)
files.sort()
return files
def _scan_by_filename(files: list, progress_callback: Optional[Callable],
group_callback: Optional[Callable] = None,
similarity_threshold: float = 0.8) -> list[list[dict]]:
"""Raggruppa file per similarità nome (Jaccard sui token normalizzati),
algoritmo INCREMENTALE: per ogni nuovo file cerca match tra i gruppi già
formati (lookup O(K) dove K = numero gruppi). Emette streaming via
`group_callback` appena un gruppo raggiunge ≥ 2 file.
"""
from core.upgrader import _normalize_stem # riuso
def _pc(idx, total_n, name, status, err=""):
if not progress_callback:
return
try:
progress_callback(idx, total_n, name, status, err)
except TypeError:
progress_callback(idx, total_n, name, status)
def _gc(group_id: str, entries: list) -> None:
if group_callback:
try:
group_callback({"id": group_id, "entries": list(entries)})
except Exception:
pass
total = len(files)
# Ogni voce: {"id": str, "key_tokens": frozenset, "entries": [dict]}
groups: list = []
for i, fp_path in enumerate(files, start=1):
if is_stopped():
_pc(i - 1, total, "", "stopped")
break
try:
size = fp_path.stat().st_size
except OSError as e:
_pc(i, total, fp_path.name, "error", f"stat: {e}")
continue
tokens = frozenset(_normalize_stem(fp_path.stem))
if not tokens:
_pc(i, total, fp_path.name, "error", "nome senza token utili")
continue
try:
bitrate = get_bitrate(fp_path)
except Exception:
bitrate = 0
entry = {
"path": str(fp_path), "size": size, "bitrate": bitrate,
"duration": 0, "fingerprint": "",
}
# Cerca match nei gruppi già formati (lineare sui gruppi, non sui file)
matched = None
for g in groups:
common = len(tokens & g["key_tokens"])
if common == 0:
continue
union = len(tokens | g["key_tokens"])
if union > 0 and (common / union) >= similarity_threshold:
matched = g
break
if matched is not None:
was_solo = len(matched["entries"]) == 1
matched["entries"].append(entry)
matched["entries"].sort(key=lambda e: (-e["bitrate"], -e["size"]))
# Streaming: emit ogni volta che il gruppo diventa/rimane ≥ 2 file
_gc(matched["id"], matched["entries"])
else:
gid = f"fn_{len(groups)}_{fp_path.stem[:20]}"
groups.append({
"id": gid,
"key_tokens": set(tokens),
"entries": [entry],
})
_pc(i, total, fp_path.name, "cached")
# Ritorna solo i gruppi con >= 2 file
result = [sorted(g["entries"], key=lambda e: (-e["bitrate"], -e["size"]))
for g in groups if len(g["entries"]) >= 2]
result.sort(key=lambda g: -max(e["size"] for e in g))
_pc(total, total, "", "completed")
return result
def scan_folder(
directory: str,
recursive: bool = True,
progress_callback: Optional[Callable] = None,
method: str = "fingerprint",
group_callback: Optional[Callable] = None,
) -> list[list[dict]]:
"""Ritorna la lista di gruppi di file duplicati (>= 2 file).
Ogni file nel gruppo e' un dict:
{path, size, bitrate, duration, fingerprint}
Gruppi ordinati per size del file piu' grande DESC (i gruppi che
occupano piu' spazio vengono prima). All'interno di ogni gruppo:
bitrate DESC, poi size DESC (il primo e' quello "da tenere").
`method`:
- "fingerprint" (default): Chromaprint via fpcalc, preciso ma lento.
Cache SQLite persistente. Raggruppa per fingerprint identico.
- "filename": similarità Jaccard sui nomi file. Veloce ma euristico.
Non richiede fpcalc.
"""
reset_stop()
files = _iter_audio_files(directory, recursive)
total = len(files)
if total == 0:
if progress_callback:
progress_callback(0, 0, "", "completed")
return []
if method == "filename":
return _scan_by_filename(files, progress_callback, group_callback)
fpcalc = find_fpcalc()
if not fpcalc:
# Senza fpcalc non possiamo fare nulla. Segnaliamo errore su ogni
# file e ritorniamo lista vuota.
if progress_callback:
progress_callback(0, total, "", "error")
return []
conn = _open_cache()
try:
# {fingerprint: [entry, ...]}
by_fp: dict[str, list[dict]] = {}
def _pc(idx, total_n, name, status, err=""):
"""Chiama progress_callback in modo retrocompatibile: la firma
legacy è a 4 args, quella nuova a 5 con `error_msg` opzionale."""
if not progress_callback:
return
try:
progress_callback(idx, total_n, name, status, err)
except TypeError:
progress_callback(idx, total_n, name, status)
for i, fp_path in enumerate(files, start=1):
if is_stopped():
_pc(i - 1, total, "", "stopped")
return []
try:
st = fp_path.stat()
size = st.st_size
mtime = st.st_mtime
except OSError as e:
_pc(i, total, fp_path.name, "error", f"stat: {e}")
continue
path_str = str(fp_path)
cached = _cache_get(conn, path_str, size, mtime)
if cached:
fp_hash = cached["fingerprint"]
duration = cached["duration"]
bitrate = cached["bitrate"] or get_bitrate(fp_path)
_pc(i, total, fp_path.name, "cached")
else:
_pc(i, total, fp_path.name, "computing")
res = compute_fingerprint(fpcalc, path_str)
if not res or not res.get("fingerprint"):
err_msg = (res or {}).get("_error", "errore sconosciuto")
_pc(i, total, fp_path.name, "error", err_msg)
continue
fp_hash = res["fingerprint"]
duration = res["duration"]
try:
bitrate = get_bitrate(fp_path)
except Exception:
bitrate = 0
_cache_put(conn, path_str, size, mtime, duration, fp_hash, bitrate)
entry = {
"path": path_str,
"size": int(size),
"bitrate": int(bitrate or 0),
"duration": float(duration or 0),
"fingerprint": fp_hash,
}
grp = by_fp.setdefault(fp_hash, [])
grp.append(entry)
# Streaming: appena il gruppo raggiunge (o supera) 2 elementi,
# emetti update (JS accumula/aggiorna in tempo reale)
if group_callback and len(grp) >= 2:
# Ordinamento intra-gruppo prima di emit (best-to-keep primo)
grp.sort(key=lambda e: (-int(e.get("bitrate") or 0),
-int(e.get("size") or 0)))
try:
group_callback({
"id": f"fp_{fp_hash[:24]}",
"entries": list(grp),
})
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
# Filtra: solo gruppi con >= 2 file
groups = [g for g in by_fp.values() if len(g) >= 2]
# Sort dei file dentro il gruppo: bitrate DESC, size DESC.
# Sort dei gruppi: size del file piu' grande DESC (usa max del gruppo).
for g in groups:
g.sort(key=lambda e: (-int(e.get("bitrate") or 0),
-int(e.get("size") or 0)))
groups.sort(key=lambda g: -max(int(e.get("size") or 0) for e in g))
if progress_callback:
progress_callback(total, total, "", "completed")
return groups
# ------------------------------------------------------------------
# Trash
# ------------------------------------------------------------------
def move_to_trash(paths: list[str]) -> dict:
"""Sposta i file in cestino tramite send2trash.
Ritorna {moved: [...], failed: [{path, error}, ...]}. Non solleva
mai eccezioni: gli errori per file singolo finiscono in `failed`.
Aggiorna la cache SQLite rimuovendo i record dei file spostati (per
quelli riusciti), cosi' un re-scan non li propone piu'.
"""
# Import interno per rendere il modulo importabile anche se
# send2trash non e' installato (i test possono mockarlo).
try:
from send2trash import send2trash
except Exception as e: # pragma: no cover — solo se pacchetto mancante
return {
"moved": [],
"failed": [{"path": p, "error": f"send2trash non disponibile: {e}"}
for p in (paths or [])],
}
moved: list[str] = []
failed: list[dict] = []
for p in (paths or []):
try:
send2trash(p)
moved.append(p)
except Exception as e:
failed.append({"path": p, "error": str(e)})
# Cache cleanup best-effort (non fatale se fallisce)
if moved:
try:
conn = _open_cache()
try:
for p in moved:
conn.execute("DELETE FROM files WHERE path = ?", (p,))
conn.commit()
finally:
conn.close()
except Exception:
pass
return {"moved": moved, "failed": failed}
+78 -16
View File
@@ -12,6 +12,29 @@ from typing import Callable, Optional
from core.paths import find_ytdlp, find_ffmpeg_dir, subprocess_flags
_ALLOWED_BROWSERS = {"chrome", "safari", "firefox", "edge", "brave", "chromium", "opera", "vivaldi"}
def _cookie_args(cookies_path: Optional[str]) -> list:
"""Ritorna gli argomenti yt-dlp per i cookies.
Priorità: file cookies_path se esiste → altrimenti --cookies-from-browser
<name> se `cookies_browser` è settato in config → altrimenti niente.
"""
if cookies_path and Path(cookies_path).exists():
return ["--cookies", cookies_path]
# Fallback: legge il browser dalla config al volo (evita di cambiare
# firma di tutte le funzioni download_*)
try:
from core.config import load_config
browser = (load_config().get("cookies_browser") or "").strip().lower()
except Exception:
browser = ""
if browser in _ALLOWED_BROWSERS:
return ["--cookies-from-browser", browser]
return []
# Flag globale per interruzione
_stop_event = threading.Event()
_current_process: Optional[subprocess.Popen] = None
@@ -99,8 +122,7 @@ def _search_youtube(query: str, cookies_path: Optional[str] = None) -> tuple[str
"--no-warnings",
"--flat-playlist",
]
if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path])
cmd.extend(_cookie_args(cookies_path))
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **subprocess_flags())
if result.returncode != 0:
@@ -121,6 +143,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 +154,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 +213,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,14 +232,13 @@ 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()
if ffmpeg_dir:
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path])
cmd.extend(_cookie_args(cookies_path))
try:
with _process_lock:
@@ -231,6 +269,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:
@@ -277,8 +321,7 @@ def download_direct_url(
"--no-warnings",
url,
]
if cookies_path and Path(cookies_path).exists():
probe_cmd.extend(["--cookies", cookies_path])
probe_cmd.extend(_cookie_args(cookies_path))
try:
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
@@ -352,8 +395,7 @@ def download_direct_url(
ffmpeg_dir = find_ffmpeg_dir()
if ffmpeg_dir:
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path])
cmd.extend(_cookie_args(cookies_path))
try:
with _process_lock:
@@ -406,6 +448,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 +464,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 +506,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,14 +524,13 @@ 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()
if ffmpeg_dir:
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path])
cmd.extend(_cookie_args(cookies_path))
try:
with _process_lock:
@@ -502,6 +560,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:
@@ -562,8 +626,7 @@ def download_video(
probe_cmd = [
ytdlp, "--dump-json", "--flat-playlist", "--no-download", "--no-warnings", url,
]
if cookies_path and Path(cookies_path).exists():
probe_cmd.extend(["--cookies", cookies_path])
probe_cmd.extend(_cookie_args(cookies_path))
try:
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
@@ -638,8 +701,7 @@ def download_video(
ffmpeg_dir = find_ffmpeg_dir()
if ffmpeg_dir:
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path])
cmd.extend(_cookie_args(cookies_path))
try:
with _process_lock:
+32
View File
@@ -45,6 +45,13 @@ def _bundle_dirs() -> list[Path]:
sub_macos = frameworks / sub / "Contents" / "MacOS"
if sub_macos.exists():
dirs.append(sub_macos)
else:
# Dev: cerca in <project_root>/bundle_bin/ così `python main.py`
# usa gli stessi binari del bundle (aggiornati via build_*.py)
# invece di Homebrew/PATH che possono essere obsoleti.
project_bundle = Path(__file__).resolve().parent.parent / "bundle_bin"
if project_bundle.exists():
dirs.append(project_bundle)
return dirs
@@ -145,3 +152,28 @@ def find_ffmpeg() -> Optional[str]:
def find_ffprobe() -> Optional[str]:
"""Ritorna il path completo di ffprobe."""
return _find_binary("ffprobe")
def find_fpcalc() -> Optional[str]:
"""Ritorna il path completo di fpcalc (Chromaprint), o None.
Cerca nelle stesse directory di find_ffmpeg / find_ytdlp: bundle
PyInstaller prima, poi percorsi noti (Homebrew su macOS, LOCALAPPDATA
su Windows), infine PATH generico. In dev mode aggiunge anche
`<project_root>/bundle_bin/` così l'app funziona con `python main.py`
dopo aver scaricato fpcalc via build script.
`fpcalc` viene usato dal modulo core.dedup per calcolare fingerprint
audio (identificazione di duplicati).
"""
found = _find_binary("fpcalc")
if found:
return found
# Fallback dev: bundle_bin del progetto
if not _is_frozen():
exe_name = _exe("fpcalc")
project_bundle = Path(__file__).resolve().parent.parent / "bundle_bin"
cand = project_bundle / exe_name
if cand.exists():
return str(cand)
return None
+69 -5
View File
@@ -308,16 +308,74 @@ def search_tracks(token: str, query: str, limit: int = 50) -> list:
def _track_to_dict(t: dict) -> dict:
"""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
View File
@@ -0,0 +1,109 @@
"""Scrittura tag ID3 su file MP3 via mutagen, con cover art da URL."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Optional
import requests
from mutagen.easyid3 import EasyID3
from mutagen.id3 import ID3, APIC
from mutagen.mp3 import MP3
SAFE_FILENAME_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
def sanitize_filename_stem(stem: str, max_len: int = 180) -> str:
"""Rimuove caratteri non validi per un filename e limita lunghezza."""
s = SAFE_FILENAME_RE.sub("_", stem or "").strip()
s = re.sub(r"\s+", " ", s)
if len(s) > max_len:
s = s[:max_len].rstrip()
return s or "untitled"
def build_filename_stem(artist: str, title: str) -> str:
"""Compone 'Artista - Titolo' sanitizzato."""
artist = (artist or "").strip()
title = (title or "").strip()
if artist and title:
return sanitize_filename_stem(f"{artist} - {title}")
return sanitize_filename_stem(title or artist or "untitled")
def _download_cover(url: str) -> Optional[bytes]:
"""Scarica bytes cover, ritorna None su qualsiasi errore."""
try:
resp = requests.get(url, timeout=15)
if resp.status_code == 200:
return resp.content
except Exception:
pass
return None
def _cover_mime(url: str, content: bytes) -> str:
"""Deduci MIME dell'immagine da URL o magic bytes."""
u = url.lower()
if u.endswith(".png"):
return "image/png"
if u.endswith((".jpg", ".jpeg")):
return "image/jpeg"
# magic bytes
if content[:8] == b"\x89PNG\r\n\x1a\n":
return "image/png"
if content[:3] == b"\xff\xd8\xff":
return "image/jpeg"
return "image/jpeg" # default
def write_tags(filepath: str, metadata: dict) -> None:
"""Scrive ID3 tag su file MP3 esistente.
metadata: dict con chiavi opzionali:
title, artist, album, date (YYYY o YYYY-MM-DD), genre,
tracknumber, cover_url
Errori non fatali (file mancante, tag non scrivibili): silenziosi
per non bloccare il download principale.
"""
p = Path(filepath)
if not p.exists():
return
# 1. Tag testuali via EasyID3
try:
try:
audio = EasyID3(str(p))
except Exception:
# Se il file non ha header ID3, aggiungine uno vuoto
mp3 = MP3(str(p))
if mp3.tags is None:
mp3.add_tags()
mp3.save()
audio = EasyID3(str(p))
for key in ("title", "artist", "album", "date", "genre", "tracknumber"):
val = metadata.get(key)
if val:
audio[key] = str(val)
audio.save(str(p))
except Exception:
return # ID3 broken, non provo la cover
# 2. Cover art via ID3 APIC
cover_url = (metadata.get("cover_url") or "").strip()
if not cover_url:
return
cover_bytes = _download_cover(cover_url)
if not cover_bytes:
return
try:
mime = _cover_mime(cover_url, cover_bytes)
tags = ID3(str(p))
tags.delall("APIC")
tags.add(APIC(encoding=3, mime=mime, type=3, desc="Cover", data=cover_bytes))
tags.save(str(p))
except Exception:
pass
+277
View File
@@ -0,0 +1,277 @@
"""Fetch Top 100 Traxsource per genere.
Approccio: session curl_cffi (bypass CF via cookie) + BeautifulSoup HTML scraping.
Spec: docs/superpowers/specs/2026-08-01-traxsource-charts-design.md.
"""
from __future__ import annotations
import re
import time
from dataclasses import dataclass
# Generi musicali Traxsource (slug -> (id, display_name)).
# Aggiornato via scripts/refresh_traxsource_genres.py. Esclusi "sounds/samples/loops",
# "acapella", "beats", "efx-dj-tools", "stems" (non generi ma tipi di prodotto).
GENRES: dict = {
"afro-house": (27, "Afro House"),
"afro-latin-brazilian": (23, "Afro / Latin / Brazilian"),
"broken-beat-nu-jazz": (2, "Broken Beat / Nu Jazz"),
"classic-house": (12, "Classic House"),
"deep-house": (13, "Deep House"),
"drum-and-bass": (31, "Drum & Bass"),
"electro-house": (11, "Electro House"),
"electronica": (5, "Electronica"),
"garage": (29, "Garage"),
"house": (4, "House"),
"jackin-house": (15, "Jackin House"),
"leftfield": (14, "Leftfield"),
"lounge-chill-out": (1, "Lounge / Chill Out"),
"melodic-progressive-house": (19, "Melodic / Progressive House"),
"minimal-deep-tech": (16, "Minimal / Deep Tech"),
"nu-disco-indie-dance": (17, "Nu Disco / Indie Dance"),
"pop-dance": (32, "Pop Dance"),
"r-and-b-hip-hop": (6, "R&B / Hip Hop"),
"soul-funk-disco": (3, "Soul / Funk / Disco"),
"soulful-house": (24, "Soulful House"),
"tech-house": (18, "Tech House"),
"techno": (20, "Techno"),
"world": (30, "World"),
}
@dataclass(frozen=True)
class TraxsourceTrack:
position: int
title: str
mix: str
artists: str
label: str
traxsource_id: int
slug: str
image_url: str = ""
cover_url_large: str = ""
def list_genres() -> list:
result = [
{"slug": slug, "id": gid, "name": name}
for slug, (gid, name) in GENRES.items()
]
result.sort(key=lambda g: g["name"].casefold())
return result
class TraxsourceError(Exception):
"""Base per errori Traxsource."""
class TraxsourceUnreachableError(TraxsourceError):
"""Rete / 5xx dopo retry."""
class TraxsourceParseError(TraxsourceError):
"""HTML ricevuto ma non conforme allo schema atteso."""
_MIX_PAREN_RE = re.compile(r"^(.*)\s*\(([^()]+)\)\s*$")
_SIZE_RE = re.compile(r"/\d+x\d+/")
def _split_title_mix(full_title: str) -> tuple:
"""Estrae mix dalle parentesi finali. 'Foo (Extended Mix)' -> ('Foo', 'Extended Mix').
Se non ci sono parentesi finali, mix = ''."""
if not full_title:
return ("", "")
m = _MIX_PAREN_RE.match(full_title.strip())
if m:
return (m.group(1).strip(), m.group(2).strip())
return (full_title.strip(), "")
def _format_artists(names: list) -> str:
"""['A', 'B', 'C'] -> 'A, B & C'. Strips whitespace."""
clean = [n.strip() for n in names if n and n.strip()]
if not clean:
return ""
if len(clean) == 1:
return clean[0]
return ", ".join(clean[:-1]) + " & " + clean[-1]
def _large_cover(url: str) -> str:
"""Sostituisce /NxN/ nel path con /500x500/. Se pattern assente, ritorna invariato."""
if not url:
return ""
return _SIZE_RE.sub("/500x500/", url)
_TOP100_LINK_RE = re.compile(r'href="(/title/\d+/top-100-[a-z0-9-]+)"')
def _discover_top100_url(genre_html: str) -> str:
"""Estrae il path relativo della playlist Top 100 corrente dalla pagina di un genere."""
m = _TOP100_LINK_RE.search(genre_html)
if not m:
raise TraxsourceParseError("link Top 100 non trovato nella pagina genere")
return m.group(1)
def _parse_tracks(html: str) -> list:
"""Parsa la pagina Top 100 (title playlist) e ritorna list[TraxsourceTrack].
Selettori (verificati su fixture tech-house 2026-07):
row = div.trk-row.play-trk (data-trid=<int>)
position = div.tnum (inside div.tnum-pos)
title <a> = div.trk-cell.title a[href^="/track/"]
version = span.version (contiene child span.duration da rimuovere)
artists = a.com-artists (uno o piu)
label <a> = div.trk-cell.label a
cover img = div.trk-cell.thumb img (src /scripts/image.php/52x52/...)
"""
# Lazy import — bs4 non e' hard-dep del modulo (importato solo quando serve).
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
rows = soup.select("div.trk-row.play-trk")
if not rows:
raise TraxsourceParseError("nessuna track (div.trk-row.play-trk) trovata")
out: list = []
for i, row in enumerate(rows, 1):
try:
trid = int(row.get("data-trid") or 0)
pos_el = row.select_one("div.tnum")
position = i # fallback su enumerate se pos manca / non e' un numero
if pos_el:
pos_txt = pos_el.get_text(strip=True)
if pos_txt.isdigit():
position = int(pos_txt)
title_a = row.select_one('div.trk-cell.title a[href^="/track/"]')
if not title_a:
continue
title = title_a.get_text(strip=True)
href = title_a.get("href") or ""
slug = href.rsplit("/", 1)[-1]
# Mix version: contenuto di span.version, escluso span.duration
mix = ""
version_el = row.select_one("span.version")
if version_el:
dur_el = version_el.select_one("span.duration")
if dur_el:
dur_el.extract()
mix = version_el.get_text(strip=True)
artist_names = [a.get_text(strip=True) for a in row.select("a.com-artists")]
artists = _format_artists(artist_names)
label_a = row.select_one("div.trk-cell.label a")
label = label_a.get_text(strip=True) if label_a else ""
img = row.select_one('div.trk-cell.thumb img[src*="/scripts/image.php/"]')
image_url = (img.get("src") or "") if img else ""
cover_large = _large_cover(image_url)
out.append(TraxsourceTrack(
position=position,
title=title,
mix=mix,
artists=artists,
label=label,
traxsource_id=trid,
slug=slug,
image_url=image_url,
cover_url_large=cover_large,
))
except Exception as e:
raise TraxsourceParseError(f"errore parse track[{i}]: {e}") from e
return out
# --- fetch_top100 con session curl_cffi + retry + cache ------------------
_IMPERSONATE = "chrome131"
_REQUEST_TIMEOUT = 15
_MAX_ATTEMPTS = 3
_BACKOFF_SEC = [1, 3]
_CACHE_TTL_SEC = 15 * 60
_cache: dict = {}
_session_singleton = None
def _session():
"""Ritorna la Session curl_cffi singleton, preriscaldata con GET a /."""
global _session_singleton
if _session_singleton is None:
from curl_cffi import requests as _cffi
_session_singleton = _cffi.Session(impersonate=_IMPERSONATE)
try:
_session_singleton.get("https://www.traxsource.com/", timeout=_REQUEST_TIMEOUT)
except Exception:
pass # cookie CF possono arrivare comunque
return _session_singleton
def _do_get(session, url: str) -> str:
"""GET con retry + backoff. Include Referer per pagine interne."""
last_exc = None
for attempt in range(_MAX_ATTEMPTS):
try:
resp = session.get(
url,
timeout=_REQUEST_TIMEOUT,
headers={"Referer": "https://www.traxsource.com/"},
)
if resp.status_code >= 500 or resp.status_code == 403:
raise Exception(f"HTTP {resp.status_code}")
resp.raise_for_status()
return resp.text
except Exception as e:
last_exc = e
if attempt < _MAX_ATTEMPTS - 1:
time.sleep(_BACKOFF_SEC[attempt])
raise TraxsourceUnreachableError(
f"Traxsource irraggiungibile dopo {_MAX_ATTEMPTS} tentativi: {last_exc}"
)
def fetch_top100(slug: str, force_refresh: bool = False) -> list:
"""Fetches Top 100 corrente per il genere.
1. Verifica slug in GENRES
2. Fetch pagina genre -> _discover_top100_url
3. Fetch pagina Top 100 -> _parse_tracks
4. Cache 15 min
Raises:
ValueError: slug non in GENRES
TraxsourceUnreachableError: rete/5xx dopo retry
TraxsourceParseError: HTML non conforme
"""
if slug not in GENRES:
raise ValueError(f"slug genere non valido: {slug!r}")
now = time.time()
if not force_refresh:
cached = _cache.get(slug)
if cached and (now - cached[0]) < _CACHE_TTL_SEC:
return cached[1]
gid, _name = GENRES[slug]
sess = _session()
genre_url = f"https://www.traxsource.com/genre/{gid}/{slug}"
genre_html = _do_get(sess, genre_url)
top100_path = _discover_top100_url(genre_html)
top100_url = "https://www.traxsource.com" + top100_path
top100_html = _do_get(sess, top100_url)
tracks = _parse_tracks(top100_html)
_cache[slug] = (now, tracks)
return tracks
+336 -13
View File
@@ -3,16 +3,28 @@
from __future__ import annotations
import json
import queue
import re
import shutil
import subprocess
import threading
from pathlib import Path
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"}
# 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
_stop_event = threading.Event()
_current_process: Optional[subprocess.Popen] = None
@@ -70,6 +82,143 @@ def get_bitrate(filepath: str | Path) -> int:
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.copy (non copy2): mtime del target = ora, non quello
# del sorgente. Così è chiaro nel Finder che il file è stato
# aggiornato dall'upgrade.
shutil.copy(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]:
if done_file.exists():
return set(done_file.read_text(encoding="utf-8").splitlines())
@@ -123,7 +272,7 @@ def update_cover_only(
"--output", str(temp_dir / "cover"),
video_url,
]
ffmpeg_dir = _find_ffmpeg_dir()
ffmpeg_dir = find_ffmpeg_dir()
if ffmpeg_dir:
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
if cookies_path and Path(cookies_path).exists():
@@ -180,8 +329,21 @@ def upgrade_folder(
cookies_path: Optional[str] = None,
recursive: bool = False,
progress_callback: Optional[Callable] = None,
archive_dir: Optional[str] = None,
resolve_callback: Optional[Callable] = 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()
ytdlp = find_ytdlp()
@@ -195,7 +357,7 @@ def upgrade_folder(
else:
folders = [Path(directory)]
all_items: list[tuple[Path, Path]] = []
all_items: list = []
for folder in folders:
for ext in AUDIO_EXTENSIONS:
for f in sorted(folder.glob(f"*{ext}")):
@@ -207,6 +369,16 @@ def upgrade_folder(
progress_callback(0, 0, "", "no_files", 0, 0)
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
for filepath, folder in all_items:
@@ -230,6 +402,95 @@ def upgrade_folder(
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(str(filepath), 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:
progress_callback(processed, total, filepath.name, "searching", current_kbps, 0)
@@ -288,17 +549,79 @@ def upgrade_folder(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
**subprocess_flags(),
)
proc = _current_process
for line in _current_process.stdout:
if is_stopped():
_current_process.terminate()
return
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)
# Watchdog: kill del subprocess se supera _DOWNLOAD_TIMEOUT_SEC.
# Previene hang indefinito su video problematici (geo-block,
# YouTube throttle, rete lenta).
def _watchdog_kill():
try:
if proc.poll() is None:
proc.kill() # SIGKILL — SIGTERM può lasciare ffmpeg orfano che tiene aperto il pipe
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:
_current_process = None
+11
View File
@@ -62,11 +62,22 @@ 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 ""
# yt-dlp `upload_date` è YYYYMMDD (stringa). Lo converto in ISO YYYY-MM-DD.
# Con --flat-playlist può essere assente; in tal caso resta stringa vuota.
raw_date = str(e.get("upload_date") or "").strip()
release_date = ""
if len(raw_date) == 8 and raw_date.isdigit():
release_date = f"{raw_date[0:4]}-{raw_date[4:6]}-{raw_date[6:8]}"
elif raw_date:
release_date = raw_date # forma sconosciuta, passa così
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,
"release_date": release_date,
})
return result
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,256 @@
# Traxsource Charts — Design Spec
- **Data:** 2026-08-01
- **Autore:** LuZa + Claude
- **Stato:** Approvato, pronto per implementation plan
- **Target release:** MusicTools v1.9.0
## Obiettivo
Nuova tab "▲ Traxsource" nell'app che permette di:
1. Scegliere un genere musicale da un elenco di ~15 generi Traxsource
2. Caricare la **Top 100** ufficiale del mese corrente per quel genere
3. Anteprima checkbox + download riusando il pipeline esistente (Spotify search → yt-dlp → tag ID3 + cover)
Sostituisce l'attività manuale di copiare/incollare tracklist Traxsource.
## Non-goals
- Chart diverse dalla Top 100 (Must Have, Just Added, ecc.)
- Ricerca artista/label specifica su Traxsource
- Preview audio (Traxsource ha DRM)
- Duration tracks (non è nell'HTML della lista; sarebbe una richiesta extra per track)
- Login/account Traxsource
## Ricognizione tecnica (fatta 2026-08-01)
- **Cloudflare Managed Challenge attivo** su tutte le pagine tranne home e /top. Bypass: session-based con `curl_cffi.Session(impersonate='chrome131')`, prima GET a `/`, poi le pagine sensibili con `Referer: https://www.traxsource.com/`.
- **Non è Next.js** → no `__NEXT_DATA__`. HTML server-rendered.
- **Struttura URL:**
- Genre index: `/genre/<id>/<slug>` — es. `/genre/18/tech-house`. Contiene solo 10 top track più altri widget
- Top 100 corrente: `/title/<title_id>/top-100-<genre>-of-<month>-<year>` — es. `/title/2847986/top-100-tech-house-of-july-2026`. Contiene 100 track completi in un unico documento (~394KB)
- **Il title_id cambia ogni mese.** Non è hardcodabile. Va scoperto dinamicamente
- **Discovery Top 100 URL:** nella pagina `/genre/<id>/<slug>` c'è (almeno un) `<a href="/title/<id>/top-100-<slug>-of-<month>-<year>">`. Estrai col regex `href="(/title/\d+/top-100-[a-z0-9-]+)"`
- **Track markup (stabile):**
```html
<div data-trid="14842066" class="top-item play-trk ptk-14842066">
<div class="ttib position">1</div>
<div class="image"><img src="https://.../52x52/HASH.jpg" /></div>
<div class="ttib info">
<a href="/track/14842066/badman-sound-extended-mix" class="com-title">Badman Sound (Extended Mix)</a>
<a href="/artist/92248/hannah-wants" class="com-artists">Hannah Wants</a>,
<a href="/artist/321517/trace" class="com-artists">Trace</a>
<a href="/label/325/nervous" class="com-label">Nervous</a>
</div>
</div>
```
Class names sono stabili (semantici, non generati da bundler).
- **Cover art:** URL contiene la size (`/52x52/`). Sostituendo con `/500x500/` si ottiene la versione grande per tagging ID3.
## Approccio scelto
- Session `curl_cffi` con `impersonate='chrome131'`. Cookie CF conservati automaticamente. Preriscaldamento con GET a `/` al primo utilizzo.
- Parser: `BeautifulSoup4` con selettori CSS puliti — `div.top-item.play-trk` per righe, `a.com-title` per titolo, `a.com-artists` per artisti, `a.com-label` per label, `div.ttib.position` per rank, `img` per cover URL.
- Regex sul titolo per estrarre "mix name" tra parentesi: `Song Title (Extended Mix)` → title="Song Title", mix="Extended Mix".
- Cache in-memory 15 min per la lista, come Beatport.
- Genere → (numeric_id, slug, display_name) map hardcoded (estratti dalla home /top).
**Alternative scartate:**
- `requests` puro: bloccato da CF, verificato
- API interna Traxsource: non esistono API pubbliche gratuite documentate; reverse engineering rischio ban
- Playwright: overkill (~100MB dep) quando session + BeautifulSoup basta
## Architettura
### Nuovo modulo `core/traxsource.py`
```python
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
# Mappa slug → (numeric_id, display_name)
# Verificati 2026-08-01 dalla home /top di Traxsource
GENRES: dict = {
"afro-house": (33, "Afro House"),
"afro-latin-brazilian": (23, "Afro / Latin / Brazilian"),
"amapiano": (37, "Amapiano"),
"bass-club": (25, "Bass / Club"),
"breaks-uk-bass": (14, "Breaks / UK Bass"),
"classic-house": (12, "Classic House"),
"deep-house": (13, "Deep House"),
"dj-tools": (16, "DJ Tools"),
"downtempo-nu-disco-indie-dance": (22, "Downtempo / Nu Disco / Indie Dance"),
"electronica": (5, "Electronica"),
"electro-house": (11, "Electro House"),
"funky-jackin-groovy": (15, "Funky / Jackin / Groovy"),
"garage": (29, "Garage"),
"house": (1, "House"),
"lounge-chill-out": (10, "Lounge / Chill Out"),
"melodic-house-techno-progressive-house": (34, "Melodic House / Techno / Progressive House"),
"minimal-deep-tech": (27, "Minimal / Deep Tech"),
"organic-house-downtempo": (36, "Organic House / Downtempo"),
"progressive-house": (28, "Progressive House"),
"r-and-b-hip-hop": (6, "R&B / Hip Hop"),
"soulful-house": (24, "Soulful House"),
"tech-house": (18, "Tech House"),
"techno-peak-time-driving": (26, "Techno (Peak Time / Driving)"),
"techno-raw-deep-hypnotic": (32, "Techno (Raw / Deep / Hypnotic)"),
"trance": (30, "Trance"),
"world-reggae": (21, "World / Reggae"),
}
# NOTA: la mappa è enumerata in fase di implementazione fetchando /top con curl_cffi
# e cercando href="/genre/<id>/<slug>". Vedi Task 1 del plan.
@dataclass(frozen=True)
class TraxsourceTrack:
position: int
title: str
mix: str
artists: str # "A, B & C" formattato
label: str
traxsource_id: int
slug: str
image_url: str = "" # URL thumbnail (per UI)
cover_url_large: str = "" # URL cover 500x500 (per tagging)
class TraxsourceError(Exception): pass
class TraxsourceUnreachableError(TraxsourceError): pass
class TraxsourceParseError(TraxsourceError): pass
def list_genres() -> list:
"""Ritorna [{slug, id, name}, ...] ordinato alfabeticamente per name."""
def fetch_top100(slug: str, force_refresh: bool = False) -> list[TraxsourceTrack]:
"""Fetches Top 100 corrente per il genere.
1. Scopre l'URL della playlist Top 100 del mese via GET /genre/<id>/<slug>
2. Fetch della playlist /title/<id>/top-100-...
3. Parse HTML con BeautifulSoup
4. Cache in-memory 15 min
Raises:
ValueError se slug non in GENRES
TraxsourceUnreachableError su rete/5xx dopo retry
TraxsourceParseError su HTML non conforme (title link non trovato, meno di 50 track, ecc.)
"""
```
**Internals:**
- `_session()` singleton — inizializza `curl_cffi.Session(impersonate='chrome131')` e fa GET a `/` una volta per riscaldare CF cookies. Riusata per tutte le fetch successive.
- `_discover_top100_url(session, slug) → str` — fetcha `/genre/<id>/<slug>`, regex per il link Top 100
- `_parse_tracks(html) → list[TraxsourceTrack]` — BeautifulSoup, itera `div.top-item.play-trk`, estrae campi
- `_split_title_mix(full_title) → (title, mix)` — parsa `"Foo (Extended Mix)"` → `("Foo", "Extended Mix")`. Se no parentesi, mix = "".
- `_format_artists(anchors) → str` — join `[a.text for a in anchors]` con formattazione "A, B & C"
- `_large_cover(url) → str` — sostituisce `/52x52/` con `/500x500/` nel path
**Retry + timeout:** riusa gli stessi pattern di `core/beatport.py`: 3 tentativi con backoff [1, 3]s, timeout 15s.
### Nuovi metodi in `api/bridge.py`
Paralleli ai metodi Beatport (stesso schema):
- `traxsource_genres() → list[dict]`
- `traxsource_fetch_chart(slug, force_refresh) → {ok, tracks} | {ok:false, error, message}` — salva anche `traxsource_last_genre` in config
- `traxsource_check_existing(tracks, genre_name) → list[bool]` — check in `<output_dir>/Traxsource_<genre>/`
- `traxsource_download_selected(tracks, genre_name) → dict` — costruisce metadata (title/artist/album=label/date=YYYY-MM/genre=display_name/cover_url) e chiama `start_tracks_download` riusando il pipeline con tagging ID3
**Metadata per il tagger:**
- title, artist: dai campi track
- album: label Traxsource (best proxy — non c'è "album" per singole tracce)
- date: mese/anno corrente (dato che la Top 100 è mensile)
- genre: display_name del genere
- cover_url: `cover_url_large` (500x500)
### Frontend
Nuova tab in sidebar dopo YouTube (o Beatport):
```html
<button class="nav-item" data-view="traxsource" data-feature="audio">
<span class="nav-icon">▲</span><span>Traxsource</span>
</button>
```
Section `#view-traxsource` con struttura identica a Beatport (dropdown genere + Carica Top 100 + tabella + toolbar + log). Riusa tutte le classi `.beatport-*` per lo stile.
Modulo `TraxsourceUI` in `webui/js/app.js` — copia diretta di `BeatportUI` con selettori cambiati (`traxsource-*` invece di `beatport-*`) e chiamate API alle nuove funzioni.
**Ordinamento colonne** (sortable) e **cover art** già supportati riusando la stessa struttura tabella + modulo helper `_sortPairs`/`_bindSortableHeaders`/`_updateSortArrows` esistenti.
### Persistenza
Nuovo campo in `core/config.py::DEFAULTS`:
```python
"traxsource_last_genre": "tech-house",
```
### Dipendenze
- `beautifulsoup4` — nuova dep runtime. Aggiungere in `requirements.txt`. Serve `--collect-data bs4` in build_windows.py + build_macos.py? Verificare — bs4 di solito no.
## Testing
**Unit tests `tests/test_traxsource.py` (senza rete, con fixture HTML):**
Serve una fixture HTML reale della pagina Top 100 (~400KB). Task 1 la genera con `curl_cffi` come fatto per Beatport (`beatport_melodic_top100.html`).
| Test | Verifica |
|---|---|
| `test_list_genres_shape` | Lista dict con {slug, id, name}, ordinati |
| `test_parse_top100_extracts_100_tracks` | Fixture HTML → 100 track |
| `test_parse_positions_sequential` | 1..100 senza buchi |
| `test_parse_track_shape` | Ogni track ha title, artists, label, cover URLs non vuoti |
| `test_split_title_mix_with_parens` | `"Foo (Extended Mix)"` → `("Foo", "Extended Mix")` |
| `test_split_title_mix_no_parens` | `"Foo"` → `("Foo", "")` |
| `test_format_artists_multi` | `["A", "B", "C"]` → `"A, B & C"` |
| `test_large_cover_url_substitution` | `.../52x52/x.jpg` → `.../500x500/x.jpg` |
| `test_discover_top100_url_returns_playlist_link` | HTML genre stub → estrae `/title/N/top-100-...` |
| `test_fetch_top100_invalid_slug_raises_value_error` | slug non in GENRES → ValueError early |
Mock HTTP con `unittest.mock.patch("core.traxsource._session")` (patch della session singleton).
**Test manuale end-to-end:**
1. Avvia app → tab Traxsource
2. Selezione "Tech House" → Carica Top 100 → tabella 100 righe in <5s
3. Verifica cover art (thumbnail nella tabella)
4. Deseleziona 90, tieni 10 → Scarica selezionati
5. File in `MUSICA/Traxsource_Tech House/` con tag ID3 completi (title, artist, album=label, genre=Tech House, cover 500x500)
6. Ricarica stesso genere → cache hit
7. Riavvio → ultimo genere ricordato
## Rollout
1. Branch `feat/traxsource-charts`
2. Bump `core/config.py::VERSION` → `v1.9.0` (minor: nuova tab visibile)
3. Note release `/tmp/notes-v1.9.0.md`
4. Merge → tag → CI → server DB update (stesso flusso di sempre)
## Struttura file impattati
**Nuovi:**
- `core/traxsource.py` (~250 LOC)
- `tests/test_traxsource.py` (~200 LOC)
- `tests/fixtures/traxsource_tech_house_top100.html` (~400KB)
- `scripts/refresh_traxsource_genres.py` (~50 LOC, opzionale)
**Modificati:**
- `requirements.txt` — `beautifulsoup4>=4.12.0`
- `build_windows.py` + `build_macos.py` — verificare se serve `--collect-data bs4`
- `core/config.py` — VERSION bump + `traxsource_last_genre`
- `api/bridge.py` — 4 metodi Api `traxsource_*` (~150 LOC)
- `webui/index.html` — nav-item + section (~90 LOC)
- `webui/js/app.js` — modulo `TraxsourceUI` (~250 LOC, copia BeatportUI adattata)
## Considerazioni operative
- **Fragility scraping:** i class name di Traxsource (`com-title`, `com-artists`, `top-item`, ecc.) sono usati anche nel JS del sito → stabili nel tempo. Rischio: se Traxsource migra a un framework nuovo (React/Next), parser va rifatto.
- **Rate limiting:** un solo GET per la genre + un GET per la playlist Top 100 = 2 richieste per caricare la chart. Cache 15 min. Zero rischio ban in uso normale.
- **Legal:** stesso perimetro di Beatport — lettura di pagine pubbliche, no ToS violation esplicito.
- **Comparabilità con Beatport:** un utente power potrebbe usare entrambe le tab per triangolare le release del mese. UX consistente = curva apprendimento zero.
+15
View File
@@ -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'
+3
View File
@@ -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"
@@ -9,6 +10,8 @@ pyobjc-framework-AVFoundation>=10.0 ; sys_platform == "darwin"
pythonnet==3.0.5 ; sys_platform == "win32"
clr-loader==0.2.7.post0 ; sys_platform == "win32"
curl_cffi>=0.9.0
beautifulsoup4>=4.12.0
send2trash>=1.8.0
# --- dev only ---
pytest>=8.0.0
+34
View File
@@ -0,0 +1,34 @@
"""Estrae la lista dei generi Traxsource dalla pagina /top.
Uso: python3 scripts/refresh_traxsource_genres.py > /tmp/tx_genres.txt
Poi copia manualmente in core/traxsource.py::GENRES."""
from __future__ import annotations
import re
import sys
from curl_cffi import requests
def main() -> int:
s = requests.Session(impersonate="chrome131")
s.get("https://www.traxsource.com/", timeout=15)
r = s.get("https://www.traxsource.com/top", timeout=15,
headers={"Referer": "https://www.traxsource.com/"})
if r.status_code != 200:
print(f"HTTP {r.status_code}", file=sys.stderr)
return 1
# href="/genre/<id>/<slug>"
hits = re.findall(r'href="/genre/(\d+)/([a-z0-9-]+)"', r.text)
seen = set()
for gid, slug in hits:
if slug in seen:
continue
seen.add(slug)
# Display name = slug con capitalizzazione euristica (l'utente lo raffina a mano)
name = " ".join(w.capitalize() for w in slug.replace("-", " ").split())
print(f' "{slug}": ({gid}, "{name}"),')
return 0
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+104
View File
@@ -0,0 +1,104 @@
"""Test per core.converter — file scanning + stop event.
I test evitano di invocare ffmpeg reale (che potrebbe non essere in PATH
in CI). La funzione di conversione vera e propria e' coperta manualmente.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from core import converter
class TestListWavFiles:
def test_scans_recursively(self, tmp_path: Path):
# Struttura:
# root/a.wav
# root/b.WAV (case insensitive: glob su POSIX distingue,
# quindi controllo esplicito che almeno i .wav
# minuscoli siano restituiti)
# root/c.mp3
# root/sub/d.wav
(tmp_path / "a.wav").write_bytes(b"RIFF")
(tmp_path / "c.mp3").write_bytes(b"ID3")
sub = tmp_path / "sub"
sub.mkdir()
(sub / "d.wav").write_bytes(b"RIFF")
got = converter.list_wav_files(str(tmp_path), recursive=True)
assert len(got) == 2
names = {Path(p).name for p in got}
assert names == {"a.wav", "d.wav"}
# Ordinamento stabile
assert got == sorted(got)
def test_non_recursive_ignores_subfolders(self, tmp_path: Path):
(tmp_path / "a.wav").write_bytes(b"RIFF")
sub = tmp_path / "sub"
sub.mkdir()
(sub / "b.wav").write_bytes(b"RIFF")
got = converter.list_wav_files(str(tmp_path), recursive=False)
assert len(got) == 1
assert Path(got[0]).name == "a.wav"
def test_empty_dir_returns_empty(self, tmp_path: Path):
assert converter.list_wav_files(str(tmp_path)) == []
def test_missing_dir_returns_empty(self, tmp_path: Path):
missing = tmp_path / "does-not-exist"
assert converter.list_wav_files(str(missing)) == []
def test_ignores_non_wav_files(self, tmp_path: Path):
(tmp_path / "song.mp3").write_bytes(b"ID3")
(tmp_path / "song.flac").write_bytes(b"fLaC")
(tmp_path / "notes.txt").write_text("hello")
assert converter.list_wav_files(str(tmp_path)) == []
class TestStopEvent:
def setup_method(self):
# Ogni test parte con lo stop event pulito.
converter.reset_stop()
def teardown_method(self):
converter.reset_stop()
def test_reset_initially_not_stopped(self):
converter.reset_stop()
assert converter.is_stopped() is False
def test_request_stop_sets_flag(self):
converter.request_stop()
assert converter.is_stopped() is True
def test_reset_after_stop_clears_flag(self):
converter.request_stop()
assert converter.is_stopped() is True
converter.reset_stop()
assert converter.is_stopped() is False
class TestConvertWavToMp3Errors:
def test_missing_input_raises(self, tmp_path: Path):
missing = tmp_path / "nope.wav"
out = tmp_path / "out.mp3"
with pytest.raises(FileNotFoundError):
converter.convert_wav_to_mp3(str(missing), str(out))
class TestVbrQualityMap:
def test_all_bitrates_have_mapping(self):
for br in (128, 192, 256, 320):
assert br in converter._VBR_QUALITY
def test_higher_bitrate_lower_or_equal_quality_number(self):
# -q:a: piu' basso = migliore. Coerenza monotona sulle chiavi.
vals = [converter._VBR_QUALITY[br] for br in (128, 192, 256, 320)]
assert vals == sorted(vals, reverse=True)
+193
View File
@@ -0,0 +1,193 @@
"""Test per core.dedup — audio fingerprinting via fpcalc + cache SQLite.
Tutti gli unit test usano mock per fpcalc / send2trash: nessuna
integrazione reale, nessun file audio necessario.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from unittest import mock
import pytest
from core import dedup
# ------------------------------------------------------------------
# Fixture: dedup con cache DB isolato in tmp_path
# ------------------------------------------------------------------
@pytest.fixture
def patched_cache(tmp_path, monkeypatch):
"""Isola la cache SQLite in tmp_path per non toccare il config dir."""
db = tmp_path / "dedup_cache_test.db"
monkeypatch.setattr(dedup, "_cache_db_path", lambda: db)
return db
def _make_fake_audio(tmp_path: Path, name: str, size: int = 1024) -> Path:
"""Crea un file 'audio' fake (byte casuali con estensione .mp3)."""
f = tmp_path / name
f.write_bytes(b"\x00" * size)
return f
# ------------------------------------------------------------------
# scan_folder
# ------------------------------------------------------------------
class TestScanFolder:
def test_no_audio_files(self, tmp_path, patched_cache):
"""Cartella senza audio -> gruppi vuoti, nessuna eccezione."""
# Solo un file .txt (non audio)
(tmp_path / "readme.txt").write_text("hello")
# Anche senza fpcalc disponibile, con 0 audio file ritorna [].
with mock.patch.object(dedup, "find_fpcalc", return_value="/fake/fpcalc"):
groups = dedup.scan_folder(str(tmp_path), recursive=False)
assert groups == []
def test_uses_cache_on_second_scan(self, tmp_path, patched_cache):
"""Prima scan chiama fpcalc; seconda scan riusa la cache."""
_make_fake_audio(tmp_path, "song.mp3", size=2048)
calls: list[str] = []
def fake_compute(fpcalc, path):
calls.append(path)
return {"duration": 180.5, "fingerprint": "FP-A"}
with mock.patch.object(dedup, "find_fpcalc", return_value="/fake/fpcalc"), \
mock.patch.object(dedup, "compute_fingerprint", side_effect=fake_compute), \
mock.patch.object(dedup, "get_bitrate", return_value=320):
# Prima invocazione: fpcalc DEVE essere chiamato
groups1 = dedup.scan_folder(str(tmp_path), recursive=False)
first_calls = len(calls)
# Seconda invocazione (stesso file, stesso mtime/size):
# cache HIT, fpcalc NON viene richiamato
groups2 = dedup.scan_folder(str(tmp_path), recursive=False)
second_calls = len(calls)
assert first_calls == 1, "prima scan deve chiamare fpcalc una volta"
assert second_calls == 1, "seconda scan deve riusare la cache"
# Con un solo file, nessun gruppo di duplicati
assert groups1 == []
assert groups2 == []
def test_groups_duplicates(self, tmp_path, patched_cache):
"""3 file con lo stesso fingerprint -> 1 gruppo di 3, ordinato per bitrate DESC."""
_make_fake_audio(tmp_path, "a.mp3", size=1000)
_make_fake_audio(tmp_path, "b.mp3", size=3000) # size maggiore
_make_fake_audio(tmp_path, "c.mp3", size=2000)
# Tutti stesso fingerprint. Bitrate differente per verificare
# l'ordinamento: b=320 (top), a=192, c=128.
bitrate_by_name = {"a.mp3": 192, "b.mp3": 320, "c.mp3": 128}
with mock.patch.object(dedup, "find_fpcalc", return_value="/fake/fpcalc"), \
mock.patch.object(dedup, "compute_fingerprint",
return_value={"duration": 200, "fingerprint": "SAME-FP"}), \
mock.patch.object(dedup, "get_bitrate",
side_effect=lambda p: bitrate_by_name[Path(p).name]):
groups = dedup.scan_folder(str(tmp_path), recursive=False)
assert len(groups) == 1, "esattamente un gruppo di duplicati"
g = groups[0]
assert len(g) == 3, "tre file nel gruppo"
# Ordine: bitrate DESC -> b (320), a (192), c (128)
assert [Path(e["path"]).name for e in g] == ["b.mp3", "a.mp3", "c.mp3"]
# Ogni entry ha i campi attesi
for e in g:
assert set(e.keys()) >= {"path", "size", "bitrate", "duration", "fingerprint"}
assert e["fingerprint"] == "SAME-FP"
def test_ignores_singletons(self, tmp_path, patched_cache):
"""File con fingerprint unico non compaiono nei gruppi."""
_make_fake_audio(tmp_path, "dup1.mp3")
_make_fake_audio(tmp_path, "dup2.mp3")
_make_fake_audio(tmp_path, "unique.mp3")
fp_by_name = {"dup1.mp3": "FP-X", "dup2.mp3": "FP-X", "unique.mp3": "FP-Y"}
def fake_compute(fpcalc, path):
return {"duration": 100, "fingerprint": fp_by_name[Path(path).name]}
with mock.patch.object(dedup, "find_fpcalc", return_value="/fake/fpcalc"), \
mock.patch.object(dedup, "compute_fingerprint", side_effect=fake_compute), \
mock.patch.object(dedup, "get_bitrate", return_value=256):
groups = dedup.scan_folder(str(tmp_path), recursive=False)
# Solo il gruppo con dup1/dup2
assert len(groups) == 1
names = {Path(e["path"]).name for e in groups[0]}
assert names == {"dup1.mp3", "dup2.mp3"}
# unique.mp3 non appare in nessun gruppo
for g in groups:
for e in g:
assert Path(e["path"]).name != "unique.mp3"
# ------------------------------------------------------------------
# move_to_trash
# ------------------------------------------------------------------
class TestMoveToTrash:
def test_returns_moved_and_failed_summary(self, tmp_path, patched_cache):
"""Verifica che il summary contenga moved/failed correttamente."""
# 2 path OK + 1 path che alza eccezione
ok1 = str(tmp_path / "ok1.mp3")
ok2 = str(tmp_path / "ok2.mp3")
bad = str(tmp_path / "bad.mp3")
def fake_send(p):
if p == bad:
raise OSError("simulated failure")
# ok: no-op
# Il modulo importa send2trash *dentro* la funzione, quindi
# dobbiamo patchare il modulo importato.
with mock.patch("send2trash.send2trash", side_effect=fake_send):
res = dedup.move_to_trash([ok1, bad, ok2])
assert set(res["moved"]) == {ok1, ok2}
assert len(res["failed"]) == 1
assert res["failed"][0]["path"] == bad
assert "simulated failure" in res["failed"][0]["error"]
# ------------------------------------------------------------------
# compute_fingerprint
# ------------------------------------------------------------------
class TestComputeFingerprint:
def test_timeout_returns_error(self):
"""Timeout di fpcalc -> dict con _error, no fingerprint."""
with mock.patch("core.dedup.subprocess.run",
side_effect=subprocess.TimeoutExpired(cmd="fpcalc", timeout=30)):
result = dedup.compute_fingerprint("/fake/fpcalc", "/some/file.mp3")
assert result and "_error" in result and "timeout" in result["_error"]
assert "fingerprint" not in result
def test_success_returns_dict(self):
"""Output JSON valido -> {duration, fingerprint}."""
fake_proc = mock.Mock()
fake_proc.returncode = 0
fake_proc.stdout = '{"duration": 123.4, "fingerprint": "ABCDEF"}'
with mock.patch("core.dedup.subprocess.run", return_value=fake_proc):
result = dedup.compute_fingerprint("/fake/fpcalc", "/some/file.mp3")
assert result == {"duration": 123.4, "fingerprint": "ABCDEF"}
def test_bad_json_returns_error(self):
fake_proc = mock.Mock()
fake_proc.returncode = 0
fake_proc.stdout = "not json at all"
with mock.patch("core.dedup.subprocess.run", return_value=fake_proc):
result = dedup.compute_fingerprint("/fake/fpcalc", "/some/file.mp3")
assert result and "_error" in result and "JSON" in result["_error"]
assert "fingerprint" not in result
def test_missing_fpcalc_returns_error(self):
# Nessuna chiamata subprocess se fpcalc e' vuoto
result = dedup.compute_fingerprint("", "/some/file.mp3")
assert result and "_error" in result and "fpcalc" in result["_error"]
assert "fingerprint" not in result
+57
View File
@@ -0,0 +1,57 @@
"""Test per core.tagger — filename sanitization + write_tags safety net."""
from __future__ import annotations
from core import tagger
class TestSanitizeFilenameStem:
def test_removes_bad_chars(self):
# Tutti i char vietati su Windows/macOS: < > : " / \ | ? *
got = tagger.sanitize_filename_stem('bad<name>:"/\\|?*test')
# Nessuno dei char proibiti sopravvive
for ch in '<>:"/\\|?*':
assert ch not in got
# E ho ottenuto qualcosa di non vuoto
assert got
def test_collapses_whitespace(self):
assert tagger.sanitize_filename_stem("a b c") == "a b c"
def test_truncates_long_stem(self):
s = "x" * 500
got = tagger.sanitize_filename_stem(s, max_len=180)
assert len(got) <= 180
def test_empty_returns_untitled(self):
assert tagger.sanitize_filename_stem("") == "untitled"
assert tagger.sanitize_filename_stem(" ") == "untitled"
class TestBuildFilenameStem:
def test_format_artist_dash_title(self):
assert tagger.build_filename_stem("Kapuchon", "Hot Sauce") == "Kapuchon - Hot Sauce"
def test_multiple_artists_ok(self):
got = tagger.build_filename_stem("Kapuchon, Miss Monique & GLZ", "Hot Sauce (Extended)")
assert got == "Kapuchon, Miss Monique & GLZ - Hot Sauce (Extended)"
def test_missing_artist_uses_title_only(self):
assert tagger.build_filename_stem("", "Only Title") == "Only Title"
def test_missing_title_uses_artist_only(self):
assert tagger.build_filename_stem("Only Artist", "") == "Only Artist"
def test_sanitizes_bad_chars(self):
got = tagger.build_filename_stem("A/B", "C:D")
# Nessuno slash o colon residuo
assert "/" not in got
assert ":" not in got
class TestWriteTagsMissingFile:
def test_missing_file_is_noop(self):
# Non deve sollevare alcuna eccezione se il file non esiste
tagger.write_tags("/nonexistent/path/to/file.mp3", {
"title": "x", "artist": "y", "cover_url": "http://example.com/x.jpg",
})
+217
View File
@@ -0,0 +1,217 @@
"""Test per core.traxsource."""
from __future__ import annotations
import pytest
from core import traxsource
class TestListGenres:
def test_returns_list_of_dicts(self):
result = traxsource.list_genres()
assert isinstance(result, list)
assert len(result) >= 10
for g in result:
assert set(g.keys()) == {"slug", "id", "name"}
assert isinstance(g["slug"], str) and g["slug"]
assert isinstance(g["id"], int) and g["id"] > 0
assert isinstance(g["name"], str) and g["name"]
def test_sorted_alphabetically_by_name(self):
result = traxsource.list_genres()
names = [g["name"] for g in result]
assert names == sorted(names, key=str.casefold)
def test_tech_house_present(self):
result = traxsource.list_genres()
slugs = [g["slug"] for g in result]
assert "tech-house" in slugs
class TestTraxsourceTrack:
def test_frozen(self):
t = traxsource.TraxsourceTrack(
position=1, title="X", mix="Y", artists="A", label="L",
traxsource_id=1, slug="x",
)
with pytest.raises(Exception):
t.title = "Z"
def test_defaults(self):
t = traxsource.TraxsourceTrack(
position=1, title="X", mix="", artists="A", label="",
traxsource_id=1, slug="x",
)
assert t.image_url == ""
assert t.cover_url_large == ""
class TestSplitTitleMix:
def test_with_parens(self):
assert traxsource._split_title_mix("Foo (Extended Mix)") == ("Foo", "Extended Mix")
def test_without_parens(self):
assert traxsource._split_title_mix("Foo") == ("Foo", "")
def test_multiple_parens_takes_last(self):
# es. "Foo (feat. Bar) (Original Mix)" -> mix = "Original Mix"
assert traxsource._split_title_mix("Foo (feat. Bar) (Original Mix)") == ("Foo (feat. Bar)", "Original Mix")
def test_empty(self):
assert traxsource._split_title_mix("") == ("", "")
class TestFormatArtists:
def test_single(self):
assert traxsource._format_artists(["Kapuchon"]) == "Kapuchon"
def test_two(self):
assert traxsource._format_artists(["A", "B"]) == "A & B"
def test_three(self):
assert traxsource._format_artists(["A", "B", "C"]) == "A, B & C"
def test_empty(self):
assert traxsource._format_artists([]) == ""
def test_strips_whitespace(self):
assert traxsource._format_artists([" A ", " B "]) == "A & B"
class TestLargeCover:
def test_substitutes_52x52_with_500x500(self):
url = "https://www.traxsource.com/scripts/image.php/52x52/abc.jpg"
assert traxsource._large_cover(url) == "https://www.traxsource.com/scripts/image.php/500x500/abc.jpg"
def test_no_change_when_no_pattern(self):
assert traxsource._large_cover("https://example.com/x.jpg") == "https://example.com/x.jpg"
def test_empty(self):
assert traxsource._large_cover("") == ""
class TestExceptions:
def test_exceptions_are_subclasses(self):
assert issubclass(traxsource.TraxsourceUnreachableError, traxsource.TraxsourceError)
assert issubclass(traxsource.TraxsourceParseError, traxsource.TraxsourceError)
class TestDiscoverTop100Url:
def test_extracts_link_from_genre_page(self, fixtures_dir):
html = (fixtures_dir / "traxsource_tech_house_genre.html").read_text()
url = traxsource._discover_top100_url(html)
assert url.startswith("/title/")
assert "top-100" in url
def test_raises_when_no_link(self):
with pytest.raises(traxsource.TraxsourceParseError, match="Top 100"):
traxsource._discover_top100_url("<html>nulla</html>")
class TestParseTracks:
def test_parses_100_tracks(self, fixtures_dir):
html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
tracks = traxsource._parse_tracks(html)
assert len(tracks) == 100
def test_positions_sequential_1_to_100(self, fixtures_dir):
html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
tracks = traxsource._parse_tracks(html)
positions = [t.position for t in tracks]
assert positions == list(range(1, 101))
def test_track_shape(self, fixtures_dir):
html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
tracks = traxsource._parse_tracks(html)
first = tracks[0]
assert first.title
assert first.artists
assert first.traxsource_id > 0
assert first.slug
assert first.image_url.startswith("https://")
assert first.cover_url_large.startswith("https://")
assert "500x500" in first.cover_url_large
def test_raises_when_no_tracks(self):
with pytest.raises(traxsource.TraxsourceParseError, match="track"):
traxsource._parse_tracks("<html>vuoto</html>")
from unittest.mock import patch, MagicMock
def _mock_response(text: str, status_code: int = 200) -> MagicMock:
resp = MagicMock()
resp.text = text
resp.status_code = status_code
def _raise():
if status_code >= 400:
raise Exception(f"HTTP {status_code}")
resp.raise_for_status = _raise
return resp
class TestFetchTop100:
@pytest.fixture
def genre_html(self, fixtures_dir):
return (fixtures_dir / "traxsource_tech_house_genre.html").read_text()
@pytest.fixture
def top100_html(self, fixtures_dir):
return (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
def test_success_returns_100_tracks(self, genre_html, top100_html):
traxsource._cache.clear()
mock_sess = MagicMock()
mock_sess.get.side_effect = [
_mock_response(genre_html, 200),
_mock_response(top100_html, 200),
]
with patch("core.traxsource._session", return_value=mock_sess):
tracks = traxsource.fetch_top100("tech-house")
assert len(tracks) == 100
def test_invalid_slug_raises_value_error(self):
with pytest.raises(ValueError, match="slug"):
traxsource.fetch_top100("not-a-genre")
def test_5xx_retries_and_raises_unreachable(self):
traxsource._cache.clear()
mock_sess = MagicMock()
mock_sess.get.return_value = _mock_response("", 503)
with patch("core.traxsource._session", return_value=mock_sess), \
patch("core.traxsource.time.sleep"):
with pytest.raises(traxsource.TraxsourceUnreachableError):
traxsource.fetch_top100("tech-house")
# 3 tentativi
assert mock_sess.get.call_count == 3
def test_cache_hit_within_ttl(self, genre_html, top100_html):
traxsource._cache.clear()
mock_sess = MagicMock()
mock_sess.get.side_effect = [
_mock_response(genre_html, 200),
_mock_response(top100_html, 200),
]
with patch("core.traxsource._session", return_value=mock_sess):
traxsource.fetch_top100("tech-house")
traxsource.fetch_top100("tech-house")
# 2 chiamate al primo fetch (genre + top100), 0 al secondo
assert mock_sess.get.call_count == 2
def test_force_refresh_bypasses_cache(self, genre_html, top100_html):
traxsource._cache.clear()
mock_sess = MagicMock()
# 4 risposte (2 fetch x 2 richieste ciascuno)
mock_sess.get.side_effect = [
_mock_response(genre_html, 200),
_mock_response(top100_html, 200),
_mock_response(genre_html, 200),
_mock_response(top100_html, 200),
]
with patch("core.traxsource._session", return_value=mock_sess):
traxsource.fetch_top100("tech-house")
traxsource.fetch_top100("tech-house", force_refresh=True)
assert mock_sess.get.call_count == 4
+169
View File
@@ -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
+274 -1
View File
@@ -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,10 +1669,32 @@ 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-date { width: 100px; color: var(--text-2); font-variant-numeric: tabular-nums; font-size: 12px; white-space: nowrap; }
.beatport-table .col-state { width: 130px; font-size: 12px; }
.beatport-table thead th.sortable {
cursor: pointer;
user-select: none;
transition: color 0.15s ease;
}
.beatport-table thead th.sortable:hover { color: var(--text); }
.beatport-table thead th .sort-arrow {
font-size: 10px;
margin-left: 4px;
color: var(--indigo-text);
}
.beatport-table input[type="checkbox"] {
width: 16px;
height: 16px;
@@ -1697,3 +1721,252 @@ input[type="number"]::-webkit-inner-spin-button {
.hint-inline { margin-left: 0; }
.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 picker: più largo dei modal standard + footer con wrap
per far entrare i 4 bottoni senza troncare */
#upgradePickerModal .modal {
min-width: 620px;
max-width: 780px;
}
#upgradePickerModal .modal-foot {
flex-wrap: wrap;
justify-content: flex-end;
}
.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-play {
display: flex;
align-items: center;
flex-shrink: 0;
}
.upgrade-preview-btn {
width: 34px;
height: 34px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--bg-input, transparent);
color: var(--text);
font-size: 13px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin-left: 6px;
transition: background 0.15s ease, transform 0.1s ease;
}
.upgrade-preview-btn:hover { background: var(--bg-input-hover, rgba(255,255,255,0.05)); }
.upgrade-preview-btn.playing {
background: var(--green, #1DB954);
color: #000;
border-color: var(--green, #1DB954);
}
.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;
}
/* ===============================
DEDUP tab (audio duplicati)
=============================== */
.dedup-summary {
font-size: 14px;
color: var(--text-2);
font-weight: 600;
}
.dedup-group-card {
padding: 14px 16px;
}
.dedup-group-header {
font-size: 13px;
color: var(--text-2);
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.dedup-group-header strong {
color: var(--text-1);
font-size: 14px;
}
.dedup-fp {
font-family: "SF Mono", Menlo, Consolas, monospace;
color: var(--text-3);
font-size: 11px;
}
.dedup-file-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.dedup-file-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px;
border-radius: 8px;
background: var(--bg-elev);
transition: background 0.15s ease;
}
.dedup-file-row:hover {
background: var(--bg-elev-hover, var(--bg-elev));
}
.dedup-file-check {
flex-shrink: 0;
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--red);
}
.dedup-file-check:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.dedup-file-info {
flex: 1;
min-width: 0;
}
.dedup-file-title {
font-size: 13px;
font-weight: 600;
color: var(--text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dedup-file-meta {
font-size: 11px;
color: var(--text-3);
margin-top: 2px;
font-variant-numeric: tabular-nums;
}
.dedup-file-path {
font-size: 10px;
color: var(--text-3);
margin-top: 2px;
font-family: "SF Mono", Menlo, Consolas, monospace;
word-break: break-all;
opacity: 0.6;
}
.dedup-file-play {
flex-shrink: 0;
display: flex;
align-items: center;
}
/* File "da tenere" (bitrate massimo del gruppo) — verde */
.dedup-keep {
background: var(--green-dim);
border-left: 3px solid var(--green);
}
.dedup-keep .dedup-file-title {
color: var(--green-text);
}
.dedup-keep-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
background: var(--green);
color: white;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.5px;
margin-right: 6px;
vertical-align: middle;
}
.dedup-footer-bar {
position: sticky;
bottom: 0;
z-index: 5;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 16px;
margin-top: 12px;
border-radius: var(--r-md);
background: var(--bg-card);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid var(--border);
}
/* Lista risultati Dedup con scroll interno: l'header (hero sticky) e il
footer restano visibili anche se ci sono centinaia di gruppi. */
.dedup-groups-scroll {
max-height: 55vh;
overflow-y: auto;
padding-right: 6px; /* spazio per la scrollbar sul bordo */
}
+302 -13
View File
@@ -33,6 +33,10 @@
<span class="nav-icon">🎧</span>
<span>Beatport</span>
</button>
<button class="nav-item" data-view="traxsource" data-feature="audio">
<span class="nav-icon">▲</span>
<span>Traxsource</span>
</button>
<button class="nav-item" data-view="spotify" data-feature="audio">
<span class="nav-icon">🟢</span>
<span>Spotify</span>
@@ -41,6 +45,14 @@
<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="dedup" data-feature="audio">
<span class="nav-icon">🗑</span>
<span>Dedup</span>
</button>
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
<span class="nav-icon">⚡</span>
<span>Upgrade</span>
@@ -264,6 +276,23 @@
<label class="field-label">Soglia HQ (kbps)</label>
<input type="number" id="upThreshold" class="input pill input-sm" value="310" />
</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>
<label style="display:flex;gap:6px;align-items:center;margin-top:8px;font-size:13px;color:var(--text-2);">
<input type="checkbox" id="upArchiveAutoPick"/>
<span>Scelta automatica se ci sono più match (usa quello a bitrate più alto)</span>
</label>
<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 — oppure attiva "Scelta automatica" per non fermarti.</div>
</div>
</div>
</div>
<div class="card actions">
@@ -534,6 +563,24 @@
</div>
</div>
<div class="field">
<label class="field-label">Cookies da browser (bypass 403 YouTube)</label>
<div class="row">
<select id="cookiesBrowserSelect" class="input pill" style="max-width:220px;">
<option value="">Nessuno</option>
<option value="chrome">Chrome</option>
<option value="safari">Safari</option>
<option value="firefox">Firefox</option>
<option value="edge">Edge</option>
<option value="brave">Brave</option>
</select>
</div>
<div class="hint">
<span class="hint-ico">ⓘ</span>
<div>Alcuni video YouTube (musica protetta, region-lock, età) danno HTTP 403 senza cookies autenticati. Selezionando un browser, l'app legge i cookies dal tuo profilo — richiede che tu sia loggato su YouTube in quel browser. Se hai anche cookies.txt sopra, ha priorità il file.</div>
</div>
</div>
<div class="field">
<label class="field-label">Cartella output predefinita</label>
<div class="row">
@@ -891,10 +938,12 @@
<thead>
<tr>
<th class="col-check"><input type="checkbox" id="beatport-select-all" /></th>
<th class="col-pos">#</th>
<th>Artista</th>
<th>Titolo (Mix)</th>
<th class="col-dur">Durata</th>
<th class="col-cover"></th>
<th class="col-pos sortable" data-sort="position">#</th>
<th class="sortable" data-sort="artists">Artista</th>
<th class="sortable" data-sort="title">Titolo (Mix)</th>
<th class="col-dur sortable" data-sort="duration_sec">Durata</th>
<th class="col-date sortable" data-sort="release_date">Data</th>
<th class="col-state">Stato</th>
</tr>
</thead>
@@ -918,6 +967,64 @@
</div>
</section>
<!-- ===== VIEW: TRAXSOURCE ===== -->
<section class="view" id="view-traxsource">
<header class="hero hero-purple">
<div class="hero-content">
<div class="hero-eyebrow purple">TRAXSOURCE CHARTS</div>
<h1 class="hero-title">Top 100 Traxsource per genere</h1>
<p class="hero-subtitle">La classifica mensile ufficiale. Scarica in un click con tag ID3 completi (label, cover art, genere).</p>
</div>
<div class="hero-deco">▲</div>
</header>
<h2 class="section-label">Genere</h2>
<div class="card">
<div class="beatport-header">
<select id="traxsource-genre" class="input pill"></select>
<button class="btn btn-primary pill" id="traxsource-load-btn">
<span class="ico">↓</span> Carica Top 100
</button>
<span class="hint-inline">Shift+click per bypassare la cache</span>
</div>
<div id="traxsource-status" class="beatport-status"></div>
<div id="traxsource-output-info" class="beatport-output-info"></div>
</div>
<div class="card beatport-table-card">
<div class="beatport-table-wrap">
<table id="traxsource-table" class="beatport-table" hidden>
<thead>
<tr>
<th class="col-check"><input type="checkbox" id="traxsource-select-all" /></th>
<th class="col-cover"></th>
<th class="col-pos sortable" data-sort="position">#</th>
<th class="sortable" data-sort="artists">Artista</th>
<th class="sortable" data-sort="title">Titolo (Mix)</th>
<th class="sortable" data-sort="label">Label</th>
<th class="col-state">Stato</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="beatport-toolbar" id="traxsource-toolbar" hidden>
<span class="counter" id="traxsource-selected-count">0/0 selezionati</span>
<button class="btn btn-primary pill" id="traxsource-download-btn" disabled>
<span class="ico">▶</span> Scarica selezionati
</button>
<button class="btn btn-danger pill" id="traxsource-stop-btn" hidden>
<span class="ico">◼</span> Interrompi
</button>
</div>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="traxsource-log"></div>
</div>
</section>
<!-- ===== VIEW: SPOTIFY SEARCH ===== -->
<section class="view" id="view-spotify">
<header class="hero hero-indigo">
@@ -951,11 +1058,13 @@
<thead>
<tr>
<th class="col-check"><input type="checkbox" id="spotify-select-all" checked /></th>
<th class="col-pos">#</th>
<th>Artista</th>
<th>Titolo</th>
<th>Album</th>
<th class="col-dur">Durata</th>
<th class="col-cover"></th>
<th class="col-pos sortable" data-sort="position">#</th>
<th class="sortable" data-sort="artists">Artista</th>
<th class="sortable" data-sort="name">Titolo</th>
<th class="sortable" data-sort="album">Album</th>
<th class="col-dur sortable" data-sort="duration_sec">Durata</th>
<th class="col-date sortable" data-sort="release_date">Data</th>
<th class="col-state"></th>
</tr>
</thead>
@@ -1008,10 +1117,12 @@
<thead>
<tr>
<th class="col-check"><input type="checkbox" id="youtube-select-all" checked /></th>
<th class="col-pos">#</th>
<th>Titolo video</th>
<th>Canale</th>
<th class="col-dur">Durata</th>
<th class="col-cover"></th>
<th class="col-pos sortable" data-sort="position">#</th>
<th class="sortable" data-sort="title">Titolo video</th>
<th class="sortable" data-sort="channel">Canale</th>
<th class="col-dur sortable" data-sort="duration_sec">Durata</th>
<th class="col-date sortable" data-sort="release_date">Data</th>
<th class="col-state"></th>
</tr>
</thead>
@@ -1035,6 +1146,164 @@
</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>
<!-- ===== VIEW: DEDUP (audio duplicati via Chromaprint) ===== -->
<section class="view" id="view-dedup">
<header class="hero hero-red">
<div class="hero-content">
<div class="hero-eyebrow red">DEDUP AUDIO</div>
<h1 class="hero-title">Trova e rimuovi duplicati audio</h1>
<p class="hero-subtitle">Audio fingerprinting via Chromaprint: riconosce brani identici indipendentemente dal bitrate, dal formato o dai tag.</p>
</div>
<div class="hero-deco">🗑</div>
</header>
<h2 class="section-label">Cartella</h2>
<div class="card">
<div class="beatport-header">
<button id="dedup-pick-folder" class="btn btn-primary pill">📁 Scegli cartella</button>
<button id="dedup-start-btn" class="btn btn-primary pill" disabled>
<span class="ico">▶</span> Scansiona
</button>
<button id="dedup-stop-btn" class="btn btn-danger pill" hidden>
<span class="ico">◼</span> Interrompi
</button>
</div>
<div class="beatport-header" style="margin-top:12px;gap:16px;">
<span id="dedup-path-display" class="beatport-output-info" style="margin:0;">Nessuna cartella selezionata</span>
</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;">
<input type="checkbox" id="dedup-recursive" checked />
<span>Ricerca ricorsiva (include sottocartelle)</span>
</label>
</div>
<div class="beatport-header" style="margin-top:12px;gap:16px;">
<label style="display:flex;gap:8px;align-items:center;color:var(--text-2);font-size:13px;">
<span>Metodo:</span>
<select id="dedup-method" class="input" style="padding:4px 8px;font-size:13px;">
<option value="fingerprint">Audio fingerprint (preciso, lento)</option>
<option value="filename">Nome file (veloce, meno preciso)</option>
</select>
</label>
</div>
<div id="dedup-status" class="beatport-status"></div>
</div>
<h2 class="section-label">Progresso</h2>
<div class="card">
<div class="progress">
<div class="progress-bar"><div id="dedupProgressFill" class="progress-fill pink"></div></div>
<div class="progress-meta">
<span id="dedupPercent" class="percent pink">0%</span>
<span id="dedupCounter">In attesa</span>
</div>
</div>
</div>
<h2 class="section-label">Risultati</h2>
<div class="card" id="dedup-summary-card" hidden>
<div class="dedup-summary" id="dedup-summary-line"></div>
</div>
<div id="dedup-groups-wrap" class="dedup-groups-scroll"></div>
<div class="dedup-footer-bar" id="dedup-footer-bar" hidden>
<span class="counter" id="dedup-selected-count">0 file da cancellare</span>
<button id="dedup-select-suggested-btn" class="btn btn-ghost pill">
✓ Seleziona tutti i consigliati
</button>
<button id="dedup-trash-btn" class="btn btn-danger pill" disabled>
<span class="ico">🗑</span> Sposta in cestino
</button>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="dedup-log"></div>
</div>
</section>
</main>
</div>
@@ -1108,6 +1377,26 @@
</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-ghost pill" id="upgradePickerSkipBtn">Salta</button>
<button class="btn btn-danger pill" id="upgradePickerCancelAllBtn">■ Annulla tutto</button>
</div>
</div>
</div>
<!-- ============== TOAST ============== -->
<div class="toast" id="toast"></div>
+1296 -18
View File
File diff suppressed because it is too large. Load diff