1#!/usr/bin/env python
2# Copyright (c) 2004-2007 Jeremy Evans
3#
4# Permission is hereby granted, free of charge, to any person obtaining a copy
5# of this software and associated documentation files (the "Software"), to deal
6# in the Software without restriction, including without limitation the rights
7# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8# copies of the Software, and to permit persons to whom the Software is
9# furnished to do so, subject to the following conditions:
10#
11# The above copyright notice and this permission notice shall be included in
12# all copies or substantial portions of the Software.
13#
14# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20# SOFTWARE.
21
22'''Module for manipulating APE and ID3v1 tags
23
24Public Function Arguments
25-------------------------
26fil: filename string OR already opened file or file-like object that supports
27    flush, seek, read, truncate, tell, and write
28fields: dictionary like object of tag fields that has an iteritems method
29    which is an iterator of key, value tuples.
30    APE:
31        key: must be a regular string with length 2-255 inclusive, containing
32            only ASCII characters in the range 0x20-0x7f
33        value: must be a string or a list or tuple of them, or an ApeItem
34    ID3:
35        key: must be title, artist, album, year, comment, genre, or track*
36            (i.e. track or tracknumber)
37        value: should be a string except for track* and genre
38            track*: integer or sting representation of one
39            genre: integer or string (if string, must be a case insensitive
40                match for one of the strings in id3genres to be recognized)
41removefields (updateape and updatetags): iterable of fields to remove from the
42    APE tag (and set to blank in the ID3 tag).
43
44Public Functions Return
45-----------------------
460 on success of delete functions
47bool on success of has functions
48string on success of getraw functions
49dict on success of create, update, replace, modify, or getfields
50    key is the field name as a string
51    (APE) value is an ApeItem, which is a list subclass with the field values
52        stored in the list as strings, and the following special attributes:
53        key: same as key of dict
54        readonly: whether the field was marked read only
55        type: type of tag field (utf8, binary, external, or reserved),
56              utf8 type means values in list are unicode strings
57    (ID3) value is a regular string
58
59Public Functions Raise
60----------------------
61IOError on problem accessing file (make sure read/write access is allowed
62    for the file if you are trying to modify the tag)
63(APE functions only) UnicodeError on problems converting regular strings to
64    UTF-8 (See note, or just use unicode strings)
65TagError on other errors
66
67Callback Functions
68------------------
69The modify* functions take callback functions and extra keyword arguments.
70The callback functions are called with the tag dictionary and any extra keyword
71arguments given in the call to modify*.  This dictionary should be modified and
72must be returned by the callback functions.  There isn't much error checking
73done after this stage, so incorrectly written callback functions may result in
74corrupt tags or exceptions being raised elsewhere in the module.  The
75modifytags function takes two separate callback functions, one for the APE tag
76and one for the ID3 tag.  See the _update*tagcallback functions for examples of
77how callback functions should be written.
78
79Notes
80-----
81When using functions that modify both tags, the accepted arguments and return
82    value are the same for the APE funtion.
83Raising errors other than IOError, UnicodeError, or TagError is considered a
84    bug unless fields contains a non-basestring (or a list containing a
85    non-basestring).
86Only APEv2 tags are supported. APEv1 tags without a header are not supported.
87Only writes ID3v1.1 tags.  Assumes all tags are ID3v1.1.  The only exception to
88    this is when it detects an ID3v1.0 tag, it will return 0 as the track
89    number in getfields.
90The APE tag is appended to the end of the file.  If the file already has an
91    ID3v1 tag at the end, it is recognized and the APE tag is placed directly
92    before it.
93Default maximum size for the APE tag is 8192 bytes, as recommended by the APE
94    spec.  This can be changed by modifying the _maxapesize variable.
95Read-only flags can be read, created, and modified (they are not respected).
96If you are storing non 7-bit ASCII data in a tag, you should pass in unicode
97    strings instead of regular strings, or pass in an already created ApeItem.
98Inserting binary data into tags is "strongly unrecommended."
99This library doesn't check to make sure that tag items marked as external are
100    in the proper format.
101APEv2 specification is here:
102    http://wiki.hydrogenaudio.org/index.php?title=APEv2_specification
103'''
104
105from os.path import isfile as _isfile
106from struct import pack as _pack, unpack as _unpack
107
108# Variable definitions
109
110__version__ = '1.2'
111_maxapesize = 8192
112_commands = '''create update replace delete getfields getrawtag getnewrawtag
113  hastag'''.split()
114_tagmustexistcommands = 'update getfields getrawtag'.split()
115_stringallowedcommands = 'getrawtag getnewrawtag getfields hastag'.split()
116_filelikeattrs = 'flush read seek tell truncate write'.split()
117_badapeitemkeys = 'id3 tag oggs mp+'.split()
118_badapeitemkeychars = ''.join([chr(x) for x in range(32) + range(128,256)])
119_apeitemtypes = 'utf8 binary external reserved'.split()
120_apeheaderflags = "\x00\x00\xA0"
121_apefooterflags = "\x00\x00\x80"
122_apepreamble = "APETAGEX\xD0\x07\x00\x00"
123_id3tagformat = 'TAG%(title)s%(artist)s%(album)s%(year)s%(comment)s' \
124                '\x00%(track)s%(genre)s'
125_id3fields = {'title': (3,33), 'artist': (33,63), 'album': (63,93),
126              'year': (93,97), 'comment': (97,125) } # (start, end)
127_id3genresstr = '''Blues, Classic Rock, Country, Dance, Disco, Funk, Grunge,
128    Hip-Hop, Jazz, Metal, New Age, Oldies, Other, Pop, R & B, Rap, Reggae,
129    Rock, Techno, Industrial, Alternative, Ska, Death Metal, Prank, Soundtrack,
130    Euro-Techno, Ambient, Trip-Hop, Vocal, Jazz + Funk, Fusion, Trance,
131    Classical, Instrumental, Acid, House, Game, Sound Clip, Gospel, Noise,
132    Alternative Rock, Bass, Soul, Punk, Space, Meditative, Instrumental Pop,
133    Instrumental Rock, Ethnic, Gothic, Darkwave, Techno-Industrial, Electronic,
134    Pop-Fol, Eurodance, Dream, Southern Rock, Comedy, Cult, Gangsta, Top 40,
135    Christian Rap, Pop/Funk, Jungle, Native US, Cabaret, New Wave, Psychadelic,
136    Rave, Showtunes, Trailer, Lo-Fi, Tribal, Acid Punk, Acid Jazz, Polka,
137    Retro, Musical, Rock & Roll, Hard Rock, Folk, Folk-Rock, National Folk,
138    Swing, Fast Fusion, Bebop, Latin, Revival, Celtic, Bluegrass, Avantgarde,
139    Gothic Rock, Progressive Rock, Psychedelic Rock, Symphonic Rock, Slow Rock,
140    Big Band, Chorus, Easy Listening, Acoustic, Humour, Speech, Chanson, Opera,
141    Chamber Music, Sonata, Symphony, Booty Bass, Primus, Porn Groove, Satire,
142    Slow Jam, Club, Tango, Samba, Folklore, Ballad, Power Ballad, Rhytmic Soul,
143    Freestyle, Duet, Punk Rock, Drum Solo, Acapella, Euro-House, Dance Hall,
144    Goa, Drum & Bass, Club-House, Hardcore, Terror, Indie, BritPop, Negerpunk,
145    Polsk Punk, Beat, Christian Gangsta Rap, Heavy Metal, Black Metal,
146    Crossover, Contemporary Christian, Christian Rock, Merengue, Salsa,
147    Trash Meta, Anime, Jpop, Synthpop'''
148_apeitemkeys = '''Title, Artist, Album, Year, Comment, Genre, Track,
149    Debut Album, Subtitle, Publisher, Conductor, Composer, Copyright,
150    Publicationright, File, EAN/UPC, ISBN, Catalog, LC, Record Date,
151    Record Location, Media, Index, Related, ISRC, Abstract, Language,
152    Bibliography, Introplay, Dummy'''
153id3genres = [x.strip() for x in _id3genresstr.split(',')]
154_id3genresdict = {}
155for i, x in enumerate(id3genres):
156    _id3genresdict[x.lower()] = i
157apeitemkeys = [x.strip() for x in _apeitemkeys.split(',')]
158del x
159del i
160
161# Classes
162
163class TagError(StandardError):
164    '''Raised when there is an error during a tagging operation'''
165    pass
166
167class ApeItem(list):
168    '''Contains individual APE tag items'''
169    def __init__(self, key = None, values = [], type = 'utf8', readonly = False):
170        list.__init__(self)
171        if key is None:
172            return
173        if not self.validkey(key):
174            raise TagError, 'Invalid item key for ape tag item: %r' % key
175        if type not in _apeitemtypes:
176            raise TagError, 'Invalid item type for ape tag item: %r' % type
177        self.key = key
178        self.readonly = bool(readonly)
179        self.type = type
180        if isinstance(values, basestring):
181            values = [values]
182        if type == 'utf8' or type == 'external':
183            values = [unicode(value) for value in values]
184        self.extend(values)
185
186    def maketag(self):
187        '''Return on disk representation of tag item
188
189        self.parsetag(self.maketag(), 0) should result in no change to self
190        '''
191        if self.type == 'utf8' or self.type == 'external':
192            values = '\x00'.join([value.encode('utf8') for value in self])
193        else:
194            values = '\x00'.join(self)
195        size = _pack("<i",len(values))
196        flags = chr(int(self.readonly) + 2 * (_apeitemtypes.index(self.type)))
197        return '%s\x00\x00\x00%s%s\x00%s' % (size, flags, self.key, values)
198
199    def parsetag(self, data, curpos):
200        '''Parse next tag from data string, starting at current position'''
201        del self[:]
202        itemlength = _unpack("<i",data[curpos:curpos+4])[0]
203        if itemlength < 0:
204            raise TagError, 'Corrupt tag, invalid item length at position ' \
205                            '%i: %i bytes' % (curpos, itemlength)
206        if data[curpos+4:curpos+7] != '\x00\x00\x00':
207            raise TagError, 'Corrupt tag, invalid item flags, bits 8-31 ' \
208                            'nonzero at position %i' % curpos
209        type, readonly = divmod(ord(data[curpos+7]), 2)
210        if type > 3:
211            raise TagError, 'Corrupt tag, invalid item flags, bits 3-7 ' \
212                            'nonzero at position %i' % curpos
213        self.type = _apeitemtypes[type]
214        self.readonly = bool(readonly)
215        curpos += 8
216        keyend = data.find("\x00", curpos)
217        if keyend < curpos:
218            raise TagError, 'Corrupt tag, unterminated item key at position ' \
219                            '%i' % curpos
220        itemkey = data[curpos:keyend]
221        if not self.validkey(itemkey):
222            raise TagError, 'Corrupt tag, invalid item key at position ' \
223                            '%i: %r' % (curpos, itemkey)
224        self.key = itemkey
225        curpos = keyend + itemlength + 1
226        itemvalue = data[keyend+1:curpos]
227        if self.type == 'utf8' or self.type == 'external':
228            self.extend(itemvalue.decode('utf8').split('\x00'))
229        else:
230            self.append(itemvalue)
231        return curpos
232
233    def validkey(self, key):
234        '''Check key to make sure it is a valid ApeItem key'''
235        return isinstance(key, str) and 2 <= len(key) <= 255 \
236            and not _stringoverlaps(key, _badapeitemkeychars) \
237            and key.lower() not in _badapeitemkeys
238
239# Private functions
240
241def _ape(fil, action, callback = None, callbackkwargs = {}, updateid3 = False):
242    '''Get or Modify APE tag for file'''
243    apesize = 0
244    tagstart = None
245    filesize, id3data, data = _getfilesizeandid3andapefooter(fil)
246
247    if _apepreamble != data[:12]:
248        if action in _tagmustexistcommands:
249            raise TagError, "Nonexistant or corrupt tag, can't %s" % action
250        elif action == "delete":
251            return 0
252        data = ''
253        tagstart = filesize - len(id3data)
254    elif _apefooterflags != data[21:24] or \
255        (data[20] != '\0' and data[20] != '\1'):
256            raise TagError, "Bad tag footer flags"
257    else:
258        # file has a valid APE footer
259        apesize = _unpack("<i",data[12:16])[0] + 32
260        if apesize > _maxapesize:
261            raise TagError, 'Existing tag is too large: %i bytes' % apesize
262        if apesize + len(id3data) > filesize:
263            raise TagError, 'Existing tag says it is larger than the file: ' \
264                            '%i bytes' % apesize
265        fil.seek(-apesize - len(id3data), 2)
266        tagstart = fil.tell()
267        data = fil.read(apesize)
268        if _apepreamble != data[:12] or _apeheaderflags != data[21:24] or \
269           (data[20] != '\0' and data[20] != '\1'):
270            raise TagError, 'Nonexistent or corrupt tag, missing tag header'
271        if apesize != _unpack("<i",data[12:16])[0] + 32:
272            raise TagError, 'Corrupt tag, header and footer sizes do not match'
273        if action == "delete":
274            fil.seek(tagstart)
275            if not updateid3:
276                fil.write(id3data)
277            fil.truncate()
278            fil.flush()
279            return 0
280
281    if action == "hastag":
282        if updateid3:
283            return bool(data) and bool(id3data)
284        return bool(data)
285    if action == "getrawtag":
286        if updateid3:
287            return data, id3data
288        return data
289    if action == "getfields":
290        if updateid3:
291            return _restoredictcase(_parseapetag(data)), \
292              _id3(id3data, "getfields")
293        return _restoredictcase(_parseapetag(data))
294    if not data or action == "replace":
295        apeitems = {}
296    else:
297        apeitems = _parseapetag(data)
298
299    if callable(callback):
300        apeitems = callback(apeitems, **callbackkwargs)
301
302    newtag = _makeapev2tag(apeitems)
303
304    if action == "getnewrawtag":
305        if updateid3:
306            return newtag, _id3(id3data, "getnewrawtag")
307        return newtag
308
309    if len(newtag) > _maxapesize:
310        raise TagError, 'New tag is too large: %i bytes' % len(data)
311
312    if updateid3:
313        if action == 'replace':
314            id3data = ''
315        elif action != 'create' and not id3data:
316            raise TagError, "Nonexistant or corrupt tag, can't %s" % action
317        if callable(updateid3):
318            id3data = _id3(id3data, "getnewrawtag", updateid3, callbackkwargs)
319        else:
320            callbackkwargs['convertfromape'] = True
321            id3data = _id3(id3data, "getnewrawtag", _updateid3tagcallback,
322              callbackkwargs)
323
324    fil.seek(tagstart)
325    fil.write(newtag + id3data)
326    fil.truncate()
327    fil.flush()
328    return _restoredictcase(apeitems)
329
330def _apefieldstoid3fields(fields):
331    '''Convert APE tag fields to ID3 tag fields '''
332    id3fields = {}
333    for key, value in fields.iteritems():
334        key = key.lower()
335        if isinstance(value, (list, tuple)):
336            if not value:
337                value = ''
338            else:
339                value = ', '.join(value)
340        if key.startswith('track'):
341            try:
342                value = int(value)
343            except ValueError:
344                value = 0
345            if (0 <= value < 256):
346                id3fields['track'] = value
347            else:
348                id3fields['track'] = 0
349        elif key == 'genre':
350            if isinstance(value, basestring) and value.lower() in _id3genresdict:
351                id3fields[key] = value
352            else:
353                id3fields[key] = ''
354        elif key == 'date':
355            try:
356                id3fields['year'] = str(int(value))
357            except ValueError:
358                pass
359        elif key in _id3fields:
360            if isinstance(value, unicode):
361                value = value.encode('utf8')
362            id3fields[key] = value
363    return id3fields
364
365_apelengthreduce = lambda i1, i2: i1 + len(i2)
366
367def _checkargs(fil, action):
368    '''Check that arguments are valid, convert them, or raise an error'''
369    if not (isinstance(action,str) and action.lower() in _commands):
370        raise TagError, "%r is not a valid action" % action
371    action = action.lower()
372    fil = _getfileobj(fil, action)
373    for attr in _filelikeattrs:
374        if not hasattr(fil, attr) or not callable(getattr(fil, attr)):
375            raise TagError, "fil does not support method %r" % attr
376    return fil, action
377
378def _checkfields(fields):
379    '''Check that the fields quacks like a dict'''
380    if not hasattr(fields, 'items') or not callable(fields.items):
381        raise TagError, "fields does not support method 'items'"
382
383def _checkremovefields(removefields):
384    '''Check that removefields is iterable'''
385    if not hasattr(removefields, '__iter__') \
386       or not callable(removefields.__iter__):
387        raise TagError, "removefields is not an iterable"
388
389def _getfileobj(fil, action):
390    '''Return a file object if given a filename, otherwise return file'''
391    if isinstance(fil, basestring) and _isfile(fil):
392        if action in _stringallowedcommands:
393            mode = 'rb'
394        else:
395            mode = 'r+b'
396        return file(fil, mode)
397    return fil
398
399def _getfilesizeandid3andapefooter(fil):
400    '''Return file size and ID3 tag if it exists, and seek to start of APE footer'''
401    fil.seek(0, 2)
402    filesize = fil.tell()
403    id3 = ''
404    apefooter = ''
405    if filesize < 64: #No possible APE or ID3 tag
406        apefooter = ''
407    elif filesize < 128: #No possible ID3 tag
408        fil.seek(filesize - 32)
409        apefooter = fil.read(32)
410    else:
411        fil.seek(filesize - 128)
412        data = fil.read(128)
413        if data[:3] != 'TAG':
414            apefooter = data[96:]
415        else:
416            id3 = data
417            if filesize >= 160:
418                fil.seek(filesize - 160)
419                apefooter = fil.read(32)
420    return filesize, id3, apefooter
421
422def _id3(fil, action, callback = None, callbackkwargs={}):
423    '''Get or Modify ID3 tag for file'''
424    if isinstance(fil, str):
425        if action not in _stringallowedcommands:
426            raise TagError, "String not allowed for %s action" % action
427        data = fil
428    else:
429        fil.seek(0, 2)
430        tagstart = fil.tell()
431        if tagstart < 128:
432            data = ''
433        else:
434            fil.seek(-128,2)
435            data = fil.read(128)
436        if data[0:3] != 'TAG':
437            # Tag doesn't exist
438            if action == "delete":
439                return 0
440            if action in _tagmustexistcommands:
441                raise TagError, "Nonexistant or corrupt tag, can't %s" % action
442            data = ''
443        else:
444            tagstart -= 128
445            if action == "delete":
446                fil.truncate(tagstart)
447                return 0
448
449    if action == "hastag":
450        return bool(data)
451    if action == "getrawtag":
452        return data
453    if action == "getfields":
454        return _parseid3tag(data)
455
456    if not data or action == "replace":
457        tagfields = {}
458    else:
459        tagfields = _parseid3tag(data)
460
461    if callable(callback):
462        tagfields = callback(tagfields, **callbackkwargs)
463
464    newtag = _makeid3tag(tagfields)
465
466    if action == "getnewrawtag":
467        return newtag
468
469    fil.seek(tagstart)
470    fil.write(newtag)
471    fil.flush()
472    return _parseid3tag(newtag)
473
474def _makeapev2tag(apeitems):
475    '''Construct an APE tag string from a dict of ApeItems'''
476    apeentries = [item.maketag() for item in apeitems.itervalues()]
477    apeentries.sort(_sortapeitems)
478    apesize = _pack("<i",reduce(_apelengthreduce, apeentries, 32))
479    numitems = _pack("<i",len(apeentries))
480    headerfooter = _apepreamble + apesize + numitems
481    apeentries.insert(0, headerfooter + '\0' + _apeheaderflags + "\x00" * 8)
482    apeentries.append(headerfooter + '\0' + _apefooterflags + "\x00" * 8)
483    return "".join(apeentries)
484
485def _makeid3tag(fields):
486    '''Make an ID3 tag from the given dictionary'''
487    newfields = {}
488    for field, value in fields.iteritems():
489        if not isinstance(field, str):
490            continue
491        newfields[field.lower()] = fields[field]
492        field = field.lower()
493        if field.startswith('track'):
494            try:
495                if not value:
496                    value = 0
497                newfields['track'] = chr(int(value))
498            except ValueError:
499                raise TagError, '%r is an invalid value for %r' % (value, field)
500        elif field == 'genre':
501            if not isinstance(value, int):
502                if not isinstance(value, basestring):
503                    raise TagError, "%r is an invalid value for 'genre'" % value
504                value = value.lower()
505                if not value:
506                    value = 255
507                elif value in _id3genresdict:
508                    value = _id3genresdict[value]
509                else:
510                    raise TagError, "%r is an invalid value for 'genre'" % value
511            elif not (0 <= value < 256):
512                value = 255
513            newfields[field] = chr(value)
514    for field, (startpos, endpos) in _id3fields.iteritems():
515        maxlength = endpos - startpos
516        if field in newfields:
517            fieldlength = len(newfields[field])
518            if fieldlength > maxlength:
519                newfields[field] = newfields[field][:maxlength]
520            elif fieldlength < maxlength:
521                newfields[field] = newfields[field] + \
522                '\x00' * (maxlength - fieldlength)
523            # If fieldlength = maxlength, no changes need to be made
524        else:
525            newfields[field] = '\x00' * maxlength
526    if 'track' not in newfields:
527        newfields['track'] = '\x00'
528    if 'genre' not in newfields:
529        newfields['genre'] = '\xff'
530    return _id3tagformat % newfields
531
532def _parseapetag(data):
533    '''Parse an APEv2 tag and return a dictionary of tag fields'''
534    apeitems = {}
535    numitems = _unpack("<i",data[16:20])[0]
536    if numitems != _unpack("<i",data[-16:-12])[0]:
537        raise TagError, 'Corrupt tag, mismatched header and footer item count'
538    # 32 is size of footer, 11 is minimum item length item
539    if numitems > (len(data) - 32)/11:
540        raise TagError, 'Corrupt tag, specifies more items that is possible ' \
541                        'given space remaining: %i items' % numitems
542    curpos = 32
543    tagitemend = len(data) - 32
544    for x in range(numitems):
545        if curpos >= tagitemend:
546            raise TagError, 'Corrupt tag, end of tag reached with more items' \
547                            'specified'
548        item = ApeItem()
549        curpos = item.parsetag(data, curpos)
550        itemkey = item.key.lower()
551        if itemkey in apeitems:
552            raise TagError, 'Corrupt tag, duplicate item key: %r' % itemkey
553        apeitems[itemkey] = item
554    if tagitemend - curpos:
555        raise TagError, 'Corrupt tag, parsing complete but not at end ' \
556            'of input: %i bytes remaining' % (len(data) - curpos)
557    return apeitems
558
559def _parseid3tag(data):
560    '''Parse an ID3 tag and return a dictionary of tag fields'''
561    fields = {}
562    for key,(start,end) in _id3fields.iteritems():
563        fields[key] = data[start:end].rstrip("\x00")
564    if data[125] == "\x00":
565        # ID3v1.1 tags have tracks
566        fields["track"] = str(ord(data[126]))
567    else:
568        fields["track"] = '0'
569    genreid = ord(data[127])
570    if genreid < len(id3genres):
571        fields["genre"] = id3genres[genreid]
572    else:
573        fields["genre"] = ''
574    return fields
575
576def _printapeitems(apeitems):
577    '''Pretty print given APE Items'''
578    items = apeitems.items()
579    items.sort()
580    print 'APE Tag\n-------'
581    for key, value in items:
582        if value.readonly:
583            key = '[read only] %s' % key
584        if value.type == 'utf8':
585            value = u', '.join([v.encode('ascii', 'replace') for v in value])
586        else:
587            key = '[%s] %s' % (value.type, key)
588            if value.type == 'binary':
589                value = '[binary data]'
590            else:
591                value = ', '.join(value)
592        print '%s: %s' % (key, value)
593
594def _printid3items(tagfields):
595    '''Pretty print given ID3 Fields'''
596    items = tagfields.items()
597    items.sort()
598    print 'ID3 Tag\n-------'
599    for key, value in items:
600        if value:
601            print '%s: %s' % (key, value)
602
603def _removeapeitems(apeitems, removefields):
604    '''Remove items from the APE tag'''
605    for key in [key.lower() for key in removefields if hasattr(key, 'lower')]:
606        if key in apeitems:
607            del apeitems[key]
608
609def _restoredictcase(apeitems):
610    '''Restore the case of the dictionary keys for the ApeItems'''
611    fixeditems = {}
612    for value in apeitems.itervalues():
613        fixeditems[value.key] = value
614    return fixeditems
615
616def _stringoverlaps(string1, string2):
617    '''Check if any character in either string is in the other string'''
618    if len(string1) > len(string2):
619        string1, string2 = string2, string1
620    for char in string1:
621        if char in string2:
622            return True
623    return False
624
625_sortapeitems = lambda a, b: cmp(len(a), len(b))
626
627def _tag(function, fil, action="update", *args, **kwargs):
628    '''Preform tagging operation, check args, open/close file if necessary'''
629    origfil = fil
630    fil, action = _checkargs(fil, action)
631    if 'callbackkwargs' in kwargs:
632        if 'fields' in kwargs['callbackkwargs']:
633            _checkfields(kwargs['callbackkwargs']['fields'])
634    try:
635        return function(fil, action, *args, **kwargs)
636    finally:
637        if isinstance(origfil, basestring):
638            # filename given as an argument, close file object
639            fil.close()
640
641def _updateapeitems(apeitems, fields):
642    '''Add/Update apeitems using data from fields'''
643    for key, value in fields.iteritems():
644        if isinstance(value, ApeItem):
645            apeitems[value.key.lower()] = value
646        else:
647            apeitems[key.lower()] = ApeItem(key, value)
648    return apeitems
649
650def _updateapetagcallback(apeitems, fields={}, removefields=[]):
651    '''Add and/or remove fields from the apeitems'''
652    if removefields:
653        _removeapeitems(apeitems, removefields)
654    return _updateapeitems(apeitems, fields)
655
656def _updateid3fields(tagfields, fields):
657    '''Update ID3v1 tagfields using fields'''
658    for field, value in fields.iteritems():
659       if isinstance(field, str):
660           tagfields[field.lower()] = value
661    return tagfields
662
663def _updateid3tagcallback(tagfields, fields={}, removefields=[],
664  convertfromape = False):
665    '''Add and/or remove fields from the ID3v1 tagfields'''
666    if convertfromape:
667        fields = _apefieldstoid3fields(fields)
668    for field in removefields:
669        if field.lower() in tagfields:
670            tagfields[field.lower()] = ''
671    return _updateid3fields(tagfields, fields)
672
673# Public functions
674
675def createape(fil, fields = {}):
676    '''Create/update APE tag in fil with the information in fields'''
677    return _tag(_ape, fil, 'create', callback=_updateapetagcallback,
678      callbackkwargs={'fields':fields})
679
680def createid3(fil, fields = {}):
681    '''Create/update ID3v1 tag in fil with the information in fields'''
682    return _tag(_id3, fil, 'create', callback=_updateid3tagcallback,
683      callbackkwargs={'fields':fields})
684
685def createtags(fil, fields = {}):
686    '''Create/update both APE and ID3v1 tags on fil with the information in fields'''
687    return _tag(_ape, fil, 'create', callback=_updateapetagcallback,
688      callbackkwargs={'fields':fields}, updateid3=True)
689
690def deleteape(fil):
691    '''Delete APE tag from fil if it exists'''
692    return _tag(_ape, fil, action='delete')
693
694def deleteid3(fil):
695    '''Delete ID3v1 tag from fil if it exists'''
696    return _tag(_id3, fil, action='delete')
697
698def deletetags(fil):
699    '''Delete APE and ID3v1 tags from fil if either exists'''
700    deleteid3(fil)
701    return _tag(_ape, fil, action='delete', updateid3=True)
702
703def getapefields(fil):
704    '''Return fields from APE tag in fil'''
705    return _tag(_ape, fil, action='getfields')
706
707def getid3fields(fil):
708    '''Return fields from ID3v1 tag in fil (including blank fields)'''
709    return _tag(_id3, fil, action='getfields')
710
711def gettagfields(fil):
712    '''Get APE and ID3v1 tag fields tuple'''
713    return _tag(_ape, fil, action='getfields', updateid3=True)
714
715def getrawape(fil):
716    '''Return raw APE tag from fil'''
717    return _tag(_ape, fil, action='getrawtag')
718
719def getrawid3(fil):
720    '''Return raw ID3v1 tag from fil'''
721    return _tag(_id3, fil, action='getrawtag')
722
723def getrawtags(fil):
724    '''Get raw APE and ID3v1 tag tuple'''
725    return _tag(_ape, fil, action='getrawtag', updateid3=True)
726
727def hasapetag(fil):
728    '''Return raw APE tag from fil'''
729    return _tag(_ape, fil, action='hastag')
730
731def hasid3tag(fil):
732    '''Return raw ID3v1 tag from fil'''
733    return _tag(_id3, fil, action='hastag')
734
735def hastags(fil):
736    '''Get raw APE and ID3v1 tag tuple'''
737    return _tag(_ape, fil, action='hastag', updateid3=True)
738
739def modifyape(fil, callback, action='update', **kwargs):
740    '''Modify APE tag using user-defined callback and kwargs'''
741    return _tag(_ape, fil, action=action, callback=callback,
742      callbackkwargs=kwargs)
743
744def modifyid3(fil, callback, action='update', **kwargs):
745    '''Modify ID3v1 tag using user-defined callback and kwargs'''
746    return _tag(_id3, fil, action=action, callback=callback,
747      callbackkwargs=kwargs)
748
749def modifytags(fil, apecallback, id3callback=True, action='update', **kwargs):
750    '''Modify APE and ID3v1 tags using user-defined callbacks and kwargs
751
752    Both apecallback and id3callback receive the same kwargs provided, so they
753    need to have the same interface.
754    '''
755    return _tag(_ape, fil, action=action, callback=apecallback,
756      updateid3=id3callback, callbackkwargs=kwargs)
757
758def printapetag(fil):
759    '''Print APE tag fields for fil'''
760    _printapeitems(getapefields(fil))
761
762def printid3tag(fil):
763    '''Print ID3 tag fields for fil'''
764    _printid3items(getid3fields(fil))
765
766def printtags(fil):
767    '''Print APE and ID3 tag fields for fil'''
768    apeitems, tagfields = gettagfields(fil)
769    _printapeitems(apeitems)
770    _printid3items(tagfields)
771
772def replaceape(fil, fields = {}):
773    '''Replace/create APE tag in fil with the information in fields'''
774    return _tag(_ape, fil, 'replace', callback=_updateapetagcallback,
775      callbackkwargs={'fields':fields})
776
777def replaceid3(fil, fields = {}):
778    '''Replace/create ID3v1 tag in fil with the information in fields'''
779    return _tag(_id3, fil, 'replace', callback=_updateid3tagcallback,
780      callbackkwargs={'fields':fields})
781
782def replacetags(fil, fields = {}):
783    '''Replace/create both APE and ID3v1 tags on fil with the information in fields'''
784    return _tag(_ape, fil, 'replace', callback=_updateapetagcallback,
785      callbackkwargs={'fields':fields}, updateid3=True)
786
787def updateape(fil, fields = {}, removefields = []):
788    '''Update APE tag in fil with the information in fields'''
789    _checkremovefields(removefields)
790    return _tag(_ape, fil, 'update', callback=_updateapetagcallback,
791      callbackkwargs={'fields':fields, 'removefields':removefields})
792
793def updateid3(fil, fields = {}):
794    '''Update ID3v1 tag in fil with the information in fields'''
795    return _tag(_id3, fil, 'update', callback=_updateid3tagcallback,
796      callbackkwargs={'fields':fields})
797
798def updatetags(fil, fields = {}, removefields = []):
799    '''Update both APE and ID3v1 tags on fil with the information in fields'''
800    _checkremovefields(removefields)
801    return _tag(_ape, fil, 'update', callback=_updateapetagcallback,
802      callbackkwargs={'fields':fields, 'removefields':removefields},
803      updateid3=True)
804
805if __name__ == '__main__':
806    import sys
807    for filename in sys.argv[1:]:
808        if _isfile(filename):
809            print '\n%s' % filename
810            try:
811                printtags(filename)
812            except TagError:
813                print 'Missing APE or ID3 Tag'
814        else:
815            print "%s: file doesn't exist" % filename
816