"""MP3 のタグ(ID3v2)を、枠ごとに全部表示する(見るだけ・書き換えない)。
エクスプローラーのプロパティや ffprobe の一覧には出ない枠(曲ページの URL=WOAS、
来歴情報 C2PA が入った GEOB など)もすべて並べる。公開前に、身元につながる値が
入っていないかを確かめるために使う。
python -X utf8 scripts/audio_tags.py <曲のファイル.mp3>
このスクリプトはファイルを開いて読むだけで、何も書き出さない。
タグを消す・変える場合は、使ったサービスの規約(透かし・メタデータ・来歴情報の扱い)を先に確かめること。
"""
import re
import struct
import sys
TEXT_NAMES = {
"TIT2": "曲名", "TPE1": "アーティスト", "TPE2": "アルバムアーティスト", "TALB": "アルバム",
"TSSE": "エンコーダー", "TDRC": "日付", "TCON": "ジャンル", "TCOM": "作曲者",
}
UUID = re.compile(rb"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
URL = re.compile(rb"https?://[\x21-\x7e]+")
DATE = re.compile(rb"20\d\d-\d\d-\d\dT\d\d:\d\d")
def syncsafe(b):
return (b[0] << 21) | (b[1] << 14) | (b[2] << 7) | b[3]
def decode(enc, raw):
if enc in (1, 2): # UTF-16 は終端を2バイトずつ落とす(1バイトずつ落とすと文字が壊れる)
while raw.endswith(b"\x00\x00"):
raw = raw[:-2]
else:
raw = raw.rstrip(b"\x00")
try:
if enc == 0:
return raw.decode("latin1")
if enc == 1:
return raw.decode("utf-16")
if enc == 2:
return raw.decode("utf-16-be")
return raw.decode("utf-8")
except UnicodeDecodeError:
return repr(raw[:80])
def split_term(enc, raw):
"""エンコードに合わせて、最初の終端で2つに分ける"""
if enc in (1, 2):
k = 0
while k + 1 < len(raw) and raw[k:k + 2] != b"\x00\x00":
k += 2
return raw[:k], raw[k + 2:]
k = raw.find(b"\x00")
return (raw, b"") if k < 0 else (raw[:k], raw[k + 1:])
def short(s, n=120):
s = s.replace("\r", " ").replace("\n", " / ")
return s if len(s) <= n else s[:n] + "…"
def main(path):
data = open(path, "rb").read()
if data[:3] != b"ID3":
print("ID3v2 のタグがありません")
return
ver = data[3]
body = data[10:10 + syncsafe(data[6:10])]
print(f"ID3v2.{ver}(タグ {len(body):,} バイト)\n")
hits = []
i = 0
while i + 10 <= len(body) and body[i:i + 4] != b"\x00\x00\x00\x00":
fid = body[i:i + 4].decode("latin1")
size = syncsafe(body[i + 4:i + 8]) if ver == 4 else struct.unpack(">I", body[i + 4:i + 8])[0]
p = body[i + 10:i + 10 + size]
i += 10 + size
if fid.startswith("T") and fid != "TXXX":
print(f"{fid}({TEXT_NAMES.get(fid, 'テキスト')}): {short(decode(p[0], p[1:]))}")
elif fid == "TXXX":
desc, val = split_term(p[0], p[1:])
print(f"TXXX(ユーザー定義・{decode(p[0], desc)}): {short(decode(p[0], val))}")
elif fid.startswith("W"):
print(f"{fid}(URL): {p.rstrip(bytes(1)).decode('latin1', 'replace')}")
elif fid in ("COMM", "USLT"):
desc, val = split_term(p[0], p[4:])
name = "コメント" if fid == "COMM" else "歌詞"
print(f"{fid}({name}): {short(decode(p[0], val))}")
elif fid == "APIC":
mime, rest = split_term(0, p[1:])
print(f"APIC(画像): {mime.decode('latin1')}・{size:,} バイト(画像の中身は別に目で確かめる)")
elif fid == "GEOB":
mime, rest = split_term(0, p[1:])
fn, rest = split_term(p[0], rest)
desc, obj = split_term(p[0], rest)
print(f"GEOB(埋め込みデータ): {mime.decode('latin1')}・{decode(p[0], desc)}・{len(obj):,} バイト")
if b"c2pa" in mime:
print(" → C2PA(コンテンツの来歴情報)。発行元の署名つき。中身は次の検出結果を見る")
else:
print(f"{fid}: {size:,} バイト")
for pat, label in ((URL, "URL"), (UUID, "ID(UUID形式)"), (DATE, "日時")):
for m in sorted(set(pat.findall(p))):
hits.append((fid, label, m.decode("latin1")))
print("\n身元や出どころにつながりうる値(URL・ID・日時):")
if not hits:
print(" 見つかりませんでした")
for fid, label, v in hits:
print(f" {fid}: {label}: {short(v, 90)}")
print("\n※ アーティスト名など、名前の枠は上の一覧で目で確かめてください。")
if __name__ == "__main__":
if len(sys.argv) != 2:
print(__doc__)
sys.exit(1)
main(sys.argv[1])
MIT ライセンスで配布しています。