#!/usr/bin/python

# vgmtag.py v0.1.2
"""This module adds the VGMTag class, which can be used to
write vgmtag.dat files for tagging vgmstream files (or really, any
arbitrary file using the same format).  The format is as follows:

vgmtag.dat files closely resemble a combination of INI files and
Vorbis comments in that they consist of definition lines (or fields)
of the form NAME=VALUE, sections defined by [SECTION], and comment
lines that begin with a ';' or '#'.  In addition to comment lines,
blank lines are ignored.

Unlike INI or Vorbis comments, however, lines that begin with '|' are
"continuations" of previous fields, allowing for multi-line VALUEs.
If field NAMEs are to begin with a ';', '#', '|', or '\\', they may be
escaped by placing a '\\' before the character in question.

Each section in a vgmtag.dat file should be named after the lower-case
CRC32 of the given file being tagged, and NAMEs should be treated
case-insensitively as in Vorbis comments (however, this module does
not currently do the latter.)

NAMEs and VALUEs of fields may take any value that would also be valid
in a Vorbis comment, and it is recommended that NAMEs be treated
similar to those suggested by the Vorbis comment standard (i.e. TITLE
should be the title of the track, COMPOSER the composer, and so on).

It is not recommended to keep binary VALUEs in vgmtag.dat files.
Instead, it is suggested that VALUEs surrounded by angle brackets
('<VALUE>') be treated as URIs, as described in RFC 3986 [1].
Depending on the application then, these URIs may be used to import
binary data such as images.

If a binary VALUE is absolutely needed within a vgmtag.dat file, it
should be encoded using Base64.

The vgmtag.dat file describing a given stream file is expected to be
in the same directory as the streams themselves, although this is not
a hard and fast requirement.  This utility assumes this is the case,
however, and it is strongly recommended for portability reasons.

Additional suggested standard field NAMEs appropriate for VGM files
are forthcoming.

[1] <http://www.diigo.com/annotated/c3f9ed98a3d939725846ca035ad1bb32>

EXAMPLE VGMTAG.DAT FILE FOR A01.brstm IN Super Smash Bros. Brawl:
-------

[39bc8efd]
ALBUM=Super Smash Bros. Brawl
TITLE=Ground Theme - Super Mario Bros.
ARTIST=Koji Kondo
COMPOSER=Koji Kondo
DESCRIPTION=Here is a multi-line track description.
|It is very long so it needs two lines.

"""


import binascii
import os
import optparse
import sys

class VGMTagFormatError(Exception):
    """An error occurred when parsing or writing out a vgmtag.dat file."""
    pass

class VGMTag(object):
    """A dict-like object containing the contents of a vgmtag.dat file."""
    
    def __init__(self, path=None):
        self.tags = {}
        if path is not None:
            self.read(path)
    
    def read(self, path):
        """Read the vgmtag.dat file given by path into this VGMTag object."""
        f = open(path, 'r')
        crc = None
        field = None
        value = None
        self.tags = {}
        for line in f.readlines():
            line = line.rstrip('\r\n')
            if len(line.strip()) == 0:
                continue
            elif line[0] == ';' or line[0] == '#':
                continue
            elif line[0] == '[' and line[-1] == ']':
                if field is not None and value is not None:
                    self.tags[crc].setdefault(field, []).append(value)
                crc = line[1:-1]
                self.tags[crc] = {}
            elif line[0] == '|' and value is not None and crc is not None:
                value += '\n' + line[1:]
            elif crc is not None and len(line) >= 2 and line[0] == '\\' and '=' in line:
                if field is not None and value is not None:
                    self.tags[crc].setdefault(field, []).append(value)
                (field, value) = line.split('=', 1)
                field = field[1:]
            elif crc is not None and '=' in line:
                if field is not None and value is not None:
                    self.tags[crc].setdefault(field, []).append(value)
                (field, value) = line.split('=', 1)
            else:
                raise VGMTagFormatError
        if field is not None and value is not None:
            self.tags[crc].setdefault(field, []).append(value)
        f.close()
    
    def write(self, path):
        """Write the contents of this VGMTag object to the vgmtag.dat file at
        path."""
        f = open(path, 'wb')
        f.write('; Written by vgmtag.py 0.1\r\n')
        for crc in self.tags:
            if len(self.tags[crc]) == 0:
                continue
            f.write('[%s]\r\n' % crc)
            for field in self.tags[crc]:
                field = str(field)
                if isinstance(self.tags[crc][field], list):
                    for value in self.tags[crc][field]:
                        value = str(value).replace('\r\n', '\n').replace('\n', '\n|').replace('\n', '\r\n')
                        if len(field) > 0 and field[0] in ('|', '[', ';', '#'):
                            f.write('\%s=%s\r\n' % (field, value))
                        else:
                            f.write('%s=%s\r\n' % (field, value))
                else:
                    value = str(self.tags[crc][field]).replace('\r\n', '\n').replace('\n', '\n|').replace('\n', '\r\n')
                    if len(field) > 0 and field[0] in ('|', '[', ';', '#'):
                        f.write('\%s=%s\r\n' % (field, value))
                    else:
                        f.write('%s=%s\r\n' % (field, value))
            f.write('\r\n')
        f.close()
    
    def __getitem__(self, key):
        """Get the dict of tags for the given file specified by key.

        If key is a valid path as a string, it will be treated as a
        path to a file for which tags are requested.  If key appears
        to be a CRC checksum, it will be treated as the checksum for
        the file for which tags are requested.  If key is a file-like
        object, it will be treated as the file for which tags are
        requested."""
        if isinstance(key, basestring):
            if os.path.exists(key):
                # key was a path.
                f = open(key, 'r')
                data = f.read()
                f.close()
                crc = '%08x' % binascii.crc32(data)
            elif key.lower().strip() == '%08x' % int(key.lower().strip(), 16):
                crc = key.lower().strip()
            else:
                raise KeyError(key)
        elif isinstance(key, int):
            crc = '%08x' % key
        elif hasattr(key, 'read') and callable(key.read):
            data = key.read()
            crc = '%08x' % binascii.crc32(data)
        else:
            raise KeyError(key)
        
        return self.tags.setdefault(crc, {})

    def __setitem__(self, key, value):
        """Set the dict of tags for the given file specified by key.

        If key is a valid path as a string, it will be treated as a
        path to a file for which tags are requested.  If key appears
        to be a CRC checksum, it will be treated as the checksum for
        the file for which tags are requested.  If key is a file-like
        object, it will be treated as the file for which tags are
        requested."""
        if isinstance(key, basestring):
            if os.path.exists(key):
                # key was a path.
                f = open(key, 'r')
                data = f.read()
                f.close()
                crc = '%08x' % binascii.crc32(data)
            elif key.lower().strip() == '%08x' % int(key.lower().strip(), 16):
                crc = key.lower().strip()
            else:
                raise KeyError(key)
        elif isinstance(key, int):
            crc = '%08x' % key
        elif hasattr(key, 'read') and callable(key.read):
            data = key.read()
            crc = '%08x' % binascii.crc32(data)
        else:
            raise KeyError(key)
        
        self.tags[crc] = value

def _writeTest():
    """Try writing out."""
    tags = VGMTag()
    tags['39bc8efd']['ALBUM'] = 'Super Smash Bros. Brawl'
    tags.write('vgmtag.dat')

def _readTest():
    """Try reading the values from the write test."""
    tags = VGMTag('vgmtag.dat')
    if tags['39bc8efd']['ALBUM'] == 'Super Smash Bros. Brawl':
        print 'CRC TEST: passed'
    else:
        print 'CRC TEST: failed'
    
    if tags['A01.brstm']['ALBUM'] == 'Super Smash Bros. Brawl':
        print 'PATH TEST: passed'
    else:
        print 'PATH TEST: failed'
    
    if tags[0x39bc8efd]['ALBUM'] == 'Super Smash Bros. Brawl':
        print 'INT TEST: passed'
    else:
        print 'INT TEST: failed'
    
    f = open('A01.brstm', 'r')
    if tags[f]['ALBUM'] == 'Super Smash Bros. Brawl':
        print 'FILE TEST: passed'
    else:
        print 'FILE TEST: failed'

def main():
    usage = "usage: %prog [options] FILE [FILE...]"
    options = [
        optparse.make_option('-C', '--show-crc32',
                             action='store_true', dest='showCRC',
                             help='Show the CRC32 value of each file.'),
        optparse.make_option('--show-tag',
                             action='store', dest='showTag', metavar='NAME',
                             help='Show all tags where the field name matches NAME.'),
        optparse.make_option('--remove-tag',
                             action='store', dest='removeTag', metavar='NAME',
                             help='Remove all tags whose field name is NAME.'),
        optparse.make_option('--remove-first-tag',
                             action='store', dest='removeFirstTag', metavar='NAME',
                             help='Remove first tag whose field name is NAME.'),
        optparse.make_option('--remove-all-tags',
                             action='store_true', dest='removeAllTags',
                             help='Remove all tags.'),
        optparse.make_option('-t', '--set-tag',
                             action='store', dest='setTag', metavar='FIELD',
                             help='Add a tag. The FIELD must comply with the Vorbis comment spec, of the form NAME=VALUE.'),
        optparse.make_option('--set-tag-from-file',
                             action='store', dest='setTagFromFile', metavar='FIELD',
                             help='Like --set-tag, except the VALUE is a filename whose contents will be read verbatim to set the tag value. Do not try to store binary data in tag fields! Use URI links ("<./_folderOpenImage.jpg>") or base-64 encode your data first.'),
        optparse.make_option('--import-tags-from',
                             action='store', dest='importTagsFrom', metavar='FILE',
                             help='Import tags from a file. Use - for stdin. Each line should be of the form NAME=VALUE, be a continuation starting with "|" or a comment starting with ";". NAMEs that start with "|", ";", or "\\" may be escaped with "\\". Specify --remove-all-tags before --import-tags-from if necessary. If FILE is - (stdin), only one file may be specified.'),
        optparse.make_option('--export-tags-to',
                             action='store', dest='exportTagsTo', metavar='FILE',
                             help='Export tags to a file. Use - for stdout. Only one file may be specified. Each line will be on the form NAME=VALUE or a continuation starting with "|". NAMEs that start with "|", ";", or "\\" will be escaped with "\\".')
        ]
    parser = optparse.OptionParser(usage, option_list=options)
    
    (options, args) = parser.parse_args()
    if len(args) < 1:
        parser.error('incorrect number of arguments')
    
    all_tags = {}
    for path in args:
        # Find the dirname and read vgmtag.dat if needed.
        tags = all_tags.setdefault(os.path.abspath(os.path.dirname(path)),
                                   VGMTag(os.path.abspath(os.path.dirname(path)) + '/vgmtag.dat'))
        
        # Calculate the CRC32
        try:
            f = open(path, 'rb')
        except IOError:
            print "Couldn't open '%s'" % path
            continue
        data = f.read()
        f.close()
        crc = binascii.crc32(data)
        
        if options.removeTag:
            if options.removeTag in tags[crc]:
                del tags[crc][options.removeTag]
            tags.write(os.path.abspath(os.path.dirname(path)) + '/vgmtag.dat')
        
        if options.removeFirstTag:
            if options.removeFirstTag in tags[crc]:
                tags[crc][options.removeFirstTag] = tags[crc][options.removeFirstTag][1:]
            tags.write(os.path.abspath(os.path.dirname(path)) + '/vgmtag.dat')
        
        if options.removeAllTags:
            tags[crc] = {}
            tags.write(os.path.abspath(os.path.dirname(path)) + '/vgmtag.dat')
        
        if options.setTag:
            (field, value) = options.setTag.split('=', 1)
            tags[crc].setdefault(field, []).append(value)
            tags.write(os.path.abspath(os.path.dirname(path)) + '/vgmtag.dat')
        
        if options.setTagFromFile:
            (field, value) = options.setTagFromFile.split('=', 1)
            f = open(value, 'rb')
            tags[crc].setdefault(field, []).append(f.read())
            f.close()
            tags.write(os.path.abspath(os.path.dirname(path)) + '/vgmtag.dat')
        
        if options.importTagsFrom:
            if options.importTagsFrom == '-' and len(args) > 1:
                parser.error('--import-tags-from may be used to tag only one file when FILE is stdin')
            f = open(options.importTagsFrom, 'rb')
            field = None
            value = None
            for line in f.readlines():
                line = line.rstrip('\r\n')
                if len(line.strip()) == 0:
                    continue
                elif line[0] == ';' or line[0] == '#':
                    continue
                elif line[0] == '|' and value != None:
                    value += '\n' + line[1:]
                elif len(line) >= 2 and line[0] == '\\' and '=' in line:
                    if field is not None and value is not None:
                        tags[crc].setdefault(field, []).append(value)
                    (field, value) = line.split('=', 1)
                    field = field[1:]
                elif '=' in line:
                    if field is not None and value is not None:
                        tags[crc].setdefault(field, []).append(value)
                    (field, value) = line.split('=', 1)
                else:
                    raise VGMTagFormatError
            if field is not None and value is not None:
                tags[crc].setdefault(field, []).append(value)
            f.close()
            tags.write(os.path.abspath(os.path.dirname(path)) + '/vgmtag.dat')
        
        if options.showCRC:
            if len(args) > 1:
                print '%s: %08x' % (path, crc)
            else:
                print '%08x' % crc
        elif options.showTag and options.showTag in tags[crc]:
            if len(args) > 1 and not options.showCRC:
                print '%s:' % path
            for value in tags[crc][options.showTag]:
                print '%s=%s' % (options.showTag, value)
        elif options.showTag and options.showTag not in tags[crc]:
            if len(args) > 1 and not options.showCRC:
                print "%s doesn't contain value for %s" % (path, options.showTag)
            else:
                print 'no value for %s' % options.showTag
        
        if options.exportTagsTo:
            if len(args) > 1:
                parser.error('--export-tags-to may be used with only one file')
            if options.exportTagsTo == '-':
                f = sys.stdout
            else:
                f = open(options.exportTagsTo, 'w')
            for field in tags[crc]:
                field = str(field)
                if isinstance(tags[crc][field], list):
                    for value in tags[crc][field]:
                        value = str(value).replace('\r\n', '\n').replace('\n', '\n|').replace('\n', '\r\n')
                        if len(field) > 0 and field[0] in ('|', '[', ';', '#'):
                            f.write('\%s=%s\n' % (field, value))
                        else:
                            f.write('%s=%s\n' % (field, value))
                else:
                    value = str(tags[crc][field]).replace('\r\n', '\n').replace('\n', '\n|')
                    if len(field) > 0 and field[0] in ('|', '[', ';', '#'):
                        f.write('\%s=%s\n' % (field, value))
                    else:
                        f.write('%s=%s\n' % (field, value))

if __name__ == "__main__":
    main()

