Compare commits

...
5 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
lucianoandClaude Opus 4.7 9644ffec35 feat: metadati AI detection + prompt/lyrics + pannello frame grezzi (v1.13.0)
Metadati tab estesa per audit e cleanup file AI-generated:

- Nuovi campi read/write: source_url (WOAS ID3), lyrics (USLT), plus
  \xa9lyr per M4A
- Badge AI con riconoscimento: Suno, Udio, Riffusion, Mubert, AIVA,
  Boomy, SoundRAW, Loudly + presenza C2PA manifest (GEOB frame)
- Pannello "Metadati avanzati" collapsable: tabella di tutti i frame
  ID3 grezzi (chiave, tipo text/binary, preview, size)
- Rimozione selettiva frame custom via bridge remove_metadata_frames:
  ID3.__delitem__() + save() per MP3, MP4.tags per M4A. Frame standard
  protetti (TIT2/TPE1/APIC ecc.) marcati 🔒 nella UI, si svuotano dai
  campi form
- Rilevamento AI riusabile via core.metadata._detect_ai(comment,
  source_url, extra) — case-insensitive, returns (is_ai, source_name)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-24 11:13:58 +02:00
9 changed files with 1849 additions and 2 deletions

No files matched your search

+145
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}
@@ -446,6 +449,21 @@ class Api:
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} return {"ok": False, "error": str(e)}
def remove_metadata_frames(self, path: str, frame_keys: list) -> dict:
"""Rimuove i frame ID3/atomi indicati dal file. Usato dal pannello
'Metadati avanzati' per pulire TXXX, GEOB (C2PA), USLT extra, ecc."""
if not path or not os.path.exists(path):
return {"ok": False, "error": "File non trovato"}
gate = self._gate("metadata")
if gate:
return gate
try:
from core.metadata import remove_frames
n = remove_frames(path, frame_keys or [])
return {"ok": True, "removed": n}
except Exception as e:
return {"ok": False, "error": str(e)}
def save_metadata(self, payload: dict) -> dict: def save_metadata(self, payload: dict) -> dict:
path = (payload.get("path") or "").strip() path = (payload.get("path") or "").strip()
if not path: if not path:
@@ -2369,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.12.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
+173
View File
@@ -14,6 +14,7 @@ from mutagen import File as MutagenFile
from mutagen.id3 import ( from mutagen.id3 import (
ID3, ID3NoHeaderError, ID3, ID3NoHeaderError,
APIC, TIT2, TPE1, TPE2, TALB, TDRC, TRCK, TCON, COMM, TBPM, TKEY, APIC, TIT2, TPE1, TPE2, TALB, TDRC, TRCK, TCON, COMM, TBPM, TKEY,
USLT, WOAS,
) )
from mutagen.mp4 import MP4, MP4Cover from mutagen.mp4 import MP4, MP4Cover
from mutagen.flac import FLAC, Picture from mutagen.flac import FLAC, Picture
@@ -84,6 +85,69 @@ def _text(value) -> str:
return str(value) return str(value)
_AI_MARKERS = {
"suno": ["suno.com", "made with suno", "sunoai"],
"udio": ["udio.com", "made with udio"],
"riffusion": ["riffusion.com", "made with riffusion"],
"mubert": ["mubert.com", "made with mubert"],
"aiva": ["aiva.ai"],
"boomy": ["boomy.com", "made with boomy"],
"soundraw": ["soundraw.io"],
"loudly": ["loudly.com"],
}
def _detect_ai(comment: str, source_url: str, extra: str = "") -> tuple:
"""Ritorna (is_ai: bool, source_name: str). Case-insensitive.
`extra` = altre stringhe da controllare (es. presenza C2PA manifest)."""
blob = f"{comment} {source_url} {extra}".lower()
for source, markers in _AI_MARKERS.items():
for m in markers:
if m in blob:
return True, source
if "c2pa" in blob:
return True, ""
return False, ""
def _dump_id3_frames(tags) -> list:
"""Lista compatta di tutti i frame ID3 (esclusa APIC binaria).
Ogni voce: {key, preview, size, kind}. Utile per audit AI-generated."""
out = []
for key in tags.keys():
try:
frame = tags[key]
except Exception:
continue
kind = "text"
preview = ""
size = 0
if key.startswith("APIC"):
kind = "binary"
preview = f"{frame.mime or 'image'} ({len(frame.data)} bytes)"
size = len(frame.data)
elif key.startswith("GEOB"):
kind = "binary"
data = getattr(frame, "data", b"") or b""
desc = getattr(frame, "desc", "") or ""
mime = getattr(frame, "mime", "") or ""
preview = f"{desc or mime or 'blob'} ({len(data)} bytes)"
size = len(data)
elif key.startswith("USLT"):
txt = getattr(frame, "text", "") or ""
preview = txt[:180] + ("…" if len(txt) > 180 else "")
size = len(txt)
elif key.startswith("WOAS") or key.startswith("WXXX") or key.startswith("WOAR"):
preview = getattr(frame, "url", "") or str(frame)
size = len(preview)
else:
txt = str(frame)
preview = txt[:180] + ("…" if len(txt) > 180 else "")
size = len(txt)
out.append({"key": key, "preview": preview, "size": size, "kind": kind})
return out
def _read_mp3(path: str, result: dict) -> dict: def _read_mp3(path: str, result: dict) -> dict:
result["format"] = "MP3" result["format"] = "MP3"
try: try:
@@ -91,6 +155,8 @@ def _read_mp3(path: str, result: dict) -> dict:
except ID3NoHeaderError: except ID3NoHeaderError:
return result return result
result["raw_frames"] = _dump_id3_frames(tags)
result["title"] = _text(tags.get("TIT2")) result["title"] = _text(tags.get("TIT2"))
result["artist"] = _text(tags.get("TPE1")) result["artist"] = _text(tags.get("TPE1"))
result["album_artist"] = _text(tags.get("TPE2")) result["album_artist"] = _text(tags.get("TPE2"))
@@ -100,12 +166,19 @@ def _read_mp3(path: str, result: dict) -> dict:
result["genre"] = _text(tags.get("TCON")) result["genre"] = _text(tags.get("TCON"))
result["bpm"] = _text(tags.get("TBPM")) result["bpm"] = _text(tags.get("TBPM"))
result["key"] = _text(tags.get("TKEY")) result["key"] = _text(tags.get("TKEY"))
result["source_url"] = _text(tags.get("WOAS"))
for k in tags.keys(): for k in tags.keys():
if k.startswith("COMM"): if k.startswith("COMM"):
result["comment"] = _text(tags[k]) result["comment"] = _text(tags[k])
break break
for k in tags.keys():
if k.startswith("USLT"):
frame = tags[k]
result["lyrics"] = getattr(frame, "text", "") or ""
break
for k in tags.keys(): for k in tags.keys():
if k.startswith("APIC"): if k.startswith("APIC"):
apic = tags[k] apic = tags[k]
@@ -113,13 +186,44 @@ def _read_mp3(path: str, result: dict) -> dict:
result["cover_mime"] = apic.mime or "image/jpeg" result["cover_mime"] = apic.mime or "image/jpeg"
break break
# AI detection: C2PA manifest (GEOB frame) o markers testuali
has_c2pa = any("c2pa" in k.lower() for k in tags.keys())
result["ai_generated"], result["ai_source"] = _detect_ai(
result["comment"], result["source_url"], "c2pa" if has_c2pa else "",
)
return result return result
def _dump_mp4_atoms(tags) -> list:
out = []
for key, val in tags.items():
preview = ""
kind = "text"
size = 0
if key == "covr":
kind = "binary"
data = bytes(val[0]) if val else b""
preview = f"cover ({len(data)} bytes)"
size = len(data)
else:
try:
preview = str(val[0] if isinstance(val, list) and val else val)
except Exception:
preview = "<binary>"
if isinstance(preview, str):
size = len(preview)
if len(preview) > 180:
preview = preview[:180] + "…"
out.append({"key": key, "preview": preview, "size": size, "kind": kind})
return out
def _read_mp4(path: str, result: dict) -> dict: def _read_mp4(path: str, result: dict) -> dict:
result["format"] = "M4A/MP4" result["format"] = "M4A/MP4"
m = MP4(path) m = MP4(path)
tags = m.tags or {} tags = m.tags or {}
result["raw_frames"] = _dump_mp4_atoms(tags)
result["title"] = _text(tags.get("\xa9nam")) result["title"] = _text(tags.get("\xa9nam"))
result["artist"] = _text(tags.get("\xa9ART")) result["artist"] = _text(tags.get("\xa9ART"))
result["album_artist"] = _text(tags.get("aART")) result["album_artist"] = _text(tags.get("aART"))
@@ -132,12 +236,24 @@ def _read_mp4(path: str, result: dict) -> dict:
result["genre"] = _text(tags.get("\xa9gen")) result["genre"] = _text(tags.get("\xa9gen"))
result["comment"] = _text(tags.get("\xa9cmt")) result["comment"] = _text(tags.get("\xa9cmt"))
result["bpm"] = _text(tags.get("tmpo")) result["bpm"] = _text(tags.get("tmpo"))
result["lyrics"] = _text(tags.get("\xa9lyr"))
# M4A non ha WOAS: cerchiamo in freeform ----:com.apple.iTunes:URL
for k in tags.keys():
if isinstance(k, str) and k.startswith("----") and "URL" in k.upper():
v = tags[k]
if v:
result["source_url"] = v[0].decode("utf-8", errors="ignore") if isinstance(v[0], bytes) else str(v[0])
break
covers = tags.get("covr") covers = tags.get("covr")
if covers: if covers:
c = covers[0] c = covers[0]
result["cover_base64"] = base64.b64encode(bytes(c)).decode("ascii") result["cover_base64"] = base64.b64encode(bytes(c)).decode("ascii")
result["cover_mime"] = "image/jpeg" if c.imageformat == MP4Cover.FORMAT_JPEG else "image/png" result["cover_mime"] = "image/jpeg" if c.imageformat == MP4Cover.FORMAT_JPEG else "image/png"
result["ai_generated"], result["ai_source"] = _detect_ai(
result["comment"], result["source_url"],
)
return result return result
@@ -292,8 +408,13 @@ def read_metadata(path: str) -> dict:
"bpm": "", "key": "", "bpm": "", "key": "",
"duration": 0, "bitrate": 0, "duration": 0, "bitrate": 0,
"cover_base64": "", "cover_mime": "", "cover_base64": "", "cover_mime": "",
"lyrics": "", # USLT (MP3), \xa9lyr (M4A), UNSYNCEDLYRICS (FLAC)
"source_url": "", # WOAS (MP3), custom (altri) — es. URL Suno
"ai_generated": False, # True se rilevato marker AI (C2PA, "made with suno"...)
"ai_source": "", # es. "suno", "udio", "riffusion" — sorgente dedotta
"where_from": _read_where_from(str(p)), "where_from": _read_where_from(str(p)),
"is_macos": sys.platform == "darwin", "is_macos": sys.platform == "darwin",
"raw_frames": [], # list of {key, preview, size, kind} — tutti i frame ID3/atomi
} }
audio = MutagenFile(str(p)) audio = MutagenFile(str(p))
@@ -346,6 +467,22 @@ def _write_mp3(path: str, data: dict, cover_path: Optional[str], remove_cover: b
if comment: if comment:
tags.add(COMM(encoding=3, lang="ita", desc="", text=comment)) tags.add(COMM(encoding=3, lang="ita", desc="", text=comment))
# Lyrics (USLT). Rimuovi tutti gli USLT esistenti, poi (ri)crea se non vuoto
for k in list(tags.keys()):
if k.startswith("USLT"):
del tags[k]
lyrics = data.get("lyrics", "")
if lyrics:
tags.add(USLT(encoding=3, lang="eng", desc="", text=lyrics))
# Source URL (WOAS)
if "source_url" in data:
src = data.get("source_url", "")
if "WOAS" in tags:
del tags["WOAS"]
if src:
tags.add(WOAS(url=src))
# Cover # Cover
if remove_cover or cover_path: if remove_cover or cover_path:
for k in list(tags.keys()): for k in list(tags.keys()):
@@ -379,6 +516,7 @@ def _write_mp4(path: str, data: dict, cover_path: Optional[str], remove_cover: b
set_or_del("\xa9day", data.get("year", "")) set_or_del("\xa9day", data.get("year", ""))
set_or_del("\xa9gen", data.get("genre", "")) set_or_del("\xa9gen", data.get("genre", ""))
set_or_del("\xa9cmt", data.get("comment", "")) set_or_del("\xa9cmt", data.get("comment", ""))
set_or_del("\xa9lyr", data.get("lyrics", ""))
track = data.get("track", "") track = data.get("track", "")
if track: if track:
@@ -518,3 +656,38 @@ def write_metadata(path: str, data: dict, cover_path: Optional[str] = None,
# macOS extended attribute: kMDItemWhereFroms (lista URL/origini) # macOS extended attribute: kMDItemWhereFroms (lista URL/origini)
if "where_from" in data: if "where_from" in data:
_write_where_from(path, data.get("where_from") or []) _write_where_from(path, data.get("where_from") or [])
def remove_frames(path: str, frame_keys: list) -> int:
"""Rimuove specifici frame ID3 (MP3) o atomi (MP4) dal file.
Ritorna il numero di frame rimossi. `frame_keys` sono le chiavi
esatte così come tornano da `raw_frames` (es. "TXXX:comment",
"GEOB:c2pa manifest store", "\xa9lyr")."""
if not frame_keys:
return 0
ext = Path(path).suffix.lower()
removed = 0
if ext == ".mp3":
try:
tags = ID3(path)
except ID3NoHeaderError:
return 0
for key in frame_keys:
if key in tags:
del tags[key]
removed += 1
if removed:
tags.save(path)
elif ext in (".m4a", ".mp4", ".aac"):
m = MP4(path)
if m.tags is None:
return 0
for key in frame_keys:
if key in m.tags:
del m.tags[key]
removed += 1
if removed:
m.save()
else:
raise ValueError(f"Rimozione frame non supportata per: {ext}")
return removed
+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
+177
View File
@@ -578,6 +578,87 @@ input[type="number"]::-webkit-inner-spin-button {
.path-display:empty, .path-display:empty,
.path-display.empty { color: var(--text-3); } .path-display.empty { color: var(--text-3); }
.ai-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.5px;
text-transform: uppercase;
background: linear-gradient(135deg, var(--purple), var(--pink));
color: #fff;
box-shadow: 0 2px 6px rgba(168, 85, 247, 0.35);
white-space: nowrap;
cursor: default;
}
.ai-badge[hidden] { display: none !important; }
.ai-badge::before { content: "🤖"; }
.raw-frames-details {
margin-top: 8px;
padding: 12px;
background: var(--bg-card-2);
border-radius: var(--r-sm);
border: 1px solid var(--border);
}
.raw-frames-details summary {
cursor: pointer;
font-weight: 700;
font-size: 13px;
color: var(--text-2);
padding: 2px 0;
user-select: none;
}
.raw-frames-details summary:hover { color: var(--text); }
.raw-frames-details[open] summary { margin-bottom: 10px; color: var(--text); }
.raw-frames-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.raw-frames-table th,
.raw-frames-table td {
padding: 6px 8px;
border-bottom: 1px solid var(--divider);
text-align: left;
vertical-align: top;
}
.raw-frames-table th {
color: var(--text-3);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 10px;
}
.raw-frames-table td.raw-key {
font-family: ui-monospace, SF Mono, Menlo, monospace;
color: var(--indigo-text);
white-space: nowrap;
}
.raw-frames-table td.raw-preview {
word-break: break-word;
color: var(--text);
}
.raw-frames-table td.raw-size {
text-align: right;
color: var(--text-3);
white-space: nowrap;
}
.raw-frames-table .kind-binary {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
background: var(--amber-dim);
color: var(--amber-text);
text-transform: uppercase;
}
/* =============================== /* ===============================
BUTTONS BUTTONS
=============================== */ =============================== */
@@ -712,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%;
@@ -744,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
=============================== */ =============================== */
@@ -2264,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);
}
+147
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">
@@ -444,6 +448,7 @@
<div class="row"> <div class="row">
<button class="btn btn-amber pill" id="metaPickBtn">📂 Apri file audio</button> <button class="btn btn-amber pill" id="metaPickBtn">📂 Apri file audio</button>
<div class="path-display" id="metaPathDisplay">Nessun file selezionato</div> <div class="path-display" id="metaPathDisplay">Nessun file selezionato</div>
<span id="metaAiBadge" class="ai-badge" hidden title="Brano generato con AI"></span>
</div> </div>
<div class="meta-info" id="metaInfo" hidden></div> <div class="meta-info" id="metaInfo" hidden></div>
</div> </div>
@@ -492,6 +497,42 @@
<label class="field-label">Commento</label> <label class="field-label">Commento</label>
<textarea id="metaComment" class="input textarea" rows="3"></textarea> <textarea id="metaComment" class="input textarea" rows="3"></textarea>
</div> </div>
<div class="field full">
<label class="field-label">URL sorgente (WOAS)</label>
<input type="text" id="metaSourceUrl" class="input pill" placeholder="https://...">
<div class="cover-hint">Link alla pagina d'origine del brano (es. Suno, YouTube, SoundCloud). Sopravvive alle copie del file.</div>
</div>
<div class="field full">
<label class="field-label">Testo / Prompt (USLT)</label>
<textarea id="metaLyrics" class="input textarea" rows="8" placeholder="Lyrics del brano o prompt AI utilizzato..."></textarea>
<div class="cover-hint">Testo o prompt strutturato. Suno lo compila automaticamente al momento della generazione.</div>
</div>
<details class="field full raw-frames-details">
<summary>Metadati avanzati (tutti i frame ID3 grezzi)</summary>
<table class="raw-frames-table" id="metaRawFrames">
<thead>
<tr>
<th style="width:32px;"><input type="checkbox" id="metaRawSelectAll" title="Seleziona tutti"></th>
<th>Frame</th>
<th>Tipo</th>
<th>Contenuto</th>
<th style="text-align:right;">Bytes</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="row" style="margin-top:10px;justify-content:space-between;align-items:center;">
<span id="metaRawSelectedCount" style="font-size:12px;color:var(--text-3);">0 selezionati</span>
<button id="metaRawRemoveBtn" class="btn btn-danger pill btn-sm" disabled>
🗑 Rimuovi selezionati
</button>
</div>
<div class="cover-hint" style="margin-top:8px;">
I frame standard (TIT2 titolo, TPE1 artista, TALB album, APIC cover…) si gestiscono con i campi sopra.
Qui rimuovi frame custom come TXXX, GEOB (C2PA), USLT (lyrics) o COMM extra.
</div>
</details>
<div class="field full" id="metaWhereFromField" hidden> <div class="field full" id="metaWhereFromField" hidden>
<label class="field-label"> <label class="field-label">
Origine (macOS · kMDItemWhereFroms) Origine (macOS · kMDItemWhereFroms)
@@ -554,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">
@@ -1672,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>
+430
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() {
@@ -1147,6 +1152,51 @@ function populateMetaForm(d) {
$("#metaBpm").value = d.bpm || ""; $("#metaBpm").value = d.bpm || "";
$("#metaKey").value = d.key || ""; $("#metaKey").value = d.key || "";
$("#metaComment").value = d.comment || ""; $("#metaComment").value = d.comment || "";
$("#metaSourceUrl").value = d.source_url || "";
$("#metaLyrics").value = d.lyrics || "";
// Raw frames table
const rawBody = document.querySelector("#metaRawFrames tbody");
if (rawBody) {
// Frame gestiti dai campi standard: no checkbox (li rimuovi svuotando il campo)
const PROTECTED_MP3 = /^(TIT2|TPE1|TPE2|TALB|TDRC|TRCK|TCON|TBPM|TKEY|APIC)/;
const PROTECTED_MP4 = new Set(["\xa9nam","\xa9ART","aART","\xa9alb","\xa9day","trkn","\xa9gen","tmpo","covr"]);
rawBody.innerHTML = "";
(d.raw_frames || []).forEach((f) => {
const tr = document.createElement("tr");
const kindHtml = f.kind === "binary"
? '<span class="kind-binary">binary</span>'
: '<span style="color:var(--text-3);font-size:11px;">text</span>';
const isProtected = PROTECTED_MP3.test(f.key) || PROTECTED_MP4.has(f.key);
const cbHtml = isProtected
? '<span title="Frame standard — gestito dai campi sopra" style="color:var(--text-3);font-size:14px;">🔒</span>'
: `<input type="checkbox" class="meta-raw-check" data-key="${_escapeHtml(f.key)}">`;
tr.innerHTML = `
<td style="text-align:center;">${cbHtml}</td>
<td class="raw-key">${_escapeHtml(f.key)}</td>
<td>${kindHtml}</td>
<td class="raw-preview">${_escapeHtml(f.preview || "")}</td>
<td class="raw-size">${(f.size || 0).toLocaleString()}</td>
`;
rawBody.appendChild(tr);
});
// Reset stato selezione
const master = $("#metaRawSelectAll");
if (master) { master.checked = false; master.indeterminate = false; }
_updateRawFramesSelection();
}
// AI badge
const aiBadge = $("#metaAiBadge");
if (aiBadge) {
if (d.ai_generated) {
aiBadge.hidden = false;
aiBadge.textContent = d.ai_source ? `AI · ${d.ai_source}` : "AI Generated";
} else {
aiBadge.hidden = true;
aiBadge.textContent = "";
}
}
// WhereFroms (macOS only) // WhereFroms (macOS only)
if (d.is_macos) { if (d.is_macos) {
@@ -1204,6 +1254,51 @@ $("#metaPickBtn").addEventListener("click", async () => {
if (path) await loadMetaFile(path); if (path) await loadMetaFile(path);
}); });
function _updateRawFramesSelection() {
const boxes = $$("#metaRawFrames .meta-raw-check");
const checked = boxes.filter((b) => b.checked);
const cnt = $("#metaRawSelectedCount");
if (cnt) cnt.textContent = `${checked.length} selezionati`;
const btn = $("#metaRawRemoveBtn");
if (btn) btn.disabled = checked.length === 0;
const master = $("#metaRawSelectAll");
if (master) {
if (boxes.length === 0) { master.checked = false; master.indeterminate = false; }
else if (checked.length === 0) { master.checked = false; master.indeterminate = false; }
else if (checked.length === boxes.length) { master.checked = true; master.indeterminate = false; }
else { master.checked = false; master.indeterminate = true; }
}
}
document.addEventListener("change", (e) => {
const t = e.target;
if (t && t.classList && t.classList.contains("meta-raw-check")) {
_updateRawFramesSelection();
}
});
$("#metaRawSelectAll")?.addEventListener("change", (e) => {
$$("#metaRawFrames .meta-raw-check").forEach((b) => { b.checked = e.target.checked; });
_updateRawFramesSelection();
});
$("#metaRawRemoveBtn")?.addEventListener("click", async () => {
const keys = $$("#metaRawFrames .meta-raw-check")
.filter((b) => b.checked)
.map((b) => b.dataset.key);
if (keys.length === 0) return;
if (!window.confirm(`Rimuovere ${keys.length} frame dal file?\n\n${keys.join("\n")}\n\nOperazione permanente.`)) return;
const path = state.meta?.path;
if (!path) return;
const res = await window.pywebview.api.remove_metadata_frames(path, keys);
if (res && res.ok) {
toast(`Rimossi ${res.removed || 0} frame`, "ok");
await loadMetaFile(path); // ricarica per aggiornare tabella
} else {
toast("Errore: " + (res?.error || "sconosciuto"), "error");
}
});
$("#metaCoverBtn").addEventListener("click", async () => { $("#metaCoverBtn").addEventListener("click", async () => {
const path = await window.pywebview.api.pick_image_file(); const path = await window.pywebview.api.pick_image_file();
if (!path) return; if (!path) return;
@@ -1254,6 +1349,8 @@ $("#metaSaveBtn").addEventListener("click", async () => {
bpm: $("#metaBpm").value, bpm: $("#metaBpm").value,
key: $("#metaKey").value, key: $("#metaKey").value,
comment: $("#metaComment").value, comment: $("#metaComment").value,
source_url: $("#metaSourceUrl").value.trim(),
lyrics: $("#metaLyrics").value,
where_from: $("#metaWhereFrom").value where_from: $("#metaWhereFrom").value
.split("\n") .split("\n")
.map(s => s.trim()) .map(s => s.trim())
@@ -1320,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,
}; };
@@ -4128,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
// ============================================================ // ============================================================