Allow passthrough of MPEG-1 video, but force remuxing, since TiVos don't
[pyTivo/wmcbrine.git] / mutagen / mp4.py
blob5ee9cfeb2b86fe751418d27594aa71770b70f6e4
1 # Copyright 2006 Joe Wreschnig <piman@sacredchao.net>
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
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 format -- format of the image (either FORMAT_JPEG or FORMAT_PNG)
48 """
49 FORMAT_JPEG = 0x0D
50 FORMAT_PNG = 0x0E
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
58 class Atom(object):
59 """An individual atom.
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
67 This structure should only be used internally by Mutagen.
68 """
70 children = None
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
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)
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)
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
108 def __getitem__(self, remaining):
109 """Look up a child atom, potentially recursively.
111 e.g. atom['udta', 'meta'] => <Atom name='meta' ...>
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]
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)
134 class Atoms(object):
135 """Root atoms in a given file.
137 Attributes:
138 atoms -- a list of top-level atoms as Atom objects
140 This structure should only be used internally by Mutagen.
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))
150 def path(self, *names):
151 """Look up and return the complete path of an atom.
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.
157 path = [self]
158 for name in names:
159 path.append(path[-1][name,])
160 return path[1:]
162 def __getitem__(self, names):
163 """Look up a child atom.
165 'names' may be a list of atoms (['moov', 'udta']) or a string
166 specifying the complete path ('moov.udta').
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]
176 def __repr__(self):
177 return "\n".join([repr(child) for child in self.atoms])
179 class MP4Tags(DictProxy, Metadata):
180 """Dictionary containing Apple iTunes metadata list key/values.
182 Keys are four byte identifiers, except for freeform ('----')
183 keys. Values are usually unicode strings, but some atoms have a
184 special structure:
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
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)
218 Tuples of ints (multiple values per key are supported):
219 'trkn' -- track number, total tracks
220 'disk' -- disc number, total discs
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.
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.
234 MP4 tag data cannot exist outside of the structure of an MP4 file,
235 so this class should not be manually instantiated.
237 Unknown non-text tags are removed.
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:])
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)
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))
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()
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)
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)
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
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
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
346 fileobj.seek(offset)
347 fileobj.write(data)
348 self.__update_parents(fileobj, path, delta)
349 self.__update_offsets(fileobj, atoms, delta, offset)
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))
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))
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))
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
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]))
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]))
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)
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)
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
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)]
474 def __render_tempo(self, key, value):
475 try:
476 if len(value) == 0:
477 return self.__render_data(key, 0x15, "")
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")
486 values = map(cdata.to_ushort_be, value)
487 return self.__render_data(key, 0x15, values)
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))])
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))
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])
529 def delete(self, filename):
530 self.clear()
531 self.save(filename)
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),
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)
560 class MP4Info(object):
561 """MPEG-4 stream information.
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
571 bitrate = 0
572 channels = 0
573 sample_rate = 0
574 bits_per_sample = 0
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")
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
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
630 def pprint(self):
631 return "MPEG-4 audio, %.2f seconds, %d bps" % (
632 self.length, self.bitrate)
634 class MP4(FileType):
635 """An MPEG-4 audio file, probably containing AAC.
637 If more than one track is present in the file, the first is used.
638 Only audio ('soun') tracks will be read.
641 _mimes = ["audio/mp4", "audio/x-m4a", "audio/mpeg4", "audio/aac"]
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()
659 def add_tags(self):
660 self.tags = MP4Tags()
662 def score(filename, fileobj, header):
663 return ("ftyp" in header) + ("mp4" in header)
664 score = staticmethod(score)
666 Open = MP4
668 def delete(filename):
669 """Remove tags from a file."""
670 MP4(filename).delete()