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:
1 parent
61879c42d3
commit
1d7832e246
3 files changed
+79
-1
No files matched your search
+59
-1
@@ -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 [])
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user