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
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']
47 format -- format of the image (either FORMAT_JPEG or FORMAT_PNG)
52 def __new__(cls
, data
, format
=None):
53 self
= str.__new
__(cls
, data
)
54 if format
is None: format
= MP4Cover
.FORMAT_JPEG
59 """An individual atom.
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.
72 def __init__(self
, fileobj
):
73 self
.offset
= fileobj
.tell()
74 self
.length
, self
.name
= struct
.unpack(">I4s", fileobj
.read(8))
76 self
.length
, = struct
.unpack(">Q", fileobj
.read(8))
80 if self
.name
in _CONTAINERS
:
82 fileobj
.seek(_SKIP_SIZE
.get(self
.name
, 0), 1)
83 while fileobj
.tell() < self
.offset
+ self
.length
:
84 self
.children
.append(Atom(fileobj
))
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
92 if size
<= 0xFFFFFFFF:
93 return struct
.pack(">I4s", size
, name
) + data
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
:
105 for atom
in child
.findall(name
, True):
108 def __getitem__(self
, remaining
):
109 """Look up a child atom, potentially recursively.
111 e.g. atom['udta', 'meta'] => <Atom name='meta' ...>
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:]]
121 raise KeyError, "%r not found" % remaining
[0]
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
)
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
)
135 """Root atoms in a given file.
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
):
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
159 path
.append(path
[-1][name
,])
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:]]
174 raise KeyError, "%s not found" % names
[0]
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
186 Text values (multiple values per key are supported):
187 '\xa9nam' -- track title
190 'aART' -- album artist
191 '\xa9wrt' -- composer
194 'desc' -- description (usually used in podcasts)
195 'purd' -- purchase date
196 '\xa9grp' -- grouping
199 'purl' -- podcast URL
200 'egid' -- podcast episode GUID
201 'catg' -- podcast category
202 'keyw' -- podcast keywords
203 '\xa9too' -- encoded by
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
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
223 'tmpo' -- tempo/BPM, 16 bit int
224 'covr' -- cover artwork, list of MP4Cover objects (which are
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
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
))))
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."""
270 items
.sort(self
.__key
_sort
)
271 for key
, value
in items
:
272 info
= self
.__atoms
.get(key
[:4], (None, MP4Tags
.__render
_text
))
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+")
282 atoms
= Atoms(fileobj
)
284 path
= atoms
.path("moov", "udta", "meta", "ilst")
286 self
.__save
_new
(fileobj
, atoms
, data
)
288 self
.__save
_existing
(fileobj
, atoms
, path
, data
)
292 def __pad_ilst(self
, data
, length
=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)
300 "meta", "\x00\x00\x00\x00" + hdlr
+ ilst
+ self
.__pad
_ilst
(ilst
))
302 path
= atoms
.path("moov", "udta")
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
)
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.
320 # Check for padding "free" atoms
322 index
= meta
.children
.index(ilst
)
324 prev
= meta
.children
[index
-1]
325 if prev
.name
== "free":
327 length
+= prev
.length
331 next
= meta
.children
[index
+1]
332 if next
.name
== "free":
333 length
+= next
.length
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
)
343 data
+= self
.__pad
_ilst
(data
, -delta
- 8)
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."""
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
:
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
:
374 fileobj
.seek(atom
.offset
+ 9)
375 data
= fileobj
.read(atom
.length
- 9)
376 flags
= cdata
.uint_be("\x00" + data
[:3])
378 o
= cdata
.ulonglong_be(data
[7:15])
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."""
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
)
394 for atom
in atoms
["moof"].findall('tfhd', True):
395 self
.__update
_tfhd
(fileobj
, atom
, delta
, offset
)
399 def __parse_data(self
, atom
, data
):
401 while pos
< atom
.length
- 8:
402 length
, name
, flags
= struct
.unpack(">I4sI", data
[pos
:pos
+12])
404 raise MP4MetadataError(
405 "unexpected atom %r inside %r" % (name
, atom
.name
))
406 yield flags
, data
[pos
+16: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
)
413 def __parse_freeform(self
, atom
, data
):
414 length
= cdata
.uint_be(data
[:4])
415 mean
= data
[12:length
]
417 length
= cdata
.uint_be(data
[pos
:pos
+4])
418 name
= data
[pos
+12:pos
+length
]
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
])
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
):
436 return Atom
.render("----", mean
+ name
+ "".join([
437 struct
.pack(">I4s2I", len(data
) + 16, "data", 1, 0) + data
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
):
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))
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
):
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
))
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
):
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
)
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
):
498 while pos
< atom
.length
- 8:
499 length
, name
, format
= struct
.unpack(">I4sI", data
[pos
:pos
+12])
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
))
508 def __render_cover(self
, key
, value
):
511 try: format
= cover
.format
512 except AttributeError: format
= MP4Cover
.FORMAT_JPEG
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
]
522 self
[atom
.name
] = value
523 def __render_text(self
, key
, value
, flags
=1):
524 if isinstance(value
, basestring
):
526 return self
.__render
_data
(
527 key
, flags
, [text
.encode('utf-8') for text
in value
])
529 def delete(self
, filename
):
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),
549 for key
, value
in self
.iteritems():
550 key
= key
.decode('latin1')
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
))))
557 values
.append("%s=%s" % (key
, value
))
558 return "\n".join(values
)
560 class MP4Info(object):
561 """MPEG-4 stream information.
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
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":
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:
595 end
= offset
+ struct
.calcsize(format
)
596 unit
, length
= struct
.unpack(format
, data
[offset
:end
])
597 self
.length
= float(length
) / unit
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])
608 if data
[56:60] == "esds" and ord(data
[64:65]) == 0x03:
610 # skip extended descriptor type tag, length, ES ID
611 # and stream priority
612 if data
[pos
:pos
+3] == "\x80\x80\x80":
615 # decoder config descriptor type
616 if ord(data
[pos
]) == 0x04:
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":
625 self
.bitrate
= cdata
.uint_be(data
[pos
:pos
+4])
626 except (ValueError, KeyError):
627 # stsd atoms are optional
631 return "MPEG-4 audio, %.2f seconds, %d bps" % (
632 self
.length
, self
.bitrate
)
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")
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
:
654 except StandardError, err
:
655 raise MP4MetadataError
, err
, sys
.exc_info()[2]
660 self
.tags
= MP4Tags()
662 def score(filename
, fileobj
, header
):
663 return ("ftyp" in header
) + ("mp4" in header
)
664 score
= staticmethod(score
)
668 def delete(filename
):
669 """Remove tags from a file."""
670 MP4(filename
).delete()