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
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']
47 imageformat -- format of the image (either FORMAT_JPEG or FORMAT_PNG)
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
57 except AttributeError:
58 self
.format
= imageformat
62 """An individual atom.
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.
75 def __init__(self
, fileobj
):
76 self
.offset
= fileobj
.tell()
77 self
.length
, self
.name
= struct
.unpack(">I4s", fileobj
.read(8))
79 self
.length
, = struct
.unpack(">Q", fileobj
.read(8))
83 if self
.name
in _CONTAINERS
:
85 fileobj
.seek(_SKIP_SIZE
.get(self
.name
, 0), 1)
86 while fileobj
.tell() < self
.offset
+ self
.length
:
87 self
.children
.append(Atom(fileobj
))
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
95 if size
<= 0xFFFFFFFF:
96 return struct
.pack(">I4s", size
, name
) + data
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
:
108 for atom
in child
.findall(name
, True):
111 def __getitem__(self
, remaining
):
112 """Look up a child atom, potentially recursively.
114 e.g. atom['udta', 'meta'] => <Atom name='meta' ...>
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:]]
124 raise KeyError, "%r not found" % remaining
[0]
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
)
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
)
138 """Root atoms in a given file.
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
):
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
162 path
.append(path
[-1][name
,])
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:]]
177 raise KeyError, "%s not found" % names
[0]
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
189 Text values (multiple values per key are supported):
190 '\xa9nam' -- track title
193 'aART' -- album artist
194 '\xa9wrt' -- composer
197 'desc' -- description (usually used in podcasts)
198 'purd' -- purchase date
199 '\xa9grp' -- grouping
202 'purl' -- podcast URL
203 'egid' -- podcast episode GUID
204 'catg' -- podcast category
205 'keyw' -- podcast keywords
206 '\xa9too' -- encoded by
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
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
226 'tmpo' -- tempo/BPM, 16 bit int
227 'covr' -- cover artwork, list of MP4Cover objects (which are
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
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
):
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
))))
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."""
275 items
.sort(self
.__key
_sort
)
276 for key
, value
in items
:
277 info
= self
.__atoms
.get(key
[:4], (None, type(self
).__render
_text
))
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+")
287 atoms
= Atoms(fileobj
)
289 path
= atoms
.path("moov", "udta", "meta", "ilst")
291 self
.__save
_new
(fileobj
, atoms
, data
)
293 self
.__save
_existing
(fileobj
, atoms
, path
, data
)
297 def __pad_ilst(self
, data
, length
=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)
305 "meta", "\x00\x00\x00\x00" + hdlr
+ ilst
+ self
.__pad
_ilst
(ilst
))
307 path
= atoms
.path("moov", "udta")
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
)
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.
325 # Check for padding "free" atoms
327 index
= meta
.children
.index(ilst
)
329 prev
= meta
.children
[index
-1]
330 if prev
.name
== "free":
332 length
+= prev
.length
336 next
= meta
.children
[index
+1]
337 if next
.name
== "free":
338 length
+= next
.length
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
)
348 data
+= self
.__pad
_ilst
(data
, -delta
- 8)
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."""
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
))
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
:
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
:
385 fileobj
.seek(atom
.offset
+ 9)
386 data
= fileobj
.read(atom
.length
- 9)
387 flags
= cdata
.uint_be("\x00" + data
[:3])
389 o
= cdata
.ulonglong_be(data
[7:15])
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."""
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
)
405 for atom
in atoms
["moof"].findall('tfhd', True):
406 self
.__update
_tfhd
(fileobj
, atom
, delta
, offset
)
410 def __parse_data(self
, atom
, data
):
412 while pos
< atom
.length
- 8:
413 length
, name
, flags
= struct
.unpack(">I4sI", data
[pos
:pos
+12])
415 raise MP4MetadataError(
416 "unexpected atom %r inside %r" % (name
, atom
.name
))
417 yield flags
, data
[pos
+16: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
)
424 def __parse_freeform(self
, atom
, data
):
425 length
= cdata
.uint_be(data
[:4])
426 mean
= data
[12:length
]
428 length
= cdata
.uint_be(data
[pos
:pos
+4])
429 name
= data
[pos
+12:pos
+length
]
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
])
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
):
447 return Atom
.render("----", mean
+ name
+ "".join([
448 struct
.pack(">I4s2I", len(data
) + 16, "data", 1, 0) + data
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
):
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))
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
):
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
))
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
):
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
)
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
):
509 while pos
< atom
.length
- 8:
510 length
, name
, imageformat
= struct
.unpack(">I4sI", data
[pos
:pos
+12])
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
))
523 def __render_cover(self
, key
, value
):
526 try: imageformat
= cover
.imageformat
527 except AttributeError: imageformat
= MP4Cover
.FORMAT_JPEG
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
]
537 self
[atom
.name
] = value
538 def __render_text(self
, key
, value
, flags
=1):
539 if isinstance(value
, basestring
):
541 return self
.__render
_data
(
542 key
, flags
, map(utf8
, value
))
544 def delete(self
, filename
):
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),
564 for key
, value
in self
.iteritems():
565 key
= key
.decode('latin1')
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
))))
572 values
.append("%s=%s" % (key
, value
))
573 return "\n".join(values
)
575 class MP4Info(object):
576 """MPEG-4 stream information.
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
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":
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:
610 end
= offset
+ struct
.calcsize(fmt
)
611 unit
, length
= struct
.unpack(fmt
, data
[offset
:end
])
612 self
.length
= float(length
) / unit
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])
623 if data
[56:60] == "esds" and ord(data
[64:65]) == 0x03:
625 # skip extended descriptor type tag, length, ES ID
626 # and stream priority
627 if data
[pos
:pos
+3] == "\x80\x80\x80":
630 # decoder config descriptor type
631 if ord(data
[pos
]) == 0x04:
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":
640 self
.bitrate
= cdata
.uint_be(data
[pos
:pos
+4])
641 except (ValueError, KeyError):
642 # stsd atoms are optional
646 return "MPEG-4 audio, %.2f seconds, %d bps" % (
647 self
.length
, self
.bitrate
)
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.
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")
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
:
671 except StandardError, err
:
672 raise MP4MetadataError
, err
, sys
.exc_info()[2]
677 self
.tags
= self
.MP4Tags()
679 def score(filename
, fileobj
, header
):
680 return ("ftyp" in header
) + ("mp4" in header
)
681 score
= staticmethod(score
)
685 def delete(filename
):
686 """Remove tags from a file."""
687 MP4(filename
).delete()