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