銀月の符号

Python 使い見習いの日記・雑記

ID3v2 タグを自力で読んでみる

遅すぎる Python 書初め。もう 12 日だ。

ID3 タグを扱うライブラリはいくつかあるけれども、それらを使わずに ID3v2 タグを読んでみた。

パソコンさわりはじめた 10 年前の自分の感覚だと、 ID3 を読むのはえらい人が作ったツールをつかって行うものだ、といったところ。けれども、5 年前ならがんばれば自力で読める、今なら数時間、数百行の Python コードでさくっと読める、と変わってきた。成長できている、のかな?

とりあえず対象バージョンは id3v2.4.0 で。タイトルとかちょっと覗き見できる程度の駄スクリプト、役には立たない。でも ID3 タグについて詳しくなったのはよかったと思う。The ID3v2 documents をつたない英語力でゆっくり読みつつ作成。

実用的なスクリプトにするには仕様から外れているファイルをどう読むか、こまかく定めていく必要がありそう。手元のファイルにも、 Shift-JIS 、 '\x00' 終端していないテキスト入りの ID3v2.3.0 をいくつか発見した(2/12追記、 v2.3.0 では '\x00' 終端する必要はない)。作成されたハード、ソフトしだいで独自仕様はたくさんありそう。

(2/18追記、フレームのサイズの読み込み処理などに誤りあり。数値を記録する箇所全般では Synchsafe 整数値が用いられているので struct.unpack('I', '\x00\x00\x00\x10') のような整数扱いをするのは間違い。)

# coding: utf-8

import struct

class ID3Header(object):
    def __init__(
            self, raw_data, identifier, mejor_version, revision_number,
            flags, size):
        self.raw_data = raw_data
        self.identifier = identifier
        self.mejor_version = mejor_version
        self.revision_number = revision_number
        self.flags = flags
        self.size = size

    @classmethod
    def parse(cls, raw_data):
        temp = struct.unpack('>3s2BB4B', raw_data)
        identifier = temp[0]
        mejor_version = temp[1]
        revision_number = temp[2]
        flags = temp[3]
        size = 0
        for i in temp[4:8]:
            size *= 128
            size += i

        return cls(
                raw_data, identifier, mejor_version, revision_number,
                flags, size)

    @property
    def unsynchronisation(self):
        return bool(self.flags & 0b01000000)

    @property
    def extended_header(self):
        return bool(self.flags & 0b00100000)

    @property
    def experimental_indicator(self):
        return bool(self.flags & 0b00010000)

class ID3ExtendedHeader(object):
    def __init__(self, raw_data, size, flags, size_of_padding):
        self.raw_data = raw_data
        self.size = size
        self.flags = flags
        self.size_of_padding = size_of_padding

    @classmethod
    def parse(cls, raw_data):
        temp = struct.unpack('>I2BI', raw_data)
        size = temp[0]
        flags = temp[1:3]
        size_of_padding = temp[3]

        return cls(raw_data, size, flags, size_of_padding)

class ID3Frame(object):
    codes = {
            '\x00': 'cp932',
            #'\x00': 'latin_1',
            '\x01': 'utf_16',
            '\x02': 'utf_16be',
            '\x03': 'utf_8'}
    tarminater = {
            '\x00': '\x00',
            '\x01': '\x00\x00',
            '\x02': '\x00\x00',
            '\x03': '\x00'}

    def __init__(self, raw_data, id, size, flags, data):
        self.raw_data = raw_data
        self.id = id
        self.size = size
        self.flags = flags
        self.data = data

    @classmethod
    def parse(cls, raw_data):
        frames = []

        pos = 0
        end = len(raw_data)

        raw_frame_header_size = 10
        while pos < end:
            frame_header_end = pos + raw_frame_header_size
            raw_frame_header = raw_data[pos:frame_header_end]
            temp = struct.unpack('>4sI2B', raw_frame_header)
            id = temp[0]
            size = temp[1]
            flags = temp[2:4]

            if id == '\x00\x00\x00\x00':
                break

            frame_end = pos + size + 10
            data = raw_data[frame_header_end:frame_end]
            frames.append(
                    cls(raw_data[pos:frame_end], id, size, flags, data))
            pos = frame_end

        return frames

    def __str__(self):
        return str(self.__unicode__())

    def __unicode__(self):
        if self.id[0] == 'T' and self.id[0:4] != 'TXXX':
            encode = self.codes[self.data[0]]
            s = self.data[1:].split(self.tarminater[self.data[0]], 1)[0]
            return u'%s: %s' % (
                    self.id, s.decode(encode, 'ignore'))
        else:
            return u'%s: %r' % (self.id, self.data)

class ID3Tag(object):
    version = u'ID3v2.4.0'

    def __init__(self, raw_data):
        start = pos = raw_data.find('ID3')

        raw_header_size = 10
        raw_header = raw_data[pos:pos+raw_header_size]
        self.header = ID3Header.parse(raw_header)
        pos += raw_header_size

        if self.header.extended_header:
            raw_extended_header_size = 10
            raw_extended_header = rawdata[pos:pos+raw_extended_header]
            self.extended_header = ID3ExtendedHeader.parse(
                    raw_extended_header)
            pos += raw_extended_header_size

        raw_frames = raw_data[pos:start+self.header.size]
        self.frames = ID3Frame.parse(raw_frames)

    @classmethod
    def frompath(cls, path):
        with open(path, 'rb') as f:
            rawdata = f.read()
        return cls(rawdata)

def main():
    import sys

    for i, path in enumerate(sys.argv[1:]):
        if i:
            print u'----------------'
        print u'path: %s' % path
        id3 = ID3Tag.frompath(path)
        print u'tag version: %s' % id3.header.mejor_version
        print
        for frame in id3.frames:
            print frame

if __name__ == '__main__':
    main()