Use cp1252 instead of iso8859-1 for Windows 8-bit characters.
[pyTivo/wmcbrine.git] / mutagen / mp4.py
blob2e7510d26e9319fbf7175a8a241eff85c88dc30c
1 # Copyright 2006 Joe Wreschnig
3 # This program is free software; you can redistribute it and/or modify
4 # it under the terms of the GNU General Public License version 2 as
5 # published by the Free Software Foundation.
7 # $Id: mp4.py 4233 2007-12-28 07:24:59Z luks $
9 """Read and write MPEG-4 audio files with iTunes metadata.
11 This module will read MPEG-4 audio information and metadata,
12 as found in Apple's MP4 (aka M4A, M4B, M4P) files.
14 There is no official specification for this format. The source code
15 for TagLib, FAAD, and various MPEG specifications at
16 http://developer.apple.com/documentation/QuickTime/QTFF/,
17 http://www.geocities.com/xhelmboyx/quicktime/formats/mp4-layout.txt,
18 http://standards.iso.org/ittf/PubliclyAvailableStandards/c041828_ISO_IEC_14496-12_2005(E).zip,
19 and http://wiki.multimedia.cx/index.php?title=Apple_QuickTime were all
20 consulted.
21 """
23 import struct
24 import sys
26 from mutagen import FileType, Metadata
27 from mutagen._constants import GENRES
28 from mutagen._util import cdata, insert_bytes, delete_bytes, DictProxy, utf8
30 class error(IOError): pass
31 class MP4MetadataError(error): pass
32 class MP4StreamInfoError(error): pass
33 class MP4MetadataValueError(ValueError, MP4MetadataError): pass
35 # This is not an exhaustive list of container atoms, but just the
36 # ones this module needs to peek inside.
37 _CONTAINERS = ["moov", "udta", "trak", "mdia", "meta", "ilst",
38 "stbl", "minf", "moof", "traf"]
39 _SKIP_SIZE = { "meta": 4 }
41 __all__ = ['MP4', 'Open', 'delete', 'MP4Cover']
43 class MP4Cover(str):
44 """A cover artwork.
46 Attributes:
47 imageformat -- format of the image (either FORMAT_JPEG or FORMAT_PNG)
48 """
49 FORMAT_JPEG = 0x0D
50 FORMAT_PNG = 0x0E
52 def __new__(cls, data, imageformat=None):
53 self = str.__new__(cls, data)
54 if imageformat is None: imageformat = MP4Cover.FORMAT_JPEG
55 self.imageformat = imageformat
56 try: self.format
57 except AttributeError:
58 self.format = imageformat
59 return self
61 class Atom(object):
62 """An individual atom.
64 Attributes:
65 children -- list child atoms (or None for non-container atoms)
66 length -- length of this atom, including length and name
67 name -- four byte name of the atom, as a str
68 offset -- location in the constructor-given fileobj of this atom
70 This structure should only be used internally by Mutagen.
71 """
73 children = None
75 def __init__(self, fileobj):
76 self.offset = fileobj.tell()
77 self.length, self.name = struct.unpack(">I4s", fileobj.read(8))
78 if self.length == 1:
79 self.length, = struct.unpack(">Q", fileobj.read(8))
80 elif self.length < 8:
81 return
83 if self.name in _CONTAINERS:
84 self.children = []
85 fileobj.seek(_SKIP_SIZE.get(self.name, 0), 1)
86 while fileobj.tell() < self.offset + self.length:
87 self.children.append(Atom(fileobj))
88 else:
89 fileobj.seek(self.offset + self.length, 0)
91 def render(name, data):
92 """Render raw atom data."""
93 # this raises OverflowError if Py_ssize_t can't handle the atom data
94 size = len(data) + 8
95 if size <= 0xFFFFFFFF:
96 return struct.pack(">I4s", size, name) + data
97 else:
98 return struct.pack(">I4sQ", 1, name, size + 8) + data
99 render = staticmethod(render)
101 def findall(self, name, recursive=False):
102 """Recursively find all child atoms by specified name."""
103 if self.children is not None:
104 for child in self.children:
105 if child.name == name:
106 yield child
107 if recursive:
108 for atom in child.findall(name, True):
109 yield atom
111 def __getitem__(self, remaining):
112 """Look up a child atom, potentially recursively.
114 e.g. atom['udta', 'meta'] => <Atom name='meta' ...>
116 if not remaining:
117 return self
118 elif self.children is None:
119 raise KeyError("%r is not a container" % self.name)
120 for child in self.children:
121 if child.name == remaining[0]:
122 return child[remaining[1:]]
123 else:
124 raise KeyError, "%r not found" % remaining[0]
126 def __repr__(self):
127 klass = self.__class__.__name__
128 if self.children is None:
129 return "<%s name=%r length=%r offset=%r>" % (
130 klass, self.name, self.length, self.offset)
131 else:
132 children = "\n".join([" " + line for child in self.children
133 for line in repr(child).splitlines()])
134 return "<%s name=%r length=%r offset=%r\n%s>" % (
135 klass, self.name, self.length, self.offset, children)
137 class Atoms(object):
138 """Root atoms in a given file.
140 Attributes:
141 atoms -- a list of top-level atoms as Atom objects
143 This structure should only be used internally by Mutagen.
145 def __init__(self, fileobj):
146 self.atoms = []
147 fileobj.seek(0, 2)
148 end = fileobj.tell()
149 fileobj.seek(0)
150 while fileobj.tell() + 8 <= end:
151 self.atoms.append(Atom(fileobj))
153 def path(self, *names):
154 """Look up and return the complete path of an atom.
156 For example, atoms.path('moov', 'udta', 'meta') will return a
157 list of three atoms, corresponding to the moov, udta, and meta
158 atoms.
160 path = [self]
161 for name in names:
162 path.append(path[-1][name,])
163 return path[1:]
165 def __getitem__(self, names):
166 """Look up a child atom.
168 'names' may be a list of atoms (['moov', 'udta']) or a string
169 specifying the complete path ('moov.udta').
171 if isinstance(names, basestring):
172 names = names.split(".")
173 for child in self.atoms:
174 if child.name == names[0]:
175 return child[names[1:]]
176 else:
177 raise KeyError, "%s not found" % names[0]
179 def __repr__(self):
180 return "\n".join([repr(child) for child in self.atoms])
182 class MP4Tags(DictProxy, Metadata):
183 """Dictionary containing Apple iTunes metadata list key/values.
185 Keys are four byte identifiers, except for freeform ('----')
186 keys. Values are usually unicode strings, but some atoms have a
187 special structure:
189 Text values (multiple values per key are supported):
190 '\xa9nam' -- track title
191 '\xa9alb' -- album
192 '\xa9ART' -- artist
193 'aART' -- album artist
194 '\xa9wrt' -- composer
195 '\xa9day' -- year
196 '\xa9cmt' -- comment
197 'desc' -- description (usually used in podcasts)
198 'purd' -- purchase date
199 '\xa9grp' -- grouping
200 '\xa9gen' -- genre
201 '\xa9lyr' -- lyrics
202 'purl' -- podcast URL
203 'egid' -- podcast episode GUID
204 'catg' -- podcast category
205 'keyw' -- podcast keywords
206 '\xa9too' -- encoded by
207 'cprt' -- copyright
208 'soal' -- album sort order
209 'soaa' -- album artist sort order
210 'soar' -- artist sort order
211 'sonm' -- title sort order
212 'soco' -- composer sort order
213 'sosn' -- show sort order
214 'tvsh' -- show name
216 Boolean values:
217 'cpil' -- part of a compilation
218 'pgap' -- part of a gapless album
219 'pcst' -- podcast (iTunes reads this only on import)
221 Tuples of ints (multiple values per key are supported):
222 'trkn' -- track number, total tracks
223 'disk' -- disc number, total discs
225 Others:
226 'tmpo' -- tempo/BPM, 16 bit int
227 'covr' -- cover artwork, list of MP4Cover objects (which are
228 tagged strs)
229 'gnre' -- ID3v1 genre. Not supported, use '\xa9gen' instead.
231 The freeform '----' frames use a key in the format '----:mean:name'
232 where 'mean' is usually 'com.apple.iTunes' and 'name' is a unique
233 identifier for this frame. The value is a str, but is probably
234 text that can be decoded as UTF-8. Multiple values per key are
235 supported.
237 MP4 tag data cannot exist outside of the structure of an MP4 file,
238 so this class should not be manually instantiated.
240 Unknown non-text tags are removed.
243 def load(self, atoms, fileobj):
244 try: ilst = atoms["moov.udta.meta.ilst"]
245 except KeyError, key:
246 raise MP4MetadataError(key)
247 for atom in ilst.children:
248 fileobj.seek(atom.offset + 8)
249 data = fileobj.read(atom.length - 8)
250 info = self.__atoms.get(atom.name, (type(self).__parse_text, None))
251 info[0](self, atom, data, *info[2:])
253 def __key_sort(item1, item2):
254 (key1, v1) = item1
255 (key2, v2) = item2
256 # iTunes always writes the tags in order of "relevance", try
257 # to copy it as closely as possible.
258 order = ["\xa9nam", "\xa9ART", "\xa9wrt", "\xa9alb",
259 "\xa9gen", "gnre", "trkn", "disk",
260 "\xa9day", "cpil", "pgap", "pcst", "tmpo",
261 "\xa9too", "----", "covr", "\xa9lyr"]
262 order = dict(zip(order, range(len(order))))
263 last = len(order)
264 # If there's no key-based way to distinguish, order by length.
265 # If there's still no way, go by string comparison on the
266 # values, so we at least have something determinstic.
267 return (cmp(order.get(key1[:4], last), order.get(key2[:4], last)) or
268 cmp(len(v1), len(v2)) or cmp(v1, v2))
269 __key_sort = staticmethod(__key_sort)
271 def save(self, filename):
272 """Save the metadata to the given filename."""
273 values = []
274 items = self.items()
275 items.sort(self.__key_sort)
276 for key, value in items:
277 info = self.__atoms.get(key[:4], (None, type(self).__render_text))
278 try:
279 values.append(info[1](self, key, value, *info[2:]))
280 except (TypeError, ValueError), s:
281 raise MP4MetadataValueError, s, sys.exc_info()[2]
282 data = Atom.render("ilst", "".join(values))
284 # Find the old atoms.
285 fileobj = open(filename, "rb+")
286 try:
287 atoms = Atoms(fileobj)
288 try:
289 path = atoms.path("moov", "udta", "meta", "ilst")
290 except KeyError:
291 self.__save_new(fileobj, atoms, data)
292 else:
293 self.__save_existing(fileobj, atoms, path, data)
294 finally:
295 fileobj.close()
297 def __pad_ilst(self, data, length=None):
298 if length is None:
299 length = ((len(data) + 1023) & ~1023) - len(data)
300 return Atom.render("free", "\x00" * length)
302 def __save_new(self, fileobj, atoms, ilst):
303 hdlr = Atom.render("hdlr", "\x00" * 8 + "mdirappl" + "\x00" * 9)
304 meta = Atom.render(
305 "meta", "\x00\x00\x00\x00" + hdlr + ilst + self.__pad_ilst(ilst))
306 try:
307 path = atoms.path("moov", "udta")
308 except KeyError:
309 # moov.udta not found -- create one
310 path = atoms.path("moov")
311 meta = Atom.render("udta", meta)
312 offset = path[-1].offset + 8
313 insert_bytes(fileobj, len(meta), offset)
314 fileobj.seek(offset)
315 fileobj.write(meta)
316 self.__update_parents(fileobj, path, len(meta))
317 self.__update_offsets(fileobj, atoms, len(meta), offset)
319 def __save_existing(self, fileobj, atoms, path, data):
320 # Replace the old ilst atom.
321 ilst = path.pop()
322 offset = ilst.offset
323 length = ilst.length
325 # Check for padding "free" atoms
326 meta = path[-1]
327 index = meta.children.index(ilst)
328 try:
329 prev = meta.children[index-1]
330 if prev.name == "free":
331 offset = prev.offset
332 length += prev.length
333 except IndexError:
334 pass
335 try:
336 next = meta.children[index+1]
337 if next.name == "free":
338 length += next.length
339 except IndexError:
340 pass
342 delta = len(data) - length
343 if delta > 0 or (delta < 0 and delta > -8):
344 data += self.__pad_ilst(data)
345 delta = len(data) - length
346 insert_bytes(fileobj, delta, offset)
347 elif delta < 0:
348 data += self.__pad_ilst(data, -delta - 8)
349 delta = 0
351 fileobj.seek(offset)
352 fileobj.write(data)
353 self.__update_parents(fileobj, path, delta)
354 self.__update_offsets(fileobj, atoms, delta, offset)
356 def __update_parents(self, fileobj, path, delta):
357 """Update all parent atoms with the new size."""
358 for atom in path:
359 fileobj.seek(atom.offset)
360 size = cdata.uint_be(fileobj.read(4))
361 if size == 1: # 64bit
362 # skip name (4B) and read size (8B)
363 size = cdata.ulonglong_be(fileobj.read(12)[4:])
364 fileobj.seek(atom.offset + 8)
365 fileobj.write(cdata.to_ulonglong_be(size + delta))
366 else: # 32bit
367 fileobj.seek(atom.offset)
368 fileobj.write(cdata.to_uint_be(size + delta))
370 def __update_offset_table(self, fileobj, fmt, atom, delta, offset):
371 """Update offset table in the specified atom."""
372 if atom.offset > offset:
373 atom.offset += delta
374 fileobj.seek(atom.offset + 12)
375 data = fileobj.read(atom.length - 12)
376 fmt = fmt % cdata.uint_be(data[:4])
377 offsets = struct.unpack(fmt, data[4:])
378 offsets = [o + (0, delta)[offset < o] for o in offsets]
379 fileobj.seek(atom.offset + 16)
380 fileobj.write(struct.pack(fmt, *offsets))
382 def __update_tfhd(self, fileobj, atom, delta, offset):
383 if atom.offset > offset:
384 atom.offset += delta
385 fileobj.seek(atom.offset + 9)
386 data = fileobj.read(atom.length - 9)
387 flags = cdata.uint_be("\x00" + data[:3])
388 if flags & 1:
389 o = cdata.ulonglong_be(data[7:15])
390 if o > offset:
391 o += delta
392 fileobj.seek(atom.offset + 16)
393 fileobj.write(cdata.to_ulonglong_be(o))
395 def __update_offsets(self, fileobj, atoms, delta, offset):
396 """Update offset tables in all 'stco' and 'co64' atoms."""
397 if delta == 0:
398 return
399 moov = atoms["moov"]
400 for atom in moov.findall('stco', True):
401 self.__update_offset_table(fileobj, ">%dI", atom, delta, offset)
402 for atom in moov.findall('co64', True):
403 self.__update_offset_table(fileobj, ">%dQ", atom, delta, offset)
404 try:
405 for atom in atoms["moof"].findall('tfhd', True):
406 self.__update_tfhd(fileobj, atom, delta, offset)
407 except KeyError:
408 pass
410 def __parse_data(self, atom, data):
411 pos = 0
412 while pos < atom.length - 8:
413 length, name, flags = struct.unpack(">I4sI", data[pos:pos+12])
414 if name != "data":
415 raise MP4MetadataError(
416 "unexpected atom %r inside %r" % (name, atom.name))
417 yield flags, data[pos+16:pos+length]
418 pos += length
419 def __render_data(self, key, flags, value):
420 return Atom.render(key, "".join([
421 Atom.render("data", struct.pack(">2I", flags, 0) + data)
422 for data in value]))
424 def __parse_freeform(self, atom, data):
425 length = cdata.uint_be(data[:4])
426 mean = data[12:length]
427 pos = length
428 length = cdata.uint_be(data[pos:pos+4])
429 name = data[pos+12:pos+length]
430 pos += length
431 value = []
432 while pos < atom.length - 8:
433 length, atom_name = struct.unpack(">I4s", data[pos:pos+8])
434 if atom_name != "data":
435 raise MP4MetadataError(
436 "unexpected atom %r inside %r" % (atom_name, atom.name))
437 value.append(data[pos+16:pos+length])
438 pos += length
439 if value:
440 self["%s:%s:%s" % (atom.name, mean, name)] = value
441 def __render_freeform(self, key, value):
442 dummy, mean, name = key.split(":", 2)
443 mean = struct.pack(">I4sI", len(mean) + 12, "mean", 0) + mean
444 name = struct.pack(">I4sI", len(name) + 12, "name", 0) + name
445 if isinstance(value, basestring):
446 value = [value]
447 return Atom.render("----", mean + name + "".join([
448 struct.pack(">I4s2I", len(data) + 16, "data", 1, 0) + data
449 for data in value]))
451 def __parse_pair(self, atom, data):
452 self[atom.name] = [struct.unpack(">2H", data[2:6]) for
453 flags, data in self.__parse_data(atom, data)]
454 def __render_pair(self, key, value):
455 data = []
456 for (track, total) in value:
457 if 0 <= track < 1 << 16 and 0 <= total < 1 << 16:
458 data.append(struct.pack(">4H", 0, track, total, 0))
459 else:
460 raise MP4MetadataValueError(
461 "invalid numeric pair %r" % ((track, total),))
462 return self.__render_data(key, 0, data)
464 def __render_pair_no_trailing(self, key, value):
465 data = []
466 for (track, total) in value:
467 if 0 <= track < 1 << 16 and 0 <= total < 1 << 16:
468 data.append(struct.pack(">3H", 0, track, total))
469 else:
470 raise MP4MetadataValueError(
471 "invalid numeric pair %r" % ((track, total),))
472 return self.__render_data(key, 0, data)
474 def __parse_genre(self, atom, data):
475 # Translate to a freeform genre.
476 genre = cdata.short_be(data[16:18])
477 if "\xa9gen" not in self:
478 try: self["\xa9gen"] = [GENRES[genre - 1]]
479 except IndexError: pass
481 def __parse_tempo(self, atom, data):
482 self[atom.name] = [cdata.ushort_be(value[1]) for
483 value in self.__parse_data(atom, data)]
485 def __render_tempo(self, key, value):
486 try:
487 if len(value) == 0:
488 return self.__render_data(key, 0x15, "")
490 if min(value) < 0 or max(value) >= 2**16:
491 raise MP4MetadataValueError(
492 "invalid 16 bit integers: %r" % value)
493 except TypeError:
494 raise MP4MetadataValueError(
495 "tmpo must be a list of 16 bit integers")
497 values = map(cdata.to_ushort_be, value)
498 return self.__render_data(key, 0x15, values)
500 def __parse_bool(self, atom, data):
501 try: self[atom.name] = bool(ord(data[16:17]))
502 except TypeError: self[atom.name] = False
503 def __render_bool(self, key, value):
504 return self.__render_data(key, 0x15, [chr(bool(value))])
506 def __parse_cover(self, atom, data):
507 self[atom.name] = []
508 pos = 0
509 while pos < atom.length - 8:
510 length, name, imageformat = struct.unpack(">I4sI", data[pos:pos+12])
511 if name != "data":
512 if name == "name":
513 pos += length
514 continue
515 raise MP4MetadataError(
516 "unexpected atom %r inside 'covr'" % name)
517 if imageformat not in (MP4Cover.FORMAT_JPEG, MP4Cover.FORMAT_PNG):
518 imageformat = MP4Cover.FORMAT_JPEG
519 cover = MP4Cover(data[pos+16:pos+length], imageformat)
520 self[atom.name].append(
521 MP4Cover(data[pos+16:pos+length], imageformat))
522 pos += length
523 def __render_cover(self, key, value):
524 atom_data = []
525 for cover in value:
526 try: imageformat = cover.imageformat
527 except AttributeError: imageformat = MP4Cover.FORMAT_JPEG
528 atom_data.append(
529 Atom.render("data", struct.pack(">2I", imageformat, 0) + cover))
530 return Atom.render(key, "".join(atom_data))
532 def __parse_text(self, atom, data, expected_flags=1):
533 value = [text.decode('utf-8', 'replace') for flags, text
534 in self.__parse_data(atom, data)
535 if flags == expected_flags]
536 if value:
537 self[atom.name] = value
538 def __render_text(self, key, value, flags=1):
539 if isinstance(value, basestring):
540 value = [value]
541 return self.__render_data(
542 key, flags, map(utf8, value))
544 def delete(self, filename):
545 self.clear()
546 self.save(filename)
548 __atoms = {
549 "----": (__parse_freeform, __render_freeform),
550 "trkn": (__parse_pair, __render_pair),
551 "disk": (__parse_pair, __render_pair_no_trailing),
552 "gnre": (__parse_genre, None),
553 "tmpo": (__parse_tempo, __render_tempo),
554 "cpil": (__parse_bool, __render_bool),
555 "pgap": (__parse_bool, __render_bool),
556 "pcst": (__parse_bool, __render_bool),
557 "covr": (__parse_cover, __render_cover),
558 "purl": (__parse_text, __render_text, 0),
559 "egid": (__parse_text, __render_text, 0),
562 def pprint(self):
563 values = []
564 for key, value in self.iteritems():
565 key = key.decode('latin1')
566 if key == "covr":
567 values.append("%s=%s" % (key, ", ".join(
568 ["[%d bytes of data]" % len(data) for data in value])))
569 elif isinstance(value, list):
570 values.append("%s=%s" % (key, " / ".join(map(unicode, value))))
571 else:
572 values.append("%s=%s" % (key, value))
573 return "\n".join(values)
575 class MP4Info(object):
576 """MPEG-4 stream information.
578 Attributes:
579 bitrate -- bitrate in bits per second, as an int
580 length -- file length in seconds, as a float
581 channels -- number of audio channels
582 sample_rate -- audio sampling rate in Hz
583 bits_per_sample -- bits per sample
586 bitrate = 0
587 channels = 0
588 sample_rate = 0
589 bits_per_sample = 0
591 def __init__(self, atoms, fileobj):
592 for trak in list(atoms["moov"].findall("trak")):
593 hdlr = trak["mdia", "hdlr"]
594 fileobj.seek(hdlr.offset)
595 data = fileobj.read(hdlr.length)
596 if data[16:20] == "soun":
597 break
598 else:
599 raise MP4StreamInfoError("track has no audio data")
601 mdhd = trak["mdia", "mdhd"]
602 fileobj.seek(mdhd.offset)
603 data = fileobj.read(mdhd.length)
604 if ord(data[8]) == 0:
605 offset = 20
606 fmt = ">2I"
607 else:
608 offset = 28
609 fmt = ">IQ"
610 end = offset + struct.calcsize(fmt)
611 unit, length = struct.unpack(fmt, data[offset:end])
612 self.length = float(length) / unit
614 try:
615 atom = trak["mdia", "minf", "stbl", "stsd"]
616 fileobj.seek(atom.offset)
617 data = fileobj.read(atom.length)
618 if data[20:24] == "mp4a":
619 length = cdata.uint_be(data[16:20])
620 (self.channels, self.bits_per_sample, _,
621 self.sample_rate) = struct.unpack(">3HI", data[40:50])
622 # ES descriptor type
623 if data[56:60] == "esds" and ord(data[64:65]) == 0x03:
624 pos = 65
625 # skip extended descriptor type tag, length, ES ID
626 # and stream priority
627 if data[pos:pos+3] == "\x80\x80\x80":
628 pos += 3
629 pos += 4
630 # decoder config descriptor type
631 if ord(data[pos]) == 0x04:
632 pos += 1
633 # skip extended descriptor type tag, length,
634 # object type ID, stream type, buffer size
635 # and maximum bitrate
636 if data[pos:pos+3] == "\x80\x80\x80":
637 pos += 3
638 pos += 10
639 # average bitrate
640 self.bitrate = cdata.uint_be(data[pos:pos+4])
641 except (ValueError, KeyError):
642 # stsd atoms are optional
643 pass
645 def pprint(self):
646 return "MPEG-4 audio, %.2f seconds, %d bps" % (
647 self.length, self.bitrate)
649 class MP4(FileType):
650 """An MPEG-4 audio file, probably containing AAC.
652 If more than one track is present in the file, the first is used.
653 Only audio ('soun') tracks will be read.
656 MP4Tags = MP4Tags
658 _mimes = ["audio/mp4", "audio/x-m4a", "audio/mpeg4", "audio/aac"]
660 def load(self, filename):
661 self.filename = filename
662 fileobj = open(filename, "rb")
663 try:
664 atoms = Atoms(fileobj)
665 try: self.info = MP4Info(atoms, fileobj)
666 except StandardError, err:
667 raise MP4StreamInfoError, err, sys.exc_info()[2]
668 try: self.tags = self.MP4Tags(atoms, fileobj)
669 except MP4MetadataError:
670 self.tags = None
671 except StandardError, err:
672 raise MP4MetadataError, err, sys.exc_info()[2]
673 finally:
674 fileobj.close()
676 def add_tags(self):
677 self.tags = self.MP4Tags()
679 def score(filename, fileobj, header):
680 return ("ftyp" in header) + ("mp4" in header)
681 score = staticmethod(score)
683 Open = MP4
685 def delete(filename):
686 """Remove tags from a file."""
687 MP4(filename).delete()