Compare commits

..
4 Commits
Author SHA1 Message Date
lucianoandClaude Opus 4.7 14e8e0fff5 tools: MUSICTOOLS_SSH env override per gen-license.sh
Build MusicTools / build-windows (push) Waiting to run
Build MusicTools / build-macos (push) Waiting to run
Server musictools.djluza.com può essere irraggiungibile via internet
mentre la LAN sì. Nuovo override:
  MUSICTOOLS_SSH="musictools@192.168.30.250" ./gen-license.sh ...
Senza override continua a usare il dominio pubblico come default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-09 14:41:00 +02:00
lucianoandClaude Opus 4.7 8266722654 ui: tab Diritti con tabella streaming + export CSV + settings Jamendo
- Nav item "⚖️ Diritti" nel gruppo Organizza (dopo Upgrade), feature=audio
- View #view-royalty: hero amber, folder picker, checkbox recursive,
  progress bar, tabella streaming (file · artista · titolo · stato ·
  match+licenza) con badge colorati
- Hint esplicito: "Non trovato" NON implica SIAE
- Modulo RoyaltyUI (pattern CatalogUI/DedupUI): streaming via
  royalty:progress / royalty:entry / royalty:done, link ai match
  aperti via open_external_url, export CSV client-side (Blob + download)
- Settings: nuovo campo jamendo_client_id (opzionale) + hint link
  devportal.jamendo.com; save/load via save_settings
- CSS: .royalty-scroll, .royalty-table, .royalty-badge-*,
  .royalty-row-rf/error, .progress-fill.amber, .percent.amber

node --check webui/js/app.js OK · 184 test verdi.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-09 14:29:45 +02:00
lucianoandClaude Opus 4.7 ddacd050d8 bridge: metodi Api per tab Diritti + bump v1.14.0 + config jamendo_client_id
- VERSION v1.13.0 -> v1.14.0
- DEFAULTS: royalty_last_folder, royalty_recursive, jamendo_client_id
- save_settings: persiste jamendo_client_id
- Nuovi metodi Api:
    - royalty_pick_folder
    - royalty_start_scan {directory, recursive}
    - royalty_stop_scan
  piu' worker _royalty_worker che emette:
    - royalty:progress (per file: idx/total/filename/status + overall%)
    - royalty:entry (streaming per ogni risultato)
    - royalty:done (summary {ok, total, royalty_free_count,
                              unknown_count, error_count})

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-09 14:26:22 +02:00
lucianoandClaude Opus 4.7 018d310f53 royalty: modulo check ccMixter + Jamendo con cache SQLite
Nuova feature "Diritti": verifica royalty-free status di brani locali
contro DB CC/open (ccMixter sempre attivo, Jamendo opzionale via
jamendo_client_id in config). Approccio honest-only:

- status 'royalty_free' SOLO con match Levenshtein combinato >= 0.80
- status 'unknown' NON implica automaticamente SIAE (= da verificare)
- status 'error' per fingerprint fail / network / tag illeggibili

Pipeline:
- Scan AUDIO_EXTENSIONS da core.upgrader (ricorsivo opzionale)
- Legge artist/title da ID3 (core.metadata.read_metadata), fallback
  a core.catalog._parse_filename sul nome file
- Query parallele ccMixter + Jamendo con throttle 0.3s
- Cache SQLite in <config_dir>/royalty_cache.db

21 test verdi (mock requests via responses, cache isolata in tmp_path).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-09 14:25:10 +02:00
8 changed files with 1451 additions and 2 deletions

No files matched your search

+130
View File
@@ -50,6 +50,7 @@ from core import dedup
from core import catalog from core import catalog
from core import flatten from core import flatten
from core import charts from core import charts
from core import royalty_check as royalty
SPOTIFY_GUIDE_TEXT = """\ SPOTIFY_GUIDE_TEXT = """\
@@ -118,6 +119,7 @@ class Api:
self._dedup_thread: Optional[threading.Thread] = None self._dedup_thread: Optional[threading.Thread] = None
self._catalog_thread: Optional[threading.Thread] = None self._catalog_thread: Optional[threading.Thread] = None
self._flatten_thread: Optional[threading.Thread] = None self._flatten_thread: Optional[threading.Thread] = None
self._royalty_thread: Optional[threading.Thread] = None
# Coda usata dal resolve_callback per attendere la scelta utente # Coda usata dal resolve_callback per attendere la scelta utente
# sul modal "match locali multipli" della tab Upgrade. # sul modal "match locali multipli" della tab Upgrade.
self._upgrade_resolve_q: "queue.Queue[dict]" = queue.Queue(1) self._upgrade_resolve_q: "queue.Queue[dict]" = queue.Queue(1)
@@ -211,6 +213,7 @@ class Api:
"cookies_browser": (payload.get("cookies_browser") or "").strip().lower(), "cookies_browser": (payload.get("cookies_browser") or "").strip().lower(),
"output_dir": (payload.get("output_dir") or "").strip(), "output_dir": (payload.get("output_dir") or "").strip(),
"theme": payload.get("theme", "dark"), "theme": payload.get("theme", "dark"),
"jamendo_client_id": (payload.get("jamendo_client_id") or "").strip(),
}) })
save_config(config) save_config(config)
return {"ok": True} return {"ok": True}
@@ -2384,3 +2387,130 @@ class Api:
self._emit("charts:add_to_queue", payload) self._emit("charts:add_to_queue", payload)
self._log("charts", f"[INFO] {len(tracks)} brani inviati alla coda Scarica") self._log("charts", f"[INFO] {len(tracks)} brani inviati alla coda Scarica")
return {"ok": True, "count": len(tracks)} return {"ok": True, "count": len(tracks)}
# ================================================================
# DIRITTI — verifica royalty-free status (ccMixter + Jamendo)
# ================================================================
def royalty_pick_folder(self) -> str:
"""Folder picker per la cartella da analizzare."""
return self.browse_directory()
def royalty_start_scan(self, payload: dict) -> dict:
"""Avvia worker di scansione royalty-free.
Payload: {directory, recursive}.
Persiste `royalty_last_folder` e `royalty_recursive` in config.
"""
if self._royalty_thread and self._royalty_thread.is_alive():
return {"ok": False, "error": "Analisi diritti gia in corso"}
directory = (payload.get("directory") or "").strip()
recursive = bool(payload.get("recursive", True))
if not directory:
return {"ok": False, "error": "Cartella non impostata"}
if not os.path.isdir(directory):
return {"ok": False, "error": "Cartella non trovata"}
try:
cfg = load_config()
cfg["royalty_last_folder"] = directory
cfg["royalty_recursive"] = recursive
save_config(cfg)
except Exception:
pass
self._royalty_thread = threading.Thread(
target=self._royalty_worker,
args=(directory, recursive),
daemon=True,
)
self._royalty_thread.start()
return {"ok": True}
def royalty_stop_scan(self) -> dict:
royalty.request_stop()
self._log("royalty", "[INFO] Interruzione richiesta...")
return {"ok": True}
def _royalty_worker(self, directory: str, recursive: bool) -> None:
"""Esegue scan royalty-free in background, emette streaming."""
view = "royalty"
royalty.reset_stop()
self._log(view,
f"[INFO] Analisi diritti: {directory} (recursive={recursive})")
_last = [0.0]
_THROTTLE = 0.05
def progress_cb(idx: int, total: int, filename: str,
status: str, err_msg: str = "") -> None:
if status == "checking":
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] Analisi completata ({total} file).")
elif status == "stopped":
self._log(view, "[INFO] Analisi interrotta.")
elif status == "error" and filename:
detail = f": {err_msg}" if err_msg else ""
self._log(view, f"[ERRORE] {filename}{detail}")
self._emit("royalty:progress", payload_evt)
def entry_cb(entry: dict) -> None:
"""Streaming: appena un file e' processato, emit alla UI."""
try:
self._emit("royalty:entry", entry)
except Exception:
pass
# Log per file: solo quando trova un match RF
try:
st = entry.get("status", "")
if st == "royalty_free":
matches = entry.get("matches") or []
src = matches[0].get("source", "?") if matches else "?"
lic = matches[0].get("license", "") if matches else ""
from pathlib import Path as _P
name = _P(entry.get("path", "")).name
self._log(view, f"[OK] {name} → {src} ({lic})")
except Exception:
pass
try:
entries = royalty.scan_folder(
directory,
recursive=recursive,
progress_callback=progress_cb,
entry_callback=entry_cb,
)
except Exception as e:
self._log(view, f"[ERRORE] {e}")
self._emit("royalty:done", {"ok": False, "error": str(e),
"entries": []})
return
n_rf = sum(1 for e in entries if e.get("status") == "royalty_free")
n_unk = sum(1 for e in entries if e.get("status") == "unknown")
n_err = sum(1 for e in entries if e.get("status") == "error")
self._log(view,
f"[INFO] Royalty-free: {n_rf} · sconosciuti: {n_unk} · "
f"errori: {n_err} (totale {len(entries)})")
self._emit("royalty:done", {
"ok": True,
"entries": entries,
"total": len(entries),
"royalty_free_count": n_rf,
"unknown_count": n_unk,
"error_count": n_err,
})
+5 -1
View File
@@ -5,7 +5,7 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
VERSION = "v1.13.0" VERSION = "v1.14.0"
APP_NAME = "MusicTools" APP_NAME = "MusicTools"
@@ -95,6 +95,10 @@ DEFAULTS = {
"charts_lastfm_genre": "dance", # "" | pop, rock, dance, ... "charts_lastfm_genre": "dance", # "" | pop, rock, dance, ...
"charts_spotify_playlist": "top50_global", # chiave in charts._SPOTIFY_CHARTS "charts_spotify_playlist": "top50_global", # chiave in charts._SPOTIFY_CHARTS
"lastfm_api_key": "", # opzionale: override della chiave demo "lastfm_api_key": "", # opzionale: override della chiave demo
# ---- Diritti / Royalty-free check ----
"royalty_last_folder": "",
"royalty_recursive": True,
"jamendo_client_id": "", # opzionale: registrati su devportal.jamendo.com
# ---- Licenza ---- # ---- Licenza ----
"license_key": "", # chiave fornita all'utente via email "license_key": "", # chiave fornita all'utente via email
"license_email": "", # email associata all'acquisto "license_email": "", # email associata all'acquisto
+398
View File
@@ -0,0 +1,398 @@
"""Verifica royalty-free status di brani locali contro DB CC/open.
Approccio onesto: whitelist royalty-free. L'app segnala un brano come
"royalty_free" SOLO se trova un match affidabile in uno dei DB CC/open.
"Non trovato" (status 'unknown') NON implica automaticamente SIAE —
significa semplicemente "da verificare manualmente".
Pipeline:
1. Scansiona la cartella (filtra per `AUDIO_EXTENSIONS` di
`core.upgrader`).
2. Per ogni file legge i tag ID3 (artist + title) via
`core.metadata.read_metadata`; fallback a
`core.catalog._parse_filename` se i tag sono mancanti.
3. Query sorgenti royalty-free:
- ccMixter (nessuna auth, sempre attivo)
- Jamendo (richiede `jamendo_client_id` in config; se vuoto, skip)
4. Levenshtein combinato artist+title >= `_SIMILARITY_THRESHOLD` →
status 'royalty_free'. Altrimenti 'unknown'.
Progress callback firma:
(processed, total, filename, status[, err_msg])
Status: 'checking' | 'stopped' | 'completed' | 'error'.
`entry_callback(entry)` viene chiamato per ogni file processato
(streaming alla UI).
"""
from __future__ import annotations
import json
import sqlite3
import threading
import time
from difflib import SequenceMatcher
from pathlib import Path
from typing import Callable, Optional
import requests
from core.upgrader import AUDIO_EXTENSIONS
# ------------------------------------------------------------------
# Costanti
# ------------------------------------------------------------------
_CCMIXTER_URL = "https://ccmixter.org/api/query"
_JAMENDO_URL = "https://api.jamendo.com/v3.0/tracks/"
_HTTP_TIMEOUT = 15
_RATE_LIMIT_SEC = 0.3
_SIMILARITY_THRESHOLD = 0.80
# ------------------------------------------------------------------
# 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 file di cache SQLite (patchable nei test)."""
from core.config import _get_config_dir
return _get_config_dir() / "royalty_cache.db"
def _init_db(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS lookups(
key TEXT PRIMARY KEY,
payload TEXT,
cached_at REAL
);
"""
)
# ------------------------------------------------------------------
# Similarity
# ------------------------------------------------------------------
def _similarity(a: str, b: str) -> float:
"""Ritorna SequenceMatcher ratio (0.0 - 1.0) case-insensitive."""
if not a or not b:
return 0.0
return SequenceMatcher(None, a.lower().strip(), b.lower().strip()).ratio()
# ------------------------------------------------------------------
# Rate limit (globale)
# ------------------------------------------------------------------
_last_req = [0.0]
def _throttle() -> None:
now = time.monotonic()
elapsed = now - _last_req[0]
if elapsed < _RATE_LIMIT_SEC:
time.sleep(_RATE_LIMIT_SEC - elapsed)
_last_req[0] = time.monotonic()
# ------------------------------------------------------------------
# Query sorgenti
# ------------------------------------------------------------------
def _query_ccmixter(query: str) -> list:
"""Query ccMixter API. Ritorna lista di candidati normalizzati."""
if not query:
return []
_throttle()
try:
resp = requests.get(
_CCMIXTER_URL,
params={"f": "json", "search": query, "limit": 10},
timeout=_HTTP_TIMEOUT,
)
if resp.status_code != 200:
return []
data = resp.json()
except Exception:
return []
if not isinstance(data, list):
return []
out = []
for item in data:
if not isinstance(item, dict):
continue
out.append({
"source": "ccmixter",
"title": item.get("upload_name", "") or "",
"artist": (item.get("user_real_name")
or item.get("user_name", "")
or ""),
"license": item.get("license_name", "") or "",
"license_url": item.get("license_url", "") or "",
"url": item.get("file_page_url", "") or "",
})
return out
def _query_jamendo(query: str, client_id: str) -> list:
"""Query Jamendo API. Senza client_id ritorna []."""
if not client_id or not query:
return []
_throttle()
try:
resp = requests.get(
_JAMENDO_URL,
params={
"client_id": client_id,
"format": "json",
"limit": 5,
"search": query,
},
timeout=_HTTP_TIMEOUT,
)
if resp.status_code != 200:
return []
data = resp.json()
except Exception:
return []
if not isinstance(data, dict):
return []
status = (data.get("headers", {}) or {}).get("status", "")
if status and status != "success":
return []
out = []
for item in data.get("results", []) or []:
if not isinstance(item, dict):
continue
out.append({
"source": "jamendo",
"title": item.get("name", "") or "",
"artist": item.get("artist_name", "") or "",
"license": item.get("license_name") or "Creative Commons",
"license_url": item.get("license_ccurl", "") or "",
"url": item.get("shareurl", "") or "",
})
return out
# ------------------------------------------------------------------
# Check singolo file
# ------------------------------------------------------------------
def check_file(path: str, jamendo_client_id: str = "") -> dict:
"""Verifica un singolo file. Ritorna:
{path, status, matches, query, artist, title[, error]}
status: 'royalty_free' | 'unknown' | 'error'.
"""
from core.metadata import read_metadata
from core.catalog import _parse_filename
try:
md = read_metadata(path)
except Exception as e:
return {
"path": path,
"status": "error",
"matches": [],
"query": "",
"artist": "",
"title": "",
"error": str(e),
}
artist = (md.get("artist") or "").strip()
title = (md.get("title") or "").strip()
if not artist or not title:
parsed_artist, parsed_title = _parse_filename(Path(path).stem)
artist = artist or parsed_artist
title = title or parsed_title
query = f"{artist} {title}".strip() if artist else title
if not query:
return {
"path": path,
"status": "unknown",
"matches": [],
"query": "",
"artist": artist,
"title": title,
"error": "no query (tag + filename vuoti)",
}
# Cache lookup
conn = sqlite3.connect(str(_cache_db_path()))
try:
_init_db(conn)
row = conn.execute(
"SELECT payload FROM lookups WHERE key=?", (query,)
).fetchone()
if row:
try:
cached = json.loads(row[0])
cached["path"] = path
# Preserva artist/title correnti nel caso siano stati
# ricavati dal filename ma la cache li aveva salvati gia'
return cached
except Exception:
pass
candidates = _query_ccmixter(query)
candidates += _query_jamendo(query, jamendo_client_id)
matches = []
for c in candidates:
score_title = _similarity(title, c.get("title", ""))
score_artist = (
_similarity(artist, c.get("artist", ""))
if artist and c.get("artist") else 0.0
)
if artist:
combined = score_title * 0.6 + score_artist * 0.4
else:
combined = score_title
if combined >= _SIMILARITY_THRESHOLD:
enriched = dict(c)
enriched["score"] = round(combined, 2)
matches.append(enriched)
# Ordina per score desc
matches.sort(key=lambda m: m.get("score", 0.0), reverse=True)
status = "royalty_free" if matches else "unknown"
result = {
"path": path,
"status": status,
"matches": matches,
"query": query,
"artist": artist,
"title": title,
}
# Scrivi in cache (senza il path, che e' per-file)
try:
payload = {k: v for k, v in result.items() if k != "path"}
conn.execute(
"INSERT OR REPLACE INTO lookups(key, payload, cached_at) "
"VALUES (?, ?, ?)",
(query, json.dumps(payload, ensure_ascii=False), time.time()),
)
conn.commit()
except Exception:
pass
return result
finally:
try:
conn.close()
except Exception:
pass
# ------------------------------------------------------------------
# Scan folder
# ------------------------------------------------------------------
def _emit_progress(cb: Optional[Callable], idx: int, total: int,
name: str, status: str, err: str = "") -> None:
"""Chiama progress_callback in modo retrocompatibile (4 o 5 args)."""
if not cb:
return
try:
cb(idx, total, name, status, err)
except TypeError:
try:
cb(idx, total, name, status)
except Exception:
pass
except Exception:
pass
def scan_folder(
directory: str,
recursive: bool = True,
progress_callback: Optional[Callable] = None,
entry_callback: Optional[Callable] = None,
) -> list:
"""Scansiona la cartella e verifica ogni file audio.
Ritorna la lista dei risultati per file (vedi `check_file`).
`entry_callback(entry)` e' chiamato appena ogni file e' processato.
"""
reset_stop()
base = Path(directory)
if not base.exists() or not base.is_dir():
_emit_progress(progress_callback, 0, 0, "", "completed", "")
return []
files: list = []
iterator = base.rglob("*") if recursive else base.iterdir()
for f in iterator:
try:
if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS:
files.append(f)
except OSError:
continue
files.sort()
total = len(files)
if total == 0:
_emit_progress(progress_callback, 0, 0, "", "completed", "")
return []
# Jamendo client_id (opzionale)
jamendo_id = ""
try:
from core.config import load_config
jamendo_id = (load_config().get("jamendo_client_id") or "").strip()
except Exception:
jamendo_id = ""
results: list = []
for i, fp in enumerate(files, start=1):
if is_stopped():
_emit_progress(progress_callback, i - 1, total, "", "stopped", "")
return results
_emit_progress(progress_callback, i, total, fp.name, "checking", "")
try:
r = check_file(str(fp), jamendo_client_id=jamendo_id)
except Exception as e:
r = {
"path": str(fp),
"status": "error",
"matches": [],
"query": "",
"artist": "",
"title": "",
"error": str(e),
}
_emit_progress(progress_callback, i, total, fp.name, "error", str(e))
results.append(r)
if entry_callback:
try:
entry_callback(r)
except Exception:
pass
if not is_stopped():
_emit_progress(progress_callback, total, total, "", "completed", "")
return results
+1 -1
View File
@@ -22,7 +22,7 @@ if [ -z "$EMAIL" ]; then
exit 1 exit 1
fi fi
SSH_TARGET="musictools@musictools.djluza.com" SSH_TARGET="${MUSICTOOLS_SSH:-musictools@musictools.djluza.com}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ssh "$SSH_TARGET" 'mkdir -p ~/api/scripts' ssh "$SSH_TARGET" 'mkdir -p ~/api/scripts'
+373
View File
@@ -0,0 +1,373 @@
"""Test per core.royalty_check — ccMixter + Jamendo + cache SQLite.
Nessuna integrazione reale: tutte le chiamate di rete sono mockate con
`responses`. La cache SQLite e' isolata in tmp_path.
"""
from __future__ import annotations
from pathlib import Path
from unittest import mock
import pytest
import responses
from core import royalty_check as rc
# ------------------------------------------------------------------
# Fixture: cache isolata per test
# ------------------------------------------------------------------
@pytest.fixture
def patched_cache(tmp_path, monkeypatch):
db = tmp_path / "royalty_cache_test.db"
monkeypatch.setattr(rc, "_cache_db_path", lambda: db)
# Azzera il throttle globale per non rallentare i test
monkeypatch.setattr(rc, "_last_req", [0.0])
monkeypatch.setattr(rc, "_RATE_LIMIT_SEC", 0.0)
return db
# ==================================================================
# _similarity
# ==================================================================
class TestSimilarity:
def test_similarity_perfect_match(self):
assert rc._similarity("hello world", "hello world") == pytest.approx(1.0)
def test_similarity_case_insensitive(self):
assert rc._similarity("Hello World", "hello world") == pytest.approx(1.0)
def test_similarity_empty_returns_zero(self):
assert rc._similarity("", "foo") == 0.0
assert rc._similarity("foo", "") == 0.0
assert rc._similarity("", "") == 0.0
def test_similarity_partial(self):
s = rc._similarity("Daft Punk - Around The World", "Around The World")
# SequenceMatcher usa chunks comuni: valore stabile e > 0.5
assert 0.5 < s < 1.0
# ==================================================================
# _query_ccmixter
# ==================================================================
class TestQueryCcmixter:
_SAMPLE = [
{
"upload_name": "Around The World",
"user_real_name": "Jane Doe",
"user_name": "jane",
"license_name": "Attribution Noncommercial (3.0)",
"license_url": "https://creativecommons.org/licenses/by-nc/3.0/",
"file_page_url": "https://ccmixter.org/files/jane/1",
},
{
"upload_name": "Other Song",
"user_real_name": "",
"user_name": "bob",
"license_name": "CC BY 4.0",
"license_url": "https://creativecommons.org/licenses/by/4.0/",
"file_page_url": "https://ccmixter.org/files/bob/2",
},
]
@responses.activate
def test_query_ccmixter_parses_response(self, patched_cache):
responses.add(
responses.GET,
rc._CCMIXTER_URL,
json=self._SAMPLE,
status=200,
)
res = rc._query_ccmixter("Around The World")
assert len(res) == 2
assert res[0]["source"] == "ccmixter"
assert res[0]["title"] == "Around The World"
assert res[0]["artist"] == "Jane Doe"
assert "licenses" in res[0]["license_url"]
# Fallback a user_name quando user_real_name e' vuoto
assert res[1]["artist"] == "bob"
@responses.activate
def test_query_ccmixter_empty_on_http_error(self, patched_cache):
responses.add(
responses.GET,
rc._CCMIXTER_URL,
json={"error": "boom"},
status=500,
)
assert rc._query_ccmixter("whatever") == []
@responses.activate
def test_query_ccmixter_empty_on_bad_json(self, patched_cache):
responses.add(
responses.GET,
rc._CCMIXTER_URL,
json={"not": "a list"},
status=200,
)
assert rc._query_ccmixter("whatever") == []
def test_query_ccmixter_empty_query(self, patched_cache):
# Nessuna chiamata di rete attesa — se la facesse responses fallirebbe
assert rc._query_ccmixter("") == []
# ==================================================================
# _query_jamendo
# ==================================================================
class TestQueryJamendo:
_SAMPLE = {
"headers": {"status": "success"},
"results": [
{
"id": "123",
"name": "Freedom Song",
"artist_name": "Open Artist",
"license_name": "CC BY-SA",
"license_ccurl": "https://creativecommons.org/licenses/by-sa/4.0/",
"shareurl": "https://www.jamendo.com/track/123",
"audio": "https://.../123.mp3",
}
],
}
def test_query_jamendo_requires_client_id(self, patched_cache):
# Nessun client_id -> ritorno vuoto senza chiamata di rete
# (responses non e' attivato: una chiamata farebbe raise)
assert rc._query_jamendo("Freedom Song", client_id="") == []
@responses.activate
def test_query_jamendo_parses_response(self, patched_cache):
responses.add(
responses.GET,
rc._JAMENDO_URL,
json=self._SAMPLE,
status=200,
)
res = rc._query_jamendo("Freedom Song", client_id="abcd1234")
assert len(res) == 1
assert res[0]["source"] == "jamendo"
assert res[0]["title"] == "Freedom Song"
assert res[0]["artist"] == "Open Artist"
assert res[0]["license"] == "CC BY-SA"
assert res[0]["url"].startswith("https://www.jamendo.com/")
@responses.activate
def test_query_jamendo_status_failure_returns_empty(self, patched_cache):
responses.add(
responses.GET,
rc._JAMENDO_URL,
json={"headers": {"status": "failed", "error_message": "bad key"},
"results": []},
status=200,
)
assert rc._query_jamendo("Freedom Song", client_id="bad") == []
# ==================================================================
# check_file
# ==================================================================
def _fake_metadata(artist: str, title: str):
"""Factory per un dict compatibile con core.metadata.read_metadata."""
return {"artist": artist, "title": title}
class TestCheckFile:
def test_check_file_matches_royalty_free(self, patched_cache, tmp_path):
# Prepara un path "esistente" solo per plausibilita' (il
# read_metadata e' mockato: non viene mai letto davvero).
p = tmp_path / "track.mp3"
p.write_bytes(b"\x00")
cc_hit = [{
"upload_name": "Around The World",
"user_real_name": "Jane Doe",
"user_name": "jane",
"license_name": "CC BY 3.0",
"license_url": "https://creativecommons.org/licenses/by/3.0/",
"file_page_url": "https://ccmixter.org/x/1",
}]
with mock.patch.object(rc, "_query_ccmixter", return_value=[{
"source": "ccmixter",
"title": "Around The World",
"artist": "Jane Doe",
"license": "CC BY 3.0",
"license_url": cc_hit[0]["license_url"],
"url": cc_hit[0]["file_page_url"],
}]), mock.patch.object(rc, "_query_jamendo", return_value=[]), \
mock.patch("core.metadata.read_metadata",
return_value=_fake_metadata("Jane Doe",
"Around The World")):
res = rc.check_file(str(p))
assert res["status"] == "royalty_free"
assert len(res["matches"]) == 1
assert res["matches"][0]["source"] == "ccmixter"
assert res["matches"][0]["score"] >= 0.8
assert res["artist"] == "Jane Doe"
assert res["title"] == "Around The World"
def test_check_file_no_match_returns_unknown(self, patched_cache, tmp_path):
p = tmp_path / "unknown.mp3"
p.write_bytes(b"\x00")
with mock.patch.object(rc, "_query_ccmixter", return_value=[{
"source": "ccmixter",
"title": "Completamente Diverso",
"artist": "Qualcun Altro",
"license": "CC BY",
"license_url": "",
"url": "",
}]), mock.patch.object(rc, "_query_jamendo", return_value=[]), \
mock.patch("core.metadata.read_metadata",
return_value=_fake_metadata("Daft Punk",
"Harder Better Faster")):
res = rc.check_file(str(p))
assert res["status"] == "unknown"
assert res["matches"] == []
# artist/title conservati dai tag
assert res["artist"] == "Daft Punk"
def test_check_file_uses_cache(self, patched_cache, tmp_path):
p = tmp_path / "cached.mp3"
p.write_bytes(b"\x00")
cc_mock = mock.MagicMock(return_value=[{
"source": "ccmixter",
"title": "Hello",
"artist": "World",
"license": "CC BY",
"license_url": "",
"url": "",
}])
jm_mock = mock.MagicMock(return_value=[])
md_mock = mock.MagicMock(return_value=_fake_metadata("World", "Hello"))
with mock.patch.object(rc, "_query_ccmixter", cc_mock), \
mock.patch.object(rc, "_query_jamendo", jm_mock), \
mock.patch("core.metadata.read_metadata", md_mock):
res1 = rc.check_file(str(p))
res2 = rc.check_file(str(p))
assert res1["status"] == "royalty_free"
assert res2["status"] == "royalty_free"
# Seconda chiamata hitta la cache: le query API non vengono richiamate
assert cc_mock.call_count == 1
assert jm_mock.call_count == 1
def test_check_file_fallback_to_filename_when_tags_missing(
self, patched_cache, tmp_path):
"""Senza tag ID3, usa _parse_filename sul nome del file."""
# Filename stile "Artista - Titolo.mp3"
p = tmp_path / "Jane Doe - Around The World.mp3"
p.write_bytes(b"\x00")
with mock.patch.object(rc, "_query_ccmixter", return_value=[{
"source": "ccmixter",
"title": "Around The World",
"artist": "Jane Doe",
"license": "CC BY",
"license_url": "",
"url": "",
}]), mock.patch.object(rc, "_query_jamendo", return_value=[]), \
mock.patch("core.metadata.read_metadata",
return_value=_fake_metadata("", "")):
res = rc.check_file(str(p))
assert res["status"] == "royalty_free"
assert res["artist"] == "Jane Doe"
assert res["title"] == "Around The World"
def test_check_file_metadata_error_returns_error_status(
self, patched_cache, tmp_path):
p = tmp_path / "broken.mp3"
p.write_bytes(b"\x00")
with mock.patch("core.metadata.read_metadata",
side_effect=ValueError("formato illeggibile")):
res = rc.check_file(str(p))
assert res["status"] == "error"
assert "formato illeggibile" in res.get("error", "")
# ==================================================================
# scan_folder
# ==================================================================
class TestScanFolder:
def test_scan_folder_streams_entries(self, patched_cache, tmp_path):
# Crea 3 file audio nella cartella
for name in ("a.mp3", "b.mp3", "c.flac"):
(tmp_path / name).write_bytes(b"\x00")
fake_result = {
"path": "",
"status": "unknown",
"matches": [],
"query": "q",
"artist": "A",
"title": "T",
}
collected = []
def _entry_cb(entry):
collected.append(entry)
with mock.patch.object(rc, "check_file", return_value=fake_result):
results = rc.scan_folder(
str(tmp_path),
recursive=False,
entry_callback=_entry_cb,
)
assert len(results) == 3
assert len(collected) == 3
def test_scan_folder_filters_non_audio(self, patched_cache, tmp_path):
(tmp_path / "ok.mp3").write_bytes(b"\x00")
(tmp_path / "note.txt").write_text("hello")
(tmp_path / "image.png").write_bytes(b"\x00")
with mock.patch.object(rc, "check_file",
return_value={"path": "", "status": "unknown",
"matches": [], "query": "",
"artist": "", "title": ""}):
results = rc.scan_folder(str(tmp_path), recursive=False)
assert len(results) == 1
def test_scan_folder_empty_directory(self, patched_cache, tmp_path):
empty = tmp_path / "empty"
empty.mkdir()
results = rc.scan_folder(str(empty), recursive=False)
assert results == []
def test_scan_folder_nonexistent_directory(self, patched_cache, tmp_path):
results = rc.scan_folder(str(tmp_path / "missing"), recursive=False)
assert results == []
def test_scan_folder_progress_callback_called(self, patched_cache, tmp_path):
(tmp_path / "a.mp3").write_bytes(b"\x00")
(tmp_path / "b.mp3").write_bytes(b"\x00")
events = []
def _progress(idx, total, name, status, err=""):
events.append((idx, total, status))
fake_result = {
"path": "", "status": "unknown", "matches": [],
"query": "", "artist": "", "title": "",
}
with mock.patch.object(rc, "check_file", return_value=fake_result):
rc.scan_folder(str(tmp_path), recursive=False,
progress_callback=_progress)
# Attendo almeno "checking" per ciascun file + "completed" finale
assert any(e[2] == "completed" for e in events)
assert sum(1 for e in events if e[2] == "checking") >= 2
+96
View File
@@ -793,6 +793,7 @@ input[type="number"]::-webkit-inner-spin-button {
.percent.blue { color: var(--blue-text); } .percent.blue { color: var(--blue-text); }
.percent.pink { color: var(--pink-hover); } .percent.pink { color: var(--pink-hover); }
.percent.purple { color: var(--purple-text); } .percent.purple { color: var(--purple-text); }
.percent.amber { color: var(--amber-text); }
.progress { .progress {
width: 100%; width: 100%;
@@ -825,6 +826,10 @@ input[type="number"]::-webkit-inner-spin-button {
background: linear-gradient(90deg, var(--purple), var(--purple-hover)); background: linear-gradient(90deg, var(--purple), var(--purple-hover));
} }
.progress-fill.amber {
background: linear-gradient(90deg, var(--amber), var(--amber-hover));
}
/* =============================== /* ===============================
LOG BOX LOG BOX
=============================== */ =============================== */
@@ -2345,3 +2350,94 @@ input[type="number"]::-webkit-inner-spin-button {
border: 1px solid var(--border); border: 1px solid var(--border);
} }
/* ==================================================================
* Royalty (tab Diritti) — verifica royalty-free status.
* Riusa .catalog-table per lo stile base; override specifici qui.
* ================================================================== */
.royalty-scroll {
max-height: 55vh;
overflow-y: auto;
overflow-x: auto;
padding-right: 6px;
border: 1px solid var(--border);
border-radius: var(--r-md);
background: var(--bg-card);
}
.royalty-table tbody td {
padding: 8px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 320px;
}
.royalty-col-file { max-width: 260px; }
.royalty-col-artist { max-width: 160px; }
.royalty-col-title { max-width: 220px; }
.royalty-col-status { width: 120px; }
.royalty-col-match { max-width: 320px; }
/* Badge stato royalty */
.royalty-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.3px;
text-transform: uppercase;
white-space: nowrap;
}
.royalty-badge-rf {
background: var(--green-dim, rgba(16, 185, 129, 0.15));
color: var(--green-text);
}
.royalty-badge-unknown {
background: var(--bg-input);
color: var(--text-3);
}
.royalty-badge-error {
background: rgba(220, 38, 38, 0.15);
color: var(--red);
}
/* Dettaglio match (sorgente + licenza + link) */
.royalty-match-cell {
display: block;
white-space: normal;
line-height: 1.4;
color: var(--text-2);
font-size: 12px;
}
.royalty-match-cell a {
color: var(--amber-text);
text-decoration: none;
}
.royalty-match-cell a:hover {
text-decoration: underline;
}
.royalty-match-source {
font-weight: 700;
color: var(--text-1);
margin-right: 6px;
}
.royalty-match-score {
color: var(--text-3);
font-size: 11px;
margin-left: 6px;
}
/* Riga con stato royalty_free: tinta verde tenue di sfondo */
.royalty-table tbody tr.royalty-row-rf {
background: rgba(16, 185, 129, 0.06);
}
.royalty-table tbody tr.royalty-row-rf:hover {
background: rgba(16, 185, 129, 0.12);
}
.royalty-table tbody tr.royalty-row-error {
background: rgba(220, 38, 38, 0.05);
}
+110
View File
@@ -76,6 +76,10 @@
<span class="nav-icon">⚡</span> <span class="nav-icon">⚡</span>
<span>Upgrade</span> <span>Upgrade</span>
</button> </button>
<button class="nav-item" data-view="royalty" data-feature="audio">
<span class="nav-icon">⚖️</span>
<span>Diritti</span>
</button>
<div class="nav-section-label">Altro</div> <div class="nav-section-label">Altro</div>
<button class="nav-item" data-view="record" data-feature="record"> <button class="nav-item" data-view="record" data-feature="record">
@@ -591,6 +595,22 @@
</div> </div>
</div> </div>
<h2 class="section-label">Jamendo (tab Diritti)</h2>
<div class="card">
<div class="field">
<label class="field-label">Jamendo client_id (opzionale, per check royalty-free)</label>
<input type="text" id="jamendoClientIdInput" class="input pill" placeholder="Es: 0e23c33a" />
<div class="hint">
<span class="hint-ico">ⓘ</span>
<div>
Registrati gratis su <code>devportal.jamendo.com</code> per avere una chiave e
verificare contro il catalogo Jamendo (~500k brani Creative Commons).
Senza chiave la tab Diritti usa solo ccMixter.
</div>
</div>
</div>
</div>
<h2 class="section-label">Download</h2> <h2 class="section-label">Download</h2>
<div class="card"> <div class="card">
<div class="field"> <div class="field">
@@ -1709,6 +1729,96 @@
</div> </div>
</section> </section>
<!-- ===== VIEW: DIRITTI (royalty-free check) ===== -->
<section class="view" id="view-royalty">
<header class="hero hero-amber">
<div class="hero-content">
<div class="hero-eyebrow amber">DIRITTI</div>
<h1 class="hero-title">Royalty-free o da verificare</h1>
<p class="hero-subtitle">
Confronta i brani locali con i cataloghi Creative Commons
(ccMixter, Jamendo). Segnala solo i brani con match certo RF —
gli altri richiedono verifica manuale.
</p>
</div>
<div class="hero-deco">⚖️</div>
</header>
<h2 class="section-label">Cartella da analizzare</h2>
<div class="card">
<div class="beatport-header">
<button id="royalty-pick-folder" class="btn btn-primary pill">📁 Scegli cartella</button>
<button id="royalty-start-btn" class="btn btn-amber pill" disabled>
<span class="ico">▶</span> Avvia analisi
</button>
<button id="royalty-stop-btn" class="btn btn-danger pill" hidden>
<span class="ico">◼</span> Interrompi
</button>
</div>
<div class="beatport-header" style="margin-top:12px;">
<span id="royalty-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="royalty-recursive" checked />
<span>Ricerca ricorsiva (include sottocartelle)</span>
</label>
</div>
<div class="hint" style="margin-top:10px;">
<span class="hint-ico">ⓘ</span>
<div>
L'app verifica solo contro DB <strong>Creative Commons / royalty-free</strong>.
"Non trovato" NON implica automaticamente SIAE: significa "da verificare manualmente".
Per abilitare anche Jamendo registra una <code>client_id</code> gratuita su
<code>devportal.jamendo.com</code> e inseriscila in Impostazioni.
</div>
</div>
<div id="royalty-status" class="beatport-status"></div>
</div>
<h2 class="section-label">Progresso</h2>
<div class="card">
<div class="progress">
<div class="progress-bar"><div id="royaltyProgressFill" class="progress-fill amber"></div></div>
<div class="progress-meta">
<span id="royaltyPercent" class="percent amber">0%</span>
<span id="royaltyCounter">In attesa</span>
</div>
</div>
</div>
<h2 class="section-label">Risultati</h2>
<div class="card" id="royalty-summary-card" hidden>
<div class="catalog-summary" id="royalty-summary-line"></div>
</div>
<div id="royalty-table-wrap" class="royalty-scroll" hidden>
<table class="catalog-table royalty-table">
<thead>
<tr>
<th class="royalty-col-file">File</th>
<th class="royalty-col-artist">Artista</th>
<th class="royalty-col-title">Titolo</th>
<th class="royalty-col-status">Stato</th>
<th class="royalty-col-match">Match / Licenza</th>
</tr>
</thead>
<tbody id="royalty-table-body"></tbody>
</table>
</div>
<div class="catalog-footer-bar" id="royalty-footer-bar" hidden>
<span class="counter" id="royalty-footer-count">0 file analizzati</span>
<button id="royalty-export-csv" class="btn btn-ghost pill" disabled>
<span class="ico">📄</span> Esporta CSV
</button>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="royalty-log"></div>
</div>
</section>
</main> </main>
</div> </div>
+338
View File
@@ -412,6 +412,7 @@ const logEls = {
catalog: () => $("#catalog-log"), catalog: () => $("#catalog-log"),
flatten: () => $("#flatten-log"), flatten: () => $("#flatten-log"),
charts: () => $("#charts-log"), charts: () => $("#charts-log"),
royalty: () => $("#royalty-log"),
}; };
function classifyLog(msg) { function classifyLog(msg) {
@@ -489,6 +490,7 @@ async function init() {
$("#hqThresholdInput").value = state.config.hq_threshold || 310; $("#hqThresholdInput").value = state.config.hq_threshold || 310;
$("#cookiesInput").value = state.config.cookies_path || ""; $("#cookiesInput").value = state.config.cookies_path || "";
if ($("#cookiesBrowserSelect")) $("#cookiesBrowserSelect").value = state.config.cookies_browser || ""; if ($("#cookiesBrowserSelect")) $("#cookiesBrowserSelect").value = state.config.cookies_browser || "";
if ($("#jamendoClientIdInput")) $("#jamendoClientIdInput").value = state.config.jamendo_client_id || "";
$("#outputInput").value = state.config.output_dir || ""; $("#outputInput").value = state.config.output_dir || "";
$("#themeSelect").value = state.config.theme || "dark"; $("#themeSelect").value = state.config.theme || "dark";
// Popola path del log corrente // Popola path del log corrente
@@ -546,6 +548,9 @@ async function init() {
// Charts tab (iTunes / Spotify / M2O / Last.fm) // Charts tab (iTunes / Spotify / M2O / Last.fm)
await ChartsUI.init(); await ChartsUI.init();
// Diritti tab (royalty-free check ccMixter + Jamendo)
await RoyaltyUI.init();
} }
async function refreshRecDevices() { async function refreshRecDevices() {
@@ -1412,6 +1417,7 @@ $("#saveBtn").addEventListener("click", async () => {
hq_threshold: parseInt($("#hqThresholdInput").value, 10) || 310, hq_threshold: parseInt($("#hqThresholdInput").value, 10) || 310,
cookies_path: $("#cookiesInput").value, cookies_path: $("#cookiesInput").value,
cookies_browser: $("#cookiesBrowserSelect") ? $("#cookiesBrowserSelect").value : "", cookies_browser: $("#cookiesBrowserSelect") ? $("#cookiesBrowserSelect").value : "",
jamendo_client_id: $("#jamendoClientIdInput") ? $("#jamendoClientIdInput").value : "",
output_dir: $("#outputInput").value, output_dir: $("#outputInput").value,
theme: $("#themeSelect").value, theme: $("#themeSelect").value,
}; };
@@ -4220,6 +4226,338 @@ const ChartsUI = (() => {
return { init }; return { init };
})(); })();
// ============================================================
// RoyaltyUI — verifica royalty-free (ccMixter + Jamendo)
// ============================================================
const RoyaltyUI = (() => {
const rstate = {
folder: "",
recursive: true,
scanning: false,
// Map path -> entry
entries: new Map(),
};
function setStatus(text, kind) {
const el = $("#royalty-status");
if (!el) return;
el.textContent = text || "";
el.className = "beatport-status" + (kind ? " " + kind : "");
}
function _statusBadge(entry) {
const s = entry.status || "";
if (s === "royalty_free") {
return `<span class="royalty-badge royalty-badge-rf">🟢 Royalty-free</span>`;
}
if (s === "error") {
const err = entry.error ? ` title="${_escapeHtml(entry.error)}"` : "";
return `<span class="royalty-badge royalty-badge-error"${err}>🔴 Errore</span>`;
}
return `<span class="royalty-badge royalty-badge-unknown">⚪ Non trovato</span>`;
}
function _matchCell(entry) {
const matches = entry.matches || [];
if (!matches.length) {
if (entry.status === "error") {
return `<span class="royalty-match-cell" style="color:var(--red);">${_escapeHtml(entry.error || "errore")}</span>`;
}
return `<span class="royalty-match-cell" style="color:var(--text-3);">—</span>`;
}
const m = matches[0];
const src = _escapeHtml(m.source || "?");
const lic = _escapeHtml(m.license || "Creative Commons");
const url = m.url || "";
const licUrl = m.license_url || "";
const score = (typeof m.score === "number")
? `<span class="royalty-match-score">score ${m.score}</span>`
: "";
const linkTrack = url
? `<a href="#" data-ext-url="${_escapeHtml(url)}">traccia</a>`
: "";
const linkLic = licUrl
? `<a href="#" data-ext-url="${_escapeHtml(licUrl)}">${lic}</a>`
: lic;
const links = [linkTrack, linkLic].filter(Boolean).join(" · ");
const extra = matches.length > 1
? `<div style="color:var(--text-3);font-size:11px;margin-top:2px;">+${matches.length - 1} altri match</div>`
: "";
return `<span class="royalty-match-cell"><span class="royalty-match-source">${src}</span>${links}${score}</span>${extra}`;
}
function _renderRow(entry) {
const path = entry.path || "";
const tr = document.createElement("tr");
tr.dataset.path = path;
if (entry.status === "royalty_free") tr.classList.add("royalty-row-rf");
else if (entry.status === "error") tr.classList.add("royalty-row-error");
// File
const tdFile = document.createElement("td");
tdFile.className = "royalty-col-file";
tdFile.innerHTML = `<span class="catalog-file-cell" title="${_escapeHtml(path)}">${_escapeHtml(_basename(path))}</span>`;
tr.appendChild(tdFile);
// Artista
const tdArtist = document.createElement("td");
tdArtist.className = "royalty-col-artist";
tdArtist.textContent = entry.artist || "—";
tdArtist.title = entry.artist || "";
tr.appendChild(tdArtist);
// Titolo
const tdTitle = document.createElement("td");
tdTitle.className = "royalty-col-title";
tdTitle.textContent = entry.title || "—";
tdTitle.title = entry.title || "";
tr.appendChild(tdTitle);
// Stato
const tdStatus = document.createElement("td");
tdStatus.className = "royalty-col-status";
tdStatus.innerHTML = _statusBadge(entry);
tr.appendChild(tdStatus);
// Match / licenza
const tdMatch = document.createElement("td");
tdMatch.className = "royalty-col-match";
tdMatch.innerHTML = _matchCell(entry);
tr.appendChild(tdMatch);
return tr;
}
function _updateSummary() {
const line = $("#royalty-summary-line");
const card = $("#royalty-summary-card");
if (!line || !card) return;
const n = rstate.entries.size;
if (n === 0) { card.hidden = true; return; }
let rf = 0, unk = 0, errs = 0;
rstate.entries.forEach((e) => {
if (e.status === "royalty_free") rf++;
else if (e.status === "error") errs++;
else unk++;
});
line.textContent =
`${n} file · ${rf} royalty-free · ${unk} da verificare · ${errs} errori`;
card.hidden = false;
const foot = $("#royalty-footer-bar");
const cnt = $("#royalty-footer-count");
const exp = $("#royalty-export-csv");
if (foot) foot.hidden = false;
if (cnt) cnt.textContent = `${n} file analizzati`;
if (exp) exp.disabled = n === 0;
}
function _upsertEntry(entry) {
if (!entry || !entry.path) return;
const body = $("#royalty-table-body");
if (!body) return;
const existed = rstate.entries.has(entry.path);
rstate.entries.set(entry.path, entry);
const row = _renderRow(entry);
if (existed) {
const old = body.querySelector(`tr[data-path="${CSS.escape(entry.path)}"]`);
if (old) body.replaceChild(row, old);
else body.appendChild(row);
} else {
body.appendChild(row);
}
const wrap = $("#royalty-table-wrap");
if (wrap) wrap.hidden = false;
_updateSummary();
}
function _clearTable() {
const body = $("#royalty-table-body");
if (body) body.innerHTML = "";
const wrap = $("#royalty-table-wrap");
if (wrap) wrap.hidden = true;
const card = $("#royalty-summary-card");
if (card) card.hidden = true;
const foot = $("#royalty-footer-bar");
if (foot) foot.hidden = true;
const exp = $("#royalty-export-csv");
if (exp) exp.disabled = true;
rstate.entries.clear();
}
async function pickFolder() {
const p = await window.pywebview.api.royalty_pick_folder();
if (p && typeof p === "string") {
rstate.folder = p;
$("#royalty-path-display").textContent = p;
$("#royalty-start-btn").disabled = false;
setStatus("", "");
}
}
async function startScan() {
if (!rstate.folder) return;
rstate.recursive = $("#royalty-recursive").checked;
rstate.scanning = true;
_clearTable();
$("#royalty-start-btn").disabled = true;
$("#royalty-pick-folder").disabled = true;
$("#royalty-stop-btn").hidden = false;
$("#royalty-log").innerHTML = "";
$("#royaltyProgressFill").style.width = "0%";
$("#royaltyPercent").textContent = "0%";
$("#royaltyCounter").textContent = "Analisi in corso…";
setStatus("Analisi in corso...", "loading");
let res;
try {
res = await window.pywebview.api.royalty_start_scan({
directory: rstate.folder,
recursive: rstate.recursive,
});
} catch (e) {
setStatus("Errore avvio: " + ((e && e.message) || e), "error");
finishScan();
return;
}
if (!res || !res.ok) {
const msg = (res && res.error) || "Impossibile avviare l'analisi";
setStatus(msg, "error");
if (!handleGateBlock(res || {})) toast(msg, "error");
finishScan();
}
}
async function stopScan() {
try { await window.pywebview.api.royalty_stop_scan(); } catch (e) { console.error(e); }
}
function finishScan() {
rstate.scanning = false;
$("#royalty-start-btn").disabled = !rstate.folder;
$("#royalty-pick-folder").disabled = false;
$("#royalty-stop-btn").hidden = true;
}
function _csvEscape(s) {
const str = (s == null) ? "" : String(s);
if (/[",\n;]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
function exportCsv() {
if (rstate.entries.size === 0) return;
const rows = [["file", "artista", "titolo", "stato", "sorgente", "licenza", "licenza_url", "track_url", "score"]];
rstate.entries.forEach((e) => {
const m = (e.matches && e.matches[0]) || {};
rows.push([
_basename(e.path || ""),
e.artist || "",
e.title || "",
e.status || "",
m.source || "",
m.license || "",
m.license_url || "",
m.url || "",
(typeof m.score === "number") ? m.score : "",
]);
});
const csv = rows.map((r) => r.map(_csvEscape).join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `royalty_report_${Date.now()}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => window.URL.revokeObjectURL(url), 1500);
toast("CSV esportato", "success");
}
async function init() {
$("#royalty-pick-folder").addEventListener("click", pickFolder);
$("#royalty-start-btn").addEventListener("click", startScan);
$("#royalty-stop-btn").addEventListener("click", stopScan);
$("#royalty-export-csv").addEventListener("click", exportCsv);
$("#royalty-recursive").addEventListener("change", (e) => {
rstate.recursive = e.target.checked;
});
// Delegazione: link "traccia" / licenza aprono in browser esterno
const body = $("#royalty-table-body");
if (body) {
body.addEventListener("click", (ev) => {
const a = ev.target && ev.target.closest("a[data-ext-url]");
if (!a) return;
ev.preventDefault();
const u = a.getAttribute("data-ext-url");
if (u && window.pywebview && window.pywebview.api
&& window.pywebview.api.open_external_url) {
window.pywebview.api.open_external_url(u);
}
});
}
// Ripristina ultimo state da config
const cfg = state.config || {};
if (cfg.royalty_last_folder) {
rstate.folder = cfg.royalty_last_folder;
$("#royalty-path-display").textContent = cfg.royalty_last_folder;
$("#royalty-start-btn").disabled = false;
}
if (typeof cfg.royalty_recursive === "boolean") {
$("#royalty-recursive").checked = cfg.royalty_recursive;
rstate.recursive = cfg.royalty_recursive;
}
// Bridge handlers
bridgeHandlers["royalty:progress"] = (p) => {
if (!p) return;
if (typeof p.overall === "number") {
const pct = Math.round(p.overall * 100);
$("#royaltyProgressFill").style.width = pct + "%";
$("#royaltyPercent").textContent = pct + "%";
}
if (typeof p.idx === "number" && typeof p.total === "number" && p.total > 0) {
const s = p.status || "";
let label;
if (s === "checking") label = `File ${p.idx}/${p.total} · verifica`;
else if (s === "completed") label = `Completato: ${p.total} file`;
else if (s === "stopped") label = "Interrotta";
else if (s === "error") label = `File ${p.idx}/${p.total} · errore`;
else label = `File ${p.idx}/${p.total}`;
$("#royaltyCounter").textContent = label;
}
};
bridgeHandlers["royalty:entry"] = (entry) => {
_upsertEntry(entry);
setStatus(`Elaborati ${rstate.entries.size} file...`, "loading");
};
bridgeHandlers["royalty:done"] = (p) => {
if (rstate.entries.size === 0 && p && Array.isArray(p.entries)) {
p.entries.forEach(_upsertEntry);
}
if (p && p.ok === false) {
setStatus(p.error || "Errore analisi", "error");
} else if (rstate.entries.size === 0) {
setStatus("Nessun file audio trovato", "ok");
} else {
const rf = (p && p.royalty_free_count) || 0;
const tot = (p && p.total) || rstate.entries.size;
setStatus(`Analisi completata · ${rf}/${tot} royalty-free`, "ok");
}
finishScan();
};
}
return { init };
})();
// ============================================================ // ============================================================
// Boot // Boot
// ============================================================ // ============================================================