Theme changes, fix for unicode conversion errors, misc
[rox-musicbox.git] / plugins / ID3.py
blobd5293fa9824c340269f51af9d8d6b234aa896048
1 # ID3.py version 1.2
3 # Module for manipulating ID3 informational tags in MP3 audio files
4 # $Id: ID3.py,v 1.2 2003/06/05 11:35:23 chrisshaffer Exp $
6 # Written 2 May 1999 by Ben Gertzfield <che@debian.org>
7 # This work is released under the GNU GPL, version 2 or later.
9 # Modified 10 June 1999 by Arne Zellentin <arne@unix-ag.org> to
10 # fix bug with overwriting last 128 bytes of a file without an
11 # ID3 tag
13 # Patches from Jim Speth <speth@end.com> and someone whose email
14 # I've forgotten at the moment (huge apologies, I didn't save the
15 # entire mail, just the patch!) for so-called ID3 v1.1 support,
16 # which makes the last two bytes of the comment field signify a
17 # track number. If the first byte is null but the second byte
18 # is not, the second byte is assumed to signify a track number.
20 # Also thanks to Jim for the simple function to remove nulls and
21 # whitespace from the ends of ID3 tags. I'd like to add a boolean
22 # flag defaulting to false to the ID3() constructor signifying whether
23 # or not to remove whitespace, just in case old code depended on the
24 # old behavior for some reason, but that'd make any code that wanted
25 # to use the stripping behavior not work with old ID3.py. Bleh.
27 # This is the first thing I've ever written in Python, so bear with
28 # me if it looks terrible. In a few years I'll probably look back at
29 # this and laugh and laugh..
31 # Constructor:
33 # ID3(file, filename='unknown filename', as_tuple=0)
34 # Opens file and tries to parse its ID3 header. If the ID3 header
35 # is invalid or the file access failed, raises InvalidTagError.
37 # file can either be a string specifying a filename which will be
38 # opened in binary mode, or a file object. If it's a file object,
39 # the filename should be passed in as the second argument to this
40 # constructor, otherwise file.name will be used in error messages
41 # (or 'unknown filename' if that's missing). Also, if it's a file
42 # object, it *must* be opened in r+ mode (or equivalent) to allow
43 # both reading and writing.
45 # If as_tuple is true, the dictionary interface to ID3 will return
46 # tuples containing one string each instead of a string, for
47 # compatibility with the ogg.vorbis module.
49 # When object is deconstructed, if any of the class data (below) have
50 # been changed, opens the file again read-write and writes out the
51 # new header. If the header is to be deleted, truncates the last
52 # 128 bytes of the file.
54 # Note that if ID3 cannot write the tag out to the file upon
55 # deconstruction, InvalidTagError will be raised and ignored
56 # (as we are in __del__, and exceptions just give warnings when
57 # raised in __del__.)
59 # Class Data of Interest:
61 # Note that all ID3 fields, unless otherwise specified, are a maximum of
62 # 30 characters in length. If a field is set to a string longer than
63 # the maximum, it will be truncated when it's written to disk.
65 # As of ID3 version 1.2, there are two interfaces to this data.
66 # You can use the direct interface or the dictionary-based interface.
67 # The normal dictionary methods (has_key, get, keys, values, items, etc.)
68 # should work on an ID3 object. You can assign values to either the
69 # dictionary interface or the direct interface, and they will both
70 # reflect the changes.
72 # If any of the fields are not defined in the ID3 tag, the dictionary
73 # based interface will not contain a key for that field! Test with
74 # ID3.has_key('ARTIST') etc. first.
76 # ID3.title or ID3['TITLE']
77 # Title of the song.
78 # ID3.artist or ID3['ARTIST']
79 # Artist/creator of the song.
80 # ID3.album or ID3['ALBUM']
81 # Title of the album the song is from.
82 # ID3.year or ID3['YEAR']
83 # Year the song was released. Maximum of 4 characters (Y10K bug!)
84 # ID3.genre
85 # Genre of the song. Integer value from 0 to 255. Genre specification
86 # comes from (sorry) WinAMP. http://mp3.musichall.cz/id3master/faq.htm
87 # has a list of current genres; I spell-checked this list against
88 # WinAMP's by running strings(1) on the file Winamp/Plugins/in_mp3.dll
89 # and made a few corrections.
90 # ID3['GENRE']
91 # String value corresponding to the integer in ID3.genre. If there
92 # is no genre string available for the ID3.genre number, this will
93 # be set to "Unknown Genre".
94 # ID3.comment or ID3['COMMENT']
95 # Comment about the song.
96 # ID3.track or ID3['TRACKNUMBER']
97 # Track number of the song. None if undefined.
98 # NOTE: ID3['TRACKNUMBER'] will return a *string* containing the
99 # track number, for compatibility with ogg.vorbis.
101 # ID3.genres
102 # List of all genres. ID3.genre above is used to index into this
103 # list. ID3.genres is current as of WinAMP 1.92.
105 # Methods of Interest:
107 # write()
108 # If the class data above have changed, opens the file given
109 # to the constructor read-write and writes out the new header.
110 # If the header is flagged for deletion (see delete() below)
111 # truncates the last 128 bytes of the file to remove the header.
113 # NOTE: write() is called from ID3's deconstructor, so it's technically
114 # unnecessary to call it. However, write() can raise an InvalidTagError,
115 # which can't be caught during deconstruction, so generally it's
116 # nicer to call it when writing is desired.
118 # delete()
119 # Flags the ID3 tag for deletion upon destruction of the object
121 # find_genre(genre_string)
122 # Searches for the numerical value of the given genre string in the
123 # ID3.genres table. The search is performed case-insensitively. Returns
124 # an integer from 0 to len(ID3.genres).
126 # legal_genre(genre_number)
127 # Checks if genre_number is a legal index into ID3.genres. Returns
128 # true if so, false otherwise.
130 # as_dict()
131 # Returns just the dictionary containing the ID3 tag fields.
132 # See the notes above for the dictionary interface.
135 import string, types
137 try:
138 string_types = [ types.StringType, types.UnicodeType ]
139 except AttributeError: # if no unicode support
141 string_types = [ types.StringType ]
143 def lengthen(string, num_spaces):
144 string = string[:num_spaces]
145 return string + (' ' * (num_spaces - len(string)))
147 # We would normally use string.rstrip(), but that doesn't remove \0 characters.
148 def strip_padding(s):
149 while len(s) > 0 and s[-1] in string.whitespace + "\0":
150 s = s[:-1]
152 return s
154 class InvalidTagError:
155 def __init__(self, msg):
156 self.msg = msg
157 def __str__(self):
158 return self.msg
160 class ID3:
162 genres = [
163 "Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk",
164 "Grunge", "Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other",
165 "Pop", "R&B", "Rap", "Reggae", "Rock", "Techno", "Industrial",
166 "Alternative", "Ska", "Death Metal", "Pranks", "Soundtrack",
167 "Euro-Techno", "Ambient", "Trip-Hop", "Vocal", "Jazz+Funk", "Fusion",
168 "Trance", "Classical", "Instrumental", "Acid", "House", "Game",
169 "Sound Clip", "Gospel", "Noise", "Alt. Rock", "Bass", "Soul",
170 "Punk", "Space", "Meditative", "Instrum. Pop", "Instrum. Rock",
171 "Ethnic", "Gothic", "Darkwave", "Techno-Indust.", "Electronic",
172 "Pop-Folk", "Eurodance", "Dream", "Southern Rock", "Comedy",
173 "Cult", "Gangsta", "Top 40", "Christian Rap", "Pop/Funk", "Jungle",
174 "Native American", "Cabaret", "New Wave", "Psychadelic", "Rave",
175 "Showtunes", "Trailer", "Lo-Fi", "Tribal", "Acid Punk", "Acid Jazz",
176 "Polka", "Retro", "Musical", "Rock & Roll", "Hard Rock", "Folk",
177 "Folk/Rock", "National Folk", "Swing", "Fusion", "Bebob", "Latin",
178 "Revival", "Celtic", "Bluegrass", "Avantgarde", "Gothic Rock",
179 "Progress. Rock", "Psychadel. Rock", "Symphonic Rock", "Slow Rock",
180 "Big Band", "Chorus", "Easy Listening", "Acoustic", "Humour",
181 "Speech", "Chanson", "Opera", "Chamber Music", "Sonata", "Symphony",
182 "Booty Bass", "Primus", "Porn Groove", "Satire", "Slow Jam",
183 "Club", "Tango", "Samba", "Folklore", "Ballad", "Power Ballad",
184 "Rhythmic Soul", "Freestyle", "Duet", "Punk Rock", "Drum Solo",
185 "A Capella", "Euro-House", "Dance Hall", "Goa", "Drum & Bass",
186 "Club-House", "Hardcore", "Terror", "Indie", "BritPop", "Negerpunk",
187 "Polsk Punk", "Beat", "Christian Gangsta Rap", "Heavy Metal",
188 "Black Metal", "Crossover", "Contemporary Christian", "Christian Rock",
189 "Merengue", "Salsa", "Thrash Metal", "Anime", "Jpop", "Synthpop"
192 def __init__(self, file, name='unknown filename', as_tuple=0):
193 if type(file) in string_types:
194 self.filename = file
195 # We don't open in r+b if we don't have to, to allow read-only access
196 self.file = open(file, 'rb')
197 self.can_reopen = 1
198 elif hasattr(file, 'seek'): # assume it's an open file
199 if name == 'unknown filename' and hasattr(file, 'name'):
200 self.filename = file.name
201 else:
202 self.filename = name
204 self.file = file
205 self.can_reopen = 0
207 self.d = {}
208 self.as_tuple = as_tuple
209 self.delete_tag = 0
210 self.zero()
211 self.modified = 0
212 self.has_tag = 0
213 self.had_tag = 0
215 try:
216 self.file.seek(-128, 2)
218 except IOError, msg:
219 self.modified = 0
220 raise InvalidTagError("Can't open %s: %s" % (self.filename, msg))
221 return
223 try:
224 if self.file.read(3) == 'TAG':
225 self.has_tag = 1
226 self.had_tag = 1
227 self.title = self.file.read(30)
228 self.artist = self.file.read(30)
229 self.album = self.file.read(30)
230 self.year = self.file.read(4)
231 self.comment = self.file.read(30)
233 if ord(self.comment[-2]) == 0 and ord(self.comment[-1]) != 0:
234 self.track = ord(self.comment[-1])
235 self.comment = self.comment[:-2]
236 else:
237 self.track = None
239 self.genre = ord(self.file.read(1))
241 #Strip spaces
242 self.title = strip_padding(self.title)
243 self.artist = strip_padding(self.artist)
244 self.album = strip_padding(self.album)
245 self.year = strip_padding(self.year)
246 self.comment = strip_padding(self.comment)
248 self.setup_dict()
251 except IOError, msg:
252 self.modified = 0
253 raise InvalidTagError("Invalid ID3 tag in %s: %s" % (self.filename, msg))
254 self.modified = 0
256 def setup_dict(self):
257 self.d = {}
258 if self.title: self.d["TITLE"] = self.tupleize(self.title)
259 if self.artist: self.d["ARTIST"] = self.tupleize(self.artist)
260 if self.album: self.d["ALBUM"] = self.tupleize(self.album)
261 if self.year: self.d["YEAR"] = self.tupleize(self.year)
262 if self.comment: self.d["COMMENT"] = self.tupleize(self.comment)
263 if self.legal_genre(self.genre):
264 self.d["GENRE"] = self.tupleize(self.genres[self.genre])
265 else:
266 self.d["GENRE"] = self.tupleize("Unknown Genre")
267 if self.track: self.d["TRACKNUMBER"] = self.tupleize("%02d" % (self.track)) #str(self.track))
269 def delete(self):
270 self.zero()
271 self.delete_tag = 1
272 self.has_tag = 0
274 def zero(self):
275 self.title = ''
276 self.artist = ''
277 self.album = ''
278 self.year = ''
279 self.comment = ''
280 self.track = None
281 self.genre = 255 # 'unknown', not 'blues'
282 self.setup_dict()
284 def tupleize(self, s):
285 if self.as_tuple and type(s) is not types.TupleType:
286 return (s,)
287 else:
288 return s
290 def find_genre(self, genre_to_find):
291 i = 0
292 find_me = string.lower(genre_to_find)
294 for genre in self.genres:
295 if string.lower(genre) == find_me:
296 break
297 i = i + 1
298 if i == len(self.genres):
299 return -1
300 else:
301 return i
303 def legal_genre(self, genre):
304 if type(genre) is types.IntType and 0 <= genre < len(self.genres):
305 return 1
306 else:
307 return 0
309 def write(self):
310 if self.modified:
311 try:
312 # We see if we can re-open in r+ mode now, as we need to write
313 if self.can_reopen:
314 self.file = open(self.filename, 'r+b')
316 if self.had_tag:
317 self.file.seek(-128, 2)
318 else:
319 self.file.seek(0, 2) # a new tag is appended at the end
320 if self.delete_tag and self.had_tag:
321 self.file.truncate()
322 self.had_tag = 0
323 elif self.has_tag:
324 go_on = 1
325 if self.had_tag:
326 if self.file.read(3) == "TAG":
327 self.file.seek(-128, 2)
328 else:
329 # someone has changed the file in the mean time
330 go_on = 0
331 raise IOError("File has been modified, losing tag changes")
332 if go_on:
333 self.file.write('TAG')
334 self.file.write(lengthen(self.title, 30))
335 self.file.write(lengthen(self.artist, 30))
336 self.file.write(lengthen(self.album, 30))
337 self.file.write(lengthen(self.year, 4))
339 comment = lengthen(self.comment, 30)
341 if self.track < 0 or self.track > 255:
342 self.track = None
344 if self.track != None:
345 comment = comment[:-2] + "\0" + chr(self.track)
347 self.file.write(comment)
349 if self.genre < 0 or self.genre > 255:
350 self.genre = 255
351 self.file.write(chr(self.genre))
352 self.had_tag = 1
353 self.file.flush()
354 except IOError, msg:
355 raise InvalidTagError("Cannot write modified ID3 tag to %s: %s" % (self.filename, msg))
356 else:
357 self.modified = 0
359 def as_dict(self):
360 return self.d
362 def items(self):
363 return map(None, self.keys(), self.values())
365 def keys(self):
366 return self.d.keys()
368 def values(self):
369 if self.as_tuple:
370 return map(lambda x: x[0], self.d.values())
371 else:
372 return self.d.values()
374 def has_key(self, k):
375 return self.d.has_key(k)
377 def get(self, k, x=None):
378 if self.d.has_key(k):
379 return self.d[k]
380 else:
381 return x
383 def __getitem__(self, k):
384 return self.d[k]
386 def __setitem__(self, k, v):
387 key = k
388 if not key in ['TITLE', 'ARTIST', 'ALBUM', 'YEAR', 'COMMENT',
389 'TRACKNUMBER', 'GENRE']:
390 return
391 if k == 'TRACKNUMBER':
392 if type(v) is types.IntType:
393 self.track = v
394 else:
395 self.track = string.atoi(v)
396 self.d[k] = self.tupleize(str(v))
397 elif k == 'GENRE':
398 if type(v) is types.IntType:
399 if self.legal_genre(v):
400 self.genre = v
401 self.d[k] = self.tupleize(self.genres[v])
402 else:
403 self.genre = v
404 self.d[k] = self.tupleize("Unknown Genre")
405 else:
406 self.genre = self.find_genre(str(v))
407 if self.genre == -1:
408 print v, "not found"
409 self.genre = 255
410 self.d[k] = self.tupleize("Unknown Genre")
411 else:
412 print self.genre, v
413 self.d[k] = self.tupleize(str(v))
414 else:
415 self.__dict__[string.lower(key)] = v
416 self.d[k] = self.tupleize(v)
417 self.__dict__['modified'] = 1
418 self.__dict__['has_tag'] = 1
420 def __del__(self):
421 self.write()
423 def __str__(self):
424 if self.has_tag:
425 if self.genre != None and self.genre >= 0 and \
426 self.genre < len(self.genres):
427 genre = self.genres[self.genre]
428 else:
429 genre = 'Unknown'
431 if self.track != None:
432 track = "%02d" % self.track #str(self.track)
433 else:
434 track = 'Unknown'
436 return "File : %s\nTitle : %-30.30s Artist: %-30.30s\nAlbum : %-30.30s Track : %s Year: %-4.4s\nComment: %-30.30s Genre : %s (%i)" % (self.filename, self.title, self.artist, self.album, track, self.year, self.comment, genre, self.genre)
437 else:
438 return "%s: No ID3 tag." % self.filename
440 # intercept setting of attributes to set self.modified
441 def __setattr__(self, name, value):
442 if name in ['title', 'artist', 'album', 'year', 'comment',
443 'track', 'genre']:
444 self.__dict__['modified'] = 1
445 self.__dict__['has_tag'] = 1
446 if name == 'track':
447 self.__dict__['d']['TRACKNUMBER'] = self.tupleize(str(value))
448 elif name == 'genre':
449 if self.legal_genre(value):
450 self.__dict__['d']['GENRE'] = self.tupleize(self.genres[value])
451 else:
452 self.__dict__['d']['GENRE'] = self.tupleize('Unknown Genre')
453 else:
454 self.__dict__['d'][string.upper(name)] = self.tupleize(value)
455 self.__dict__[name] = value