音楽ファイルのメタデータ文字化け問題を遂に解決した!

はじめに

DLNA 経由でアクセスする音楽のメタデータ文字化け問題を調べる にて、現在の私の環境 (メディアサーバー:ラズパイにインストールした openmediavault + MiniDLNA、音楽プレーヤー:Android スマートフォンにインストールした Bubble UPnP) での音楽データの文字化け問題についての原因を探りました。

わかったことは、メディアサーバーの挙動は関係なく、一部の MP3 音楽ファイルが持つ ID3 メタデータが文字のエンコードとして ISO 8859-1 (Latin-1) を指定しつつ Shift_JIS のデータが入っている、ということでした。

今回、そのようなメタデータを修正して文字化けを解消することができました。以下、その内容について説明します。

文字化けって具体的に何が起きているの?

今回の文字化けが具体的にどのように起きているのかを確認するために、まず Latin-1 と Shift_JIS のそれぞれの文字コードの仕組みから見て行くことに…いや、ごめんなさい。書きかけて挫折しました。わかりやすく書こうと思うとめんどくさくて、手を抜くとわかってる人には読まなくてもわかってる話、わかってない人には読んでもわからない話になりそうです (要望があればしっかり時間を取って別立てで記事を書きます)。以下は思い切り端折った説明です。

Latin-1 は西ヨーロッパ系言語に使用する文字を表す文字コード、Shift_JIS は日本語に使用する文字を表す文字コードです。上で述べた通り、音楽ファイルのメタデータの中に「ここには Latin-1 でデータが入っていますよ」と宣言しておきながら実際には Shift_JIS のデータが入っているケースがあります。Latin-1 と Shift_JIS は同じ数値を異なる文字に割り当てていますので、異なる文字コードで解釈すれば別の文字が表示される、すなわち文字化けするのです。

こうなった経緯は DLNA 経由でアクセスする音楽のメタデータ文字化け問題を調べる でも引用した下記ページで詳しく述べられていますので、参照してみてください。

ちなみにやや脱線しますが、デジタルの世界での文字に関するお話として下記の記事は面白くて参考になります (いかにも米国エンジニアの書きそうなジョーク交じりの文体ですが)。よろしければご一読を。

mutagen を使った文字化け修正

原因がわかれば修正は簡単かと思いきや、意外に面倒でした。Latin-1 が「西ヨーロッパ系言語に使用する文字を表す文字コード」というのが私にとっては曲者だったのです。もし手持ちの音楽ファイルの使用言語がすべて日本語であれば、データを決め打ちで全部 Shift_JIS として解釈してから改めて UTF-8 にエンコードして格納してやれば良いのですが、私の持っている音楽ファイルにはスペイン語等の曲名、アーティスト名を持つものがかなりの量存在するので、その方針では逆の文字化けを生む可能性があるのです。また、ID3 に格納されているデータの種類も多岐にわたり、何を対象とするのかも考える必要があります。

以下、具体的な処理内容を述べていきます。環境は以下の通りです。

  • OS: Ubuntu 18.04 (Windows 10 上の WSL2 にインストールしたもの)
  • Python: 3.10.3

ID3 のパースには前回の調査でも使用した mutagen を使用しました。

対象の限定

文字化け修正の対象とする音楽ファイルは MP3 に限定し、AAC (m4a) は対象外とします。今回の文字化けが ID3 の文字コード対応問題に起因することと、実際にファイルを見る限り AAC の音楽ファイルでは文字化けしている例が見つからないことがその理由です。

ID3 タグについては、当然ながらテキスト情報を持つタグのみを対象にします。ID3 v2.3 の仕様 によれば

All text frame identifiers begin with “T”. Only text frame identifiers begin with “T”, with the exception of the “TXXX” frame.

ID3 tag version 2.3.0 – 4.2. Text information frames

とのことなので、”T” で始まるタグに絞り込むことで可能になります。上で除外されている “TXXX” もユーザー定義のテキスト情報で、データ構造が違うようですが、mutagen がよしなに扱ってくれています。一方、実装の過程で除外したものがあります。

さらに、タグに指定されている文字コードが Latin-1 であるもののみを対象とします。この辺の処理を Python で書くとこんな感じです。

from mutagen.id3 import Encoding, ID3, TextFrame, TimeStampTextFrame, PairedTextFrame

file_path = '/path/to/music.mp3'

tags = ID3(file_path)
keys = list(tags.keys())   # tags は順次書き換えるので先にタグ名のリストを保管
for key in keys:
    if not re.match(r'^T.*', key):  # "T" で始まるタグ以外はスキップ
        continue
    tag_list = tags.getall(key)  # 各データフレームの内容も書き換えるのでリストとして保管
    for tag in tag_list:
        if (
            (not issubclass(type(tag), TextFrame)) or     # TextFrame の派生クラスのみを対象とする。
            issubclass(type(tag), TimeStampTextFrame) or  # ただし TimeStampTextFrame の派生クラスと
            issubclass(type(tag), PairedTextFrame)        # PairedTextFrame の派生クラスは除外。
        ):
            continue

        if tag.encoding != Encoding.LATIN1:
            continue

        # 以下実際の変換処理

ID3 はファイルのパスを与えるとそのファイルに含まれる ID3 タグをパースして ID3 オブジェクトを生成します。親クラスをたどると Tags クラスにたどり着き、”In many cases it has a dict like interface.” とのこと。実際 keys() で含まれるタグの名前が得られます。

tags.getall(key) で、与えられたタグ名のデータフレームのリストが得られます (今回実際に処理した結果ではどのリストも長さ1だった模様)。各データフレームはタグ名毎にクラスとして定義されています。この辺の仕様は ID3v2.3/4 Frames を、その親クラスについては Frame Base Classes を、それぞれ参照してください。今回は親クラスで処理対象か否かを判断していますが、先に名前で絞っているのでここは冗長だったかもしれません。TimeStampTextFrame、PairedTextFrame はその後の処理を考えるのが面倒だった上、前者は文字化けの可能性がないこと、後者は今回の処理対象では事前チェックで出現しなかったことから、処理対象外としています。

各データフレームの encoding プロパティには文字コードが設定されています。値は実際には0~3の整数ですが、Encoding クラスでの定義と比較して LATIN1 であるもの以外はスキップします。

文字化けの検出と修正

修正対象として絞り込んだデータフレームの text プロパティにはテキストデータが str 型 (Python では Unicode 文字列) のデータのリストとして格納されています。ここで実際に修正が必要なのは、データ上は Latin-1 である (あった) とされている文字列が本当は Shift_JIS の文字列だった、という場合です。いくつか例を挙げます。

元の文字列(A) 本来のエンコード (Latin-1 or Shift_JIS) でのバイト列 (bytes 型)(B) (A) を Latin-1 としてデコードした文字列 (str 型)(C) (A) を Shift_JIS としてデコードした文字列 (str 型)
あいう82 a0 82 a2 82 a4 (Shift_JIS)<U+0082><U+00A0><U+0082>¢<U+0082>¤あいう
abc61 62 63 (Latin-1)abcabc
ángele1 6e 67 65 6c (Latin-1)ángel疣gel

ID3 タグの中には (A) の列のデータが格納されます。しかし文字コードが Latin-1 であるとされているため、受け取った側がそれを信じてデコードすると (B) 列のような文字列が得られます。mutagen で取り出せるデータも (B) 列のものになります。「あいう」のデコード結果は元のデータとは似ても似つかないもの、つまり文字化けしたもので、上述の修正の対象に該当します。<U+XXXX> は Unicode のコードポイント (文字集合内の番地のようなもの) で、ここでは制御コード (画面上の表示のコントロール等に使用されるコード) 等の文字の実体を持たない値になっています (実際のアプリでは表示を飛ばされたり□等で表示されたりします)。

一方「abc」のような ASCII の範囲内の文字であれば、Latin-1 でも Shift_JIS でも同じ文字が再現されます。とすると、(B) から一旦 (A) を再現し、Shift_JIS としてデコードして (C) を得て、(B) と (C) が異なっていれば修正の対象である、と判断することができそうな気がします。(B) から (A) の再現は、単純に (B) を再度 Latin-1 にエンコードすれば OK です。

ただ実際には一番下の「ángel」のように、ASCII から外れる Latin-1 の文字を含むケースでも (B) と (C) は異なり、かつ (B) ではなく (C) に文字化けが起きています。よって (B) と (C) が違うという条件だけで修正を行うと新たな文字化けを生んでしまうことになります。

ここで「あいう」で出てきた制御コードに着目します。Shift_JIS の仕組みとして、2バイト文字の1バイト目は 0x81~0x9F もしくは 0xE0~0xEF に限定されているのですが、このうち 0x81~0x9F は Latin-1 や Unicode では制御コードに該当するのです。制御コード自体は他にもありますが、メタデータとして格納したい文字列に制御コードを含めることはないはずなので、制御コードが出てきたらそれは本来 Shift_JIS だった文字列であるということがわかります。0xE0~0xEF については制御コードではないので、この範囲を1バイト目とする Shift_JIS の文字と ASCII の範囲の文字だけで構成されたデータである場合はこの判断ができなくなりますが、実際に格納されている文字を見るとそのようなケースは稀であると考えられるため、今回はこの制御コードの有無を一つの判断基準としました。

制御コードの有無は下記のような関数でチェックできます (teratail – 文字列の中に制御文字が含まれているかどうか調べたい のベストアンサーを参考にしました)。

from unicodedata import category

def check_cc(s: str) -> bool:
    """str オブジェクトの制御コード有無をチェック

    Args:
        s (str): チェック対象の str オブジェクト
    Return:
        (bool): 判定結果 - 制御コードが含まれていれば True, いなければ False
    """
    if type(s) is not str:
        logger.info(f'Invalid type {type(s)} detected')
        return False
    return any(map(lambda c: category(c) == 'Cc', s))

処理の全貌

以上のような考え方をもとに、以下のようなコードで文字化け修正を行いました。なお、ここまでの説明ではずっと日本語文字コードを Shift_JIS としていましたが、実際に曲タイトルとして出現した拡張文字等の関係で CP932 を使用しています。見返すといろいろ直したくなってきますが、直してテストするのも大変なので、稼働実績があるコードをそのまま (一部コメントのみ追加して) 貼っておきます。

"""check all id3 tags"""
import logging
import re
import shutil
from pathlib import Path
from unicodedata import category

from mutagen.id3 import Encoding, ID3, ID3NoHeaderError, TextFrame, TimeStampTextFrame, PairedTextFrame

logger = logging.getLogger(__name__)

ORG_ENCODING = 'latin_1'  # 修正対象の元のエンコーディング
CAND_ENCODING = 'cp932'   # 修正後の候補のエンコーディング


def check_cc(s: str) -> bool:
    """str オブジェクトの制御コード有無をチェック

    Args:
        s (str): チェック対象の str オブジェクト
    Return:
        (bool): 判定結果 - 制御コードが含まれていれば True, いなければ False
    """
    if type(s) is not str:
        logger.info(f'Invalid type {type(s)} detected')
        return False
    return any(map(lambda c: category(c) == 'Cc', s))


def update_id3_tags(file_path: Path) -> bool:
    """ファイルのタグを更新

    T で始まるタグでエンコーディングが LATIN1 であるもののみを対象とする。

    Args:
        file_path (Path): チェック対象の Path オブジェクト
    Return:
        (bool): 更新ありの時 True, なしの時 False
    """
    tags = ID3(file_path)
    updated = False
    keys = list(tags.keys())   # tags は順次書き換えるので先にタグ名のリストを保管
    for key in keys:
        if not re.match(r'^T.*', key):  # "T" で始まるタグ以外はスキップ
            continue
        logger.debug(f'{key}')
        tag_list = tags.getall(key)  # 各データフレームの内容も書き換えるのでリストとして保管
        for tag in tag_list:
            tag_updated = False
            if (
                (not issubclass(type(tag), TextFrame)) or     # TextFrame の派生クラスのみを対象とする。
                issubclass(type(tag), TimeStampTextFrame) or  # ただし TimeStampTextFrame の派生クラスと
                issubclass(type(tag), PairedTextFrame)        # PairedTextFrame の派生クラスは除外。
            ):
                logger.debug(f'{type(tag)} found, skipping.')
                continue

            if tag.encoding != Encoding.LATIN1:
                logger.debug(f'Encoding is {tag.encoding}, skipping.')
                continue
            new_text = []
            new_encoding = Encoding.LATIN1
            for text in tag.text:  # tag.text は str 型のテキストデータのリスト
                text_bytes = text.encode(ORG_ENCODING)
                try:
                    fixed_text = text_bytes.decode(CAND_ENCODING)
                except UnicodeDecodeError:  # CP932 にデコードできない場合は Latin-1 のままが正しい
                    logger.exception(f'Tried to decode {text_bytes} as {CAND_ENCODING} but failed.')
                    continue
                if text != fixed_text and check_cc(text):  # 文字化けありの場合 (後者の条件だけでも良かったはず)
                    logger.info(f'Fixed: {str(file_path)}\t{key}\t{text}\t{text_bytes}\t{fixed_text}')
                    new_text.append(fixed_text)
                    new_encoding = Encoding.UTF8
                else:
                    logger.debug(f'Preserved: {str(file_path)}\t{key}\t{text}\t{text_bytes}\t{fixed_text}')
                    new_text.append(text)
            if new_encoding != tag.encoding:
                tag.encoding = new_encoding
                tag.text = new_text
                tag_updated = True
                logger.debug('Tag frame updated.')
        if tag_updated:
            updated = True
            logger.info(f'{key} updated.')

    if updated:  # 更新がある場合は元ファイルのバックアップを作成したうえで修正を保存
        logger.info(f'Saving update for {str(file_path)}.')
        shutil.copyfile(file_path, file_path.with_suffix(file_path.suffix + '.org'))
        tags.save()
    return updated


def main():
    logging.basicConfig(level='DEBUG')
    base_dir_path = Path('/path/to/music_file_dir')
    logger.info('searching sub directories')
    sub_dirs = [x for x in base_dir_path.iterdir() if x.is_dir()]
    logger.info(f'{len(sub_dirs)} sub directories found')
    for sub_dir in sub_dirs:
        logger.info(f'searching files in {str(sub_dir)}')
        files = list(sub_dir.glob('**/*.mp3'))
        if len(files) == 0:
            logger.info('no mp3 files found')
            continue
        files = sorted(files)  # ソートは必須ではない
        for file in files:
            parent_dir = file.parent
            if re.match(r'.*__MACOSX', str(parent_dir)):  # MACOS 用キャッシュは処理対象外
                continue
            try:
                if update_id3_tags(file):
                    logger.info(f'{str(file)} updated')
            except ID3NoHeaderError:  # ID3 が含まれない場合はログを残して次へ
                logger.exception('Error occured in check_id3_latin1()')
                continue


if __name__ == '__main__':
    main()

文字化け判定は、元の文字列と Shitf_JIS (CP932) としてデコードした文字列の比較、および制御コード有無の確認の2つで行いましたが、論理的には後者のみで良いはずです。

main() では修正対象の MP3 ファイルを取得するために、ベースのディレクトリからサブディレクトリのリストを生成し、各サブディレクトリで glob を使って再帰的に “.mp3” の拡張子のファイル名を取得してからソートして、その後順次修正処理を行っています。単に処理を回すだけならベースのディレクトリからいきなり再帰的に “.mp3” を探せば OK なのですが、ソートしないとデバッグ時にログを追いにくく、また全部でソートをかけるとファイル数が厖大で時間がかかることから、このような手順になっています。

結果確認

上記のプログラムで実際に修正処理を行い、無事全ファイルのチェックと必要な修正が完了しました。ログを見ると一つだけ ID3 タグが含まれていないファイルが存在してエラーになっていましたが、他は特に問題なかったようです。制御コードを含まない文字化けも出現していませんでした。汎用的なツールにするためにはこの部分のケアもさらに必要かと思いますが、手持ちのファイルの変換という用途に限定すればこの程度で良いと割り切っています。

Bubble UPnP で音楽ライブラリにアクセスしてみると、無事文字化けは修正されていました (Mini DLNA の DB 更新に多少時間を要したはずですが、アクセスしたのが文字化け修正処理後かなり時間が経ってからだったので、どの辺のタイミングで直っていたかはわかりません)。文字化けの例として挙げていた King Gnu の「あなたは蜃気楼」を含むアルバムもこの通り。

終わりに

懸案だった音楽メタデータの文字化け問題にようやく何とか決着をつけることができました。簡単そうに見えて結構手こずりましたし、それをブログに書くのもまた結構時間を要してしまいましたが (ゴールデンウィークは結局これで終わった感じです) 気分はすっきりです。

とはいえ、実は文字化け以外にも MiniDLNA の音楽ライブラリにはまだいくつか気になる点が残っています。その辺は追ってまたいつか…。

音楽ファイルのメタデータ文字化け問題を遂に解決した!” に対して1件のコメントがあります。

コメントを残す

メールアドレスが公開されることはありません。