audio_tags.py

scripts/audio_tags.py(116 行)

audio_tags.py
"""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 ライセンスで配布しています。