Compare commits

..
1 Commits
Author SHA1 Message Date
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
6 changed files with 399 additions and 1 deletions

No files matched your search

+15
View File
@@ -446,6 +446,21 @@ class Api:
except Exception as 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:
path = (payload.get("path") or "").strip()
if not path:
+1 -1
View File
@@ -5,7 +5,7 @@ import os
import sys
from pathlib import Path
VERSION = "v1.12.0"
VERSION = "v1.13.0"
APP_NAME = "MusicTools"
+173
View File
@@ -14,6 +14,7 @@ from mutagen import File as MutagenFile
from mutagen.id3 import (
ID3, ID3NoHeaderError,
APIC, TIT2, TPE1, TPE2, TALB, TDRC, TRCK, TCON, COMM, TBPM, TKEY,
USLT, WOAS,
)
from mutagen.mp4 import MP4, MP4Cover
from mutagen.flac import FLAC, Picture
@@ -84,6 +85,69 @@ def _text(value) -> str:
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:
result["format"] = "MP3"
try:
@@ -91,6 +155,8 @@ def _read_mp3(path: str, result: dict) -> dict:
except ID3NoHeaderError:
return result
result["raw_frames"] = _dump_id3_frames(tags)
result["title"] = _text(tags.get("TIT2"))
result["artist"] = _text(tags.get("TPE1"))
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["bpm"] = _text(tags.get("TBPM"))
result["key"] = _text(tags.get("TKEY"))
result["source_url"] = _text(tags.get("WOAS"))
for k in tags.keys():
if k.startswith("COMM"):
result["comment"] = _text(tags[k])
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():
if k.startswith("APIC"):
apic = tags[k]
@@ -113,13 +186,44 @@ def _read_mp3(path: str, result: dict) -> dict:
result["cover_mime"] = apic.mime or "image/jpeg"
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
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:
result["format"] = "M4A/MP4"
m = MP4(path)
tags = m.tags or {}
result["raw_frames"] = _dump_mp4_atoms(tags)
result["title"] = _text(tags.get("\xa9nam"))
result["artist"] = _text(tags.get("\xa9ART"))
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["comment"] = _text(tags.get("\xa9cmt"))
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")
if covers:
c = covers[0]
result["cover_base64"] = base64.b64encode(bytes(c)).decode("ascii")
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
@@ -292,8 +408,13 @@ def read_metadata(path: str) -> dict:
"bpm": "", "key": "",
"duration": 0, "bitrate": 0,
"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)),
"is_macos": sys.platform == "darwin",
"raw_frames": [], # list of {key, preview, size, kind} — tutti i frame ID3/atomi
}
audio = MutagenFile(str(p))
@@ -346,6 +467,22 @@ def _write_mp3(path: str, data: dict, cover_path: Optional[str], remove_cover: b
if 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
if remove_cover or cover_path:
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("\xa9gen", data.get("genre", ""))
set_or_del("\xa9cmt", data.get("comment", ""))
set_or_del("\xa9lyr", data.get("lyrics", ""))
track = data.get("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)
if "where_from" in data:
_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
+81
View File
@@ -578,6 +578,87 @@ input[type="number"]::-webkit-inner-spin-button {
.path-display:empty,
.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
=============================== */
+37
View File
@@ -444,6 +444,7 @@
<div class="row">
<button class="btn btn-amber pill" id="metaPickBtn">📂 Apri file audio</button>
<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 class="meta-info" id="metaInfo" hidden></div>
</div>
@@ -492,6 +493,42 @@
<label class="field-label">Commento</label>
<textarea id="metaComment" class="input textarea" rows="3"></textarea>
</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>
<label class="field-label">
Origine (macOS · kMDItemWhereFroms)
+92
View File
@@ -1147,6 +1147,51 @@ function populateMetaForm(d) {
$("#metaBpm").value = d.bpm || "";
$("#metaKey").value = d.key || "";
$("#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)
if (d.is_macos) {
@@ -1204,6 +1249,51 @@ $("#metaPickBtn").addEventListener("click", async () => {
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 () => {
const path = await window.pywebview.api.pick_image_file();
if (!path) return;
@@ -1254,6 +1344,8 @@ $("#metaSaveBtn").addEventListener("click", async () => {
bpm: $("#metaBpm").value,
key: $("#metaKey").value,
comment: $("#metaComment").value,
source_url: $("#metaSourceUrl").value.trim(),
lyrics: $("#metaLyrics").value,
where_from: $("#metaWhereFrom").value
.split("\n")
.map(s => s.trim())