Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14e8e0fff5 | ||
|
|
8266722654 | ||
|
|
ddacd050d8 | ||
|
|
018d310f53 | ||
|
|
9644ffec35 |
No files matched your search
+145
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in new issue
Block a user