Metadati: campo 'Origine (macOS · kMDItemWhereFroms)'

kMDItemWhereFroms non e un tag ID3/MP4/Vorbis: e' un extended
attribute filesystem di macOS che Safari/Mail/AirDrop e altri tool
impostano per ricordare l'origine di un file scaricato.

- core/metadata.py: _read_where_from / _write_where_from via subprocess
  xattr + plistlib (lettura/scrittura del plist binario embedded
  nell'attributo)
- read_metadata espone 'where_from' (lista URL) e 'is_macos'
- write_metadata applica l'xattr se data['where_from'] e fornito;
  lista vuota -> rimuove l'attributo
- webui: textarea 'Origine' visibile solo su macOS (is_macos=true),
  una URL per riga

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
luzadevandClaude Opus 4.7 committed 2026-06-08 10:36:18 +02:00
1 parent 61879c42d3
commit 1d7832e246
3 files changed
+79 -1

No files matched your search

+59 -1
View File
@@ -3,7 +3,10 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import plistlib
import subprocess
import struct import struct
import sys
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -19,6 +22,53 @@ from mutagen.wave import WAVE
SUPPORTED_EXTS = (".mp3", ".m4a", ".mp4", ".aac", ".flac", ".wav") SUPPORTED_EXTS = (".mp3", ".m4a", ".mp4", ".aac", ".flac", ".wav")
_WHERE_FROM_ATTR = "com.apple.metadata:kMDItemWhereFroms"
def _read_where_from(path: str) -> list:
"""Legge l'xattr macOS 'kMDItemWhereFroms' (lista di URL/origini).
Ritorna [] se non e macOS o se l'attributo non esiste."""
if sys.platform != "darwin":
return []
try:
res = subprocess.run(
["xattr", "-px", _WHERE_FROM_ATTR, path],
capture_output=True, text=True, timeout=5,
)
if res.returncode != 0:
return []
hex_str = "".join(res.stdout.split())
if not hex_str:
return []
raw = bytes.fromhex(hex_str)
items = plistlib.loads(raw)
if isinstance(items, list):
return [str(x) for x in items]
return [str(items)]
except Exception:
return []
def _write_where_from(path: str, urls: list) -> None:
"""Scrive l'xattr macOS 'kMDItemWhereFroms'. Lista vuota -> rimuove."""
if sys.platform != "darwin":
return
try:
clean = [u.strip() for u in (urls or []) if u and u.strip()]
if not clean:
subprocess.run(
["xattr", "-d", _WHERE_FROM_ATTR, path],
capture_output=True, timeout=5,
)
return
data = plistlib.dumps(clean, fmt=plistlib.FMT_BINARY)
subprocess.run(
["xattr", "-wx", _WHERE_FROM_ATTR, data.hex(), path],
capture_output=True, timeout=5, check=True,
)
except Exception:
pass
def _text(value) -> str: def _text(value) -> str:
"""Estrai testo da un frame mutagen.""" """Estrai testo da un frame mutagen."""
@@ -242,6 +292,8 @@ 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": "",
"where_from": _read_where_from(str(p)),
"is_macos": sys.platform == "darwin",
} }
audio = MutagenFile(str(p)) audio = MutagenFile(str(p))
@@ -448,7 +500,9 @@ def _write_wav(path: str, data: dict, cover_path: Optional[str], remove_cover: b
def write_metadata(path: str, data: dict, cover_path: Optional[str] = None, def write_metadata(path: str, data: dict, cover_path: Optional[str] = None,
remove_cover: bool = False) -> None: remove_cover: bool = False) -> None:
"""Salva i metadati. Se remove_cover=True rimuove la copertina esistente """Salva i metadati. Se remove_cover=True rimuove la copertina esistente
senza sostituirla. Se cover_path e fornito, la sostituisce.""" senza sostituirla. Se cover_path e fornito, la sostituisce.
Su macOS aggiorna anche l'xattr kMDItemWhereFroms se data['where_from']
e fornito (lista di stringhe)."""
ext = Path(path).suffix.lower() ext = Path(path).suffix.lower()
if ext == ".mp3": if ext == ".mp3":
_write_mp3(path, data, cover_path, remove_cover) _write_mp3(path, data, cover_path, remove_cover)
@@ -460,3 +514,7 @@ def write_metadata(path: str, data: dict, cover_path: Optional[str] = None,
_write_wav(path, data, cover_path, remove_cover) _write_wav(path, data, cover_path, remove_cover)
else: else:
raise ValueError(f"Formato non supportato: {ext}") raise ValueError(f"Formato non supportato: {ext}")
# macOS extended attribute: kMDItemWhereFroms (lista URL/origini)
if "where_from" in data:
_write_where_from(path, data.get("where_from") or [])
+7
View File
@@ -305,6 +305,13 @@
<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" id="metaWhereFromField" hidden>
<label class="field-label">
Origine (macOS · kMDItemWhereFroms)
</label>
<textarea id="metaWhereFrom" class="input textarea" rows="2" placeholder="https://... (uno per riga)"></textarea>
<div class="cover-hint">Extended attribute macOS — una URL per riga. Vuoto = rimuove l'attributo.</div>
</div>
</div> </div>
</div> </div>
+13
View File
@@ -414,6 +414,15 @@ function populateMetaForm(d) {
$("#metaKey").value = d.key || ""; $("#metaKey").value = d.key || "";
$("#metaComment").value = d.comment || ""; $("#metaComment").value = d.comment || "";
// WhereFroms (macOS only)
if (d.is_macos) {
$("#metaWhereFromField").hidden = false;
$("#metaWhereFrom").value = (d.where_from || []).join("\n");
} else {
$("#metaWhereFromField").hidden = true;
$("#metaWhereFrom").value = "";
}
// Info riga sotto il path // Info riga sotto il path
const info = $("#metaInfo"); const info = $("#metaInfo");
info.hidden = false; info.hidden = false;
@@ -511,6 +520,10 @@ $("#metaSaveBtn").addEventListener("click", async () => {
bpm: $("#metaBpm").value, bpm: $("#metaBpm").value,
key: $("#metaKey").value, key: $("#metaKey").value,
comment: $("#metaComment").value, comment: $("#metaComment").value,
where_from: $("#metaWhereFrom").value
.split("\n")
.map(s => s.trim())
.filter(Boolean),
}, },
cover_path: state.meta.newCoverPath, cover_path: state.meta.newCoverPath,
remove_cover: state.meta.removeCover, remove_cover: state.meta.removeCover,