diff -Nru mutagen-1.23/COPYING mutagen-1.30/COPYING --- mutagen-1.23/COPYING 2013-05-21 21:28:42.000000000 +0000 +++ mutagen-1.30/COPYING 2015-02-01 19:07:57.000000000 +0000 @@ -1,12 +1,12 @@ - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 - Copyright (C) 1989, 1991 Free Software Foundation, Inc. - 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - Preamble + Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public @@ -15,7 +15,7 @@ General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by -the GNU Library General Public License instead.) You can apply it to +the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not @@ -55,8 +55,8 @@ The precise terms and conditions for copying, distribution and modification follow. - - GNU GENERAL PUBLIC LICENSE + + GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains @@ -110,7 +110,7 @@ License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) - + These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in @@ -168,7 +168,7 @@ access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. - + 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is @@ -225,7 +225,7 @@ This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - + 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License @@ -255,7 +255,7 @@ of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - NO WARRANTY + NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN @@ -277,9 +277,9 @@ PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it @@ -303,17 +303,16 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - You should have received a copy of the GNU General Public License - along with this program; if not, write to the Free Software - Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA - + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: - Gnomovision version 69, Copyright (C) year name of author + Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. @@ -336,5 +335,5 @@ This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Library General +library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. diff -Nru mutagen-1.23/debian/changelog mutagen-1.30/debian/changelog --- mutagen-1.23/debian/changelog 2014-08-05 13:32:18.000000000 +0000 +++ mutagen-1.30/debian/changelog 2015-09-15 07:46:52.000000000 +0000 @@ -1,3 +1,73 @@ +mutagen (1.30-1ubuntu2) wily; urgency=medium + + * Don't build using python-sphinx-rtd-theme (universe). + + -- Matthias Klose Tue, 15 Sep 2015 09:46:15 +0200 + +mutagen (1.30-1ubuntu1) wily; urgency=medium + + * Merge with Debian; remaining changes: + * Merge from Debian unstable. Remaining changes: + - debian/control: Drop faad and oggz-tools build dependencies (in + universe). + + -- Matthias Klose Thu, 10 Sep 2015 17:01:40 +0200 + +mutagen (1.30-1) unstable; urgency=medium + + * New upstream release. + + -- Tristan Seligmann Sat, 29 Aug 2015 07:40:43 +0200 + +mutagen (1.29-1) unstable; urgency=medium + + * New upstream release. + + -- Tristan Seligmann Fri, 31 Jul 2015 08:59:21 +0200 + +mutagen (1.28-2) unstable; urgency=medium + + * Reupload to unstable with minor changes. + * Use sphinx inventory from python-doc instead of the web. + + -- Tristan Seligmann Fri, 01 May 2015 01:38:04 +0200 + +mutagen (1.28-1) experimental; urgency=low + + * New upstream release. + - New release signing key. + + -- Tristan Seligmann Sun, 15 Mar 2015 14:25:12 +0200 + +mutagen (1.27-1) experimental; urgency=low + + * New upstream release. + + -- Tristan Seligmann Mon, 08 Dec 2014 01:45:10 +0200 + +mutagen (1.25.1-1) unstable; urgency=low + + * New upstream release. + * Make debian/copyright DEP-5 compliant. + * Update debian/copyright. + * Enable signature verification in debian/watch. + + -- Tristan Seligmann Sun, 19 Oct 2014 15:23:22 +0200 + +mutagen (1.25-1) unstable; urgency=medium + + * New upstream release. + * Build python3-mutagen package. + * Bump Standards-Version. + + -- Tristan Seligmann Sat, 04 Oct 2014 00:40:46 +0200 + +mutagen (1.24-1) unstable; urgency=medium + + * New upstream release. + + -- Tristan Seligmann Sun, 28 Sep 2014 00:49:44 +0200 + mutagen (1.23-2ubuntu1) utopic; urgency=medium * Merge from Debian unstable. Remaining changes: @@ -23,20 +93,6 @@ -- Tristan Seligmann Sat, 14 Jun 2014 14:01:23 +0200 -mutagen (1.22-1ubuntu2) trusty; urgency=medium - - * Rebuild to drop files installed into /usr/share/pyshared. - - -- Matthias Klose Sun, 23 Feb 2014 13:49:01 +0000 - -mutagen (1.22-1ubuntu1) trusty; urgency=low - - * Merge from Debian unstable. Remaining changes: - - debian/control: Drop faad and oggz-tools build dependencies (in - universe). - - -- Daniel T Chen Wed, 27 Nov 2013 22:10:48 +0000 - mutagen (1.22-1) unstable; urgency=low [ Tristan Seligmann ] @@ -56,14 +112,6 @@ -- Tristan Seligmann Mon, 25 Nov 2013 01:06:19 +0200 -mutagen (1.21-1ubuntu1) saucy; urgency=low - - * Merge with Debian unstable. Remaining Ubuntu changes: - - debian/control: Drop unnecessary faad and oggz-tools build dependencies, - they are in universe. - - -- Martin Pitt Tue, 23 Jul 2013 07:40:57 +0200 - mutagen (1.21-1) unstable; urgency=low * Team upload. @@ -291,4 +339,3 @@ * Initial release. (Closes: #353505) -- Joe Wreschnig Tue, 21 Feb 2006 03:06:14 -0600 - diff -Nru mutagen-1.23/debian/control mutagen-1.30/debian/control --- mutagen-1.23/debian/control 2014-08-05 13:30:43.000000000 +0000 +++ mutagen-1.30/debian/control 2015-09-15 07:47:08.000000000 +0000 @@ -11,11 +11,14 @@ flac, libc-bin (>= 2.13), python-all (>= 2.6.6-3~), + python3-all, + pypy, python-docutils, + python2.7-doc, python-sphinx (>= 1.0.7+dfsg), vorbis-tools, dh-python -Standards-Version: 3.9.5 +Standards-Version: 3.9.6 X-Python-Version: >= 2.6 Vcs-Svn: svn://anonscm.debian.org/python-modules/packages/mutagen/trunk/ Vcs-Browser: http://anonscm.debian.org/viewvc/python-modules/packages/mutagen/trunk/ @@ -36,20 +39,36 @@ Ogg streams on an individual packet/page level. -#Package: pypy-mutagen -#Architecture: all -#Depends: ${misc:Depends}, ${python:Depends} -#Suggests: python-mutagen-doc -#Description: audio metadata editing library (PyPy) -# Mutagen is a Python module to handle audio metadata. It supports FLAC, -# M4A, MP3, Ogg FLAC, Ogg Speex, Ogg Theora, Ogg Vorbis, True Audio, and -# WavPack audio files. All versions of ID3v2 are supported, and all -# standard ID3v2.4 frames are parsed. It can read Xing headers to -# accurately calculate the bitrate and length of MP3s. ID3 and APEv2 -# tags can be edited regardless of audio format. It can also manipulate -# Ogg streams on an individual packet/page level. -# . -# This package is built for PyPy. +Package: pypy-mutagen +Architecture: all +Depends: ${misc:Depends}, ${pypy:Depends} +Suggests: python-mutagen-doc +Description: audio metadata editing library (PyPy) + Mutagen is a Python module to handle audio metadata. It supports FLAC, + M4A, MP3, Ogg FLAC, Ogg Speex, Ogg Theora, Ogg Vorbis, True Audio, and + WavPack audio files. All versions of ID3v2 are supported, and all + standard ID3v2.4 frames are parsed. It can read Xing headers to + accurately calculate the bitrate and length of MP3s. ID3 and APEv2 + tags can be edited regardless of audio format. It can also manipulate + Ogg streams on an individual packet/page level. + . + This package is built for PyPy. + + +Package: python3-mutagen +Architecture: all +Depends: ${misc:Depends}, ${python3:Depends} +Suggests: python-mutagen-doc +Description: audio metadata editing library (Python 3) + Mutagen is a Python module to handle audio metadata. It supports FLAC, + M4A, MP3, Ogg FLAC, Ogg Speex, Ogg Theora, Ogg Vorbis, True Audio, and + WavPack audio files. All versions of ID3v2 are supported, and all + standard ID3v2.4 frames are parsed. It can read Xing headers to + accurately calculate the bitrate and length of MP3s. ID3 and APEv2 + tags can be edited regardless of audio format. It can also manipulate + Ogg streams on an individual packet/page level. + . + This package is built for Python 3. Package: python-mutagen-doc diff -Nru mutagen-1.23/debian/copyright mutagen-1.30/debian/copyright --- mutagen-1.23/debian/copyright 2013-11-22 18:19:55.000000000 +0000 +++ mutagen-1.30/debian/copyright 2014-10-19 13:15:03.000000000 +0000 @@ -1,46 +1,26 @@ -This package was debianized by Joe Wreschnig on -Tue, 21 Feb 2006 02:24:36 -0600 -This package was adopted by Tristan Seligmann on -Wed, Jun 21 2006 21:25:11 +0200 - -It was downloaded from: - http://www.sacredchao.net/quodlibet/wiki/Development/Mutagen +Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: Mutagen +Upstream-Contact: Christoph Reiter +Source: https://bitbucket.org/lazka/mutagen Files: * -Copyright: Joe Wreschnig -License: GPL-2 - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License version 2 as - published by the Free Software Foundation. - -Files: tools/mid3conv -Copyright: 2006 Emfox Zhou -License: GPL-2 - -Files: tools/mutagen-pony -Copyright: 2005 Joe Wreschnig, Michael Urman -License: GPL-2 - -Files: mutagen/__init__.py, id3.py -Copyright: 2005 Michael Urman -License: GPL-2 - -Files: monkeysaudio.py, optimfrog.py -Copyright: 2006 Lukas Lalinsky -License: GPL-2 - -Files: mutagen/musepack.py -Copyright: 2006 Lukas Lalinsky - 2012 Christoph Reiter +Copyright: 2005-2006, 2009 Joe Wreschnig + 2005 Michael Urman + 2006-2007 Lucas Lalinsky + 2012-2014 Christoph Reiter + 2014 Ben Ockmore + 2014 Evan Purheiser License: GPL-2 -Files: asf.py -Copyright: 2005-2006 Joe Wreschnig, 2006-2007 Lukas Lalinsky +Files: debian/* +Copyright: 2005-2006 Joe Wreschnig + 2006-2014 Tristan Seligmann License: GPL-2 -Files: docs/id3_frames_gen.py -Copyright: 2013 Christoph Reiter License: GPL-2 - -On Debian systems, the GNU GPL version 2 can be found in -/usr/share/common-licenses/GPL-2. + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License version 2 as + published by the Free Software Foundation. + . + On Debian systems, the GNU GPL version 2 can be found in + /usr/share/common-licenses/GPL-2. diff -Nru mutagen-1.23/debian/patches/no-sphinx-rtd-theme.diff mutagen-1.30/debian/patches/no-sphinx-rtd-theme.diff --- mutagen-1.23/debian/patches/no-sphinx-rtd-theme.diff 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/debian/patches/no-sphinx-rtd-theme.diff 2015-09-15 07:47:44.000000000 +0000 @@ -0,0 +1,18 @@ +Index: b/docs/conf.py +=================================================================== +--- a/docs/conf.py ++++ b/docs/conf.py +@@ -2,7 +2,6 @@ + + import os + import sys +-import sphinx_rtd_theme + + dir_ = os.path.dirname(os.path.realpath(__file__)) + sys.path.insert(0, dir_) +@@ -23,5 +22,3 @@ release = mutagen.version_string + exclude_patterns = ['_build'] + bug_url_template = "http://bitbucket.org/lazka/mutagen/issue/%s" + pr_url_template = "http://bitbucket.org/lazka/mutagen/pull-request/%s" +-html_theme = "sphinx_rtd_theme" +-html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] diff -Nru mutagen-1.23/debian/patches/series mutagen-1.30/debian/patches/series --- mutagen-1.23/debian/patches/series 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/debian/patches/series 2015-09-15 07:47:24.000000000 +0000 @@ -0,0 +1,3 @@ +use-rtd-package +use-system-inventory +no-sphinx-rtd-theme.diff diff -Nru mutagen-1.23/debian/patches/use-rtd-package mutagen-1.30/debian/patches/use-rtd-package --- mutagen-1.23/debian/patches/use-rtd-package 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/debian/patches/use-rtd-package 2014-12-08 01:05:19.000000000 +0000 @@ -0,0 +1,45 @@ +Description: Use the Debian package of the sphinx-rtd theme +Author: Tristan Seligmann +Forwarded: no +Last-Update: 2014-12-08 +--- +This patch header follows DEP-3: http://dep.debian.net/deps/dep3/ +Index: mutagen-1.27/docs/Makefile +=================================================================== +--- mutagen-1.27.orig/docs/Makefile 2014-12-08 02:40:53.143869468 +0200 ++++ mutagen-1.27/docs/Makefile 2014-12-08 02:41:05.508058240 +0200 +@@ -1,13 +1,7 @@ +-all: _rtd_theme +- sphinx-build -Dhtml_theme=_rtd_theme -Dhtml_theme_path=. -b html -n . _build ++all: ++ sphinx-build -b html -n . _build + + clean: +- rm -rf _build _rtd_theme ++ rm -rf _build + + .PHONY: clean +- +-_rtd_theme: +- wget https://github.com/snide/sphinx_rtd_theme/archive/master.tar.gz +- tar --strip-components=1 -zxvf master.tar.gz sphinx_rtd_theme-master/sphinx_rtd_theme +- mv sphinx_rtd_theme _rtd_theme +- rm master.tar.gz +Index: mutagen-1.27/docs/conf.py +=================================================================== +--- mutagen-1.27.orig/docs/conf.py 2014-11-26 16:48:15.000000000 +0200 ++++ mutagen-1.27/docs/conf.py 2014-12-08 02:41:47.108693509 +0200 +@@ -2,6 +2,7 @@ + + import os + import sys ++import sphinx_rtd_theme + + dir_ = os.path.dirname(os.path.realpath(__file__)) + sys.path.insert(0, dir_) +@@ -21,3 +22,5 @@ + exclude_patterns = ['_build'] + bug_url_template = "http://bitbucket.org/lazka/mutagen/issue/%s" + pr_url_template = "http://bitbucket.org/lazka/mutagen/pull-request/%s" ++html_theme = "sphinx_rtd_theme" ++html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] diff -Nru mutagen-1.23/debian/patches/use-system-inventory mutagen-1.30/debian/patches/use-system-inventory --- mutagen-1.23/debian/patches/use-system-inventory 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/debian/patches/use-system-inventory 2015-05-01 00:03:31.000000000 +0000 @@ -0,0 +1,19 @@ +Description: Use the system copy of the Python documentation inventory +Author: Tristan Seligmann +Forwarded: no +--- +This patch header follows DEP-3: http://dep.debian.net/deps/dep3/ +Index: mutagen-1.28/docs/conf.py +=================================================================== +--- mutagen-1.28.orig/docs/conf.py 2015-05-01 01:54:45.878455357 +0200 ++++ mutagen-1.28/docs/conf.py 2015-05-01 01:55:18.678998574 +0200 +@@ -11,7 +11,8 @@ + + + extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'ext'] +-intersphinx_mapping = {'python': ('http://docs.python.org/2.7', None)} ++intersphinx_mapping = {'python': ('http://docs.python.org/2.7', ++ '/usr/share/doc/python2.7/html/objects.inv')} + source_suffix = '.rst' + master_doc = 'index' + project = 'mutagen' diff -Nru mutagen-1.23/debian/python-mutagen.docs mutagen-1.30/debian/python-mutagen.docs --- mutagen-1.23/debian/python-mutagen.docs 2013-11-22 18:19:55.000000000 +0000 +++ mutagen-1.30/debian/python-mutagen.docs 2014-09-27 22:50:15.000000000 +0000 @@ -1 +1 @@ -README +README.rst diff -Nru mutagen-1.23/debian/rules mutagen-1.30/debian/rules --- mutagen-1.23/debian/rules 2014-08-05 13:31:19.000000000 +0000 +++ mutagen-1.30/debian/rules 2014-10-03 22:34:52.000000000 +0000 @@ -12,7 +12,7 @@ %: - dh $@ --with python2,sphinxdoc --buildsystem=pybuild + dh $@ --with python2,python3,pypy,sphinxdoc --buildsystem=pybuild override_dh_auto_build: @@ -25,9 +25,11 @@ override_dh_auto_install: dh_auto_install - # Don't ship binaries in the pypy package + # Don't ship binaries or manpages in the pypy or python3 packages rm -rf debian/pypy-mutagen/usr/bin \ - debian/pypy-mutagen/usr/lib/pypy/share + debian/pypy-mutagen/usr/lib/pypy/share \ + debian/python3-mutagen/usr/bin \ + debian/python3-mutagen/usr/share override_dh_installchangelogs: diff -Nru mutagen-1.23/debian/upstream/signing-key.asc mutagen-1.30/debian/upstream/signing-key.asc --- mutagen-1.23/debian/upstream/signing-key.asc 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/debian/upstream/signing-key.asc 2015-04-23 22:23:57.000000000 +0000 @@ -0,0 +1,123 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: GnuPG v1 + +mQGiBEgq0SoRBADe5hH15WQuMh0LYMrU4chDkRqwuUu7pvY7Ab7HW7fGmtsoSCan +m2EFx0BbzlKE4QRtgCnhLu5J/u/vit84Joh6SiRIGD6MVvCfIB2pOHoqynS5yxGE +mgJIplmI1uJaux0hMZJ1ePxvVHIMHQ/yVMcII4GxqR9gv1mmCVqEYEJ+lwCgx14u +rTXC5zoTnUpWPbPh2RD/hwcD/0tnSPVnF5Y5LbJB9CcErArae4hVxmj5+0YFEsnl +1HPVB6t8hZNkMmLRv7cldVPTMerhuYp6cRO15MiOJDQ+JfXcCsE46y3pZB7z8CqG +FxSZqVO+pZPvN2BHUtRCBk7HMX4gYlfPvnBiy2Fr3DDgqtTlNxZcfnQrsBAypxID +MYVCA/99uqM+7j5kismcDIEwSU7yCRYjmBn0AvZfEeU7h4dG7YPl3wO8TlCX8AfF +yIW4ecytBFHAYaimnw8dRwK33N2qXQWXMw8I722u0VHe73Ssyebl93IG0bUsKfJX +SA76fp+JqgFdYJpSpJaXpoEl/bLM0hAk/K6oyOFxStFCJJNLyLQqQ2hyaXN0b3Bo +IFJlaXRlciA8Y2hyaXN0b3BoLnJlaXRlckBnbXguYXQ+iGAEExECACAFAkgq0SoC +GyMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAKCRApz2ApDGk7j1YMAKCFkXXKlfD3 +QvztUe6XNCSiEmMKqACgq0kAqm303aVAAC29RYdj7LLz7tOIYwQTEQIAIwIbIwYL +CQgHAwIEFQIIAwQWAgMBAh4BAheABQJIKxpRAhkBAAoJECnPYCkMaTuPfWoAnjRP +Ty2LKJ4pQ8wVqMpzXAOElPblAJ9SubcSJFhvf35S74ydaqF2vU+vRbQtQ2hyaXN0 +b3BoIFJlaXRlciA8cmVpdGVyLmNocmlzdG9waEBnbWFpbC5jb20+iGUEExECACUC +GyMGCwkIBwMCBhUIAgkKCwQWAgMBAh4BAheABQJRh4Q/AhkBAAoJECnPYCkMaTuP +y58AoIFCSph665pI+d5+DJRfNAS5g4IXAKC/xzVpdWHnxD43qJXyFQIyiRyfILQ1 +Q2hyaXN0b3BoIFJlaXRlciA8Y2hyaXN0b3BoLnJlaXRlckBzdHVkZW50LnR1Z3Jh +ei5hdD6IYAQTEQIAIAUCSCsaNgIbIwYLCQgHAwIEFQIIAwQWAgMBAh4BAheAAAoJ +ECnPYCkMaTuPXEsAnj+kYdn7o+zTbljNnDqIrHYPSa03AJ0aTu8RMjLxruePPC9k +kaglvOTIz7kCDQRIKtEvEAgAsguIk0S8V7l6E9B0CsF5iafdu28ZlDi07vcgaRPS +JGqo9y2on7efq5ZHSkoh4ODpLRo/iBs/hPQ81RfwIEmCqK19L31hUaQhUU8KaOrh +3wmBsraZjDKn45JzSnH5bsVJvaRK11fLrB2lTlsGqT8WhqQoiYWRUeZe+jESp9xL +rXRNeTNrz/+wRkUOh6Z3cofoEzoSgX+1Pkskf0HsZP0TzGIF11e1q4ZILQGuVW+Q +cTU4dkclJkJ/3g3sqgdYpEH6WCEXcJa0BS+tfyL6Frv1FML21G0v/0AKdOD4HMul +IdKPAWHrKyFpaTpGU37ZU7+2PQCzGQuecHfpMylFerWN/wADBQgAj+YHP81UpQKK +mGrbMZ0OYmn5bz3RzvnG0qdT/x1CYKRU6ONPTGYIPq0iHEIZsivsHdLRl/JI0VMa +M8mm2O7uFOSlrG89xGQQCydSmGLcRaVFaSpBVUd+FstkCbe32JQBEGVydxr3D/7C +0wZvMhpTAGZsL07/Zkwrp35m0ocorIJhGMR6LJQcs0vxgL4iwuJprpVds4oWGk3X +9NroUujIZsn3bsP9IZxucgLMp9lWYL4T2EkITRsziXGQSfLqo+FO+rcCawwODHTQ +14oBIP8O+RztOUw2dYsjCRDoAblRB+7Te7ONk/qkbvFqpgpEKEAvyYuXmVNmadGk +Kho6S0mxB4hJBBgRAgAJBQJIKtEvAhsMAAoJECnPYCkMaTuPh4wAn0a8F3HqcNne +IBeQBbzjJL8cTESIAKCmrA1F9Mhk6AIESuwmuyT0hbavlA== +=5usW +-----END PGP PUBLIC KEY BLOCK----- +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBFS6p94BEADaoJNiq0QlaDdLXo8W5GDRyuaJnboMvEAb3DAkGeH2eonkJ3AM +a8qmcvNM1mKnRItQ+KJKkAprEsr+pXpMYR/j89kaRlChWT6FmLcIRQBjQLod8OKJ +G3T1/AQQMkFdIQVls+1hPvFQAJdwvZbViYMPu9CONsNsbODmwnubhz5CfbWEa0Jb +LN8efnDTkdNr6mgd9kg6ITAcOa6d4Qdj00YJY8LTN7VxLkVj+0C2hw3+FRBjhOBD +BoGWKm/hpVlZkSWWFjWWTFHGBJ2osPY7Anmwxc28IHRTojcy8qElcqVaK59e+mVz +YgOB5XLrbJ1Ms4IT5y8hNiglCXM3eJDx8DXXMTQKpAtqWj3nd/Q5L5HbdV3l8fYA +8wx5Pq+5RSWfM4oJWNZLs+kid43mvHmwKnSU2sRKtnnvGqLomKJIruvHH46WSexB +Yl+W/8cTrPph6uKB5WSUa62QK4QI9qV5QrHoRBxoAO/LRQFdndvs00jpd6kMUfho +v5Wquj/BANqaAan+W0/GNp+aOWabhRcfagM64UIeQa47TbNHGcUeGTP4Y52zMQ5R +S2yizLo3L0CchrZtcDqskhwsuOS6MlwmlyCi7qJ9IFciNiontROYY1B/7ouabdO9 +AectEiOspypMl36mJqnZxTGhCz1tIoWmhOmwbn+sxCVbUjKQQQInDZExlQARAQAB +tC1DaHJpc3RvcGggUmVpdGVyIDxyZWl0ZXIuY2hyaXN0b3BoQGdtYWlsLmNvbT6J +Aj0EEwEIACcFAlS6p94CGwMFCQHhM4AFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AA +CgkQRr12H3pJsOxngQ//b5Jkoye2yo8Zv9Dr8uQYwg20d4h+8MxjOMb+glpCn9Ee +nKBKc8LE7wIyd5qoq6P5jMb0lQv6bka67NX5A5skhKiqb+a4R0/JBVEyDyVYwlLm +oxye7Zk1n8lXFxrAsN/WCDnvnuGvShw2uEpp0phROeqmpvhN8OZTOe6PruJ+esLS +PScvKDNkVMiHeaespeG5qy8/JDzUtbuvnhf+wR3AsJReG4EVE7dgrKw+Jg+gd/3I +PYJYaZpHX98gknj2lDfJVyaH56O5inrtGzQBguDUw8lD2WliF9NgkwA5S13+L8n4 +jsga5rsI2R4oxoM2aEuW9m0xLsQwDhztVp56h9/VO8ASt5SG0NFvvD760Ys9VNFG +9ps/t2VbCe4Gjfx80ydnJDB457X+F8W94YVp5FrrZrQ082flEJPsTtmBRL/mwMrO +0DgRjUgcooZQVodu/OXZFIfK1GsktmzR7ZuzbW81loR6ehJqDXtttoHTuhtTOiUl +FqZMca0YdsS8GdqC8xJBtDyx+eTZiJaqeTY/1+mfPaSN5woXhQ6E3kdE8TrVyqtu +pXQiI5MLWmPojoPGUrkCRrNYnCLbwA62tYYDqWRixUpig1a5BcntEJi8rAOtmzBG +qnlW7lKg4mkv9fVDwTuV3a5cYZozH8myncpWWOSMkOS0WybyP3B96xu4A2wN3Pe5 +Ag0EVLqn3gEQAK6W/UXHrbglHARY2xRp6jDBIupb+Nf2wVoUuCcyIhlclmdljaCL +rRB5acO/SaRaQp47jetagw+koEDtaJpLU/FuqOa7qe5E3LthTf+MeqXTWbZVO/gw +rF5EtOobU6QNriYuXwACOEaCMaV/QBIAxyjMZ66K/4Zxd3FsvhmhJOK8eHtG/94c +GTb9TWikvLS3P0m24Sm1uQX7igPMTqKclNai0fjM8Wp732LKRC6Q65rpL0eOgLZi +KCtQ9TAHVqZ8hxR5Pv7hq+CQEPAUdyAolsedpCHcT+s/m4SfFFOWnaEkEbt60PsM +3V0RDuDKF7ZL6og2YWD+AKTM9X7XDXv0EfVXgm91nnhCZ2zyR5O8JDv4QWHcGoW/ +P3urYj8u/KVDz1wVOHf2BVl5hHSRb/wp8ELwWPrEpknjEMgbndZtnjIz+UDHBhtw +KWnK/nmuHLfETGdLRtz943b6bVaSRTipfteNpWySV26YGTtgxqk+DXwidmsYUoKI +1WU09UWNP2ksHEQxr9VFhYK1X7TDDiOpJxAAIRWSFY4vNAicb2UckeujsXstL4ai +in56goenzmdnjJCbQ02BSvJhrVj+GX+wBat4BLCpykhevEBKRixQdAf3T/x3V9Lu +3DA1QoKepCOeBIhorqlJKpeuKOa2if1ai5ktZrnMMUF8b2wQ5O3p1L7tABEBAAGJ +AiUEGAEIAA8FAlS6p94CGwwFCQHhM4AACgkQRr12H3pJsOw+Rg//XconEngPw0qU +ypxGygc3N5hy7rwPGpGDnrPCV6Md1yn3YJa7+VAwmwAA/1UUz3+SzuGSVfvLZc2J +YphOgY/cIn+w1ZI33IQOu88B3ft18OZTagaqIW+C4chFOX/rygw4HFqqpj33N6Ek +BUbv2bB0Lz8h1KyQS816dD7r6tF3LJsqe/M5fHGpQXVVMCBZ85Nkx6ks7D5QKbD4 ++8oGp3Z5E8xsJCfBUTwwpqCiiez6yOziu8cB8SsXCso07mW2dvl6/yVnODHeKavO +455k0ujJhwKSUCeANSZh5gF7g4MvCRL73gDelUnv0nKlxRVwKakzU8ZgJKY5isKr +WcvI8GaVmKxe3SSowcTNlF37fka8uzUGN3LXsQjG7RXUWsB+YCZU3ggZ6uemD9pU +Kt/a14D6uL7CvoS7vDqVtvDwFyOimCKf0bxoAQAtCCkCKdzUy71lAPZ35F4lcpHi +qxuSL5rNZbehSXOCXZ3TFFKZi9wJtVN+TmdsaWypHb4ycG99nswB9kH8mrgQsWma +0Vmi7U9nmwQbUTsT6haR/Sg3eTRW5Vhh0Qbi0DYm8kt9hBJvSZqWf3ymSzoeON+9 +HQAEQD16PIeED9o1HrVO2Hw/yags/9OujEYUSYCDmzSOZ9bppRK02vh/VbyHfFXw +83lIknKjHWLfSc2ESAxOFZZaOmycaSS5Ag0EVLqowwEQAMKpe9uJJI0vczOnY4KG +pClTO+bTzvcwM8EDWvxhTfJK0iFuVAp8HmqC4gxkPLv0s4apa5+h2TLqpSePd96F +1L3P5tHdwOnCo1fp127yrqUK68J7NvY+Y7Mr3UIq8vnDiezt+hhhWxF5axYAHQqO +yaybV2N81aC7+/9i/thxtH4Z5AGpEnlLEAAIQ92KXN15yp3JFsD+Ra1nJeoyPjeA +9AdxSoKfa/qiP/0Djc7c99stJDLMkivUgbEgfa0sS/Lpl2smgXADTF8mbvOPcgYW +rs8kWifS3ifjRtvqsxOpcJzifJSzCIr4Gn+Mh7ZD7L2m0mtk9d2uU8E1DDkcZqbu +3DIBsv2K5CVVs+skDaryfDmqBaobSphLurfXGXYYxcLPSqszlV1pqpCBEXWansVu +HXAppmlueA9lHQTrF8/PlqT9giZRnQroWvWUyt1MsFgFklcIn7LYf3n5xpgi3G8g +Y/LzcLsxkTgG1MQttUreSeLskm8UMn1j4/2m8yhe4IPVaGzRRPyah4KHFPMwT8N8 +JbGYFrUTlRJeE01TKGWSLtZnETxsuLxQ1KvQ+ZdYpOsYHcBHyuUO0stx/UqopoYZ +tRQ5iW8boASf90Z/eSgcUHsoK+k/OCn+/2lvBAptTO5rYzs14YV1K+lJAlvJxIiB +Opr3M5EkZMCwFmmVk9w5wWQJABEBAAGJBEQEGAEIAA8FAlS6qMMCGwIFCQHhM4AC +KQkQRr12H3pJsOzBXSAEGQEIAAYFAlS6qMMACgkQWmLQyrYmSWQNzg//awrtKhhs +LafVuiVTyybj7JpAzt6lzsZ2x/eIfPBA93g9BvPezHYIa4525QQ7xkjeq/Hi6+M5 +GGiijyTmMcApalVeinPTrPdum9C/LGaE3ZNHos8gyBkKn2HJB/OyOiMKMeR90tO2 +eeOhJPYL9JwpYPdOvXVv9RanYcRuXfpFr9C1VbN3EARi+YAJjox6Ii+qPlIoJDkn +CwqpcBS+zgAdditPbrdMWK+yS00uxBZKqAB7ayvL7QyiLQSZoQx69lNrcRvN6IcG +432i0NIuYX1ykwZ3bcGkdxS9KLfyNN+Ug1TOcTGuofj61gm3FRaUjmslp5tTCpkS +wIvFdLSUNm6bdvfdJTSMsjv4czIrvUT8cC2h7w/5V0IFHm0DkP0cv2Fdd4yX5Q+m +fMIVgxqIozzCCYqgIGUx6bHRTrlFQzjHWJ6zSwtLkAfJSymzxc1MpVzIk5g89Ryw +bXsJaDJUftP1cNNcHBLerlfvRJMIIQKeTYOaMvLySpqSJ7e63lRiGxIWstIPu/W/ +aR7se59tMtG8tnfpqu6mAD8O93Oe09jIE+vneZHu+aBqO++CtYRjdKMhzSwUQIg4 +JqFOPuMBDUYhxl09vrQNW6mAJnUQWlT1molC4oTLSms4P/R3tplRqPH6MZl/p6yN +nbqMCKV5+0oOliyvKV3rYnnfex8vDMZfZ0mOLg//TO/2SyaZ2WreqTrX5ALdviWB +NXN2fB5akCl75lZXLLSJzCoevgoU3LsL3LlyZNv7Qzlixq8DyKDaYU8dIUfepmWZ +suO6qe5sb/4psopmv1gOtEIdTDogmD/16zyqXOTpAXoA76JUTrnOPgnfgteuENZN +luuB313IiBJqgemIttARjGw3F8Ix02/7AWacZeosSQ0/AA97hTeZyer57yPmzuBn +UEmuAWnbbaR9gCaXsfx1M8RVpsHbFsEAitEA6XbICWh5U7nqDs8u8TgoMS/ImX2n +QWrA5q10HImvehZwepcwknprsP7RVn95DMHjWRvOheqKxq94TJFDaVlYjcCdRL8f +3XL6REz7EVu6TiciRoXWV4adFMWaGvvoNeWylfDgjcXJvWkzfHT16JJZvBlX/6hh +EUZYHknYJKB1eRyYG6u9VURO4So6dEs5HAKPk0tkoSyg4Fh0Cp/YcbOVnmQHZgY2 +5voEmXIO0XpuZ8d62gdoudXwGS8EjMegceDYBgaSV4WnVqp2o3T4ommJxGbNo9hw +/huaqXdeVps2gF6eYsmNhZJHll2qQGUw89fkphzH8/cTns3pgGCVZO7dIx25gIoN +VzOZ9BEkW2RZdCc47ko8yAg96cRJtUvdzgWxceb3e4aAKIYckUnpwnSWGTALWK2h +OUucyf45G+2Sw84hhLE= +=w2qU +-----END PGP PUBLIC KEY BLOCK----- diff -Nru mutagen-1.23/debian/watch mutagen-1.30/debian/watch --- mutagen-1.23/debian/watch 2014-06-14 11:24:06.000000000 +0000 +++ mutagen-1.30/debian/watch 2014-10-19 13:20:50.000000000 +0000 @@ -1,3 +1,3 @@ version=3 -# Latest upstream version is unsigned, sadly -http://bitbucket.org/lazka/mutagen/downloads/mutagen-([0-9.]+).tar.gz +opts=pgpsigurlmangle=s/$/.sig/ \ + http://bitbucket.org/lazka/mutagen/downloads/mutagen-([0-9.]+).tar.gz diff -Nru mutagen-1.23/docs/api/aac.rst mutagen-1.30/docs/api/aac.rst --- mutagen-1.23/docs/api/aac.rst 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/docs/api/aac.rst 2015-02-01 19:07:57.000000000 +0000 @@ -0,0 +1,14 @@ +AAC +=== + +.. automodule:: mutagen.aac + +.. autoexception:: mutagen.aac.AACError + +.. autoclass:: mutagen.aac.AAC(filename) + :show-inheritance: + :members: + +.. autoclass:: mutagen.aac.AACInfo() + :show-inheritance: + :members: diff -Nru mutagen-1.23/docs/api/base.rst mutagen-1.30/docs/api/base.rst --- mutagen-1.23/docs/api/base.rst 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/docs/api/base.rst 2015-02-01 19:07:57.000000000 +0000 @@ -24,6 +24,13 @@ .. automethod:: save() +.. autoclass:: mutagen.StreamInfo + :members: pprint + + +.. autoclass:: mutagen.MutagenError + + Internal Classes ~~~~~~~~~~~~~~~~ diff -Nru mutagen-1.23/docs/api/id3_frames.rst mutagen-1.30/docs/api/id3_frames.rst --- mutagen-1.23/docs/api/id3_frames.rst 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/docs/api/id3_frames.rst 2015-08-03 06:13:51.000000000 +0000 @@ -225,6 +225,11 @@ :members: +.. autoclass:: mutagen.id3.TDES(encoding=None, text=[]) + :show-inheritance: + :members: + + .. autoclass:: mutagen.id3.TDLY(encoding=None, text=[]) :show-inheritance: :members: @@ -265,6 +270,11 @@ :members: +.. autoclass:: mutagen.id3.TGID(encoding=None, text=[]) + :show-inheritance: + :members: + + .. autoclass:: mutagen.id3.TIME(encoding=None, text=[]) :show-inheritance: :members: @@ -484,6 +494,11 @@ :show-inheritance: :members: + +.. autoclass:: mutagen.id3.WFED(url=u'None') + :show-inheritance: + :members: + .. autoclass:: mutagen.id3.WOAF(url=u'None') :show-inheritance: diff -Nru mutagen-1.23/docs/api/id3.rst mutagen-1.30/docs/api/id3.rst --- mutagen-1.23/docs/api/id3.rst 2014-05-02 22:02:04.000000000 +0000 +++ mutagen-1.30/docs/api/id3.rst 2015-08-17 10:42:51.000000000 +0000 @@ -13,6 +13,15 @@ id3_frames +.. autoclass:: mutagen.id3.PictureType + :members: + :member-order: bysource + + +.. autoclass:: mutagen.id3.Encoding + :members: + :member-order: bysource + ID3 --- @@ -53,6 +62,9 @@ .. autoclass:: mutagen.mp3.MPEGInfo() :members: +.. autoclass:: mutagen.mp3.BitrateMode() + :members: + .. autoclass:: mutagen.mp3.EasyMP3(filename, ID3=None) :show-inheritance: :members: diff -Nru mutagen-1.23/docs/api/index.rst mutagen-1.30/docs/api/index.rst --- mutagen-1.23/docs/api/index.rst 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/docs/api/index.rst 2015-02-01 19:07:57.000000000 +0000 @@ -10,3 +10,4 @@ ape mp4 asf + aac diff -Nru mutagen-1.23/docs/changelog.rst mutagen-1.30/docs/changelog.rst --- mutagen-1.23/docs/changelog.rst 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/docs/changelog.rst 2015-08-22 15:53:33.000000000 +0000 @@ -1,4 +1,6 @@ Changelog ========= -.. literalinclude:: ../NEWS +.. py:currentmodule:: mutagen + +.. include:: ../NEWS diff -Nru mutagen-1.23/docs/conf.py mutagen-1.30/docs/conf.py --- mutagen-1.23/docs/conf.py 2014-01-09 15:28:33.000000000 +0000 +++ mutagen-1.30/docs/conf.py 2015-02-27 14:03:30.000000000 +0000 @@ -3,17 +3,21 @@ import os import sys -sys.path.insert(0, os.path.abspath('../')) +dir_ = os.path.dirname(os.path.realpath(__file__)) +sys.path.insert(0, dir_) +sys.path.insert(0, os.path.abspath(os.path.join(dir_, ".."))) import mutagen -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'ext'] intersphinx_mapping = {'python': ('http://docs.python.org/2.7', None)} source_suffix = '.rst' master_doc = 'index' project = 'mutagen' -copyright = u'2013, Joe Wreschnig, Michael Urman, Lukáš Lalinský, ' \ - u'Christoph Reiter & others' +copyright = u'2014, Joe Wreschnig, Michael Urman, Lukáš Lalinský, ' \ + u'Christoph Reiter, Ben Ockmore & others' version = mutagen.version_string release = mutagen.version_string exclude_patterns = ['_build'] +bug_url_template = "http://bitbucket.org/lazka/mutagen/issue/%s" +pr_url_template = "http://bitbucket.org/lazka/mutagen/pull-request/%s" diff -Nru mutagen-1.23/docs/ext.py mutagen-1.30/docs/ext.py --- mutagen-1.23/docs/ext.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/docs/ext.py 2015-04-25 08:47:28.000000000 +0000 @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- + +from docutils import nodes + + +def bug_role(name, rawtext, text, lineno, inliner, *args, **kwargs): + app = inliner.document.settings.env.app + url_tmpl = app.config.bug_url_template or "missing/%s" + node = nodes.reference( + rawtext, + "[%s]" % text, + refuri=url_tmpl % text) + return [node], [] + + +def pr_role(name, rawtext, text, lineno, inliner, *args, **kwargs): + app = inliner.document.settings.env.app + url_tmpl = app.config.pr_url_template or "missing/%s" + node = nodes.reference( + rawtext, + "[pr-%s]" % text, + refuri=url_tmpl % text) + return [node], [] + + +def setup(app): + app.add_role('bug', bug_role) + app.add_config_value('bug_url_template', None, 'env') + app.add_role('pr', pr_role) + app.add_config_value('pr_url_template', None, 'env') diff -Nru mutagen-1.23/docs/id3_frames_gen.py mutagen-1.30/docs/id3_frames_gen.py --- mutagen-1.23/docs/id3_frames_gen.py 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/docs/id3_frames_gen.py 2015-04-25 08:47:21.000000000 +0000 @@ -1,4 +1,5 @@ #!/usr/bin/python +# -*- coding: utf-8 -*- # Copyright 2013 Christoph Reiter # # This program is free software; you can redistribute it and/or modify Binary files /tmp/YUJl6SpwjE/mutagen-1.23/docs/images/logo.png and /tmp/To4rziyjFJ/mutagen-1.30/docs/images/logo.png differ diff -Nru mutagen-1.23/docs/images/logo.svg mutagen-1.30/docs/images/logo.svg --- mutagen-1.23/docs/images/logo.svg 2014-01-09 15:17:53.000000000 +0000 +++ mutagen-1.30/docs/images/logo.svg 2015-02-01 19:07:57.000000000 +0000 @@ -1,181 +1,9 @@ - - - - - - - - image/svg+xml - - - - - - - - - - M - M - - - - M - M - - - - - mutagen - Python multimedia tagging library - + + + + + + diff -Nru mutagen-1.23/docs/index.rst mutagen-1.30/docs/index.rst --- mutagen-1.23/docs/index.rst 2014-05-02 22:02:04.000000000 +0000 +++ mutagen-1.30/docs/index.rst 2015-02-01 19:07:57.000000000 +0000 @@ -37,8 +37,8 @@ format. It can also manipulate Ogg streams on an individual packet/page level. -Mutagen works on Python 2.6+ / PyPy and has no dependencies outside the -CPython standard library. +Mutagen works on Python 2.6, 2.7, 3.3, 3.4 (CPython and PyPy) and has no +dependencies outside the Python standard library. There is a :doc:`brief tutorial with several API examples. ` @@ -46,12 +46,10 @@ Where do I get it? ------------------ -Mutagen is hosted on `Google Code `_ and -`Bitbucket `_. The `download page -`_ will have the latest version -or check out the Mercurial repository:: +Mutagen is hosted on `Bitbucket `_. The +`download page `_ will have the +latest version or check out the Mercurial repository:: - $ hg clone https://code.google.com/p/mutagen $ hg clone https://bitbucket.org/lazka/mutagen Why Mutagen? @@ -98,6 +96,7 @@ For historical and practical reasons, Mutagen shares a `mailing list `_ and IRC channel -(#quodlibet on irc.oftc.net) with Quod Libet. If you need help using -Mutagen or would like to discuss the library, please use the mailing list. -Bugs and patches should go to the `issue tracker `_. +(#quodlibet on irc.oftc.net) with Quod Libet. If you need help using Mutagen +or would like to discuss the library, please use the mailing list. Bugs and +patches should go to the `issue tracker +`_. diff -Nru mutagen-1.23/docs/Makefile mutagen-1.30/docs/Makefile --- mutagen-1.23/docs/Makefile 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/docs/Makefile 2015-02-01 19:07:57.000000000 +0000 @@ -1,7 +1,13 @@ -all: - sphinx-build -n . _build +all: _rtd_theme + sphinx-build -Dhtml_theme=_rtd_theme -Dhtml_theme_path=. -b html -n . _build clean: - rm -rf _build + rm -rf _build _rtd_theme .PHONY: clean + +_rtd_theme: + wget https://github.com/snide/sphinx_rtd_theme/archive/master.tar.gz + tar --strip-components=1 -zxvf master.tar.gz sphinx_rtd_theme-master/sphinx_rtd_theme + mv sphinx_rtd_theme _rtd_theme + rm master.tar.gz diff -Nru mutagen-1.23/docs/man/index.rst mutagen-1.30/docs/man/index.rst --- mutagen-1.23/docs/man/index.rst 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/docs/man/index.rst 2015-02-01 19:07:57.000000000 +0000 @@ -4,6 +4,7 @@ .. toctree:: :titlesonly: + mid3cp mid3iconv mid3v2 moggsplit diff -Nru mutagen-1.23/docs/man/Makefile mutagen-1.30/docs/man/Makefile --- mutagen-1.23/docs/man/Makefile 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/docs/man/Makefile 2015-02-01 19:07:57.000000000 +0000 @@ -1,11 +1,6 @@ -# create man pages in ./_man +# update man pages -all: mid3iconv.1 mid3v2.1 moggsplit.1 mutagen-inspect.1 mutagen-pony.1 +all: mid3cp.1 mid3iconv.1 mid3v2.1 moggsplit.1 mutagen-inspect.1 mutagen-pony.1 -setup: - mkdir -p _man - -%.1:%.rst setup - rst2man $< > _man/$@ - -.PHONY: setup +%.1:%.rst + rst2man $< > ../../man/$@ diff -Nru mutagen-1.23/docs/man/mid3cp.rst mutagen-1.30/docs/man/mid3cp.rst --- mutagen-1.23/docs/man/mid3cp.rst 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/docs/man/mid3cp.rst 2015-02-01 19:07:57.000000000 +0000 @@ -0,0 +1,47 @@ +======== + mid3cp +======== + +------------- +copy ID3 tags +------------- + +:Manual section: 1 + + +SYNOPSIS +======== + +**mid3cp** [*options*] *source* *dest* + + +DESCRIPTION +=========== + +**mid3cp** copies the ID3 tags from a source file to a destination file. + +It is designed to provide similar functionality to id3lib's id3cp tool, and can +optionally write ID3v1 tags. It can also exclude specific tags from being +copied. + + +OPTIONS +======= + +--verbose, -v + Be verbose: state all operations performed, and list tags in source file. + +--write-v1 + Write ID3v1 tags to the destination file, derived from the ID3v2 tags. + +--exclude-tag, -x + Exclude a specific tag from being copied. Can be specified multiple times. + + + +AUTHOR +====== + +Marcus Sundman. + +Based on id3cp (part of id3lib) by Dirk Mahoney and Scott Thomas Haug. diff -Nru mutagen-1.23/docs/man/mid3iconv.rst mutagen-1.30/docs/man/mid3iconv.rst --- mutagen-1.23/docs/man/mid3iconv.rst 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/docs/man/mid3iconv.rst 2015-02-01 19:07:57.000000000 +0000 @@ -7,7 +7,6 @@ ------------------------- :Manual section: 1 -:Date: April 10th, 2006 SYNOPSIS diff -Nru mutagen-1.23/docs/man/mid3v2.rst mutagen-1.30/docs/man/mid3v2.rst --- mutagen-1.23/docs/man/mid3v2.rst 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/docs/man/mid3v2.rst 2015-02-01 19:07:57.000000000 +0000 @@ -7,7 +7,6 @@ ----------------------------------- :Manual section: 1 -:Date: October 30th, 2010 SYNOPSIS @@ -19,13 +18,12 @@ DESCRIPTION =========== -mid3v2 is a Mutagen-based replacement for id3lib's **id3v2**. It supports -ID3v2.4 and more frames; it also does not have the numerous bugs that -plague **id3v2**. - -This program exists mostly for compatibility with programs that want -to tag files using **id3v2**. For a more usable interface, we recommend Ex -Falso. +**mid3v2** is a Mutagen-based replacement for id3lib's id3v2. It supports +ID3v2.4 and more frames; it also does not have the numerous bugs that plague +id3v2. + +This program exists mostly for compatibility with programs that want to tag +files using id3v2. For a more usable interface, we recommend Ex Falso. OPTIONS @@ -52,8 +50,8 @@ but it is not recommended. -l, --list - List all tags in the files. The output format is *not* the same as - **id3v2**'s; instead, it is easily parsable and readable. Some tags may not + List all tags in the files. The output format is *not* the same as + id3v2's; instead, it is easily parsable and readable. Some tags may not have human-readable representations. --list-raw @@ -70,47 +68,48 @@ -D, --delete-all Delete all ID3 tags. ---delete-frames=FID1,FID2,... - Delete specific ID3v2 frames (or groups of frames) from the files. +--delete-frames=FRAMES + Delete specific ID3v2 frames (or groups of frames) from the files. + `FRAMES` is a "," separated list of frame names e.g. ``"TPE1,TALB"`` -C, --convert Convert ID3v1 tags to ID3v2 tags. This will also happen automatically during any editing. --a, --artist\=artist +-a, --artist=ARTIST Set the artist information (TPE1). --A, --album\=album +-A, --album=ALBUM Set the album information (TALB). --t, --song\=title +-t, --song=TITLE Set the title information (TIT2). --c, --comment=DESCRIPTION:COMMENT:LANGUAGE +-c, --comment= Set a comment (COMM). The language and description may be omitted, in which case the language defaults to English, and the description to an empty string. --g, --genre\=genre +-g, --genre=GENRE Set the genre information (TCON). --y, --year=, --date=YYYY-[MM-DD] +-y, --year=, --date= Set the year/date information (TDRC). --Tnum/num, --track=num/num +-T, --track= Set the track number (TRCK). Any text or URL frame (those beginning with T or W) can be modified or -added by prefixing the name of the frame with "--". For example, **--TIT3 -"Monkey!"** will set the TIT3 (subtitle) frame to **Monkey!**. +added by prefixing the name of the frame with "--". For example, ``--TIT3 +"Monkey!"`` will set the TIT3 (subtitle) frame to ``Monkey!``. The TXXX frame requires a colon-separated description key; many TXXX frames may be set in the file as long as they have different keys. To set this -key, just separate the text with a colon, e.g. **--TXXX -"ALBUMARTISTSORT:Examples, The"**. +key, just separate the text with a colon, e.g. ``--TXXX +"ALBUMARTISTSORT:Examples, The"``. -The special POPM frame can be set in a similar way: **--POPM -"bob@example.com:128:2"** to set Bob's rating to 128/255 with 2 plays. +The special POPM frame can be set in a similar way: ``--POPM +"bob@example.com:128:2"`` to set Bob's rating to 128/255 with 2 plays. BUGS diff -Nru mutagen-1.23/docs/man/moggsplit.rst mutagen-1.30/docs/man/moggsplit.rst --- mutagen-1.23/docs/man/moggsplit.rst 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/docs/man/moggsplit.rst 2015-02-01 19:07:57.000000000 +0000 @@ -7,7 +7,6 @@ ------------------------- :Manual section: 1 -:Date: Nov 14th, 2009 SYNOPSIS @@ -37,7 +36,7 @@ file's base name, *stream* for the stream's serial number, and ext for the extension give by **--extension**. - The default is **%(base)s-%(stream)d.%(ext)s**. + The default is ``%(base)s-%(stream)d.%(ext)s``. --m3u Generate an m3u playlist along with the newly generated files. Useful diff -Nru mutagen-1.23/docs/man/mutagen-inspect.rst mutagen-1.30/docs/man/mutagen-inspect.rst --- mutagen-1.23/docs/man/mutagen-inspect.rst 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/docs/man/mutagen-inspect.rst 2015-02-01 19:07:57.000000000 +0000 @@ -7,7 +7,6 @@ --------------------------------- :Manual section: 1 -:Date: May 27th, 2006 SYNOPSIS diff -Nru mutagen-1.23/docs/man/mutagen-pony.rst mutagen-1.30/docs/man/mutagen-pony.rst --- mutagen-1.23/docs/man/mutagen-pony.rst 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/docs/man/mutagen-pony.rst 2015-02-01 19:07:57.000000000 +0000 @@ -7,7 +7,6 @@ --------------------------------- :Manual section: 1 -:Date: February 20th, 2006 SYNOPSIS diff -Nru mutagen-1.23/docs/tutorial.rst mutagen-1.30/docs/tutorial.rst --- mutagen-1.23/docs/tutorial.rst 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/docs/tutorial.rst 2015-02-01 19:07:57.000000000 +0000 @@ -1,11 +1,11 @@ Mutagen Tutorial ---------------- -There are two different ways to load files in Mutagen, but both provide -similar interfaces. The first is the :class:`Metadata ` -API, which deals only in metadata tags. The second is the :class:`FileType -` API, which is a superset of the :class:`mutagen -` API, and contains information about the audio data +There are two different ways to load files in Mutagen, but both provide +similar interfaces. The first is the :class:`Metadata ` +API, which deals only in metadata tags. The second is the :class:`FileType +` API, which is a superset of the :class:`mutagen +` API, and contains information about the audio data itself. Both Metadata and FileType objects present a dict-like interface to @@ -72,6 +72,100 @@ standard at http://www.id3.org/develop.html. +ID3 Versions +^^^^^^^^^^^^ + +.. py:currentmodule:: mutagen.id3 + +Mutagen's ID3 API is primary targeted at id3v2.4, so by default any id3 tags +will be upgraded to 2.4 and saving a file will make it 2.4 as well. Saving as +2.3 is possible but needs some extra steps. + +By default mutagen will: + +* Load the file +* Upgrade any ID3v2.2 frames to their ID3v2.3/4 counterparts + (``TT2`` to ``TIT2`` for example) +* Upgrade 2.3 only frames to their 2.4 counterparts or throw them away in + case there exists no sane upgrade path. + +In code it comes down to this:: + + from mutagen.id3 import ID3 + + audio = ID3("example.mp3") + audio.save() + +The :attr:`ID3.version` attribute contains the id3 version the loaded file +had. + +For more control the following functions are important: + +* :func:`ID3` which loads the tags and if ``translate=True`` + (default) calls either :meth:`ID3.update_to_v24` or + :meth:`ID3.update_to_v23` depending on the ``v2_version`` + argument (defaults to ``4``) + +* :meth:`ID3.update_to_v24` which upgrades v2.2/3 frames to v2.4 + +* :meth:`ID3.update_to_v23` which downgrades v2.4 and upgrades v2.2 frames to v2.3 + +* :meth:`ID3.save` which will save as v2.3 if ``v2_version=3`` (defaults to + ``4``) and also allows specifying a separator for joining multiple text + values into one (defaults to ``v23_sep='/'``). + +To load any ID3 tag and save it as v2.3 do the following:: + + from mutagen.id3 import ID3 + + audio = ID3("example.mp3", v2_version=3) + audio.save(v2_version=3) + +You may notice that if you load a v2.4 file this way, the text frames will +still have multiple values or are defined to be saved using UTF-8, both of +which isn't valid in v2.3. But the resulting file will still be valid because +the following will happen in :meth:`ID3.save`: + +* Frames that use UTF-8 as text encoding will be saved as UTF-16 instead. +* Multiple values in text frames will be joined with ``v23_sep`` as passed to + :meth:`ID3.save`. + + +Nonstandard ID3v2.3 Tricks +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Saving v2.4 frames in v2.3 tags + While not standard conform, you can exclude certain v2.4 frames from being + thrown out by :meth:`ID3.update_to_v23` by removing them temporarily:: + + audio = ID3("example.mp3", translate=False) + keep_these = audio.getall("TSOP") + audio.update_to_v23() + audio.setall("TSOP", keep_these) + audio.save(v2_version=3) + +Saving Multiple Text Values in v2.3 Tags + The v2.3 standard states that after a text termination "all the following + information should be ignored and not be displayed". So, saving multiple + values separated by the text terminator should allow v2.3 only readers to + read the first value while providing a way to read all values back. + + But editing these files will probably throw out all the other values and + some implementations might get confused about the extra non-NULL data, so + this isn't recommended. + + To use the terminator as value separator pass ``v23_sep=None`` to + :meth:`ID3.save`. + + :: + + audio = ID3("example.mp3", v2_version=3) + audio.save(v2_version=3, v23_sep=None) + + Mutagen itself disregards the v2.3 spec in this case and will read them + back as multiple values. + + Easy ID3 ^^^^^^^^ @@ -118,3 +212,105 @@ access then (e.g. ``audio["title"]``) you will get a list of strings rather than a single one (``[u"An example"]`` rather than ``u"An example"``). Similarly, you can assign a list of strings rather than a single one. + + +VorbisComment +^^^^^^^^^^^^^ + +VorbisComment is the tagging format used in Ogg and FLAC container formats. In +mutagen this corresponds to the tags in all subclasses of +:class:`mutagen.ogg.OggFileType` and the :class:`mutagen.flac.FLAC` class. + +Embedded Images +~~~~~~~~~~~~~~~ + +The most common way to include images in VorbisComment is to store a base64 +encoded FLAC Picture block with the key ``metadata_block_picture`` [0]. See +the following code example on how to read and write images this way:: + + # READING / SAVING + import base64 + from mutagen.oggvorbis import OggVorbis + from mutagen.flac import Picture, error as FLACError + + file_ = OggVorbis("somefile.ogg") + + for b64_data in file_.get("metadata_block_picture", []): + try: + data = base64.b64decode(b64_data) + except (TypeError, ValueError): + continue + + try: + picture = Picture(data) + except FLACError: + continue + + extensions = { + "image/jpeg": "jpg", + "image/png": "png", + "image/gif": "gif", + } + ext = extensions.get(picture.mime, "jpg") + + with open("image.%s" % ext, "wb") as h: + h.write(picture.data) + +:: + + # WRITING + import base64 + from mutagen.oggvorbis import OggVorbis + from mutagen.flac import Picture + + file_ = OggVorbis("somefile.ogg") + + with open("image.jpeg", "rb") as h: + data = h.read() + + picture = Picture() + picture.data = data + picture.type = 17 + picture.desc = u"A bright coloured fish" + picture.mime = u"image/jpeg" + picture.width = 100 + picture.height = 100 + picture.depth = 24 + + picture_data = picture.write() + encoded_data = base64.b64encode(picture_data) + vcomment_value = encoded_data.decode("ascii") + + file_["metadata_block_picture"] = [vcomment_value] + file_.save() + + +Some programs also write base64 encoded image data directly into the +``coverart`` field and sometimes a corresponding mime type into the +``coverartmime`` field:: + + # READING + import base64 + import itertools + from mutagen.oggvorbis import OggVorbis + + file_ = OggVorbis("somefile.ogg") + + values = file_.get("coverart", []) + mimes = file_.get("coverartmime", []) + for value, mime in itertools.izip_longest(values, mimes, fillvalue=u""): + try: + image_data = base64.b64decode(value.encode("ascii")) + except (TypeError, ValueError): + continue + + print(mime) + print(image_data) + + +FLAC supports images directly, see :class:`mutagen.flac.Picture`, +:attr:`mutagen.flac.FLAC.pictures`, :meth:`mutagen.flac.FLAC.add_picture` and +:meth:`mutagen.flac.FLAC.clear_pictures`. + + +[0] https://wiki.xiph.org/VorbisComment#Cover_art diff -Nru mutagen-1.23/man/mid3cp.1 mutagen-1.30/man/mid3cp.1 --- mutagen-1.23/man/mid3cp.1 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/man/mid3cp.1 2015-02-01 19:07:57.000000000 +0000 @@ -0,0 +1,61 @@ +.\" Man page generated from reStructuredText. +. +.TH MID3CP 1 "" "" "" +.SH NAME +mid3cp \- copy ID3 tags +. +.nr rst2man-indent-level 0 +. +.de1 rstReportMargin +\\$1 \\n[an-margin] +level \\n[rst2man-indent-level] +level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] +- +\\n[rst2man-indent0] +\\n[rst2man-indent1] +\\n[rst2man-indent2] +.. +.de1 INDENT +.\" .rstReportMargin pre: +. RS \\$1 +. nr rst2man-indent\\n[rst2man-indent-level] \\n[an-margin] +. nr rst2man-indent-level +1 +.\" .rstReportMargin post: +.. +.de UNINDENT +. RE +.\" indent \\n[an-margin] +.\" old: \\n[rst2man-indent\\n[rst2man-indent-level]] +.nr rst2man-indent-level -1 +.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] +.in \\n[rst2man-indent\\n[rst2man-indent-level]]u +.. +.SH SYNOPSIS +.sp +\fBmid3cp\fP [\fIoptions\fP] \fIsource\fP \fIdest\fP +.SH DESCRIPTION +.sp +\fBmid3cp\fP copies the ID3 tags from a source file to a destination file. +.sp +It is designed to provide similar functionality to id3lib\(aqs id3cp tool, and can +optionally write ID3v1 tags. It can also exclude specific tags from being +copied. +.SH OPTIONS +.INDENT 0.0 +.TP +.B \-\-verbose\fP,\fB \-v +Be verbose: state all operations performed, and list tags in source file. +.TP +.B \-\-write\-v1 +Write ID3v1 tags to the destination file, derived from the ID3v2 tags. +.TP +.B \-\-exclude\-tag\fP,\fB \-x +Exclude a specific tag from being copied. Can be specified multiple times. +.UNINDENT +.SH AUTHOR +.sp +Marcus Sundman. +.sp +Based on id3cp (part of id3lib) by Dirk Mahoney and Scott Thomas Haug. +.\" Generated by docutils manpage writer. +. diff -Nru mutagen-1.23/man/mid3iconv.1 mutagen-1.30/man/mid3iconv.1 --- mutagen-1.23/man/mid3iconv.1 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/man/mid3iconv.1 2015-02-01 19:07:57.000000000 +0000 @@ -1,6 +1,6 @@ .\" Man page generated from reStructuredText. . -.TH MID3ICONV 1 "April 10th, 2006" "" "" +.TH MID3ICONV 1 "" "" "" .SH NAME mid3iconv \- convert ID3 tag encodings . @@ -40,20 +40,20 @@ .SH OPTIONS .INDENT 0.0 .TP -.B \-\-debug, \-d +.B \-\-debug\fP,\fB \-d Print updated tags .TP -.B \-\-dry\-run, \-p +.B \-\-dry\-run\fP,\fB \-p Do not actually modify files .TP -.B \-\-encoding, \-e +.B \-\-encoding\fP,\fB \-e Convert from this encoding. By default, your locale\(aqs default encoding is used. .TP .B \-\-force\-v1 Use an ID3v1 tag even if an ID3v2 tag is present .TP -.B \-\-quiet, \-q +.B \-\-quiet\fP,\fB \-q Only output errors .TP .B \-\-remove\-v1 diff -Nru mutagen-1.23/man/mid3v2.1 mutagen-1.30/man/mid3v2.1 --- mutagen-1.23/man/mid3v2.1 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/man/mid3v2.1 2015-02-01 19:07:57.000000000 +0000 @@ -1,6 +1,6 @@ .\" Man page generated from reStructuredText. . -.TH MID3V2 1 "October 30th, 2010" "" "" +.TH MID3V2 1 "" "" "" .SH NAME mid3v2 \- audio tag editor similar to 'id3v2' . @@ -35,39 +35,38 @@ \fBmid3v2\fP [\fIoptions\fP] \fIfilename\fP ... .SH DESCRIPTION .sp -mid3v2 is a Mutagen\-based replacement for id3lib\(aqs \fBid3v2\fP. It supports -ID3v2.4 and more frames; it also does not have the numerous bugs that -plague \fBid3v2\fP. -.sp -This program exists mostly for compatibility with programs that want -to tag files using \fBid3v2\fP. For a more usable interface, we recommend Ex -Falso. +\fBmid3v2\fP is a Mutagen\-based replacement for id3lib\(aqs id3v2. It supports +ID3v2.4 and more frames; it also does not have the numerous bugs that plague +id3v2. +.sp +This program exists mostly for compatibility with programs that want to tag +files using id3v2. For a more usable interface, we recommend Ex Falso. .SH OPTIONS .INDENT 0.0 .TP -.B \-q, \-\-quiet +.B \-q\fP,\fB \-\-quiet Be quiet: do not mention file operations that perform the user\(aqs request. Warnings will still be printed. .TP -.B \-v, \-\-verbose +.B \-v\fP,\fB \-\-verbose Be verbose: state all operations performed. This is the opposite of \-\-quiet. This is the default. .TP -.B \-e, \-\-escape +.B \-e\fP,\fB \-\-escape Enable interpretation of backslash escapes for tag values. Makes it possible to escape the colon\-separator in TXXX, COMM values like \(aq\e:\(aq and insert escape sequences like \(aq\en\(aq, \(aq\et\(aq etc. .TP -.B \-f, \-\-list\-frames +.B \-f\fP,\fB \-\-list\-frames Display all supported ID3v2.3/2.4 frames and their meanings. .TP -.B \-L, \-\-list\-genres +.B \-L\fP,\fB \-\-list\-genres List all ID3v1 numeric genres. These can be used to set TCON frames, but it is not recommended. .TP -.B \-l, \-\-list +.B \-l\fP,\fB \-\-list List all tags in the files. The output format is \fInot\fP the same as -\fBid3v2\fP\(aqs; instead, it is easily parsable and readable. Some tags may not +id3v2\(aqs; instead, it is easily parsable and readable. Some tags may not have human\-readable representations. .TP .B \-\-list\-raw @@ -75,60 +74,55 @@ nominally human\-readable, it may be very long if the tag contains embedded binary data. .TP -.B \-d, \-\-delete\-v2 +.B \-d\fP,\fB \-\-delete\-v2 Delete ID3v2 tags. .TP -.B \-s, \-\-delete\-v1 +.B \-s\fP,\fB \-\-delete\-v1 Delete ID3v1 tags. .TP -.B \-D, \-\-delete\-all +.B \-D\fP,\fB \-\-delete\-all Delete all ID3 tags. -.UNINDENT -.INDENT 0.0 .TP -.B \-\-delete\-frames=FID1,FID2,... +.BI \-\-delete\-frames\fB= FRAMES Delete specific ID3v2 frames (or groups of frames) from the files. -.UNINDENT -.INDENT 0.0 +\fIFRAMES\fP is a "," separated list of frame names e.g. \fB"TPE1,TALB"\fP .TP -.B \-C, \-\-convert +.B \-C\fP,\fB \-\-convert Convert ID3v1 tags to ID3v2 tags. This will also happen automatically during any editing. -.UNINDENT -.INDENT 0.0 .TP -.B \-a, \-\-artist=artist +.BI \-a\fP,\fB \-\-artist\fB= ARTIST Set the artist information (TPE1). .TP -.B \-A, \-\-album=album +.BI \-A\fP,\fB \-\-album\fB= ALBUM Set the album information (TALB). .TP -.B \-t, \-\-song=title +.BI \-t\fP,\fB \-\-song\fB= TITLE Set the title information (TIT2). .TP -.B \-c, \-\-comment=DESCRIPTION:COMMENT:LANGUAGE +.BI \-c\fP,\fB \-\-comment\fB= Set a comment (COMM). The language and description may be omitted, in which case the language defaults to English, and the description to an empty string. .TP -.B \-g, \-\-genre=genre +.BI \-g\fP,\fB \-\-genre\fB= GENRE Set the genre information (TCON). .TP -.B \-y, \-\-year=, \-\-date=YYYY\-[MM\-DD] +.BI \-y\fP,\fB \-\-year\fB= \fP,\fB \ \-\-date\fB= Set the year/date information (TDRC). .TP -.B \-Tnum/num, \-\-track=num/num +.BI \-T\fP,\fB \-\-track\fB= Set the track number (TRCK). .UNINDENT .sp Any text or URL frame (those beginning with T or W) can be modified or added by prefixing the name of the frame with "\-\-". For example, \fB\-\-TIT3 -"Monkey!"\fP will set the TIT3 (subtitle) frame to \fBMonkey!\fP. +"Monkey!"\fP will set the TIT3 (subtitle) frame to \fBMonkey!\fP\&. .sp The TXXX frame requires a colon\-separated description key; many TXXX frames may be set in the file as long as they have different keys. To set this key, just separate the text with a colon, e.g. \fB\-\-TXXX -"ALBUMARTISTSORT:Examples, The"\fP. +"ALBUMARTISTSORT:Examples, The"\fP\&. .sp The special POPM frame can be set in a similar way: \fB\-\-POPM "bob@example.com:128:2"\fP to set Bob\(aqs rating to 128/255 with 2 plays. diff -Nru mutagen-1.23/man/moggsplit.1 mutagen-1.30/man/moggsplit.1 --- mutagen-1.23/man/moggsplit.1 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/man/moggsplit.1 2015-02-01 19:07:57.000000000 +0000 @@ -1,6 +1,6 @@ .\" Man page generated from reStructuredText. . -.TH MOGGSPLIT 1 "Nov 14th, 2009" "" "" +.TH MOGGSPLIT 1 "" "" "" .SH NAME moggsplit \- split Ogg logical streams . @@ -43,15 +43,15 @@ .TP .B \-\-extension Use the supplied extension when generating new files; the default is -\fBogg\fP. +\fBogg\fP\&. .TP .B \-\-pattern Use the supplied pattern when generating new files. This is a Python keyword format string with three variables, \fIbase\fP for the original file\(aqs base name, \fIstream\fP for the stream\(aqs serial number, and ext for -the extension give by \fB\-\-extension\fP. +the extension give by \fB\-\-extension\fP\&. .sp -The default is \fB%(base)s\-%(stream)d.%(ext)s\fP. +The default is \fB%(base)s\-%(stream)d.%(ext)s\fP\&. .TP .B \-\-m3u Generate an m3u playlist along with the newly generated files. Useful diff -Nru mutagen-1.23/man/mutagen-inspect.1 mutagen-1.30/man/mutagen-inspect.1 --- mutagen-1.23/man/mutagen-inspect.1 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/man/mutagen-inspect.1 2015-02-01 19:07:57.000000000 +0000 @@ -1,6 +1,6 @@ .\" Man page generated from reStructuredText. . -.TH MUTAGEN-INSPECT 1 "May 27th, 2006" "" "" +.TH MUTAGEN-INSPECT 1 "" "" "" .SH NAME mutagen-inspect \- view Mutagen-supported audio tags . diff -Nru mutagen-1.23/man/mutagen-pony.1 mutagen-1.30/man/mutagen-pony.1 --- mutagen-1.23/man/mutagen-pony.1 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/man/mutagen-pony.1 2015-02-01 19:07:57.000000000 +0000 @@ -1,6 +1,6 @@ .\" Man page generated from reStructuredText. . -.TH MUTAGEN-PONY 1 "February 20th, 2006" "" "" +.TH MUTAGEN-PONY 1 "" "" "" .SH NAME mutagen-pony \- scan a collection of MP3 files . diff -Nru mutagen-1.23/MANIFEST.in mutagen-1.30/MANIFEST.in --- mutagen-1.23/MANIFEST.in 2014-01-09 15:33:00.000000000 +0000 +++ mutagen-1.30/MANIFEST.in 2015-02-01 19:07:57.000000000 +0000 @@ -1,9 +1,9 @@ include COPYING include NEWS -include README -include TODO +include README.rst include MANIFEST.in include tests/data/* +include tests/quality/* include tests/*.py include man/*.1 include docs/Makefile diff -Nru mutagen-1.23/mutagen/aac.py mutagen-1.30/mutagen/aac.py --- mutagen-1.23/mutagen/aac.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/mutagen/aac.py 2015-02-01 19:07:57.000000000 +0000 @@ -0,0 +1,407 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +""" +* ADTS - Audio Data Transport Stream +* ADIF - Audio Data Interchange Format +* See ISO/IEC 13818-7 / 14496-03 +""" + +from mutagen import StreamInfo +from mutagen._file import FileType +from mutagen._util import BitReader, BitReaderError, MutagenError +from mutagen._compat import endswith, xrange + + +_FREQS = [ + 96000, 88200, 64000, 48000, + 44100, 32000, 24000, 22050, + 16000, 12000, 11025, 8000, + 7350, +] + + +class _ADTSStream(object): + """Represents a series of frames belonging to the same stream""" + + parsed_frames = 0 + """Number of successfully parsed frames""" + + offset = 0 + """offset in bytes at which the stream starts (the first sync word)""" + + @classmethod + def find_stream(cls, fileobj, max_bytes): + """Returns a possibly valid _ADTSStream or None. + + Args: + max_bytes (int): maximum bytes to read + """ + + r = BitReader(fileobj) + stream = cls(r) + if stream.sync(max_bytes): + stream.offset = (r.get_position() - 12) // 8 + return stream + + def sync(self, max_bytes): + """Find the next sync. + Returns True if found.""" + + # at least 2 bytes for the sync + max_bytes = max(max_bytes, 2) + + r = self._r + r.align() + while max_bytes > 0: + try: + b = r.bytes(1) + if b == b"\xff": + if r.bits(4) == 0xf: + return True + r.align() + max_bytes -= 2 + else: + max_bytes -= 1 + except BitReaderError: + return False + return False + + def __init__(self, r): + """Use _ADTSStream.find_stream to create a stream""" + + self._fixed_header_key = None + self._r = r + self.offset = -1 + self.parsed_frames = 0 + + self._samples = 0 + self._payload = 0 + self._start = r.get_position() / 8 + self._last = self._start + + @property + def bitrate(self): + """Bitrate of the raw aac blocks, excluding framing/crc""" + + assert self.parsed_frames, "no frame parsed yet" + + if self._samples == 0: + return 0 + + return (8 * self._payload * self.frequency) // self._samples + + @property + def samples(self): + """samples so far""" + + assert self.parsed_frames, "no frame parsed yet" + + return self._samples + + @property + def size(self): + """bytes read in the stream so far (including framing)""" + + assert self.parsed_frames, "no frame parsed yet" + + return self._last - self._start + + @property + def channels(self): + """0 means unknown""" + + assert self.parsed_frames, "no frame parsed yet" + + b_index = self._fixed_header_key[6] + if b_index == 7: + return 8 + elif b_index > 7: + return 0 + else: + return b_index + + @property + def frequency(self): + """0 means unknown""" + + assert self.parsed_frames, "no frame parsed yet" + + f_index = self._fixed_header_key[4] + try: + return _FREQS[f_index] + except IndexError: + return 0 + + def parse_frame(self): + """True if parsing was successful. + Fails either because the frame wasn't valid or the stream ended. + """ + + try: + return self._parse_frame() + except BitReaderError: + return False + + def _parse_frame(self): + r = self._r + # start == position of sync word + start = r.get_position() - 12 + + # adts_fixed_header + id_ = r.bits(1) + layer = r.bits(2) + protection_absent = r.bits(1) + + profile = r.bits(2) + sampling_frequency_index = r.bits(4) + private_bit = r.bits(1) + # TODO: if 0 we could parse program_config_element() + channel_configuration = r.bits(3) + original_copy = r.bits(1) + home = r.bits(1) + + # the fixed header has to be the same for every frame in the stream + fixed_header_key = ( + id_, layer, protection_absent, profile, sampling_frequency_index, + private_bit, channel_configuration, original_copy, home, + ) + + if self._fixed_header_key is None: + self._fixed_header_key = fixed_header_key + else: + if self._fixed_header_key != fixed_header_key: + return False + + # adts_variable_header + r.skip(2) # copyright_identification_bit/start + frame_length = r.bits(13) + r.skip(11) # adts_buffer_fullness + nordbif = r.bits(2) + # adts_variable_header end + + crc_overhead = 0 + if not protection_absent: + crc_overhead += (nordbif + 1) * 16 + if nordbif != 0: + crc_overhead *= 2 + + left = (frame_length * 8) - (r.get_position() - start) + if left < 0: + return False + r.skip(left) + assert r.is_aligned() + + self._payload += (left - crc_overhead) / 8 + self._samples += (nordbif + 1) * 1024 + self._last = r.get_position() / 8 + + self.parsed_frames += 1 + return True + + +class ProgramConfigElement(object): + + element_instance_tag = None + object_type = None + sampling_frequency_index = None + channels = None + + def __init__(self, r): + """Reads the program_config_element() + + Raises BitReaderError + """ + + self.element_instance_tag = r.bits(4) + self.object_type = r.bits(2) + self.sampling_frequency_index = r.bits(4) + num_front_channel_elements = r.bits(4) + num_side_channel_elements = r.bits(4) + num_back_channel_elements = r.bits(4) + num_lfe_channel_elements = r.bits(2) + num_assoc_data_elements = r.bits(3) + num_valid_cc_elements = r.bits(4) + + mono_mixdown_present = r.bits(1) + if mono_mixdown_present == 1: + r.skip(4) + stereo_mixdown_present = r.bits(1) + if stereo_mixdown_present == 1: + r.skip(4) + matrix_mixdown_idx_present = r.bits(1) + if matrix_mixdown_idx_present == 1: + r.skip(3) + + elms = num_front_channel_elements + num_side_channel_elements + \ + num_back_channel_elements + channels = 0 + for i in xrange(elms): + channels += 1 + element_is_cpe = r.bits(1) + if element_is_cpe: + channels += 1 + r.skip(4) + channels += num_lfe_channel_elements + self.channels = channels + + r.skip(4 * num_lfe_channel_elements) + r.skip(4 * num_assoc_data_elements) + r.skip(5 * num_valid_cc_elements) + r.align() + comment_field_bytes = r.bits(8) + r.skip(8 * comment_field_bytes) + + +class AACError(MutagenError): + pass + + +class AACInfo(StreamInfo): + """AAC stream information. + + Attributes: + + * channels -- number of audio channels + * length -- file length in seconds, as a float + * sample_rate -- audio sampling rate in Hz + * bitrate -- audio bitrate, in bits per second + + The length of the stream is just a guess and might not be correct. + """ + + channels = 0 + length = 0 + sample_rate = 0 + bitrate = 0 + + def __init__(self, fileobj): + # skip id3v2 header + start_offset = 0 + header = fileobj.read(10) + from mutagen.id3 import BitPaddedInt + if header.startswith(b"ID3"): + size = BitPaddedInt(header[6:]) + start_offset = size + 10 + + fileobj.seek(start_offset) + adif = fileobj.read(4) + if adif == b"ADIF": + self._parse_adif(fileobj) + self._type = "ADIF" + else: + self._parse_adts(fileobj, start_offset) + self._type = "ADTS" + + def _parse_adif(self, fileobj): + r = BitReader(fileobj) + try: + copyright_id_present = r.bits(1) + if copyright_id_present: + r.skip(72) # copyright_id + r.skip(1 + 1) # original_copy, home + bitstream_type = r.bits(1) + self.bitrate = r.bits(23) + npce = r.bits(4) + if bitstream_type == 0: + r.skip(20) # adif_buffer_fullness + + pce = ProgramConfigElement(r) + try: + self.sample_rate = _FREQS[pce.sampling_frequency_index] + except IndexError: + pass + self.channels = pce.channels + + # other pces.. + for i in xrange(npce): + ProgramConfigElement(r) + r.align() + except BitReaderError as e: + raise AACError(e) + + # use bitrate + data size to guess length + start = fileobj.tell() + fileobj.seek(0, 2) + length = fileobj.tell() - start + if self.bitrate != 0: + self.length = (8.0 * length) / self.bitrate + + def _parse_adts(self, fileobj, start_offset): + max_initial_read = 512 + max_resync_read = 10 + max_sync_tries = 10 + + frames_max = 100 + frames_needed = 3 + + # Try up to X times to find a sync word and read up to Y frames. + # If more than Z frames are valid we assume a valid stream + offset = start_offset + for i in xrange(max_sync_tries): + fileobj.seek(offset) + s = _ADTSStream.find_stream(fileobj, max_initial_read) + if s is None: + raise AACError("sync not found") + # start right after the last found offset + offset += s.offset + 1 + + for i in xrange(frames_max): + if not s.parse_frame(): + break + if not s.sync(max_resync_read): + break + + if s.parsed_frames >= frames_needed: + break + else: + raise AACError( + "no valid stream found (only %d frames)" % s.parsed_frames) + + self.sample_rate = s.frequency + self.channels = s.channels + self.bitrate = s.bitrate + + # size from stream start to end of file + fileobj.seek(0, 2) + stream_size = fileobj.tell() - (offset + s.offset) + # approx + self.length = float(s.samples * stream_size) / (s.size * s.frequency) + + def pprint(self): + return "AAC (%s), %d Hz, %.2f seconds, %d channel(s), %d bps" % ( + self._type, self.sample_rate, self.length, self.channels, + self.bitrate) + + +class AAC(FileType): + """Load ADTS or ADIF streams containing AAC. + + Tagging is not supported. + Use the ID3/APEv2 classes directly instead. + """ + + _mimes = ["audio/x-aac"] + + def load(self, filename): + self.filename = filename + with open(filename, "rb") as h: + self.info = AACInfo(h) + + @staticmethod + def score(filename, fileobj, header): + filename = filename.lower() + s = endswith(filename, ".aac") or endswith(filename, ".adts") or \ + endswith(filename, ".adif") + s += b"ADIF" in header + return s + + +Open = AAC +error = AACError + +__all__ = ["AAC", "Open"] diff -Nru mutagen-1.23/mutagen/aiff.py mutagen-1.30/mutagen/aiff.py --- mutagen-1.23/mutagen/aiff.py 2014-05-02 22:13:12.000000000 +0000 +++ mutagen-1.30/mutagen/aiff.py 2015-02-07 18:06:05.000000000 +0000 @@ -1,5 +1,7 @@ -# AIFF audio stream header information and ID3 tag support for Mutagen. -# Copyright 2014 Evan Purkhiser +# -*- coding: utf-8 -*- + +# Copyright (C) 2014 Evan Purkhiser +# 2014 Ben Ockmore # # This program is free software; you can redistribute it and/or modify # it under the terms of version 2 of the GNU General Public License as @@ -7,20 +9,24 @@ """AIFF audio stream information and tags.""" +# NOTE from Ben Ockmore - according to the Py3k migration guidelines, AIFF +# chunk keys should be unicode in Py3k, and unicode or bytes in Py2k (ASCII). +# To make this easier, chunk keys should be stored internally as unicode. + import struct from struct import pack -from ._compat import endswith +from ._compat import endswith, text_type, PY3 from mutagen import StreamInfo, FileType from mutagen.id3 import ID3 -from mutagen._id3util import error as ID3Error -from mutagen._util import insert_bytes, delete_bytes +from mutagen.id3._util import ID3NoHeaderError, error as ID3Error +from mutagen._util import insert_bytes, delete_bytes, MutagenError __all__ = ["AIFF", "Open", "delete"] -class error(RuntimeError): +class error(MutagenError, RuntimeError): pass @@ -32,8 +38,22 @@ _HUGE_VAL = 1.79769313486231e+308 -def read_float(s): # 10 bytes - expon, himant, lomant = struct.unpack('>hLL', s) +def is_valid_chunk_id(id): + if not isinstance(id, text_type): + if PY3: + raise TypeError("AIFF chunk must be unicode") + + try: + id = id.decode('ascii') + except UnicodeDecodeError: + return False + + return ((len(id) <= 4) and (min(id) >= u' ') and + (max(id) <= u'~')) + + +def read_float(data): # 10 bytes + expon, himant, lomant = struct.unpack('>hLL', data) sign = 1 if expon < 0: sign = -1 @@ -64,7 +84,11 @@ raise InvalidChunk() self.id, self.data_size = struct.unpack('>4si', header) - if self.id == b'\x00' * 4: + + if not isinstance(self.id, text_type): + self.id = self.id.decode('ascii') + + if not is_valid_chunk_id(self.id): raise InvalidChunk() self.size = self.HEADER_SIZE + self.data_size @@ -103,7 +127,7 @@ # AIFF Files always start with the FORM chunk which contains a 4 byte # ID before the start of other chunks fileobj.seek(0) - self.__chunks['FORM'] = IFFChunk(fileobj) + self.__chunks[u'FORM'] = IFFChunk(fileobj) # Skip past the 4 byte FORM id fileobj.seek(IFFChunk.HEADER_SIZE + 4) @@ -116,7 +140,7 @@ # Load all of the chunks while True: try: - chunk = IFFChunk(fileobj, self['FORM']) + chunk = IFFChunk(fileobj, self[u'FORM']) except InvalidChunk: break self.__chunks[chunk.id.strip()] = chunk @@ -129,11 +153,24 @@ def __contains__(self, id_): """Check if the IFF file contains a specific chunk""" + + if not isinstance(id_, text_type): + id_ = id_.decode('ascii') + + if not is_valid_chunk_id(id_): + raise KeyError("AIFF key must be four ASCII characters.") + return id_ in self.__chunks def __getitem__(self, id_): """Get a chunk from the IFF file""" + if not isinstance(id_, text_type): + id_ = id_.decode('ascii') + + if not is_valid_chunk_id(id_): + raise KeyError("AIFF key must be four ASCII characters.") + try: return self.__chunks[id_] except KeyError: @@ -142,15 +179,29 @@ def __delitem__(self, id_): """Remove a chunk from the IFF file""" + + if not isinstance(id_, text_type): + id_ = id_.decode('ascii') + + if not is_valid_chunk_id(id_): + raise KeyError("AIFF key must be four ASCII characters.") + self.__chunks.pop(id_).delete() def insert_chunk(self, id_): """Insert a new chunk at the end of the IFF file""" + + if not isinstance(id_, text_type): + id_ = id_.decode('ascii') + + if not is_valid_chunk_id(id_): + raise KeyError("AIFF key must be four ASCII characters.") + self.__fileobj.seek(self.__next_offset) - self.__fileobj.write(pack('>4si', id_.ljust(4), 0)) + self.__fileobj.write(pack('>4si', id_.ljust(4).encode('ascii'), 0)) self.__fileobj.seek(self.__next_offset) - chunk = IFFChunk(self.__fileobj, self['FORM']) - self['FORM'].resize(self['FORM'].data_size + chunk.size) + chunk = IFFChunk(self.__fileobj, self[u'FORM']) + self[u'FORM'].resize(self[u'FORM'].data_size + chunk.size) self.__chunks[id_] = chunk self.__next_offset = chunk.offset + chunk.size @@ -178,7 +229,7 @@ def __init__(self, fileobj): iff = IFFFile(fileobj) try: - common_chunk = iff['COMM'] + common_chunk = iff[u'COMM'] except KeyError as e: raise error(str(e)) @@ -201,12 +252,11 @@ class _IFFID3(ID3): """A AIFF file with ID3v2 tags""" - def _load_header(self): + def _pre_load_header(self, fileobj): try: - self._fileobj.seek(IFFFile(self._fileobj)['ID3'].data_offset) + fileobj.seek(IFFFile(fileobj)[u'ID3'].data_offset) except (InvalidChunk, KeyError): - raise ID3Error() - super(_IFFID3, self)._load_header() + raise ID3NoHeaderError("No ID3 chunk") def save(self, filename=None, v2_version=4, v23_sep='/'): """Save ID3v2 data to the AIFF file""" @@ -223,10 +273,10 @@ iff_file = IFFFile(fileobj) try: - if 'ID3' not in iff_file: - iff_file.insert_chunk('ID3') + if u'ID3' not in iff_file: + iff_file.insert_chunk(u'ID3') - chunk = iff_file['ID3'] + chunk = iff_file[u'ID3'] fileobj.seek(chunk.data_offset) header = fileobj.read(10) @@ -264,7 +314,7 @@ with open(filename, "rb+") as file_: try: - del IFFFile(file_)['ID3'] + del IFFFile(file_)[u'ID3'] except KeyError: pass @@ -298,8 +348,10 @@ try: self.tags = _IFFID3(filename, **kwargs) - except ID3Error: + except ID3NoHeaderError: self.tags = None + except ID3Error as e: + raise error(e) try: fileobj = open(filename, "rb") diff -Nru mutagen-1.23/mutagen/apev2.py mutagen-1.30/mutagen/apev2.py --- mutagen-1.23/mutagen/apev2.py 2013-09-12 16:55:34.000000000 +0000 +++ mutagen-1.30/mutagen/apev2.py 2015-02-01 19:07:57.000000000 +0000 @@ -1,6 +1,6 @@ -# An APEv2 tag reader -# -# Copyright 2005 Joe Wreschnig +# -*- coding: utf-8 -*- + +# Copyright (C) 2005 Joe Wreschnig # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as @@ -32,18 +32,29 @@ import sys import struct +from collections import MutableSequence -from ._compat import cBytesIO, PY3, text_type, PY2, reraise, swap_to_string +from ._compat import (cBytesIO, PY3, text_type, PY2, reraise, swap_to_string, + xrange) from mutagen import Metadata, FileType, StreamInfo -from mutagen._util import DictMixin, cdata, delete_bytes, total_ordering +from mutagen._util import (DictMixin, cdata, delete_bytes, total_ordering, + MutagenError) def is_valid_apev2_key(key): - if PY3 and not isinstance(key, text_type): - raise TypeError("Keys have to be str") + if not isinstance(key, text_type): + if PY3: + raise TypeError("APEv2 key must be str") - return (2 <= len(key) <= 255 and min(key) >= ' ' and max(key) <= '~' and - key not in ["OggS", "TAG", "ID3", "MP+"]) + try: + key = key.decode('ascii') + except UnicodeDecodeError: + return False + + # PY26 - Change to set literal syntax (since set is faster than list here) + return ((2 <= len(key) <= 255) and (min(key) >= u' ') and + (max(key) <= u'~') and + (key not in [u"OggS", u"TAG", u"ID3", u"MP+"])) # There are three different kinds of APE tag values. # "0: Item contains text information coded in UTF-8 @@ -57,7 +68,7 @@ IS_HEADER = 1 << 29 -class error(IOError): +class error(IOError, MutagenError): pass @@ -100,6 +111,7 @@ self.metadata = self.header else: self.metadata = max(self.header, self.footer) + if self.metadata is None: return @@ -255,7 +267,7 @@ """Return tag key=value pairs in a human-readable format.""" items = sorted(self.items()) - return u"\n".join([u"%s=%s" % (k, v.pprint()) for k, v in items]) + return u"\n".join(u"%s=%s" % (k, v.pprint()) for k, v in items) def load(self, filename): """Load tags from a filename.""" @@ -275,7 +287,7 @@ def __parse_tag(self, tag, count): fileobj = cBytesIO(tag) - for i in range(count): + for i in xrange(count): size_data = fileobj.read(4) # someone writes wrong item counts if not size_data: @@ -301,12 +313,7 @@ reraise(APEBadItemError, err, sys.exc_info()[2]) value = fileobj.read(size) - if kind == TEXT: - value = APETextValue(value, kind) - elif kind == BINARY: - value = APEBinaryValue(value, kind) - elif kind == EXTERNAL: - value = APEExtValue(value, kind) + value = _get_value_type(kind)._new(value) self[key] = value @@ -408,10 +415,25 @@ fileobj.truncate() fileobj.seek(0, 2) + tags = [] + for key, value in self.items(): + # Packed format for an item: + # 4B: Value length + # 4B: Value type + # Key name + # 1B: Null + # Key value + value_data = value._write() + if not isinstance(key, bytes): + key = key.encode("utf-8") + tag_data = bytearray() + tag_data += struct.pack("<2I", len(value_data), value.kind << 1) + tag_data += key + b"\0" + value_data + tags.append(bytes(tag_data)) + # "APE tags items should be sorted ascending by size... This is # not a MUST, but STRONGLY recommended. Actually the items should # be sorted by importance/byte, but this is not feasible." - tags = [v._internal(k) for k, v in self.items()] tags.sort(key=len) num_tags = len(tags) tags = b"".join(tags) @@ -459,6 +481,18 @@ pass +def _get_value_type(kind): + """Returns a _APEValue subclass or raises ValueError""" + + if kind == TEXT: + return APETextValue + elif kind == BINARY: + return APEBinaryValue + elif kind == EXTERNAL: + return APEExtValue + raise ValueError("unknown kind %r" % kind) + + def APEValue(value, kind): """APEv2 tag value factory. @@ -466,60 +500,45 @@ and text data are automatically detected by APEv2.__setitem__. """ - if kind in (TEXT, EXTERNAL): - if not isinstance(value, text_type): - # stricter with py3 - if PY3: - raise TypeError("str only for text/external values") - else: - value = value.encode("utf-8") - - if kind == TEXT: - return APETextValue(value, kind) - elif kind == BINARY: - return APEBinaryValue(value, kind) - elif kind == EXTERNAL: - return APEExtValue(value, kind) - else: + try: + type_ = _get_value_type(kind) + except ValueError: raise ValueError("kind must be TEXT, BINARY, or EXTERNAL") + else: + return type_(value) -@swap_to_string -@total_ordering class _APEValue(object): - def __init__(self, value, kind): - if not isinstance(value, bytes): - raise TypeError("value not bytes") - self.kind = kind - self.value = value - def __len__(self): - return len(self.value) + kind = None + value = None - def __bytes__(self): - return self.value + def __init__(self, value, kind=None): + # kind kwarg is for backwards compat + if kind is not None and kind != self.kind: + raise ValueError + self.value = self._validate(value) - def __eq__(self, other): - return bytes(self) == other + @classmethod + def _new(cls, data): + instance = cls.__new__(cls) + instance._parse(data) + return instance - def __lt__(self, other): - return bytes(self) < other + def _parse(self, data): + """Sets value or raises APEBadItemError""" - # Packed format for an item: - # 4B: Value length - # 4B: Value type - # Key name - # 1B: Null - # Key value - def _internal(self, key): - if not isinstance(key, bytes): - key = key.encode("utf-8") - data = bytearray() - data += struct.pack("<2I", len(self.value), self.kind << 1) - data += key - data += b"\0" - data += self.value - return bytes(data) + raise NotImplementedError + + def _write(self): + """Returns bytes""" + + raise NotImplementedError + + def _validate(self, value): + """Returns validated value or raises TypeError/ValueErrr""" + + raise NotImplementedError def __repr__(self): return "%s(%r, %d)" % (type(self).__name__, self.value, self.kind) @@ -529,53 +548,120 @@ @total_ordering class _APEUtf8Value(_APEValue): - def __str__(self): - return self.value.decode("utf-8") + def _parse(self, data): + try: + self.value = data.decode("utf-8") + except UnicodeDecodeError as e: + reraise(APEBadItemError, e, sys.exc_info()[2]) + + def _validate(self, value): + if not isinstance(value, text_type): + if PY3: + raise TypeError("value not str") + else: + value = value.decode("utf-8") + return value + + def _write(self): + return self.value.encode("utf-8") + + def __len__(self): + return len(self.value) + + def __bytes__(self): + return self._write() def __eq__(self, other): - return text_type(self) == other + return self.value == other def __lt__(self, other): - return text_type(self) < other + return self.value < other + def __str__(self): + return self.value -class APETextValue(_APEUtf8Value): + +class APETextValue(_APEUtf8Value, MutableSequence): """An APEv2 text value. Text values are Unicode/UTF-8 strings. They can be accessed like strings (with a null separating the values), or arrays of strings. """ + kind = TEXT + def __iter__(self): """Iterate over the strings of the value (not the characters)""" - return iter(text_type(self).split(u"\0")) + return iter(self.value.split(u"\0")) def __getitem__(self, index): - return text_type(self).split(u"\0")[index] + return self.value.split(u"\0")[index] def __len__(self): - return self.value.count(b"\0") + 1 - - __hash__ = _APEValue.__hash__ + return self.value.count(u"\0") + 1 def __setitem__(self, index, value): if not isinstance(value, text_type): if PY3: raise TypeError("value not str") - value = value.decode("utf-8") + else: + value = value.decode("utf-8") values = list(self) values[index] = value - self.value = (u"\0".join(values)).encode("utf-8") + self.value = u"\0".join(values) + + def insert(self, index, value): + if not isinstance(value, text_type): + if PY3: + raise TypeError("value not str") + else: + value = value.decode("utf-8") + + values = list(self) + values.insert(index, value) + self.value = u"\0".join(values) + + def __delitem__(self, index): + values = list(self) + del values[index] + self.value = u"\0".join(values) def pprint(self): return u" / ".join(self) +@swap_to_string +@total_ordering class APEBinaryValue(_APEValue): """An APEv2 binary value.""" + kind = BINARY + + def _parse(self, data): + self.value = data + + def _write(self): + return self.value + + def _validate(self, value): + if not isinstance(value, bytes): + raise TypeError("value not bytes") + return bytes(value) + + def __len__(self): + return len(self.value) + + def __bytes__(self): + return self._write() + + def __eq__(self, other): + return self.value == other + + def __lt__(self, other): + return self.value < other + def pprint(self): return u"[%d bytes]" % len(self) @@ -586,8 +672,10 @@ External values are usually URI or IRI strings. """ + kind = EXTERNAL + def pprint(self): - return u"[External] %s" % text_type(self) + return u"[External] %s" % self.value class APEv2File(FileType): @@ -607,7 +695,7 @@ self.info = self._Info(open(filename, "rb")) try: self.tags = APEv2(filename) - except error: + except APENoHeaderError: self.tags = None def add_tags(self): @@ -623,5 +711,4 @@ except IOError: fileobj.seek(0) footer = fileobj.read() - filename = filename.lower() return ((b"APETAGEX" in footer) - header.startswith(b"ID3")) diff -Nru mutagen-1.23/mutagen/asf.py mutagen-1.30/mutagen/asf.py --- mutagen-1.23/mutagen/asf.py 2013-10-06 12:28:12.000000000 +0000 +++ mutagen-1.30/mutagen/asf.py 2015-02-01 19:07:57.000000000 +0000 @@ -1,5 +1,8 @@ -# Copyright 2006-2007 Lukas Lalinsky -# Copyright 2005-2006 Joe Wreschnig +# -*- coding: utf-8 -*- + +# Copyright (C) 2005-2006 Joe Wreschnig +# Copyright (C) 2006-2007 Lukas Lalinsky + # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as @@ -9,13 +12,16 @@ __all__ = ["ASF", "Open"] +import sys import struct from mutagen import FileType, Metadata, StreamInfo -from mutagen._util import insert_bytes, delete_bytes, DictMixin, total_ordering -from ._compat import swap_to_string, text_type, PY2, string_types +from mutagen._util import (insert_bytes, delete_bytes, DictMixin, + total_ordering, MutagenError) +from ._compat import swap_to_string, text_type, PY2, string_types, reraise, \ + xrange, long_, PY3 -class error(IOError): +class error(IOError, MutagenError): pass @@ -46,7 +52,7 @@ """Dictionary containing ASF attributes.""" def pprint(self): - return "\n".join(["%s=%s" % (k, v) for k, v in self]) + return "\n".join("%s=%s" % (k, v) for k, v in self) def __getitem__(self, key): """A list of values for the key. @@ -55,6 +61,11 @@ work. """ + + # PY3 only + if isinstance(key, slice): + return list.__getitem__(self, key) + values = [value for (k, value) in self if k == key] if not values: raise KeyError(key) @@ -63,7 +74,12 @@ def __delitem__(self, key): """Delete all values associated with the key.""" - to_delete = list(filter(lambda x: x[0] == key, self)) + + # PY3 only + if isinstance(key, slice): + return list.__delitem__(self, key) + + to_delete = [x for x in self if x[0] == key] if not to_delete: raise KeyError(key) else: @@ -86,26 +102,37 @@ string. """ + + # PY3 only + if isinstance(key, slice): + return list.__setitem__(self, key, values) + if not isinstance(values, list): values = [values] - try: - del(self[key]) - except KeyError: - pass + + to_append = [] for value in values: - if key in _standard_attribute_names: - value = text_type(value) - elif not isinstance(value, ASFBaseAttribute): + if not isinstance(value, ASFBaseAttribute): if isinstance(value, string_types): - if PY2 or isinstance(value, text_type): - value = ASFUnicodeAttribute(value) + value = ASFUnicodeAttribute(value) + elif PY3 and isinstance(value, bytes): + value = ASFByteArrayAttribute(value) elif isinstance(value, bool): value = ASFBoolAttribute(value) elif isinstance(value, int): value = ASFDWordAttribute(value) - elif isinstance(value, long): + elif isinstance(value, long_): value = ASFQWordAttribute(value) - self.append((key, value)) + else: + raise TypeError("Invalid type %r" % type(value)) + to_append.append((key, value)) + + try: + del(self[key]) + except KeyError: + pass + + self.extend(to_append) def keys(self): """Return all keys in the comment.""" @@ -130,7 +157,19 @@ if data: self.value = self.parse(data, **kwargs) else: - self.value = value + if value is None: + # we used to support not passing any args and instead assign + # them later, keep that working.. + self.value = None + else: + self.value = self._validate(value) + + def _validate(self, value): + """Raises TypeError or ValueError in case the user supplied value + isn't valid. + """ + + return value def data_size(self): raise NotImplementedError @@ -177,7 +216,18 @@ TYPE = 0x0000 def parse(self, data): - return data.decode("utf-16-le").strip("\x00") + try: + return data.decode("utf-16-le").strip("\x00") + except UnicodeDecodeError as e: + reraise(ASFError, e, sys.exc_info()[2]) + + def _validate(self, value): + if not isinstance(value, text_type): + if PY2: + return value.decode("utf-8") + else: + raise TypeError("%r not str" % value) + return value def _render(self): return self.value.encode("utf-16-le") + b"\x00\x00" @@ -214,11 +264,19 @@ assert isinstance(self.value, bytes) return self.value + def _validate(self, value): + if not isinstance(value, bytes): + raise TypeError("must be bytes/str: %r" % value) + return value + def data_size(self): return len(self.value) def __bytes__(self): - return "[binary data (%s bytes)]" % len(self.value) + return self.value + + def __str__(self): + return "[binary data (%d bytes)]" % len(self.value) def __eq__(self, other): return self.value == other @@ -243,9 +301,12 @@ def _render(self, dword=True): if dword: - return struct.pack(" 0: - texts.append(data[pos:end].decode("utf-16-le").strip("\x00")) + texts.append(data[pos:end].decode("utf-16-le").strip(u"\x00")) else: texts.append(None) pos = end - title, author, copyright, desc, rating = texts - for key, value in dict( - Title=title, - Author=author, - Copyright=copyright, - Description=desc, - Rating=rating - ).items(): + + for key, value in zip(self.NAMES, texts): if value is not None: - asf.tags[key] = value + value = ASFUnicodeAttribute(value=value) + asf._tags.setdefault(self.GUID, []).append((key, value)) def render(self, asf): def render_text(name): - value = asf.tags.get(name, []) - if value: - return value[0].encode("utf-16-le") + b"\x00\x00" + value = asf.to_content_description.get(name) + if value is not None: + return text_type(value).encode("utf-16-le") + b"\x00\x00" else: return b"" - texts = list(map(render_text, _standard_attribute_names)) + + texts = [render_text(x) for x in self.NAMES] data = struct.pack(" 0xFFFF or value.TYPE == GUID) - if (value.language is None and value.stream is None and - name not in self.to_extended_content_description and - not library_only): - self.to_extended_content_description[name] = value - elif (value.language is None and value.stream is not None and - name not in self.to_metadata and not library_only): - self.to_metadata[name] = value - else: + can_cont_desc = value.TYPE == UNICODE + + if library_only or value.language is not None: self.to_metadata_library.append((name, value)) + elif value.stream is not None: + if name not in self.to_metadata: + self.to_metadata[name] = value + else: + self.to_metadata_library.append((name, value)) + elif name in ContentDescriptionObject.NAMES: + if name not in self.to_content_description and can_cont_desc: + self.to_content_description[name] = value + else: + self.to_metadata_library.append((name, value)) + else: + if name not in self.to_extended_content_description: + self.to_extended_content_description[name] = value + else: + self.to_metadata_library.append((name, value)) # Add missing objects if not self.content_description_obj: @@ -703,8 +810,7 @@ struct.pack(" self.size: insert_bytes(fileobj, size - self.size, self.size) @@ -712,8 +818,6 @@ delete_bytes(fileobj, self.size - size, 0) fileobj.seek(0) fileobj.write(data) - finally: - fileobj.close() self.size = size self.num_objects = len(self.objects) @@ -731,9 +835,16 @@ self.size, self.num_objects = struct.unpack(" u'\x7f': enc = 3 + break + id3.add(mutagen.id3.TXXX(encoding=enc, text=value, desc=desc)) else: frame.text = value @@ -172,8 +175,10 @@ load = property(lambda s: s.__id3.load, lambda s, v: setattr(s.__id3, 'load', v)) - save = property(lambda s: s.__id3.save, - lambda s, v: setattr(s.__id3, 'save', v)) + def save(self, *args, **kwargs): + # ignore v2_version until we support 2.3 here + kwargs.pop("v2_version", None) + self.__id3.save(*args, **kwargs) delete = property(lambda s: s.__id3.delete, lambda s, v: setattr(s.__id3, 'delete', v)) @@ -268,6 +273,18 @@ del(id3["TDRC"]) +def original_date_get(id3, key): + return [stamp.text for stamp in id3["TDOR"].text] + + +def original_date_set(id3, key, value): + id3.add(mutagen.id3.TDOR(encoding=3, text=value)) + + +def original_date_delete(id3, key): + del(id3["TDOR"]) + + def performer_get(id3, key): people = [] wanted_role = key.split(":", 1)[1] @@ -466,18 +483,20 @@ "TSOT": "titlesort", "TSRC": "isrc", "TSST": "discsubtitle", + "TLAN": "language", }): EasyID3.RegisterTextKey(key, frameid) EasyID3.RegisterKey("genre", genre_get, genre_set, genre_delete) EasyID3.RegisterKey("date", date_get, date_set, date_delete) +EasyID3.RegisterKey("originaldate", original_date_get, original_date_set, + original_date_delete) EasyID3.RegisterKey( "performer:*", performer_get, performer_set, performer_delete, performer_list) EasyID3.RegisterKey("musicbrainz_trackid", musicbrainz_trackid_get, musicbrainz_trackid_set, musicbrainz_trackid_delete) EasyID3.RegisterKey("website", website_get, website_set, website_delete) -EasyID3.RegisterKey("website", website_get, website_set, website_delete) EasyID3.RegisterKey( "replaygain_*_gain", gain_get, gain_set, gain_delete, peakgain_list) EasyID3.RegisterKey("replaygain_*_peak", peak_get, peak_set, peak_delete) @@ -500,6 +519,12 @@ u"ASIN": "asin", u"ALBUMARTISTSORT": "albumartistsort", u"BARCODE": "barcode", + u"CATALOGNUMBER": "catalognumber", + u"MusicBrainz Release Track Id": "musicbrainz_releasetrackid", + u"MusicBrainz Release Group Id": "musicbrainz_releasegroupid", + u"MusicBrainz Work Id": "musicbrainz_workid", + u"Acoustid Fingerprint": "acoustid_fingerprint", + u"Acoustid Id": "acoustid_id", }): EasyID3.RegisterTXXXKey(key, desc) diff -Nru mutagen-1.23/mutagen/easymp4.py mutagen-1.30/mutagen/easymp4.py --- mutagen-1.23/mutagen/easymp4.py 2013-10-05 17:10:15.000000000 +0000 +++ mutagen-1.30/mutagen/easymp4.py 2015-02-01 19:07:57.000000000 +0000 @@ -1,13 +1,15 @@ -# Copyright 2009 Joe Wreschnig +# -*- coding: utf-8 -*- + +# Copyright (C) 2009 Joe Wreschnig # # This program is free software; you can redistribute it and/or modify # it under the terms of version 2 of the GNU General Public License as # published by the Free Software Foundation. from mutagen import Metadata -from mutagen._util import DictMixin, dict_match, utf8 +from mutagen._util import DictMixin, dict_match from mutagen.mp4 import MP4, MP4Tags, error, delete -from ._compat import PY2, text_type +from ._compat import PY2, text_type, PY3 __all__ = ["EasyMP4Tags", "EasyMP4", "delete", "error"] @@ -24,7 +26,7 @@ strings, and values are a list of Unicode strings (and these lists are always of length 0 or 1). - If you need access to the full MP4 metadata feature set, you should use + If you need access to the full MP4 metadata feature set, you should use MP4, not EasyMP4. """ @@ -93,7 +95,7 @@ cls.RegisterKey(key, getter, setter, deleter) @classmethod - def RegisterIntKey(cls, key, atomid, min_value=0, max_value=2**16-1): + def RegisterIntKey(cls, key, atomid, min_value=0, max_value=(2 ** 16) - 1): """Register a scalar integer key. """ @@ -102,7 +104,7 @@ def setter(tags, key, value): clamp = lambda x: int(min(max(min_value, x), max_value)) - tags[atomid] = list(map(clamp, map(int, value))) + tags[atomid] = [clamp(v) for v in map(int, value)] def deleter(tags, key): del(tags[atomid]) @@ -110,7 +112,8 @@ cls.RegisterKey(key, getter, setter, deleter) @classmethod - def RegisterIntPairKey(cls, key, atomid, min_value=0, max_value=2**16-1): + def RegisterIntPairKey(cls, key, atomid, min_value=0, + max_value=(2 ** 16) - 1): def getter(tags, key): ret = [] for (track, total) in tags[atomid]: @@ -140,7 +143,7 @@ cls.RegisterKey(key, getter, setter, deleter) @classmethod - def RegisterFreeformKey(cls, key, name, mean=b"com.apple.iTunes"): + def RegisterFreeformKey(cls, key, name, mean="com.apple.iTunes"): """Register a text key. If the key you need to register is a simple one-to-one mapping @@ -150,13 +153,20 @@ EasyMP4Tags.RegisterFreeformKey( "musicbrainz_artistid", "MusicBrainz Artist Id") """ - atomid = b"----:" + mean + b":" + name + atomid = "----:" + mean + ":" + name def getter(tags, key): return [s.decode("utf-8", "replace") for s in tags[atomid]] def setter(tags, key, value): - tags[atomid] = [utf8(v) for v in value] + encoded = [] + for v in value: + if not isinstance(v, text_type): + if PY3: + raise TypeError("%r not str" % v) + v = v.decode("utf-8") + encoded.append(v.encode("utf-8")) + tags[atomid] = encoded def deleter(tags, key): del(tags[atomid]) @@ -214,44 +224,44 @@ return "\n".join(strings) for atomid, key in { - b'\xa9nam': 'title', - b'\xa9alb': 'album', - b'\xa9ART': 'artist', - b'aART': 'albumartist', - b'\xa9day': 'date', - b'\xa9cmt': 'comment', - b'desc': 'description', - b'\xa9grp': 'grouping', - b'\xa9gen': 'genre', - b'cprt': 'copyright', - b'soal': 'albumsort', - b'soaa': 'albumartistsort', - b'soar': 'artistsort', - b'sonm': 'titlesort', - b'soco': 'composersort', + '\xa9nam': 'title', + '\xa9alb': 'album', + '\xa9ART': 'artist', + 'aART': 'albumartist', + '\xa9day': 'date', + '\xa9cmt': 'comment', + 'desc': 'description', + '\xa9grp': 'grouping', + '\xa9gen': 'genre', + 'cprt': 'copyright', + 'soal': 'albumsort', + 'soaa': 'albumartistsort', + 'soar': 'artistsort', + 'sonm': 'titlesort', + 'soco': 'composersort', }.items(): EasyMP4Tags.RegisterTextKey(key, atomid) for name, key in { - b'MusicBrainz Artist Id': 'musicbrainz_artistid', - b'MusicBrainz Track Id': 'musicbrainz_trackid', - b'MusicBrainz Album Id': 'musicbrainz_albumid', - b'MusicBrainz Album Artist Id': 'musicbrainz_albumartistid', - b'MusicIP PUID': 'musicip_puid', - b'MusicBrainz Album Status': 'musicbrainz_albumstatus', - b'MusicBrainz Album Type': 'musicbrainz_albumtype', - b'MusicBrainz Release Country': 'releasecountry', + 'MusicBrainz Artist Id': 'musicbrainz_artistid', + 'MusicBrainz Track Id': 'musicbrainz_trackid', + 'MusicBrainz Album Id': 'musicbrainz_albumid', + 'MusicBrainz Album Artist Id': 'musicbrainz_albumartistid', + 'MusicIP PUID': 'musicip_puid', + 'MusicBrainz Album Status': 'musicbrainz_albumstatus', + 'MusicBrainz Album Type': 'musicbrainz_albumtype', + 'MusicBrainz Release Country': 'releasecountry', }.items(): EasyMP4Tags.RegisterFreeformKey(key, name) for name, key in { - b"tmpo": "bpm", + "tmpo": "bpm", }.items(): EasyMP4Tags.RegisterIntKey(key, name) for name, key in { - b"trkn": "tracknumber", - b"disk": "discnumber", + "trkn": "tracknumber", + "disk": "discnumber", }.items(): EasyMP4Tags.RegisterIntPairKey(key, name) diff -Nru mutagen-1.23/mutagen/_file.py mutagen-1.30/mutagen/_file.py --- mutagen-1.23/mutagen/_file.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/mutagen/_file.py 2015-08-18 10:51:48.000000000 +0000 @@ -0,0 +1,237 @@ +# Copyright (C) 2005 Michael Urman +# -*- coding: utf-8 -*- +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +import warnings + +from mutagen._util import DictMixin + + +class FileType(DictMixin): + """An abstract object wrapping tags and audio stream information. + + Attributes: + + * info -- stream information (length, bitrate, sample rate) + * tags -- metadata tags, if any + + Each file format has different potential tags and stream + information. + + FileTypes implement an interface very similar to Metadata; the + dict interface, save, load, and delete calls on a FileType call + the appropriate methods on its tag data. + """ + + __module__ = "mutagen" + + info = None + tags = None + filename = None + _mimes = ["application/octet-stream"] + + def __init__(self, filename=None, *args, **kwargs): + if filename is None: + warnings.warn("FileType constructor requires a filename", + DeprecationWarning) + else: + self.load(filename, *args, **kwargs) + + def load(self, filename, *args, **kwargs): + raise NotImplementedError + + def __getitem__(self, key): + """Look up a metadata tag key. + + If the file has no tags at all, a KeyError is raised. + """ + + if self.tags is None: + raise KeyError(key) + else: + return self.tags[key] + + def __setitem__(self, key, value): + """Set a metadata tag. + + If the file has no tags, an appropriate format is added (but + not written until save is called). + """ + + if self.tags is None: + self.add_tags() + self.tags[key] = value + + def __delitem__(self, key): + """Delete a metadata tag key. + + If the file has no tags at all, a KeyError is raised. + """ + + if self.tags is None: + raise KeyError(key) + else: + del(self.tags[key]) + + def keys(self): + """Return a list of keys in the metadata tag. + + If the file has no tags at all, an empty list is returned. + """ + + if self.tags is None: + return [] + else: + return self.tags.keys() + + def delete(self, filename=None): + """Remove tags from a file.""" + + if self.tags is not None: + if filename is None: + filename = self.filename + else: + warnings.warn( + "delete(filename=...) is deprecated, reload the file", + DeprecationWarning) + return self.tags.delete(filename) + + def save(self, filename=None, **kwargs): + """Save metadata tags.""" + + if filename is None: + filename = self.filename + else: + warnings.warn( + "save(filename=...) is deprecated, reload the file", + DeprecationWarning) + + if self.tags is not None: + return self.tags.save(filename, **kwargs) + + def pprint(self): + """Print stream information and comment key=value pairs.""" + + stream = "%s (%s)" % (self.info.pprint(), self.mime[0]) + try: + tags = self.tags.pprint() + except AttributeError: + return stream + else: + return stream + ((tags and "\n" + tags) or "") + + def add_tags(self): + """Adds new tags to the file. + + Raises if tags already exist. + """ + + raise NotImplementedError + + @property + def mime(self): + """A list of mime types""" + + mimes = [] + for Kind in type(self).__mro__: + for mime in getattr(Kind, '_mimes', []): + if mime not in mimes: + mimes.append(mime) + return mimes + + @staticmethod + def score(filename, fileobj, header): + raise NotImplementedError + + +class StreamInfo(object): + """Abstract stream information object. + + Provides attributes for length, bitrate, sample rate etc. + + See the implementations for details. + """ + + __module__ = "mutagen" + + def pprint(self): + """Print stream information""" + + raise NotImplementedError + + +def File(filename, options=None, easy=False): + """Guess the type of the file and try to open it. + + The file type is decided by several things, such as the first 128 + bytes (which usually contains a file type identifier), the + filename extension, and the presence of existing tags. + + If no appropriate type could be found, None is returned. + + :param options: Sequence of :class:`FileType` implementations, defaults to + all included ones. + + :param easy: If the easy wrappers should be returnd if available. + For example :class:`EasyMP3 ` instead + of :class:`MP3 `. + """ + + if options is None: + from mutagen.asf import ASF + from mutagen.apev2 import APEv2File + from mutagen.flac import FLAC + if easy: + from mutagen.easyid3 import EasyID3FileType as ID3FileType + else: + from mutagen.id3 import ID3FileType + if easy: + from mutagen.mp3 import EasyMP3 as MP3 + else: + from mutagen.mp3 import MP3 + from mutagen.oggflac import OggFLAC + from mutagen.oggspeex import OggSpeex + from mutagen.oggtheora import OggTheora + from mutagen.oggvorbis import OggVorbis + from mutagen.oggopus import OggOpus + if easy: + from mutagen.trueaudio import EasyTrueAudio as TrueAudio + else: + from mutagen.trueaudio import TrueAudio + from mutagen.wavpack import WavPack + if easy: + from mutagen.easymp4 import EasyMP4 as MP4 + else: + from mutagen.mp4 import MP4 + from mutagen.musepack import Musepack + from mutagen.monkeysaudio import MonkeysAudio + from mutagen.optimfrog import OptimFROG + from mutagen.aiff import AIFF + from mutagen.aac import AAC + options = [MP3, TrueAudio, OggTheora, OggSpeex, OggVorbis, OggFLAC, + FLAC, AIFF, APEv2File, MP4, ID3FileType, WavPack, + Musepack, MonkeysAudio, OptimFROG, ASF, OggOpus, AAC] + + if not options: + return None + + fileobj = open(filename, "rb") + try: + header = fileobj.read(128) + # Sort by name after score. Otherwise import order affects + # Kind sort order, which affects treatment of things with + # equals scores. + results = [(Kind.score(filename, fileobj, header), Kind.__name__) + for Kind in options] + finally: + fileobj.close() + results = list(zip(results, options)) + results.sort() + (score, name), Kind = results[-1] + if score > 0: + return Kind(filename) + else: + return None diff -Nru mutagen-1.23/mutagen/flac.py mutagen-1.30/mutagen/flac.py --- mutagen-1.23/mutagen/flac.py 2013-09-10 16:41:56.000000000 +0000 +++ mutagen-1.30/mutagen/flac.py 2015-08-17 10:42:51.000000000 +0000 @@ -1,5 +1,6 @@ -# FLAC comment support for Mutagen -# Copyright 2005 Joe Wreschnig +# -*- coding: utf-8 -*- + +# Copyright (C) 2005 Joe Wreschnig # # This program is free software; you can redistribute it and/or modify # it under the terms of version 2 of the GNU General Public License as @@ -26,14 +27,12 @@ import mutagen from ._compat import cBytesIO, endswith, chr_ -from mutagen._util import insert_bytes +from mutagen._util import insert_bytes, MutagenError from mutagen.id3 import BitPaddedInt -import sys -if sys.version_info >= (2, 6): - from functools import reduce +from functools import reduce -class error(IOError): +class error(IOError, MutagenError): pass @@ -45,10 +44,10 @@ pass -def to_int_be(string): +def to_int_be(data): """Convert an arbitrarily-long string to a long using big-endian byte order.""" - return reduce(lambda a, b: (a << 8) + b, bytearray(string), 0) + return reduce(lambda a, b: (a << 8) + b, bytearray(data), 0) class StrictFileObject(object): @@ -84,6 +83,13 @@ """ _distrust_size = False + """For block types setting this, we don't trust the size field and + use the size of the content instead.""" + + _invalid_overflow_size = -1 + """In case the real size was bigger than what is representable by the + 24 bit size field, we save the wrong specified size here. This can + only be set if _distrust_size is True""" def __init__(self, data): """Parse the given data string or file-like as a metadata block. @@ -107,15 +113,26 @@ @staticmethod def writeblocks(blocks): """Render metadata block as a byte string.""" + data = [] - codes = [[block.code, block.write()] for block in blocks] - codes[-1][0] |= 128 - for code, datum in codes: - byte = chr_(code) - if len(datum) > 2**24: - raise error("block is too long to write") - length = struct.pack(">I", len(datum))[-3:] - data.append(byte + length + datum) + for i, block in enumerate(blocks): + is_last = (i == len(blocks) - 1) + code = (block.code | 128) if is_last else block.code + datum = block.write() + size = len(datum) + if size > 2 ** 24: + if block._distrust_size and block._invalid_overflow_size != -1: + # The original size of this block was (1) wrong and (2) + # the real size doesn't allow us to save the file + # according to the spec (too big for 24 bit uint). Instead + # simply write back the original wrong size.. at least + # we don't make the file more "broken" as it is. + size = block._invalid_overflow_size + else: + raise error("block is too long to write") + assert not size > 2 ** 24 + length = struct.pack(">I", size)[-3:] + data.extend([chr_(code), length, datum]) return b"".join(data) @staticmethod @@ -131,7 +148,7 @@ blocks.remove(p) # total padding size is the sum of padding sizes plus 4 bytes # per removed header. - size = sum([padding.length for padding in paddings]) + size = sum(padding.length for padding in paddings) padding = Padding() padding.length = size + 4 * (len(paddings) - 1) blocks.append(padding) @@ -382,10 +399,10 @@ __hash__ = object.__hash__ def __repr__(self): - return ("<%s number=%r, offset=%d, isrc=%r, type=%r, " - "pre_emphasis=%r, indexes=%r)>") % ( - type(self).__name__, self.track_number, self.start_offset, - self.isrc, self.type, self.pre_emphasis, self.indexes) + return (("<%s number=%r, offset=%d, isrc=%r, type=%r, " + "pre_emphasis=%r, indexes=%r)>") % + (type(self).__name__, self.track_number, self.start_offset, + self.isrc, self.type, self.pre_emphasis, self.indexes)) class CueSheet(MetadataBlock): @@ -484,10 +501,10 @@ return f.getvalue() def __repr__(self): - return ("<%s media_catalog_number=%r, lead_in=%r, compact_disc=%r, " - "tracks=%r>") % ( - type(self).__name__, self.media_catalog_number, - self.lead_in_samples, self.compact_disc, self.tracks) + return (("<%s media_catalog_number=%r, lead_in=%r, compact_disc=%r, " + "tracks=%r>") % + (type(self).__name__, self.media_catalog_number, + self.lead_in_samples, self.compact_disc, self.tracks)) class Picture(MetadataBlock): @@ -504,6 +521,21 @@ * colors -- number of colors for indexed palettes (like GIF), 0 for non-indexed * data -- picture data + + To create a picture from file (in order to add to a FLAC file), + instantiate this object without passing anything to the constructor and + then set the properties manually:: + + p = Picture() + + with open("Folder.jpg", "rb") as f: + pic.data = f.read() + + pic.type = id3.PictureType.COVER_FRONT + pic.mime = u"image/jpeg" + pic.width = 500 + pic.height = 500 + pic.depth = 16 # color depth """ code = 6 @@ -619,8 +651,8 @@ """Known metadata block types, indexed by ID.""" @staticmethod - def score(filename, fileobj, header): - return (header.startswith(b"fLaC") + + def score(filename, fileobj, header_data): + return (header_data.startswith(b"fLaC") + endswith(filename.lower(), ".flac") * 3) def __read_metadata_block(self, fileobj): @@ -644,7 +676,11 @@ # http://code.google.com/p/mutagen/issues/detail?id=52 # ..same for the Picture block: # http://code.google.com/p/mutagen/issues/detail?id=106 + start = fileobj.tell() block = block_type(fileobj) + real_size = fileobj.tell() - start + if real_size > 2 ** 24: + block._invalid_overflow_size = size else: data = fileobj.read(size) block = block_type(data) diff -Nru mutagen-1.23/mutagen/id3/_frames.py mutagen-1.30/mutagen/id3/_frames.py --- mutagen-1.23/mutagen/id3/_frames.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/mutagen/id3/_frames.py 2015-05-09 11:50:36.000000000 +0000 @@ -0,0 +1,1925 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2005 Michael Urman +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +import zlib +from struct import unpack + +from ._util import ID3JunkFrameError, ID3EncryptionUnsupportedError, unsynch +from ._specs import ( + BinaryDataSpec, StringSpec, Latin1TextSpec, EncodedTextSpec, ByteSpec, + EncodingSpec, ASPIIndexSpec, SizedIntegerSpec, IntegerSpec, + VolumeAdjustmentsSpec, VolumePeakSpec, VolumeAdjustmentSpec, + ChannelSpec, MultiSpec, SynchronizedTextSpec, KeyEventSpec, TimeStampSpec, + EncodedNumericPartTextSpec, EncodedNumericTextSpec, SpecError) +from .._compat import text_type, string_types, swap_to_string, iteritems + + +def is_valid_frame_id(frame_id): + return frame_id.isalnum() and frame_id.isupper() + + +def _bytes2key(b): + assert isinstance(b, bytes) + + return b.decode("latin1") + + +class Frame(object): + """Fundamental unit of ID3 data. + + ID3 tags are split into frames. Each frame has a potentially + different structure, and so this base class is not very featureful. + """ + + FLAG23_ALTERTAG = 0x8000 + FLAG23_ALTERFILE = 0x4000 + FLAG23_READONLY = 0x2000 + FLAG23_COMPRESS = 0x0080 + FLAG23_ENCRYPT = 0x0040 + FLAG23_GROUP = 0x0020 + + FLAG24_ALTERTAG = 0x4000 + FLAG24_ALTERFILE = 0x2000 + FLAG24_READONLY = 0x1000 + FLAG24_GROUPID = 0x0040 + FLAG24_COMPRESS = 0x0008 + FLAG24_ENCRYPT = 0x0004 + FLAG24_UNSYNCH = 0x0002 + FLAG24_DATALEN = 0x0001 + + _framespec = [] + + def __init__(self, *args, **kwargs): + if len(args) == 1 and len(kwargs) == 0 and \ + isinstance(args[0], type(self)): + other = args[0] + # ask the sub class to fill in our data + other._to_other(self) + else: + for checker, val in zip(self._framespec, args): + setattr(self, checker.name, checker.validate(self, val)) + for checker in self._framespec[len(args):]: + try: + validated = checker.validate( + self, kwargs.get(checker.name, None)) + except ValueError as e: + raise ValueError("%s: %s" % (checker.name, e)) + setattr(self, checker.name, validated) + + def _to_other(self, other): + # this impl covers subclasses with the same framespec + if other._framespec is not self._framespec: + raise ValueError + + for checker in other._framespec: + setattr(other, checker.name, getattr(self, checker.name)) + + def _get_v23_frame(self, **kwargs): + """Returns a frame copy which is suitable for writing into a v2.3 tag. + + kwargs get passed to the specs. + """ + + new_kwargs = {} + for checker in self._framespec: + name = checker.name + value = getattr(self, name) + new_kwargs[name] = checker._validate23(self, value, **kwargs) + return type(self)(**new_kwargs) + + @property + def HashKey(self): + """An internal key used to ensure frame uniqueness in a tag""" + + return self.FrameID + + @property + def FrameID(self): + """ID3v2 three or four character frame ID""" + + return type(self).__name__ + + def __repr__(self): + """Python representation of a frame. + + The string returned is a valid Python expression to construct + a copy of this frame. + """ + kw = [] + for attr in self._framespec: + # so repr works during __init__ + if hasattr(self, attr.name): + kw.append('%s=%r' % (attr.name, getattr(self, attr.name))) + return '%s(%s)' % (type(self).__name__, ', '.join(kw)) + + def _readData(self, data): + """Raises ID3JunkFrameError; Returns leftover data""" + + for reader in self._framespec: + if len(data): + try: + value, data = reader.read(self, data) + except SpecError as e: + raise ID3JunkFrameError(e) + else: + raise ID3JunkFrameError("no data left") + setattr(self, reader.name, value) + + return data + + def _writeData(self): + data = [] + for writer in self._framespec: + data.append(writer.write(self, getattr(self, writer.name))) + return b''.join(data) + + def pprint(self): + """Return a human-readable representation of the frame.""" + return "%s=%s" % (type(self).__name__, self._pprint()) + + def _pprint(self): + return "[unrepresentable data]" + + @classmethod + def _fromData(cls, id3, tflags, data): + """Construct this ID3 frame from raw string data. + + Raises: + + ID3JunkFrameError in case parsing failed + NotImplementedError in case parsing isn't implemented + ID3EncryptionUnsupportedError in case the frame is encrypted. + """ + + if id3.version >= id3._V24: + if tflags & (Frame.FLAG24_COMPRESS | Frame.FLAG24_DATALEN): + # The data length int is syncsafe in 2.4 (but not 2.3). + # However, we don't actually need the data length int, + # except to work around a QL 0.12 bug, and in that case + # all we need are the raw bytes. + datalen_bytes = data[:4] + data = data[4:] + if tflags & Frame.FLAG24_UNSYNCH or id3.f_unsynch: + try: + data = unsynch.decode(data) + except ValueError: + # Some things write synch-unsafe data with either the frame + # or global unsynch flag set. Try to load them as is. + # https://bitbucket.org/lazka/mutagen/issue/210 + # https://bitbucket.org/lazka/mutagen/issue/223 + pass + if tflags & Frame.FLAG24_ENCRYPT: + raise ID3EncryptionUnsupportedError + if tflags & Frame.FLAG24_COMPRESS: + try: + data = zlib.decompress(data) + except zlib.error as err: + # the initial mutagen that went out with QL 0.12 did not + # write the 4 bytes of uncompressed size. Compensate. + data = datalen_bytes + data + try: + data = zlib.decompress(data) + except zlib.error as err: + raise ID3JunkFrameError( + 'zlib: %s: %r' % (err, data)) + + elif id3.version >= id3._V23: + if tflags & Frame.FLAG23_COMPRESS: + usize, = unpack('>L', data[:4]) + data = data[4:] + if tflags & Frame.FLAG23_ENCRYPT: + raise ID3EncryptionUnsupportedError + if tflags & Frame.FLAG23_COMPRESS: + try: + data = zlib.decompress(data) + except zlib.error as err: + raise ID3JunkFrameError('zlib: %s: %r' % (err, data)) + + frame = cls() + frame._readData(data) + return frame + + def __hash__(self): + raise TypeError("Frame objects are unhashable") + + +class FrameOpt(Frame): + """A frame with optional parts. + + Some ID3 frames have optional data; this class extends Frame to + provide support for those parts. + """ + + _optionalspec = [] + + def __init__(self, *args, **kwargs): + super(FrameOpt, self).__init__(*args, **kwargs) + for spec in self._optionalspec: + if spec.name in kwargs: + validated = spec.validate(self, kwargs[spec.name]) + setattr(self, spec.name, validated) + else: + break + + def _to_other(self, other): + super(FrameOpt, self)._to_other(other) + + # this impl covers subclasses with the same optionalspec + if other._optionalspec is not self._optionalspec: + raise ValueError + + for checker in other._optionalspec: + if hasattr(self, checker.name): + setattr(other, checker.name, getattr(self, checker.name)) + + def _readData(self, data): + """Raises ID3JunkFrameError; Returns leftover data""" + + for reader in self._framespec: + if len(data): + try: + value, data = reader.read(self, data) + except SpecError as e: + raise ID3JunkFrameError(e) + else: + raise ID3JunkFrameError("no data left") + setattr(self, reader.name, value) + + if data: + for reader in self._optionalspec: + if len(data): + try: + value, data = reader.read(self, data) + except SpecError as e: + raise ID3JunkFrameError(e) + else: + break + setattr(self, reader.name, value) + + return data + + def _writeData(self): + data = [] + for writer in self._framespec: + data.append(writer.write(self, getattr(self, writer.name))) + for writer in self._optionalspec: + try: + data.append(writer.write(self, getattr(self, writer.name))) + except AttributeError: + break + return b''.join(data) + + def __repr__(self): + kw = [] + for attr in self._framespec: + kw.append('%s=%r' % (attr.name, getattr(self, attr.name))) + for attr in self._optionalspec: + if hasattr(self, attr.name): + kw.append('%s=%r' % (attr.name, getattr(self, attr.name))) + return '%s(%s)' % (type(self).__name__, ', '.join(kw)) + + +@swap_to_string +class TextFrame(Frame): + """Text strings. + + Text frames support casts to unicode or str objects, as well as + list-like indexing, extend, and append. + + Iterating over a TextFrame iterates over its strings, not its + characters. + + Text frames have a 'text' attribute which is the list of strings, + and an 'encoding' attribute; 0 for ISO-8859 1, 1 UTF-16, 2 for + UTF-16BE, and 3 for UTF-8. If you don't want to worry about + encodings, just set it to 3. + """ + + _framespec = [ + EncodingSpec('encoding'), + MultiSpec('text', EncodedTextSpec('text'), sep=u'\u0000'), + ] + + def __bytes__(self): + return text_type(self).encode('utf-8') + + def __str__(self): + return u'\u0000'.join(self.text) + + def __eq__(self, other): + if isinstance(other, bytes): + return bytes(self) == other + elif isinstance(other, text_type): + return text_type(self) == other + return self.text == other + + __hash__ = Frame.__hash__ + + def __getitem__(self, item): + return self.text[item] + + def __iter__(self): + return iter(self.text) + + def append(self, value): + """Append a string.""" + + return self.text.append(value) + + def extend(self, value): + """Extend the list by appending all strings from the given list.""" + + return self.text.extend(value) + + def _pprint(self): + return " / ".join(self.text) + + +class NumericTextFrame(TextFrame): + """Numerical text strings. + + The numeric value of these frames can be gotten with unary plus, e.g.:: + + frame = TLEN('12345') + length = +frame + """ + + _framespec = [ + EncodingSpec('encoding'), + MultiSpec('text', EncodedNumericTextSpec('text'), sep=u'\u0000'), + ] + + def __pos__(self): + """Return the numerical value of the string.""" + return int(self.text[0]) + + +class NumericPartTextFrame(TextFrame): + """Multivalue numerical text strings. + + These strings indicate 'part (e.g. track) X of Y', and unary plus + returns the first value:: + + frame = TRCK('4/15') + track = +frame # track == 4 + """ + + _framespec = [ + EncodingSpec('encoding'), + MultiSpec('text', EncodedNumericPartTextSpec('text'), sep=u'\u0000'), + ] + + def __pos__(self): + return int(self.text[0].split("/")[0]) + + +@swap_to_string +class TimeStampTextFrame(TextFrame): + """A list of time stamps. + + The 'text' attribute in this frame is a list of ID3TimeStamp + objects, not a list of strings. + """ + + _framespec = [ + EncodingSpec('encoding'), + MultiSpec('text', TimeStampSpec('stamp'), sep=u','), + ] + + def __bytes__(self): + return text_type(self).encode('utf-8') + + def __str__(self): + return u','.join([stamp.text for stamp in self.text]) + + def _pprint(self): + return u" / ".join([stamp.text for stamp in self.text]) + + +@swap_to_string +class UrlFrame(Frame): + """A frame containing a URL string. + + The ID3 specification is silent about IRIs and normalized URL + forms. Mutagen assumes all URLs in files are encoded as Latin 1, + but string conversion of this frame returns a UTF-8 representation + for compatibility with other string conversions. + + The only sane way to handle URLs in MP3s is to restrict them to + ASCII. + """ + + _framespec = [Latin1TextSpec('url')] + + def __bytes__(self): + return self.url.encode('utf-8') + + def __str__(self): + return self.url + + def __eq__(self, other): + return self.url == other + + __hash__ = Frame.__hash__ + + def _pprint(self): + return self.url + + +class UrlFrameU(UrlFrame): + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.url) + + +class TALB(TextFrame): + "Album" + + +class TBPM(NumericTextFrame): + "Beats per minute" + + +class TCOM(TextFrame): + "Composer" + + +class TCON(TextFrame): + """Content type (Genre) + + ID3 has several ways genres can be represented; for convenience, + use the 'genres' property rather than the 'text' attribute. + """ + + from mutagen._constants import GENRES + GENRES = GENRES + + def __get_genres(self): + genres = [] + import re + genre_re = re.compile(r"((?:\((?P[0-9]+|RX|CR)\))*)(?P.+)?") + for value in self.text: + # 255 possible entries in id3v1 + if value.isdigit() and int(value) < 256: + try: + genres.append(self.GENRES[int(value)]) + except IndexError: + genres.append(u"Unknown") + elif value == "CR": + genres.append(u"Cover") + elif value == "RX": + genres.append(u"Remix") + elif value: + newgenres = [] + genreid, dummy, genrename = genre_re.match(value).groups() + + if genreid: + for gid in genreid[1:-1].split(")("): + if gid.isdigit() and int(gid) < len(self.GENRES): + gid = text_type(self.GENRES[int(gid)]) + newgenres.append(gid) + elif gid == "CR": + newgenres.append(u"Cover") + elif gid == "RX": + newgenres.append(u"Remix") + else: + newgenres.append(u"Unknown") + + if genrename: + # "Unescaping" the first parenthesis + if genrename.startswith("(("): + genrename = genrename[1:] + if genrename not in newgenres: + newgenres.append(genrename) + + genres.extend(newgenres) + + return genres + + def __set_genres(self, genres): + if isinstance(genres, string_types): + genres = [genres] + self.text = [self.__decode(g) for g in genres] + + def __decode(self, value): + if isinstance(value, bytes): + enc = EncodedTextSpec._encodings[self.encoding][0] + return value.decode(enc) + else: + return value + + genres = property(__get_genres, __set_genres, None, + "A list of genres parsed from the raw text data.") + + def _pprint(self): + return " / ".join(self.genres) + + +class TCOP(TextFrame): + "Copyright (c)" + + +class TCMP(NumericTextFrame): + "iTunes Compilation Flag" + + +class TDAT(TextFrame): + "Date of recording (DDMM)" + + +class TDEN(TimeStampTextFrame): + "Encoding Time" + + +class TDES(TextFrame): + "iTunes Podcast Description" + + +class TDOR(TimeStampTextFrame): + "Original Release Time" + + +class TDLY(NumericTextFrame): + "Audio Delay (ms)" + + +class TDRC(TimeStampTextFrame): + "Recording Time" + + +class TDRL(TimeStampTextFrame): + "Release Time" + + +class TDTG(TimeStampTextFrame): + "Tagging Time" + + +class TENC(TextFrame): + "Encoder" + + +class TEXT(TextFrame): + "Lyricist" + + +class TFLT(TextFrame): + "File type" + + +class TGID(TextFrame): + "iTunes Podcast Identifier" + + +class TIME(TextFrame): + "Time of recording (HHMM)" + + +class TIT1(TextFrame): + "Content group description" + + +class TIT2(TextFrame): + "Title" + + +class TIT3(TextFrame): + "Subtitle/Description refinement" + + +class TKEY(TextFrame): + "Starting Key" + + +class TLAN(TextFrame): + "Audio Languages" + + +class TLEN(NumericTextFrame): + "Audio Length (ms)" + + +class TMED(TextFrame): + "Source Media Type" + + +class TMOO(TextFrame): + "Mood" + + +class TOAL(TextFrame): + "Original Album" + + +class TOFN(TextFrame): + "Original Filename" + + +class TOLY(TextFrame): + "Original Lyricist" + + +class TOPE(TextFrame): + "Original Artist/Performer" + + +class TORY(NumericTextFrame): + "Original Release Year" + + +class TOWN(TextFrame): + "Owner/Licensee" + + +class TPE1(TextFrame): + "Lead Artist/Performer/Soloist/Group" + + +class TPE2(TextFrame): + "Band/Orchestra/Accompaniment" + + +class TPE3(TextFrame): + "Conductor" + + +class TPE4(TextFrame): + "Interpreter/Remixer/Modifier" + + +class TPOS(NumericPartTextFrame): + "Part of set" + + +class TPRO(TextFrame): + "Produced (P)" + + +class TPUB(TextFrame): + "Publisher" + + +class TRCK(NumericPartTextFrame): + "Track Number" + + +class TRDA(TextFrame): + "Recording Dates" + + +class TRSN(TextFrame): + "Internet Radio Station Name" + + +class TRSO(TextFrame): + "Internet Radio Station Owner" + + +class TSIZ(NumericTextFrame): + "Size of audio data (bytes)" + + +class TSO2(TextFrame): + "iTunes Album Artist Sort" + + +class TSOA(TextFrame): + "Album Sort Order key" + + +class TSOC(TextFrame): + "iTunes Composer Sort" + + +class TSOP(TextFrame): + "Perfomer Sort Order key" + + +class TSOT(TextFrame): + "Title Sort Order key" + + +class TSRC(TextFrame): + "International Standard Recording Code (ISRC)" + + +class TSSE(TextFrame): + "Encoder settings" + + +class TSST(TextFrame): + "Set Subtitle" + + +class TYER(NumericTextFrame): + "Year of recording" + + +class TXXX(TextFrame): + """User-defined text data. + + TXXX frames have a 'desc' attribute which is set to any Unicode + value (though the encoding of the text and the description must be + the same). Many taggers use this frame to store freeform keys. + """ + + _framespec = [ + EncodingSpec('encoding'), + EncodedTextSpec('desc'), + MultiSpec('text', EncodedTextSpec('text'), sep=u'\u0000'), + ] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.desc) + + def _pprint(self): + return "%s=%s" % (self.desc, " / ".join(self.text)) + + +class WCOM(UrlFrameU): + "Commercial Information" + + +class WCOP(UrlFrame): + "Copyright Information" + + +class WFED(UrlFrame): + "iTunes Podcast Feed" + + +class WOAF(UrlFrame): + "Official File Information" + + +class WOAR(UrlFrameU): + "Official Artist/Performer Information" + + +class WOAS(UrlFrame): + "Official Source Information" + + +class WORS(UrlFrame): + "Official Internet Radio Information" + + +class WPAY(UrlFrame): + "Payment Information" + + +class WPUB(UrlFrame): + "Official Publisher Information" + + +class WXXX(UrlFrame): + """User-defined URL data. + + Like TXXX, this has a freeform description associated with it. + """ + + _framespec = [ + EncodingSpec('encoding'), + EncodedTextSpec('desc'), + Latin1TextSpec('url'), + ] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.desc) + + +class PairedTextFrame(Frame): + """Paired text strings. + + Some ID3 frames pair text strings, to associate names with a more + specific involvement in the song. The 'people' attribute of these + frames contains a list of pairs:: + + [['trumpet', 'Miles Davis'], ['bass', 'Paul Chambers']] + + Like text frames, these frames also have an encoding attribute. + """ + + _framespec = [ + EncodingSpec('encoding'), + MultiSpec('people', + EncodedTextSpec('involvement'), + EncodedTextSpec('person')) + ] + + def __eq__(self, other): + return self.people == other + + __hash__ = Frame.__hash__ + + +class TIPL(PairedTextFrame): + "Involved People List" + + +class TMCL(PairedTextFrame): + "Musicians Credits List" + + +class IPLS(TIPL): + "Involved People List" + + +class BinaryFrame(Frame): + """Binary data + + The 'data' attribute contains the raw byte string. + """ + + _framespec = [BinaryDataSpec('data')] + + def __eq__(self, other): + return self.data == other + + __hash__ = Frame.__hash__ + + +class MCDI(BinaryFrame): + "Binary dump of CD's TOC" + + +class ETCO(Frame): + """Event timing codes.""" + + _framespec = [ + ByteSpec("format"), + KeyEventSpec("events"), + ] + + def __eq__(self, other): + return self.events == other + + __hash__ = Frame.__hash__ + + +class MLLT(Frame): + """MPEG location lookup table. + + This frame's attributes may be changed in the future based on + feedback from real-world use. + """ + + _framespec = [ + SizedIntegerSpec('frames', 2), + SizedIntegerSpec('bytes', 3), + SizedIntegerSpec('milliseconds', 3), + ByteSpec('bits_for_bytes'), + ByteSpec('bits_for_milliseconds'), + BinaryDataSpec('data'), + ] + + def __eq__(self, other): + return self.data == other + + __hash__ = Frame.__hash__ + + +class SYTC(Frame): + """Synchronised tempo codes. + + This frame's attributes may be changed in the future based on + feedback from real-world use. + """ + + _framespec = [ + ByteSpec("format"), + BinaryDataSpec("data"), + ] + + def __eq__(self, other): + return self.data == other + + __hash__ = Frame.__hash__ + + +@swap_to_string +class USLT(Frame): + """Unsynchronised lyrics/text transcription. + + Lyrics have a three letter ISO language code ('lang'), a + description ('desc'), and a block of plain text ('text'). + """ + + _framespec = [ + EncodingSpec('encoding'), + StringSpec('lang', 3), + EncodedTextSpec('desc'), + EncodedTextSpec('text'), + ] + + @property + def HashKey(self): + return '%s:%s:%s' % (self.FrameID, self.desc, self.lang) + + def __bytes__(self): + return self.text.encode('utf-8') + + def __str__(self): + return self.text + + def __eq__(self, other): + return self.text == other + + __hash__ = Frame.__hash__ + + +@swap_to_string +class SYLT(Frame): + """Synchronised lyrics/text.""" + + _framespec = [ + EncodingSpec('encoding'), + StringSpec('lang', 3), + ByteSpec('format'), + ByteSpec('type'), + EncodedTextSpec('desc'), + SynchronizedTextSpec('text'), + ] + + @property + def HashKey(self): + return '%s:%s:%s' % (self.FrameID, self.desc, self.lang) + + def __eq__(self, other): + return str(self) == other + + __hash__ = Frame.__hash__ + + def __str__(self): + return u"".join(text for (text, time) in self.text) + + def __bytes__(self): + return text_type(self).encode("utf-8") + + +class COMM(TextFrame): + """User comment. + + User comment frames have a descrption, like TXXX, and also a three + letter ISO language code in the 'lang' attribute. + """ + + _framespec = [ + EncodingSpec('encoding'), + StringSpec('lang', 3), + EncodedTextSpec('desc'), + MultiSpec('text', EncodedTextSpec('text'), sep=u'\u0000'), + ] + + @property + def HashKey(self): + return '%s:%s:%s' % (self.FrameID, self.desc, self.lang) + + def _pprint(self): + return "%s=%s=%s" % (self.desc, self.lang, " / ".join(self.text)) + + +class RVA2(Frame): + """Relative volume adjustment (2). + + This frame is used to implemented volume scaling, and in + particular, normalization using ReplayGain. + + Attributes: + + * desc -- description or context of this adjustment + * channel -- audio channel to adjust (master is 1) + * gain -- a + or - dB gain relative to some reference level + * peak -- peak of the audio as a floating point number, [0, 1] + + When storing ReplayGain tags, use descriptions of 'album' and + 'track' on channel 1. + """ + + _framespec = [ + Latin1TextSpec('desc'), + ChannelSpec('channel'), + VolumeAdjustmentSpec('gain'), + VolumePeakSpec('peak'), + ] + + _channels = ["Other", "Master volume", "Front right", "Front left", + "Back right", "Back left", "Front centre", "Back centre", + "Subwoofer"] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.desc) + + def __eq__(self, other): + try: + return ((str(self) == other) or + (self.desc == other.desc and + self.channel == other.channel and + self.gain == other.gain and + self.peak == other.peak)) + except AttributeError: + return False + + __hash__ = Frame.__hash__ + + def __str__(self): + return "%s: %+0.4f dB/%0.4f" % ( + self._channels[self.channel], self.gain, self.peak) + + +class EQU2(Frame): + """Equalisation (2). + + Attributes: + method -- interpolation method (0 = band, 1 = linear) + desc -- identifying description + adjustments -- list of (frequency, vol_adjustment) pairs + """ + + _framespec = [ + ByteSpec("method"), + Latin1TextSpec("desc"), + VolumeAdjustmentsSpec("adjustments"), + ] + + def __eq__(self, other): + return self.adjustments == other + + __hash__ = Frame.__hash__ + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.desc) + + +# class RVAD: unsupported +# class EQUA: unsupported + + +class RVRB(Frame): + """Reverb.""" + + _framespec = [ + SizedIntegerSpec('left', 2), + SizedIntegerSpec('right', 2), + ByteSpec('bounce_left'), + ByteSpec('bounce_right'), + ByteSpec('feedback_ltl'), + ByteSpec('feedback_ltr'), + ByteSpec('feedback_rtr'), + ByteSpec('feedback_rtl'), + ByteSpec('premix_ltr'), + ByteSpec('premix_rtl'), + ] + + def __eq__(self, other): + return (self.left, self.right) == other + + __hash__ = Frame.__hash__ + + +class APIC(Frame): + """Attached (or linked) Picture. + + Attributes: + + * encoding -- text encoding for the description + * mime -- a MIME type (e.g. image/jpeg) or '-->' if the data is a URI + * type -- the source of the image (3 is the album front cover) + * desc -- a text description of the image + * data -- raw image data, as a byte string + + Mutagen will automatically compress large images when saving tags. + """ + + _framespec = [ + EncodingSpec('encoding'), + Latin1TextSpec('mime'), + ByteSpec('type'), + EncodedTextSpec('desc'), + BinaryDataSpec('data'), + ] + + def __eq__(self, other): + return self.data == other + + __hash__ = Frame.__hash__ + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.desc) + + def _validate_from_22(self, other, checker): + if checker.name == "mime": + self.mime = other.mime.decode("ascii", "ignore") + else: + super(APIC, self)._validate_from_22(other, checker) + + def _pprint(self): + return "%s (%s, %d bytes)" % ( + self.desc, self.mime, len(self.data)) + + +class PCNT(Frame): + """Play counter. + + The 'count' attribute contains the (recorded) number of times this + file has been played. + + This frame is basically obsoleted by POPM. + """ + + _framespec = [IntegerSpec('count')] + + def __eq__(self, other): + return self.count == other + + __hash__ = Frame.__hash__ + + def __pos__(self): + return self.count + + def _pprint(self): + return text_type(self.count) + + +class POPM(FrameOpt): + """Popularimeter. + + This frame keys a rating (out of 255) and a play count to an email + address. + + Attributes: + + * email -- email this POPM frame is for + * rating -- rating from 0 to 255 + * count -- number of times the files has been played (optional) + """ + + _framespec = [ + Latin1TextSpec('email'), + ByteSpec('rating'), + ] + + _optionalspec = [IntegerSpec('count')] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.email) + + def __eq__(self, other): + return self.rating == other + + __hash__ = FrameOpt.__hash__ + + def __pos__(self): + return self.rating + + def _pprint(self): + return "%s=%r %r/255" % ( + self.email, getattr(self, 'count', None), self.rating) + + +class GEOB(Frame): + """General Encapsulated Object. + + A blob of binary data, that is not a picture (those go in APIC). + + Attributes: + + * encoding -- encoding of the description + * mime -- MIME type of the data or '-->' if the data is a URI + * filename -- suggested filename if extracted + * desc -- text description of the data + * data -- raw data, as a byte string + """ + + _framespec = [ + EncodingSpec('encoding'), + Latin1TextSpec('mime'), + EncodedTextSpec('filename'), + EncodedTextSpec('desc'), + BinaryDataSpec('data'), + ] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.desc) + + def __eq__(self, other): + return self.data == other + + __hash__ = Frame.__hash__ + + +class RBUF(FrameOpt): + """Recommended buffer size. + + Attributes: + + * size -- recommended buffer size in bytes + * info -- if ID3 tags may be elsewhere in the file (optional) + * offset -- the location of the next ID3 tag, if any + + Mutagen will not find the next tag itself. + """ + + _framespec = [SizedIntegerSpec('size', 3)] + + _optionalspec = [ + ByteSpec('info'), + SizedIntegerSpec('offset', 4), + ] + + def __eq__(self, other): + return self.size == other + + __hash__ = FrameOpt.__hash__ + + def __pos__(self): + return self.size + + +@swap_to_string +class AENC(FrameOpt): + """Audio encryption. + + Attributes: + + * owner -- key identifying this encryption type + * preview_start -- unencrypted data block offset + * preview_length -- number of unencrypted blocks + * data -- data required for decryption (optional) + + Mutagen cannot decrypt files. + """ + + _framespec = [ + Latin1TextSpec('owner'), + SizedIntegerSpec('preview_start', 2), + SizedIntegerSpec('preview_length', 2), + ] + + _optionalspec = [BinaryDataSpec('data')] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.owner) + + def __bytes__(self): + return self.owner.encode('utf-8') + + def __str__(self): + return self.owner + + def __eq__(self, other): + return self.owner == other + + __hash__ = FrameOpt.__hash__ + + +class LINK(FrameOpt): + """Linked information. + + Attributes: + + * frameid -- the ID of the linked frame + * url -- the location of the linked frame + * data -- further ID information for the frame + """ + + _framespec = [ + StringSpec('frameid', 4), + Latin1TextSpec('url'), + ] + + _optionalspec = [BinaryDataSpec('data')] + + @property + def HashKey(self): + try: + return "%s:%s:%s:%s" % ( + self.FrameID, self.frameid, self.url, _bytes2key(self.data)) + except AttributeError: + return "%s:%s:%s" % (self.FrameID, self.frameid, self.url) + + def __eq__(self, other): + try: + return (self.frameid, self.url, self.data) == other + except AttributeError: + return (self.frameid, self.url) == other + + __hash__ = FrameOpt.__hash__ + + +class POSS(Frame): + """Position synchronisation frame + + Attribute: + + * format -- format of the position attribute (frames or milliseconds) + * position -- current position of the file + """ + + _framespec = [ + ByteSpec('format'), + IntegerSpec('position'), + ] + + def __pos__(self): + return self.position + + def __eq__(self, other): + return self.position == other + + __hash__ = Frame.__hash__ + + +class UFID(Frame): + """Unique file identifier. + + Attributes: + + * owner -- format/type of identifier + * data -- identifier + """ + + _framespec = [ + Latin1TextSpec('owner'), + BinaryDataSpec('data'), + ] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.owner) + + def __eq__(s, o): + if isinstance(o, UFI): + return s.owner == o.owner and s.data == o.data + else: + return s.data == o + + __hash__ = Frame.__hash__ + + def _pprint(self): + return "%s=%r" % (self.owner, self.data) + + +@swap_to_string +class USER(Frame): + """Terms of use. + + Attributes: + + * encoding -- text encoding + * lang -- ISO three letter language code + * text -- licensing terms for the audio + """ + + _framespec = [ + EncodingSpec('encoding'), + StringSpec('lang', 3), + EncodedTextSpec('text'), + ] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.lang) + + def __bytes__(self): + return self.text.encode('utf-8') + + def __str__(self): + return self.text + + def __eq__(self, other): + return self.text == other + + __hash__ = Frame.__hash__ + + def _pprint(self): + return "%r=%s" % (self.lang, self.text) + + +@swap_to_string +class OWNE(Frame): + """Ownership frame.""" + + _framespec = [ + EncodingSpec('encoding'), + Latin1TextSpec('price'), + StringSpec('date', 8), + EncodedTextSpec('seller'), + ] + + def __bytes__(self): + return self.seller.encode('utf-8') + + def __str__(self): + return self.seller + + def __eq__(self, other): + return self.seller == other + + __hash__ = Frame.__hash__ + + +class COMR(FrameOpt): + """Commercial frame.""" + + _framespec = [ + EncodingSpec('encoding'), + Latin1TextSpec('price'), + StringSpec('valid_until', 8), + Latin1TextSpec('contact'), + ByteSpec('format'), + EncodedTextSpec('seller'), + EncodedTextSpec('desc'), + ] + + _optionalspec = [ + Latin1TextSpec('mime'), + BinaryDataSpec('logo'), + ] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, _bytes2key(self._writeData())) + + def __eq__(self, other): + return self._writeData() == other._writeData() + + __hash__ = FrameOpt.__hash__ + + +@swap_to_string +class ENCR(Frame): + """Encryption method registration. + + The standard does not allow multiple ENCR frames with the same owner + or the same method. Mutagen only verifies that the owner is unique. + """ + + _framespec = [ + Latin1TextSpec('owner'), + ByteSpec('method'), + BinaryDataSpec('data'), + ] + + @property + def HashKey(self): + return "%s:%s" % (self.FrameID, self.owner) + + def __bytes__(self): + return self.data + + def __eq__(self, other): + return self.data == other + + __hash__ = Frame.__hash__ + + +@swap_to_string +class GRID(FrameOpt): + """Group identification registration.""" + + _framespec = [ + Latin1TextSpec('owner'), + ByteSpec('group'), + ] + + _optionalspec = [BinaryDataSpec('data')] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.group) + + def __pos__(self): + return self.group + + def __bytes__(self): + return self.owner.encode('utf-8') + + def __str__(self): + return self.owner + + def __eq__(self, other): + return self.owner == other or self.group == other + + __hash__ = FrameOpt.__hash__ + + +@swap_to_string +class PRIV(Frame): + """Private frame.""" + + _framespec = [ + Latin1TextSpec('owner'), + BinaryDataSpec('data'), + ] + + @property + def HashKey(self): + return '%s:%s:%s' % ( + self.FrameID, self.owner, _bytes2key(self.data)) + + def __bytes__(self): + return self.data + + def __eq__(self, other): + return self.data == other + + def _pprint(self): + return "%s=%r" % (self.owner, self.data) + + __hash__ = Frame.__hash__ + + +@swap_to_string +class SIGN(Frame): + """Signature frame.""" + + _framespec = [ + ByteSpec('group'), + BinaryDataSpec('sig'), + ] + + @property + def HashKey(self): + return '%s:%s:%s' % (self.FrameID, self.group, _bytes2key(self.sig)) + + def __bytes__(self): + return self.sig + + def __eq__(self, other): + return self.sig == other + + __hash__ = Frame.__hash__ + + +class SEEK(Frame): + """Seek frame. + + Mutagen does not find tags at seek offsets. + """ + + _framespec = [IntegerSpec('offset')] + + def __pos__(self): + return self.offset + + def __eq__(self, other): + return self.offset == other + + __hash__ = Frame.__hash__ + + +class ASPI(Frame): + """Audio seek point index. + + Attributes: S, L, N, b, and Fi. For the meaning of these, see + the ID3v2.4 specification. Fi is a list of integers. + """ + _framespec = [ + SizedIntegerSpec("S", 4), + SizedIntegerSpec("L", 4), + SizedIntegerSpec("N", 2), + ByteSpec("b"), + ASPIIndexSpec("Fi"), + ] + + def __eq__(self, other): + return self.Fi == other + + __hash__ = Frame.__hash__ + + +# ID3v2.2 frames +class UFI(UFID): + "Unique File Identifier" + + +class TT1(TIT1): + "Content group description" + + +class TT2(TIT2): + "Title" + + +class TT3(TIT3): + "Subtitle/Description refinement" + + +class TP1(TPE1): + "Lead Artist/Performer/Soloist/Group" + + +class TP2(TPE2): + "Band/Orchestra/Accompaniment" + + +class TP3(TPE3): + "Conductor" + + +class TP4(TPE4): + "Interpreter/Remixer/Modifier" + + +class TCM(TCOM): + "Composer" + + +class TXT(TEXT): + "Lyricist" + + +class TLA(TLAN): + "Audio Language(s)" + + +class TCO(TCON): + "Content Type (Genre)" + + +class TAL(TALB): + "Album" + + +class TPA(TPOS): + "Part of set" + + +class TRK(TRCK): + "Track Number" + + +class TRC(TSRC): + "International Standard Recording Code (ISRC)" + + +class TYE(TYER): + "Year of recording" + + +class TDA(TDAT): + "Date of recording (DDMM)" + + +class TIM(TIME): + "Time of recording (HHMM)" + + +class TRD(TRDA): + "Recording Dates" + + +class TMT(TMED): + "Source Media Type" + + +class TFT(TFLT): + "File Type" + + +class TBP(TBPM): + "Beats per minute" + + +class TCP(TCMP): + "iTunes Compilation Flag" + + +class TCR(TCOP): + "Copyright (C)" + + +class TPB(TPUB): + "Publisher" + + +class TEN(TENC): + "Encoder" + + +class TSS(TSSE): + "Encoder settings" + + +class TOF(TOFN): + "Original Filename" + + +class TLE(TLEN): + "Audio Length (ms)" + + +class TSI(TSIZ): + "Audio Data size (bytes)" + + +class TDY(TDLY): + "Audio Delay (ms)" + + +class TKE(TKEY): + "Starting Key" + + +class TOT(TOAL): + "Original Album" + + +class TOA(TOPE): + "Original Artist/Perfomer" + + +class TOL(TOLY): + "Original Lyricist" + + +class TOR(TORY): + "Original Release Year" + + +class TXX(TXXX): + "User-defined Text" + + +class WAF(WOAF): + "Official File Information" + + +class WAR(WOAR): + "Official Artist/Performer Information" + + +class WAS(WOAS): + "Official Source Information" + + +class WCM(WCOM): + "Commercial Information" + + +class WCP(WCOP): + "Copyright Information" + + +class WPB(WPUB): + "Official Publisher Information" + + +class WXX(WXXX): + "User-defined URL" + + +class IPL(IPLS): + "Involved people list" + + +class MCI(MCDI): + "Binary dump of CD's TOC" + + +class ETC(ETCO): + "Event timing codes" + + +class MLL(MLLT): + "MPEG location lookup table" + + +class STC(SYTC): + "Synced tempo codes" + + +class ULT(USLT): + "Unsychronised lyrics/text transcription" + + +class SLT(SYLT): + "Synchronised lyrics/text" + + +class COM(COMM): + "Comment" + + +# class RVA(RVAD) +# class EQU(EQUA) + + +class REV(RVRB): + "Reverb" + + +class PIC(APIC): + """Attached Picture. + + The 'mime' attribute of an ID3v2.2 attached picture must be either + 'PNG' or 'JPG'. + """ + + _framespec = [ + EncodingSpec('encoding'), + StringSpec('mime', 3), + ByteSpec('type'), + EncodedTextSpec('desc'), + BinaryDataSpec('data') + ] + + def _to_other(self, other): + if not isinstance(other, APIC): + raise TypeError + + other.encoding = self.encoding + other.mime = self.mime + other.type = self.type + other.desc = self.desc + other.data = self.data + + +class GEO(GEOB): + "General Encapsulated Object" + + +class CNT(PCNT): + "Play counter" + + +class POP(POPM): + "Popularimeter" + + +class BUF(RBUF): + "Recommended buffer size" + + +class CRM(Frame): + """Encrypted meta frame""" + _framespec = [Latin1TextSpec('owner'), Latin1TextSpec('desc'), + BinaryDataSpec('data')] + + def __eq__(self, other): + return self.data == other + __hash__ = Frame.__hash__ + + +class CRA(AENC): + "Audio encryption" + + +class LNK(LINK): + """Linked information""" + + _framespec = [ + StringSpec('frameid', 3), + Latin1TextSpec('url') + ] + + _optionalspec = [BinaryDataSpec('data')] + + def _to_other(self, other): + if not isinstance(other, LINK): + raise TypeError + + other.frameid = self.frameid + other.url = self.url + if hasattr(self, "data"): + other.data = self.data + + +Frames = {} +"""All supported ID3v2.3/4 frames, keyed by frame name.""" + + +Frames_2_2 = {} +"""All supported ID3v2.2 frames, keyed by frame name.""" + + +k, v = None, None +for k, v in iteritems(globals()): + if isinstance(v, type) and issubclass(v, Frame): + v.__module__ = "mutagen.id3" + + if len(k) == 3: + Frames_2_2[k] = v + elif len(k) == 4: + Frames[k] = v + +try: + del k + del v +except NameError: + pass diff -Nru mutagen-1.23/mutagen/id3/__init__.py mutagen-1.30/mutagen/id3/__init__.py --- mutagen-1.23/mutagen/id3/__init__.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/mutagen/id3/__init__.py 2015-08-17 10:17:23.000000000 +0000 @@ -0,0 +1,1084 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2005 Michael Urman +# 2006 Lukas Lalinsky +# 2013 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +"""ID3v2 reading and writing. + +This is based off of the following references: + +* http://id3.org/id3v2.4.0-structure +* http://id3.org/id3v2.4.0-frames +* http://id3.org/id3v2.3.0 +* http://id3.org/id3v2-00 +* http://id3.org/ID3v1 + +Its largest deviation from the above (versions 2.3 and 2.2) is that it +will not interpret the / characters as a separator, and will almost +always accept null separators to generate multi-valued text frames. + +Because ID3 frame structure differs between frame types, each frame is +implemented as a different class (e.g. TIT2 as mutagen.id3.TIT2). Each +frame's documentation contains a list of its attributes. + +Since this file's documentation is a little unwieldy, you are probably +interested in the :class:`ID3` class to start with. +""" + +__all__ = ['ID3', 'ID3FileType', 'Frames', 'Open', 'delete'] + +import struct +import errno + +from struct import unpack, pack, error as StructError + +import mutagen +from mutagen._util import insert_bytes, delete_bytes, DictProxy, enum +from .._compat import chr_, PY3 + +from ._util import * +from ._frames import * +from ._specs import * + + +@enum +class ID3v1SaveOptions(object): + + REMOVE = 0 + """ID3v1 tags will be removed""" + + UPDATE = 1 + """ID3v1 tags will be updated but not added""" + + CREATE = 2 + """ID3v1 tags will be created and/or updated""" + + +def _fullread(fileobj, size): + """Read a certain number of bytes from the source file. + + Raises ValueError on invalid size input or EOFError/IOError. + """ + + if size < 0: + raise ValueError('Requested bytes (%s) less than zero' % size) + data = fileobj.read(size) + if len(data) != size: + raise EOFError("Not enough data to read") + return data + + +class ID3Header(object): + + _V24 = (2, 4, 0) + _V23 = (2, 3, 0) + _V22 = (2, 2, 0) + _V11 = (1, 1) + + f_unsynch = property(lambda s: bool(s._flags & 0x80)) + f_extended = property(lambda s: bool(s._flags & 0x40)) + f_experimental = property(lambda s: bool(s._flags & 0x20)) + f_footer = property(lambda s: bool(s._flags & 0x10)) + + def __init__(self, fileobj=None): + """Raises ID3NoHeaderError, ID3UnsupportedVersionError or error""" + + if fileobj is None: + # for testing + self._flags = 0 + return + + fn = getattr(fileobj, "name", "") + try: + data = _fullread(fileobj, 10) + except EOFError: + raise ID3NoHeaderError("%s: too small" % fn) + + id3, vmaj, vrev, flags, size = unpack('>3sBBB4s', data) + self._flags = flags + self.size = BitPaddedInt(size) + 10 + self.version = (2, vmaj, vrev) + + if id3 != b'ID3': + raise ID3NoHeaderError("%r doesn't start with an ID3 tag" % fn) + + if vmaj not in [2, 3, 4]: + raise ID3UnsupportedVersionError("%r ID3v2.%d not supported" + % (fn, vmaj)) + + if not BitPaddedInt.has_valid_padding(size): + raise error("Header size not synchsafe") + + if (self.version >= self._V24) and (flags & 0x0f): + raise error( + "%r has invalid flags %#02x" % (fn, flags)) + elif (self._V23 <= self.version < self._V24) and (flags & 0x1f): + raise error( + "%r has invalid flags %#02x" % (fn, flags)) + + if self.f_extended: + try: + extsize_data = _fullread(fileobj, 4) + except EOFError: + raise error("%s: too small" % fn) + + if PY3: + frame_id = extsize_data.decode("ascii", "replace") + else: + frame_id = extsize_data + + if frame_id in Frames: + # Some tagger sets the extended header flag but + # doesn't write an extended header; in this case, the + # ID3 data follows immediately. Since no extended + # header is going to be long enough to actually match + # a frame, and if it's *not* a frame we're going to be + # completely lost anyway, this seems to be the most + # correct check. + # http://code.google.com/p/quodlibet/issues/detail?id=126 + self._flags ^= 0x40 + extsize = 0 + fileobj.seek(-4, 1) + elif self.version >= self._V24: + # "Where the 'Extended header size' is the size of the whole + # extended header, stored as a 32 bit synchsafe integer." + extsize = BitPaddedInt(extsize_data) - 4 + if not BitPaddedInt.has_valid_padding(extsize_data): + raise error( + "Extended header size not synchsafe") + else: + # "Where the 'Extended header size', currently 6 or 10 bytes, + # excludes itself." + extsize = unpack('>L', extsize_data)[0] + + try: + self._extdata = _fullread(fileobj, extsize) + except EOFError: + raise error("%s: too small" % fn) + + +class ID3(DictProxy, mutagen.Metadata): + """A file with an ID3v2 tag. + + Attributes: + + * version -- ID3 tag version as a tuple + * unknown_frames -- raw frame data of any unknown frames found + * size -- the total size of the ID3 tag, including the header + """ + + __module__ = "mutagen.id3" + + PEDANTIC = True + """Deprecated. Doesn't have any effect""" + + filename = None + + def __init__(self, *args, **kwargs): + self.unknown_frames = [] + self.__unknown_version = None + self._header = None + self._version = (2, 4, 0) + super(ID3, self).__init__(*args, **kwargs) + + @property + def version(self): + """ID3 tag version as a tuple (of the loaded file)""" + + if self._header is not None: + return self._header.version + return self._version + + @version.setter + def version(self, value): + self._version = value + + @property + def f_unsynch(self): + if self._header is not None: + return self._header.f_unsynch + return False + + @property + def f_extended(self): + if self._header is not None: + return self._header.f_extended + return False + + @property + def size(self): + if self._header is not None: + return self._header.size + return 0 + + def _pre_load_header(self, fileobj): + # XXX: for aiff to adjust the offset.. + pass + + def load(self, filename, known_frames=None, translate=True, v2_version=4): + """Load tags from a filename. + + Keyword arguments: + + * filename -- filename to load tag data from + * known_frames -- dict mapping frame IDs to Frame objects + * translate -- Update all tags to ID3v2.3/4 internally. If you + intend to save, this must be true or you have to + call update_to_v23() / update_to_v24() manually. + * v2_version -- if update_to_v23 or update_to_v24 get called (3 or 4) + + Example of loading a custom frame:: + + my_frames = dict(mutagen.id3.Frames) + class XMYF(Frame): ... + my_frames["XMYF"] = XMYF + mutagen.id3.ID3(filename, known_frames=my_frames) + """ + + if v2_version not in (3, 4): + raise ValueError("Only 3 and 4 possible for v2_version") + + self.filename = filename + self.unknown_frames = [] + self.__known_frames = known_frames + self._header = None + + with open(filename, 'rb') as fileobj: + self._pre_load_header(fileobj) + + try: + self._header = ID3Header(fileobj) + except (ID3NoHeaderError, ID3UnsupportedVersionError): + frames, offset = _find_id3v1(fileobj) + if frames is None: + raise + + self.version = ID3Header._V11 + for v in frames.values(): + self.add(v) + else: + frames = self.__known_frames + if frames is None: + if self.version >= ID3Header._V23: + frames = Frames + elif self.version >= ID3Header._V22: + frames = Frames_2_2 + + try: + data = _fullread(fileobj, self.size - 10) + except (ValueError, EOFError, IOError) as e: + raise error(e) + + for frame in self.__read_frames(data, frames=frames): + if isinstance(frame, Frame): + self.add(frame) + else: + self.unknown_frames.append(frame) + self.__unknown_version = self.version[:2] + + if translate: + if v2_version == 3: + self.update_to_v23() + else: + self.update_to_v24() + + def getall(self, key): + """Return all frames with a given name (the list may be empty). + + This is best explained by examples:: + + id3.getall('TIT2') == [id3['TIT2']] + id3.getall('TTTT') == [] + id3.getall('TXXX') == [TXXX(desc='woo', text='bar'), + TXXX(desc='baz', text='quuuux'), ...] + + Since this is based on the frame's HashKey, which is + colon-separated, you can use it to do things like + ``getall('COMM:MusicMatch')`` or ``getall('TXXX:QuodLibet:')``. + """ + if key in self: + return [self[key]] + else: + key = key + ":" + return [v for s, v in self.items() if s.startswith(key)] + + def delall(self, key): + """Delete all tags of a given kind; see getall.""" + if key in self: + del(self[key]) + else: + key = key + ":" + for k in list(self.keys()): + if k.startswith(key): + del(self[k]) + + def setall(self, key, values): + """Delete frames of the given type and add frames in 'values'.""" + self.delall(key) + for tag in values: + self[tag.HashKey] = tag + + def pprint(self): + """Return tags in a human-readable format. + + "Human-readable" is used loosely here. The format is intended + to mirror that used for Vorbis or APEv2 output, e.g. + + ``TIT2=My Title`` + + However, ID3 frames can have multiple keys: + + ``POPM=user@example.org=3 128/255`` + """ + frames = sorted(Frame.pprint(s) for s in self.values()) + return "\n".join(frames) + + def loaded_frame(self, tag): + """Deprecated; use the add method.""" + # turn 2.2 into 2.3/2.4 tags + if len(type(tag).__name__) == 3: + tag = type(tag).__base__(tag) + self[tag.HashKey] = tag + + # add = loaded_frame (and vice versa) break applications that + # expect to be able to override loaded_frame (e.g. Quod Libet), + # as does making loaded_frame call add. + def add(self, frame): + """Add a frame to the tag.""" + return self.loaded_frame(frame) + + def __read_frames(self, data, frames): + assert self.version >= ID3Header._V22 + + if self.version < ID3Header._V24 and self.f_unsynch: + try: + data = unsynch.decode(data) + except ValueError: + pass + + if self.version >= ID3Header._V23: + if self.version < ID3Header._V24: + bpi = int + else: + bpi = _determine_bpi(data, frames) + + while data: + header = data[:10] + try: + name, size, flags = unpack('>4sLH', header) + except struct.error: + return # not enough header + if name.strip(b'\x00') == b'': + return + + size = bpi(size) + framedata = data[10:10 + size] + data = data[10 + size:] + if size == 0: + continue # drop empty frames + + if PY3: + try: + name = name.decode('ascii') + except UnicodeDecodeError: + continue + + try: + # someone writes 2.3 frames with 2.2 names + if name[-1] == "\x00": + tag = Frames_2_2[name[:-1]] + name = tag.__base__.__name__ + + tag = frames[name] + except KeyError: + if is_valid_frame_id(name): + yield header + framedata + else: + try: + yield tag._fromData(self._header, flags, framedata) + except NotImplementedError: + yield header + framedata + except ID3JunkFrameError: + pass + elif self.version >= ID3Header._V22: + while data: + header = data[0:6] + try: + name, size = unpack('>3s3s', header) + except struct.error: + return # not enough header + size, = struct.unpack('>L', b'\x00' + size) + if name.strip(b'\x00') == b'': + return + + framedata = data[6:6 + size] + data = data[6 + size:] + if size == 0: + continue # drop empty frames + + if PY3: + try: + name = name.decode('ascii') + except UnicodeDecodeError: + continue + + try: + tag = frames[name] + except KeyError: + if is_valid_frame_id(name): + yield header + framedata + else: + try: + yield tag._fromData(self._header, 0, framedata) + except (ID3EncryptionUnsupportedError, + NotImplementedError): + yield header + framedata + except ID3JunkFrameError: + pass + + def _prepare_framedata(self, v2_version, v23_sep): + if v2_version == 3: + version = ID3Header._V23 + elif v2_version == 4: + version = ID3Header._V24 + else: + raise ValueError("Only 3 or 4 allowed for v2_version") + + # Sort frames by 'importance' + order = ["TIT2", "TPE1", "TRCK", "TALB", "TPOS", "TDRC", "TCON"] + order = dict((b, a) for a, b in enumerate(order)) + last = len(order) + frames = sorted(self.items(), + key=lambda a: (order.get(a[0][:4], last), a[0])) + + framedata = [self.__save_frame(frame, version=version, v23_sep=v23_sep) + for (key, frame) in frames] + + # only write unknown frames if they were loaded from the version + # we are saving with or upgraded to it + if self.__unknown_version == version[:2]: + framedata.extend(data for data in self.unknown_frames + if len(data) > 10) + + return b''.join(framedata) + + def _prepare_id3_header(self, original_header, framesize, v2_version): + try: + id3, vmaj, vrev, flags, insize = \ + unpack('>3sBBB4s', original_header) + except struct.error: + id3, insize = b'', 0 + insize = BitPaddedInt(insize) + if id3 != b'ID3': + insize = -10 + + if insize >= framesize: + outsize = insize + else: + outsize = (framesize + 1023) & ~0x3FF + + framesize = BitPaddedInt.to_str(outsize, width=4) + header = pack('>3sBBB4s', b'ID3', v2_version, 0, 0, framesize) + + return (header, outsize, insize) + + def save(self, filename=None, v1=1, v2_version=4, v23_sep='/'): + """Save changes to a file. + + Args: + filename: + Filename to save the tag to. If no filename is given, + the one most recently loaded is used. + v1 (ID3v1SaveOptions): + if 0, ID3v1 tags will be removed. + if 1, ID3v1 tags will be updated but not added. + if 2, ID3v1 tags will be created and/or updated + v2 (int): + version of ID3v2 tags (3 or 4). + v23_sep (str): + the separator used to join multiple text values + if v2_version == 3. Defaults to '/' but if it's None + will be the ID3v2v2.4 null separator. + + By default Mutagen saves ID3v2.4 tags. If you want to save ID3v2.3 + tags, you must call method update_to_v23 before saving the file. + + The lack of a way to update only an ID3v1 tag is intentional. + """ + + framedata = self._prepare_framedata(v2_version, v23_sep) + framesize = len(framedata) + + if not framedata: + try: + self.delete(filename) + except EnvironmentError as err: + from errno import ENOENT + if err.errno != ENOENT: + raise + return + + if filename is None: + filename = self.filename + try: + f = open(filename, 'rb+') + except IOError as err: + from errno import ENOENT + if err.errno != ENOENT: + raise + f = open(filename, 'ab') # create, then reopen + f = open(filename, 'rb+') + try: + idata = f.read(10) + + header = self._prepare_id3_header(idata, framesize, v2_version) + header, outsize, insize = header + + data = header + framedata + (b'\x00' * (outsize - framesize)) + + if (insize < outsize): + insert_bytes(f, outsize - insize, insize + 10) + f.seek(0) + f.write(data) + + self.__save_v1(f, v1) + + finally: + f.close() + + def __save_v1(self, f, v1): + tag, offset = _find_id3v1(f) + has_v1 = tag is not None + + f.seek(offset, 2) + if v1 == ID3v1SaveOptions.UPDATE and has_v1 or \ + v1 == ID3v1SaveOptions.CREATE: + f.write(MakeID3v1(self)) + else: + f.truncate() + + def delete(self, filename=None, delete_v1=True, delete_v2=True): + """Remove tags from a file. + + If no filename is given, the one most recently loaded is used. + + Keyword arguments: + + * delete_v1 -- delete any ID3v1 tag + * delete_v2 -- delete any ID3v2 tag + """ + if filename is None: + filename = self.filename + delete(filename, delete_v1, delete_v2) + self.clear() + + def __save_frame(self, frame, name=None, version=ID3Header._V24, + v23_sep=None): + flags = 0 + if isinstance(frame, TextFrame): + if len(str(frame)) == 0: + return b'' + + if version == ID3Header._V23: + framev23 = frame._get_v23_frame(sep=v23_sep) + framedata = framev23._writeData() + else: + framedata = frame._writeData() + + usize = len(framedata) + if usize > 2048: + # Disabled as this causes iTunes and other programs + # to fail to find these frames, which usually includes + # e.g. APIC. + # framedata = BitPaddedInt.to_str(usize) + framedata.encode('zlib') + # flags |= Frame.FLAG24_COMPRESS | Frame.FLAG24_DATALEN + pass + + if version == ID3Header._V24: + bits = 7 + elif version == ID3Header._V23: + bits = 8 + else: + raise ValueError + + datasize = BitPaddedInt.to_str(len(framedata), width=4, bits=bits) + + if name is not None: + assert isinstance(name, bytes) + frame_name = name + else: + frame_name = type(frame).__name__ + if PY3: + frame_name = frame_name.encode("ascii") + + header = pack('>4s4sH', frame_name, datasize, flags) + return header + framedata + + def __update_common(self): + """Updates done by both v23 and v24 update""" + + if "TCON" in self: + # Get rid of "(xx)Foobr" format. + self["TCON"].genres = self["TCON"].genres + + # ID3v2.2 LNK frames are just way too different to upgrade. + for frame in self.getall("LINK"): + if len(frame.frameid) != 4: + del self[frame.HashKey] + + mimes = {"PNG": "image/png", "JPG": "image/jpeg"} + for pic in self.getall("APIC"): + if pic.mime in mimes: + newpic = APIC( + encoding=pic.encoding, mime=mimes[pic.mime], + type=pic.type, desc=pic.desc, data=pic.data) + self.add(newpic) + + def update_to_v24(self): + """Convert older tags into an ID3v2.4 tag. + + This updates old ID3v2 frames to ID3v2.4 ones (e.g. TYER to + TDRC). If you intend to save tags, you must call this function + at some point; it is called by default when loading the tag. + """ + + self.__update_common() + + if self.__unknown_version == (2, 3): + # convert unknown 2.3 frames (flags/size) to 2.4 + converted = [] + for frame in self.unknown_frames: + try: + name, size, flags = unpack('>4sLH', frame[:10]) + except struct.error: + continue + + try: + frame = BinaryFrame._fromData( + self._header, flags, frame[10:]) + except (error, NotImplementedError): + continue + + converted.append(self.__save_frame(frame, name=name)) + self.unknown_frames[:] = converted + self.__unknown_version = (2, 4) + + # TDAT, TYER, and TIME have been turned into TDRC. + try: + date = text_type(self.get("TYER", "")) + if date.strip(u"\x00"): + self.pop("TYER") + dat = text_type(self.get("TDAT", "")) + if dat.strip("\x00"): + self.pop("TDAT") + date = "%s-%s-%s" % (date, dat[2:], dat[:2]) + time = text_type(self.get("TIME", "")) + if time.strip("\x00"): + self.pop("TIME") + date += "T%s:%s:00" % (time[:2], time[2:]) + if "TDRC" not in self: + self.add(TDRC(encoding=0, text=date)) + except UnicodeDecodeError: + # Old ID3 tags have *lots* of Unicode problems, so if TYER + # is bad, just chuck the frames. + pass + + # TORY can be the first part of a TDOR. + if "TORY" in self: + f = self.pop("TORY") + if "TDOR" not in self: + try: + self.add(TDOR(encoding=0, text=str(f))) + except UnicodeDecodeError: + pass + + # IPLS is now TIPL. + if "IPLS" in self: + f = self.pop("IPLS") + if "TIPL" not in self: + self.add(TIPL(encoding=f.encoding, people=f.people)) + + # These can't be trivially translated to any ID3v2.4 tags, or + # should have been removed already. + for key in ["RVAD", "EQUA", "TRDA", "TSIZ", "TDAT", "TIME", "CRM"]: + if key in self: + del(self[key]) + + def update_to_v23(self): + """Convert older (and newer) tags into an ID3v2.3 tag. + + This updates incompatible ID3v2 frames to ID3v2.3 ones. If you + intend to save tags as ID3v2.3, you must call this function + at some point. + + If you want to to go off spec and include some v2.4 frames + in v2.3, remove them before calling this and add them back afterwards. + """ + + self.__update_common() + + # we could downgrade unknown v2.4 frames here, but given that + # the main reason to save v2.3 is compatibility and this + # might increase the chance of some parser breaking.. better not + + # TMCL, TIPL -> TIPL + if "TIPL" in self or "TMCL" in self: + people = [] + if "TIPL" in self: + f = self.pop("TIPL") + people.extend(f.people) + if "TMCL" in self: + f = self.pop("TMCL") + people.extend(f.people) + if "IPLS" not in self: + self.add(IPLS(encoding=f.encoding, people=people)) + + # TDOR -> TORY + if "TDOR" in self: + f = self.pop("TDOR") + if f.text: + d = f.text[0] + if d.year and "TORY" not in self: + self.add(TORY(encoding=f.encoding, text="%04d" % d.year)) + + # TDRC -> TYER, TDAT, TIME + if "TDRC" in self: + f = self.pop("TDRC") + if f.text: + d = f.text[0] + if d.year and "TYER" not in self: + self.add(TYER(encoding=f.encoding, text="%04d" % d.year)) + if d.month and d.day and "TDAT" not in self: + self.add(TDAT(encoding=f.encoding, + text="%02d%02d" % (d.day, d.month))) + if d.hour and d.minute and "TIME" not in self: + self.add(TIME(encoding=f.encoding, + text="%02d%02d" % (d.hour, d.minute))) + + # New frames added in v2.4 + v24_frames = [ + 'ASPI', 'EQU2', 'RVA2', 'SEEK', 'SIGN', 'TDEN', 'TDOR', + 'TDRC', 'TDRL', 'TDTG', 'TIPL', 'TMCL', 'TMOO', 'TPRO', + 'TSOA', 'TSOP', 'TSOT', 'TSST', + ] + + for key in v24_frames: + if key in self: + del(self[key]) + + +def delete(filename, delete_v1=True, delete_v2=True): + """Remove tags from a file. + + Keyword arguments: + + * delete_v1 -- delete any ID3v1 tag + * delete_v2 -- delete any ID3v2 tag + """ + + with open(filename, 'rb+') as f: + + if delete_v1: + tag, offset = _find_id3v1(f) + if tag is not None: + f.seek(offset, 2) + f.truncate() + + # technically an insize=0 tag is invalid, but we delete it anyway + # (primarily because we used to write it) + if delete_v2: + f.seek(0, 0) + idata = f.read(10) + try: + id3, vmaj, vrev, flags, insize = unpack('>3sBBB4s', idata) + except struct.error: + id3, insize = b'', -1 + insize = BitPaddedInt(insize) + if id3 == b'ID3' and insize >= 0: + delete_bytes(f, insize + 10, 0) + + +# support open(filename) as interface +Open = ID3 + + +def _determine_bpi(data, frames, EMPTY=b"\x00" * 10): + """Takes id3v2.4 frame data and determines if ints or bitpaddedints + should be used for parsing. Needed because iTunes used to write + normal ints for frame sizes. + """ + + # count number of tags found as BitPaddedInt and how far past + o = 0 + asbpi = 0 + while o < len(data) - 10: + part = data[o:o + 10] + if part == EMPTY: + bpioff = -((len(data) - o) % 10) + break + name, size, flags = unpack('>4sLH', part) + size = BitPaddedInt(size) + o += 10 + size + if PY3: + try: + name = name.decode("ascii") + except UnicodeDecodeError: + continue + if name in frames: + asbpi += 1 + else: + bpioff = o - len(data) + + # count number of tags found as int and how far past + o = 0 + asint = 0 + while o < len(data) - 10: + part = data[o:o + 10] + if part == EMPTY: + intoff = -((len(data) - o) % 10) + break + name, size, flags = unpack('>4sLH', part) + o += 10 + size + if PY3: + try: + name = name.decode("ascii") + except UnicodeDecodeError: + continue + if name in frames: + asint += 1 + else: + intoff = o - len(data) + + # if more tags as int, or equal and bpi is past and int is not + if asint > asbpi or (asint == asbpi and (bpioff >= 1 and intoff <= 1)): + return int + return BitPaddedInt + + +def _find_id3v1(fileobj): + """Returns a tuple of (id3tag, offset_to_end) or (None, 0) + + offset mainly because we used to write too short tags in some cases and + we need the offset to delete them. + """ + + # id3v1 is always at the end (after apev2) + + extra_read = b"APETAGEX".index(b"TAG") + + try: + fileobj.seek(-128 - extra_read, 2) + except IOError as e: + if e.errno == errno.EINVAL: + # If the file is too small, might be ok since we wrote too small + # tags at some point. let's see how the parsing goes.. + fileobj.seek(0, 0) + else: + raise + + data = fileobj.read(128 + extra_read) + try: + idx = data.index(b"TAG") + except ValueError: + return (None, 0) + else: + # FIXME: make use of the apev2 parser here + # if TAG is part of APETAGEX assume this is an APEv2 tag + try: + ape_idx = data.index(b"APETAGEX") + except ValueError: + pass + else: + if idx == ape_idx + extra_read: + return (None, 0) + + tag = ParseID3v1(data[idx:]) + if tag is None: + return (None, 0) + + offset = idx - len(data) + return (tag, offset) + + +# ID3v1.1 support. +def ParseID3v1(data): + """Parse an ID3v1 tag, returning a list of ID3v2.4 frames. + + Returns a {frame_name: frame} dict or None. + """ + + try: + data = data[data.index(b"TAG"):] + except ValueError: + return None + if 128 < len(data) or len(data) < 124: + return None + + # Issue #69 - Previous versions of Mutagen, when encountering + # out-of-spec TDRC and TYER frames of less than four characters, + # wrote only the characters available - e.g. "1" or "" - into the + # year field. To parse those, reduce the size of the year field. + # Amazingly, "0s" works as a struct format string. + unpack_fmt = "3s30s30s30s%ds29sBB" % (len(data) - 124) + + try: + tag, title, artist, album, year, comment, track, genre = unpack( + unpack_fmt, data) + except StructError: + return None + + if tag != b"TAG": + return None + + def fix(data): + return data.split(b"\x00")[0].strip().decode('latin1') + + title, artist, album, year, comment = map( + fix, [title, artist, album, year, comment]) + + frames = {} + if title: + frames["TIT2"] = TIT2(encoding=0, text=title) + if artist: + frames["TPE1"] = TPE1(encoding=0, text=[artist]) + if album: + frames["TALB"] = TALB(encoding=0, text=album) + if year: + frames["TDRC"] = TDRC(encoding=0, text=year) + if comment: + frames["COMM"] = COMM( + encoding=0, lang="eng", desc="ID3v1 Comment", text=comment) + # Don't read a track number if it looks like the comment was + # padded with spaces instead of nulls (thanks, WinAmp). + if track and ((track != 32) or (data[-3] == b'\x00'[0])): + frames["TRCK"] = TRCK(encoding=0, text=str(track)) + if genre != 255: + frames["TCON"] = TCON(encoding=0, text=str(genre)) + return frames + + +def MakeID3v1(id3): + """Return an ID3v1.1 tag string from a dict of ID3v2.4 frames.""" + + v1 = {} + + for v2id, name in {"TIT2": "title", "TPE1": "artist", + "TALB": "album"}.items(): + if v2id in id3: + text = id3[v2id].text[0].encode('latin1', 'replace')[:30] + else: + text = b"" + v1[name] = text + (b"\x00" * (30 - len(text))) + + if "COMM" in id3: + cmnt = id3["COMM"].text[0].encode('latin1', 'replace')[:28] + else: + cmnt = b"" + v1["comment"] = cmnt + (b"\x00" * (29 - len(cmnt))) + + if "TRCK" in id3: + try: + v1["track"] = chr_(+id3["TRCK"]) + except ValueError: + v1["track"] = b"\x00" + else: + v1["track"] = b"\x00" + + if "TCON" in id3: + try: + genre = id3["TCON"].genres[0] + except IndexError: + pass + else: + if genre in TCON.GENRES: + v1["genre"] = chr_(TCON.GENRES.index(genre)) + if "genre" not in v1: + v1["genre"] = b"\xff" + + if "TDRC" in id3: + year = text_type(id3["TDRC"]).encode('ascii') + elif "TYER" in id3: + year = text_type(id3["TYER"]).encode('ascii') + else: + year = b"" + v1["year"] = (year + b"\x00\x00\x00\x00")[:4] + + return ( + b"TAG" + + v1["title"] + + v1["artist"] + + v1["album"] + + v1["year"] + + v1["comment"] + + v1["track"] + + v1["genre"] + ) + + +class ID3FileType(mutagen.FileType): + """An unknown type of file with ID3 tags.""" + + ID3 = ID3 + + class _Info(mutagen.StreamInfo): + length = 0 + + def __init__(self, fileobj, offset): + pass + + @staticmethod + def pprint(): + return "Unknown format with ID3 tag" + + @staticmethod + def score(filename, fileobj, header_data): + return header_data.startswith(b"ID3") + + def add_tags(self, ID3=None): + """Add an empty ID3 tag to the file. + + A custom tag reader may be used in instead of the default + mutagen.id3.ID3 object, e.g. an EasyID3 reader. + """ + if ID3 is None: + ID3 = self.ID3 + if self.tags is None: + self.ID3 = ID3 + self.tags = ID3() + else: + raise error("an ID3 tag already exists") + + def load(self, filename, ID3=None, **kwargs): + """Load stream and tag information from a file. + + A custom tag reader may be used in instead of the default + mutagen.id3.ID3 object, e.g. an EasyID3 reader. + """ + + if ID3 is None: + ID3 = self.ID3 + else: + # If this was initialized with EasyID3, remember that for + # when tags are auto-instantiated in add_tags. + self.ID3 = ID3 + self.filename = filename + try: + self.tags = ID3(filename, **kwargs) + except ID3NoHeaderError: + self.tags = None + + if self.tags is not None: + try: + offset = self.tags.size + except AttributeError: + offset = None + else: + offset = None + + with open(filename, "rb") as fileobj: + self.info = self._Info(fileobj, offset) diff -Nru mutagen-1.23/mutagen/id3/_specs.py mutagen-1.30/mutagen/id3/_specs.py --- mutagen-1.23/mutagen/id3/_specs.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/mutagen/id3/_specs.py 2015-08-17 10:42:51.000000000 +0000 @@ -0,0 +1,634 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2005 Michael Urman +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +import struct +from struct import unpack, pack + +from .._compat import text_type, chr_, PY3, swap_to_string, string_types +from .._util import total_ordering, decode_terminated, enum +from ._util import BitPaddedInt + + +@enum +class PictureType(object): + """Enumeration of image types defined by the ID3 standard for the APIC + frame, but also reused in WMA/FLAC/VorbisComment. + """ + + OTHER = 0 + """Other""" + + FILE_ICON = 1 + """32x32 pixels 'file icon' (PNG only)""" + + OTHER_FILE_ICON = 2 + """Other file icon""" + + COVER_FRONT = 3 + """Cover (front)""" + + COVER_BACK = 4 + """Cover (back)""" + + LEAFLET_PAGE = 5 + """Leaflet page""" + + MEDIA = 6 + """Media (e.g. label side of CD)""" + + LEAD_ARTIST = 7 + """Lead artist/lead performer/soloist""" + + ARTIST = 8 + """Artist/performer""" + + CONDUCTOR = 9 + """Conductor""" + + BAND = 10 + """Band/Orchestra""" + + COMPOSER = 11 + """Composer""" + + LYRICIST = 12 + """Lyricist/text writer""" + + RECORDING_LOCATION = 13 + """Recording Location""" + + DURING_RECORDING = 14 + """During recording""" + + DURING_PERFORMANCE = 15 + """During performance""" + + SCREEN_CAPTURE = 16 + """Movie/video screen capture""" + + FISH = 17 + """A bright coloured fish""" + + ILLUSTRATION = 18 + """Illustration""" + + BAND_LOGOTYPE = 19 + """Band/artist logotype""" + + PUBLISHER_LOGOTYPE = 20 + """Publisher/Studio logotype""" + + +class SpecError(Exception): + pass + + +class Spec(object): + + def __init__(self, name): + self.name = name + + def __hash__(self): + raise TypeError("Spec objects are unhashable") + + def _validate23(self, frame, value, **kwargs): + """Return a possibly modified value which, if written, + results in valid id3v2.3 data. + """ + + return value + + def read(self, frame, data): + """Returns the (value, left_data) or raises SpecError""" + + raise NotImplementedError + + def write(self, frame, value): + raise NotImplementedError + + def validate(self, frame, value): + """Returns the validated data or raises ValueError/TypeError""" + + raise NotImplementedError + + +class ByteSpec(Spec): + def read(self, frame, data): + return bytearray(data)[0], data[1:] + + def write(self, frame, value): + return chr_(value) + + def validate(self, frame, value): + if value is not None: + chr_(value) + return value + + +class IntegerSpec(Spec): + def read(self, frame, data): + return int(BitPaddedInt(data, bits=8)), b'' + + def write(self, frame, value): + return BitPaddedInt.to_str(value, bits=8, width=-1) + + def validate(self, frame, value): + return value + + +class SizedIntegerSpec(Spec): + def __init__(self, name, size): + self.name, self.__sz = name, size + + def read(self, frame, data): + return int(BitPaddedInt(data[:self.__sz], bits=8)), data[self.__sz:] + + def write(self, frame, value): + return BitPaddedInt.to_str(value, bits=8, width=self.__sz) + + def validate(self, frame, value): + return value + + +@enum +class Encoding(object): + """Text Encoding""" + + LATIN1 = 0 + """ISO-8859-1""" + + UTF16 = 1 + """UTF-16 with BOM""" + + UTF16BE = 2 + """UTF-16BE without BOM""" + + UTF8 = 3 + """UTF-8""" + + +class EncodingSpec(ByteSpec): + + def read(self, frame, data): + enc, data = super(EncodingSpec, self).read(frame, data) + if enc not in (Encoding.LATIN1, Encoding.UTF16, Encoding.UTF16BE, + Encoding.UTF8): + raise SpecError('Invalid Encoding: %r' % enc) + return enc, data + + def validate(self, frame, value): + if value is None: + return None + if value not in (Encoding.LATIN1, Encoding.UTF16, Encoding.UTF16BE, + Encoding.UTF8): + raise ValueError('Invalid Encoding: %r' % value) + return value + + def _validate23(self, frame, value, **kwargs): + # only 0, 1 are valid in v2.3, default to utf-16 + if value not in (Encoding.LATIN1, Encoding.UTF16): + value = Encoding.UTF16 + return value + + +class StringSpec(Spec): + """A fixed size ASCII only payload.""" + + def __init__(self, name, length): + super(StringSpec, self).__init__(name) + self.len = length + + def read(s, frame, data): + chunk = data[:s.len] + try: + ascii = chunk.decode("ascii") + except UnicodeDecodeError: + raise SpecError("not ascii") + else: + if PY3: + chunk = ascii + + return chunk, data[s.len:] + + def write(s, frame, value): + if value is None: + return b'\x00' * s.len + else: + if PY3: + value = value.encode("ascii") + return (bytes(value) + b'\x00' * s.len)[:s.len] + + def validate(s, frame, value): + if value is None: + return None + + if PY3: + if not isinstance(value, str): + raise TypeError("%s has to be str" % s.name) + value.encode("ascii") + else: + if not isinstance(value, bytes): + value = value.encode("ascii") + + if len(value) == s.len: + return value + + raise ValueError('Invalid StringSpec[%d] data: %r' % (s.len, value)) + + +class BinaryDataSpec(Spec): + def read(self, frame, data): + return data, b'' + + def write(self, frame, value): + if value is None: + return b"" + if isinstance(value, bytes): + return value + value = text_type(value).encode("ascii") + return value + + def validate(self, frame, value): + if value is None: + return None + + if isinstance(value, bytes): + return value + elif PY3: + raise TypeError("%s has to be bytes" % self.name) + + value = text_type(value).encode("ascii") + return value + + +class EncodedTextSpec(Spec): + + _encodings = { + Encoding.LATIN1: ('latin1', b'\x00'), + Encoding.UTF16: ('utf16', b'\x00\x00'), + Encoding.UTF16BE: ('utf_16_be', b'\x00\x00'), + Encoding.UTF8: ('utf8', b'\x00'), + } + + def read(self, frame, data): + enc, term = self._encodings[frame.encoding] + try: + # allow missing termination + return decode_terminated(data, enc, strict=False) + except ValueError: + # utf-16 termination with missing BOM, or single NULL + if not data[:len(term)].strip(b"\x00"): + return u"", data[len(term):] + + # utf-16 data with single NULL, see issue 169 + try: + return decode_terminated(data + b"\x00", enc) + except ValueError: + raise SpecError("Decoding error") + + def write(self, frame, value): + enc, term = self._encodings[frame.encoding] + return value.encode(enc) + term + + def validate(self, frame, value): + return text_type(value) + + +class MultiSpec(Spec): + def __init__(self, name, *specs, **kw): + super(MultiSpec, self).__init__(name) + self.specs = specs + self.sep = kw.get('sep') + + def read(self, frame, data): + values = [] + while data: + record = [] + for spec in self.specs: + value, data = spec.read(frame, data) + record.append(value) + if len(self.specs) != 1: + values.append(record) + else: + values.append(record[0]) + return values, data + + def write(self, frame, value): + data = [] + if len(self.specs) == 1: + for v in value: + data.append(self.specs[0].write(frame, v)) + else: + for record in value: + for v, s in zip(record, self.specs): + data.append(s.write(frame, v)) + return b''.join(data) + + def validate(self, frame, value): + if value is None: + return [] + if self.sep and isinstance(value, string_types): + value = value.split(self.sep) + if isinstance(value, list): + if len(self.specs) == 1: + return [self.specs[0].validate(frame, v) for v in value] + else: + return [ + [s.validate(frame, v) for (v, s) in zip(val, self.specs)] + for val in value] + raise ValueError('Invalid MultiSpec data: %r' % value) + + def _validate23(self, frame, value, **kwargs): + if len(self.specs) != 1: + return [[s._validate23(frame, v, **kwargs) + for (v, s) in zip(val, self.specs)] + for val in value] + + spec = self.specs[0] + + # Merge single text spec multispecs only. + # (TimeStampSpec beeing the exception, but it's not a valid v2.3 frame) + if not isinstance(spec, EncodedTextSpec) or \ + isinstance(spec, TimeStampSpec): + return value + + value = [spec._validate23(frame, v, **kwargs) for v in value] + if kwargs.get("sep") is not None: + return [spec.validate(frame, kwargs["sep"].join(value))] + return value + + +class EncodedNumericTextSpec(EncodedTextSpec): + pass + + +class EncodedNumericPartTextSpec(EncodedTextSpec): + pass + + +class Latin1TextSpec(EncodedTextSpec): + def read(self, frame, data): + if b'\x00' in data: + data, ret = data.split(b'\x00', 1) + else: + ret = b'' + return data.decode('latin1'), ret + + def write(self, data, value): + return value.encode('latin1') + b'\x00' + + def validate(self, frame, value): + return text_type(value) + + +@swap_to_string +@total_ordering +class ID3TimeStamp(object): + """A time stamp in ID3v2 format. + + This is a restricted form of the ISO 8601 standard; time stamps + take the form of: + YYYY-MM-DD HH:MM:SS + Or some partial form (YYYY-MM-DD HH, YYYY, etc.). + + The 'text' attribute contains the raw text data of the time stamp. + """ + + import re + + def __init__(self, text): + if isinstance(text, ID3TimeStamp): + text = text.text + elif not isinstance(text, text_type): + if PY3: + raise TypeError("not a str") + text = text.decode("utf-8") + + self.text = text + + __formats = ['%04d'] + ['%02d'] * 5 + __seps = ['-', '-', ' ', ':', ':', 'x'] + + def get_text(self): + parts = [self.year, self.month, self.day, + self.hour, self.minute, self.second] + pieces = [] + for i, part in enumerate(parts): + if part is None: + break + pieces.append(self.__formats[i] % part + self.__seps[i]) + return u''.join(pieces)[:-1] + + def set_text(self, text, splitre=re.compile('[-T:/.]|\s+')): + year, month, day, hour, minute, second = \ + splitre.split(text + ':::::')[:6] + for a in 'year month day hour minute second'.split(): + try: + v = int(locals()[a]) + except ValueError: + v = None + setattr(self, a, v) + + text = property(get_text, set_text, doc="ID3v2.4 date and time.") + + def __str__(self): + return self.text + + def __bytes__(self): + return self.text.encode("utf-8") + + def __repr__(self): + return repr(self.text) + + def __eq__(self, other): + return self.text == other.text + + def __lt__(self, other): + return self.text < other.text + + __hash__ = object.__hash__ + + def encode(self, *args): + return self.text.encode(*args) + + +class TimeStampSpec(EncodedTextSpec): + def read(self, frame, data): + value, data = super(TimeStampSpec, self).read(frame, data) + return self.validate(frame, value), data + + def write(self, frame, data): + return super(TimeStampSpec, self).write(frame, + data.text.replace(' ', 'T')) + + def validate(self, frame, value): + try: + return ID3TimeStamp(value) + except TypeError: + raise ValueError("Invalid ID3TimeStamp: %r" % value) + + +class ChannelSpec(ByteSpec): + (OTHER, MASTER, FRONTRIGHT, FRONTLEFT, BACKRIGHT, BACKLEFT, FRONTCENTRE, + BACKCENTRE, SUBWOOFER) = range(9) + + +class VolumeAdjustmentSpec(Spec): + def read(self, frame, data): + value, = unpack('>h', data[0:2]) + return value / 512.0, data[2:] + + def write(self, frame, value): + number = int(round(value * 512)) + # pack only fails in 2.7, do it manually in 2.6 + if not -32768 <= number <= 32767: + raise SpecError("not in range") + return pack('>h', number) + + def validate(self, frame, value): + if value is not None: + try: + self.write(frame, value) + except SpecError: + raise ValueError("out of range") + return value + + +class VolumePeakSpec(Spec): + def read(self, frame, data): + # http://bugs.xmms.org/attachment.cgi?id=113&action=view + peak = 0 + data_array = bytearray(data) + bits = data_array[0] + vol_bytes = min(4, (bits + 7) >> 3) + # not enough frame data + if vol_bytes + 1 > len(data): + raise SpecError("not enough frame data") + shift = ((8 - (bits & 7)) & 7) + (4 - vol_bytes) * 8 + for i in range(1, vol_bytes + 1): + peak *= 256 + peak += data_array[i] + peak *= 2 ** shift + return (float(peak) / (2 ** 31 - 1)), data[1 + vol_bytes:] + + def write(self, frame, value): + number = int(round(value * 32768)) + # pack only fails in 2.7, do it manually in 2.6 + if not 0 <= number <= 65535: + raise SpecError("not in range") + # always write as 16 bits for sanity. + return b"\x10" + pack('>H', number) + + def validate(self, frame, value): + if value is not None: + try: + self.write(frame, value) + except SpecError: + raise ValueError("out of range") + return value + + +class SynchronizedTextSpec(EncodedTextSpec): + def read(self, frame, data): + texts = [] + encoding, term = self._encodings[frame.encoding] + while data: + try: + value, data = decode_terminated(data, encoding) + except ValueError: + raise SpecError("decoding error") + + if len(data) < 4: + raise SpecError("not enough data") + time, = struct.unpack(">I", data[:4]) + + texts.append((value, time)) + data = data[4:] + return texts, b"" + + def write(self, frame, value): + data = [] + encoding, term = self._encodings[frame.encoding] + for text, time in value: + text = text.encode(encoding) + term + data.append(text + struct.pack(">I", time)) + return b"".join(data) + + def validate(self, frame, value): + return value + + +class KeyEventSpec(Spec): + def read(self, frame, data): + events = [] + while len(data) >= 5: + events.append(struct.unpack(">bI", data[:5])) + data = data[5:] + return events, data + + def write(self, frame, value): + return b"".join(struct.pack(">bI", *event) for event in value) + + def validate(self, frame, value): + return value + + +class VolumeAdjustmentsSpec(Spec): + # Not to be confused with VolumeAdjustmentSpec. + def read(self, frame, data): + adjustments = {} + while len(data) >= 4: + freq, adj = struct.unpack(">Hh", data[:4]) + data = data[4:] + freq /= 2.0 + adj /= 512.0 + adjustments[freq] = adj + adjustments = sorted(adjustments.items()) + return adjustments, data + + def write(self, frame, value): + value.sort() + return b"".join(struct.pack(">Hh", int(freq * 2), int(adj * 512)) + for (freq, adj) in value) + + def validate(self, frame, value): + return value + + +class ASPIIndexSpec(Spec): + def read(self, frame, data): + if frame.b == 16: + format = "H" + size = 2 + elif frame.b == 8: + format = "B" + size = 1 + else: + raise SpecError("invalid bit count in ASPI (%d)" % frame.b) + + indexes = data[:frame.N * size] + data = data[frame.N * size:] + try: + return list(struct.unpack(">" + format * frame.N, indexes)), data + except struct.error as e: + raise SpecError(e) + + def write(self, frame, values): + if frame.b == 16: + format = "H" + elif frame.b == 8: + format = "B" + else: + raise SpecError("frame.b must be 8 or 16") + try: + return struct.pack(">" + format * frame.N, *values) + except struct.error as e: + raise SpecError(e) + + def validate(self, frame, values): + return values diff -Nru mutagen-1.23/mutagen/id3/_util.py mutagen-1.30/mutagen/id3/_util.py --- mutagen-1.23/mutagen/id3/_util.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/mutagen/id3/_util.py 2015-05-09 11:50:53.000000000 +0000 @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2005 Michael Urman +# 2013 Christoph Reiter +# 2014 Ben Ockmore +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +from .._compat import long_, integer_types, PY3 +from .._util import MutagenError + + +class error(MutagenError): + pass + + +class ID3NoHeaderError(error, ValueError): + pass + + +class ID3UnsupportedVersionError(error, NotImplementedError): + pass + + +class ID3EncryptionUnsupportedError(error, NotImplementedError): + pass + + +class ID3JunkFrameError(error, ValueError): + pass + + +class unsynch(object): + @staticmethod + def decode(value): + fragments = bytearray(value).split(b'\xff') + if len(fragments) > 1 and not fragments[-1]: + raise ValueError('string ended unsafe') + + for f in fragments[1:]: + if (not f) or (f[0] >= 0xE0): + raise ValueError('invalid sync-safe string') + + if f[0] == 0x00: + del f[0] + + return bytes(bytearray(b'\xff').join(fragments)) + + @staticmethod + def encode(value): + fragments = bytearray(value).split(b'\xff') + for f in fragments[1:]: + if (not f) or (f[0] >= 0xE0) or (f[0] == 0x00): + f.insert(0, 0x00) + return bytes(bytearray(b'\xff').join(fragments)) + + +class _BitPaddedMixin(object): + + def as_str(self, width=4, minwidth=4): + return self.to_str(self, self.bits, self.bigendian, width, minwidth) + + @staticmethod + def to_str(value, bits=7, bigendian=True, width=4, minwidth=4): + mask = (1 << bits) - 1 + + if width != -1: + index = 0 + bytes_ = bytearray(width) + try: + while value: + bytes_[index] = value & mask + value >>= bits + index += 1 + except IndexError: + raise ValueError('Value too wide (>%d bytes)' % width) + else: + # PCNT and POPM use growing integers + # of at least 4 bytes (=minwidth) as counters. + bytes_ = bytearray() + append = bytes_.append + while value: + append(value & mask) + value >>= bits + bytes_ = bytes_.ljust(minwidth, b"\x00") + + if bigendian: + bytes_.reverse() + return bytes(bytes_) + + @staticmethod + def has_valid_padding(value, bits=7): + """Whether the padding bits are all zero""" + + assert bits <= 8 + + mask = (((1 << (8 - bits)) - 1) << bits) + + if isinstance(value, integer_types): + while value: + if value & mask: + return False + value >>= 8 + elif isinstance(value, bytes): + for byte in bytearray(value): + if byte & mask: + return False + else: + raise TypeError + + return True + + +class BitPaddedInt(int, _BitPaddedMixin): + + def __new__(cls, value, bits=7, bigendian=True): + + mask = (1 << (bits)) - 1 + numeric_value = 0 + shift = 0 + + if isinstance(value, integer_types): + while value: + numeric_value += (value & mask) << shift + value >>= 8 + shift += bits + elif isinstance(value, bytes): + if bigendian: + value = reversed(value) + for byte in bytearray(value): + numeric_value += (byte & mask) << shift + shift += bits + else: + raise TypeError + + if isinstance(numeric_value, int): + self = int.__new__(BitPaddedInt, numeric_value) + else: + self = long_.__new__(BitPaddedLong, numeric_value) + + self.bits = bits + self.bigendian = bigendian + return self + +if PY3: + BitPaddedLong = BitPaddedInt +else: + class BitPaddedLong(long_, _BitPaddedMixin): + pass + + +class ID3BadUnsynchData(error, ValueError): + """Deprecated""" + + +class ID3BadCompressedData(error, ValueError): + """Deprecated""" + + +class ID3TagError(error, ValueError): + """Deprecated""" + + +class ID3Warning(error, UserWarning): + """Deprecated""" diff -Nru mutagen-1.23/mutagen/_id3frames.py mutagen-1.30/mutagen/_id3frames.py --- mutagen-1.23/mutagen/_id3frames.py 2014-02-22 10:39:40.000000000 +0000 +++ mutagen-1.30/mutagen/_id3frames.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,1851 +0,0 @@ -# Copyright (C) 2005 Michael Urman -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of version 2 of the GNU General Public License as -# published by the Free Software Foundation. - -import zlib -from warnings import warn -from struct import unpack - -from mutagen._id3util import ( - ID3Warning, ID3JunkFrameError, ID3BadCompressedData, - ID3EncryptionUnsupportedError, ID3BadUnsynchData, unsynch) -from mutagen._id3specs import ( - BinaryDataSpec, StringSpec, Latin1TextSpec, EncodedTextSpec, ByteSpec, - EncodingSpec, ASPIIndexSpec, SizedIntegerSpec, IntegerSpec, - VolumeAdjustmentsSpec, VolumePeakSpec, VolumeAdjustmentSpec, - ChannelSpec, MultiSpec, SynchronizedTextSpec, KeyEventSpec, TimeStampSpec, - EncodedNumericPartTextSpec, EncodedNumericTextSpec) -from ._compat import text_type, string_types, swap_to_string - - -def is_valid_frame_id(frame_id): - return frame_id.isalnum() and frame_id.isupper() - - -class Frame(object): - """Fundamental unit of ID3 data. - - ID3 tags are split into frames. Each frame has a potentially - different structure, and so this base class is not very featureful. - """ - - FLAG23_ALTERTAG = 0x8000 - FLAG23_ALTERFILE = 0x4000 - FLAG23_READONLY = 0x2000 - FLAG23_COMPRESS = 0x0080 - FLAG23_ENCRYPT = 0x0040 - FLAG23_GROUP = 0x0020 - - FLAG24_ALTERTAG = 0x4000 - FLAG24_ALTERFILE = 0x2000 - FLAG24_READONLY = 0x1000 - FLAG24_GROUPID = 0x0040 - FLAG24_COMPRESS = 0x0008 - FLAG24_ENCRYPT = 0x0004 - FLAG24_UNSYNCH = 0x0002 - FLAG24_DATALEN = 0x0001 - - _framespec = [] - - def __init__(self, *args, **kwargs): - if len(args) == 1 and len(kwargs) == 0 and \ - isinstance(args[0], type(self)): - other = args[0] - for checker in self._framespec: - try: - val = checker.validate(self, getattr(other, checker.name)) - except ValueError as e: - e.message = "%s: %s" % (checker.name, e.message) - raise - setattr(self, checker.name, val) - else: - for checker, val in zip(self._framespec, args): - setattr(self, checker.name, checker.validate(self, val)) - for checker in self._framespec[len(args):]: - try: - validated = checker.validate( - self, kwargs.get(checker.name, None)) - except ValueError as e: - e.message = "%s: %s" % (checker.name, e.message) - raise - setattr(self, checker.name, validated) - - def _get_v23_frame(self, **kwargs): - """Returns a frame copy which is suitable for writing into a v2.3 tag. - - kwargs get passed to the specs. - """ - - new_kwargs = {} - for checker in self._framespec: - name = checker.name - value = getattr(self, name) - new_kwargs[name] = checker._validate23(self, value, **kwargs) - return type(self)(**new_kwargs) - - @property - def HashKey(self): - """An internal key used to ensure frame uniqueness in a tag""" - - return self.FrameID - - @property - def FrameID(self): - """ID3v2 three or four character frame ID""" - - return type(self).__name__ - - def __repr__(self): - """Python representation of a frame. - - The string returned is a valid Python expression to construct - a copy of this frame. - """ - kw = [] - for attr in self._framespec: - kw.append('%s=%r' % (attr.name, getattr(self, attr.name))) - return '%s(%s)' % (type(self).__name__, ', '.join(kw)) - - def _readData(self, data): - odata = data - for reader in self._framespec: - if len(data): - try: - value, data = reader.read(self, data) - except UnicodeDecodeError: - raise ID3JunkFrameError - else: - raise ID3JunkFrameError - setattr(self, reader.name, value) - if data.strip(b'\x00'): - warn('Leftover data: %s: %r (from %r)' % ( - type(self).__name__, data, odata), - ID3Warning) - - def _writeData(self): - data = [] - for writer in self._framespec: - data.append(writer.write(self, getattr(self, writer.name))) - return b''.join(data) - - def pprint(self): - """Return a human-readable representation of the frame.""" - return "%s=%s" % (type(self).__name__, self._pprint()) - - def _pprint(self): - return "[unrepresentable data]" - - @classmethod - def fromData(cls, id3, tflags, data): - """Construct this ID3 frame from raw string data.""" - - if id3._V24 <= id3.version: - if tflags & (Frame.FLAG24_COMPRESS | Frame.FLAG24_DATALEN): - # The data length int is syncsafe in 2.4 (but not 2.3). - # However, we don't actually need the data length int, - # except to work around a QL 0.12 bug, and in that case - # all we need are the raw bytes. - datalen_bytes = data[:4] - data = data[4:] - if tflags & Frame.FLAG24_UNSYNCH or id3.f_unsynch: - try: - data = unsynch.decode(data) - except ValueError as err: - if id3.PEDANTIC: - raise ID3BadUnsynchData('%s: %r' % (err, data)) - if tflags & Frame.FLAG24_ENCRYPT: - raise ID3EncryptionUnsupportedError - if tflags & Frame.FLAG24_COMPRESS: - try: - data = zlib.decompress(data) - except zlib.error as err: - # the initial mutagen that went out with QL 0.12 did not - # write the 4 bytes of uncompressed size. Compensate. - data = datalen_bytes + data - try: - data = zlib.decompress(data) - except zlib.error as err: - if id3.PEDANTIC: - raise ID3BadCompressedData('%s: %r' % (err, data)) - - elif id3._V23 <= id3.version: - if tflags & Frame.FLAG23_COMPRESS: - usize, = unpack('>L', data[:4]) - data = data[4:] - if tflags & Frame.FLAG23_ENCRYPT: - raise ID3EncryptionUnsupportedError - if tflags & Frame.FLAG23_COMPRESS: - try: - data = zlib.decompress(data) - except zlib.error as err: - if id3.PEDANTIC: - raise ID3BadCompressedData('%s: %r' % (err, data)) - - frame = cls() - frame._rawdata = data - frame._flags = tflags - frame._readData(data) - return frame - - def __hash__(self): - raise TypeError("Frame objects are unhashable") - - -class FrameOpt(Frame): - """A frame with optional parts. - - Some ID3 frames have optional data; this class extends Frame to - provide support for those parts. - """ - - _optionalspec = [] - - def __init__(self, *args, **kwargs): - super(FrameOpt, self).__init__(*args, **kwargs) - for spec in self._optionalspec: - if spec.name in kwargs: - validated = spec.validate(self, kwargs[spec.name]) - setattr(self, spec.name, validated) - else: - break - - def _readData(self, data): - odata = data - for reader in self._framespec: - if len(data): - value, data = reader.read(self, data) - else: - raise ID3JunkFrameError - setattr(self, reader.name, value) - if data: - for reader in self._optionalspec: - if len(data): - value, data = reader.read(self, data) - else: - break - setattr(self, reader.name, value) - if data.strip(b'\x00'): - warn('Leftover data: %s: %r (from %r)' % ( - type(self).__name__, data, odata), - ID3Warning) - - def _writeData(self): - data = [] - for writer in self._framespec: - data.append(writer.write(self, getattr(self, writer.name))) - for writer in self._optionalspec: - try: - data.append(writer.write(self, getattr(self, writer.name))) - except AttributeError: - break - return ''.join(data) - - def __repr__(self): - kw = [] - for attr in self._framespec: - kw.append('%s=%r' % (attr.name, getattr(self, attr.name))) - for attr in self._optionalspec: - if hasattr(self, attr.name): - kw.append('%s=%r' % (attr.name, getattr(self, attr.name))) - return '%s(%s)' % (type(self).__name__, ', '.join(kw)) - - -@swap_to_string -class TextFrame(Frame): - """Text strings. - - Text frames support casts to unicode or str objects, as well as - list-like indexing, extend, and append. - - Iterating over a TextFrame iterates over its strings, not its - characters. - - Text frames have a 'text' attribute which is the list of strings, - and an 'encoding' attribute; 0 for ISO-8859 1, 1 UTF-16, 2 for - UTF-16BE, and 3 for UTF-8. If you don't want to worry about - encodings, just set it to 3. - """ - - _framespec = [ - EncodingSpec('encoding'), - MultiSpec('text', EncodedTextSpec('text'), sep=u'\u0000'), - ] - - def __bytes__(self): - return text_type(self).encode('utf-8') - - def __str__(self): - return u'\u0000'.join(self.text) - - def __eq__(self, other): - if isinstance(other, bytes): - return bytes(self) == other - elif isinstance(other, text_type): - return text_type(self) == other - return self.text == other - - __hash__ = Frame.__hash__ - - def __getitem__(self, item): - return self.text[item] - - def __iter__(self): - return iter(self.text) - - def append(self, value): - """Append a string.""" - - return self.text.append(value) - - def extend(self, value): - """Extend the list by appending all strings from the given list.""" - - return self.text.extend(value) - - def _pprint(self): - return " / ".join(self.text) - - -class NumericTextFrame(TextFrame): - """Numerical text strings. - - The numeric value of these frames can be gotten with unary plus, e.g.:: - - frame = TLEN('12345') - length = +frame - """ - - _framespec = [ - EncodingSpec('encoding'), - MultiSpec('text', EncodedNumericTextSpec('text'), sep=u'\u0000'), - ] - - def __pos__(self): - """Return the numerical value of the string.""" - return int(self.text[0]) - - -class NumericPartTextFrame(TextFrame): - """Multivalue numerical text strings. - - These strings indicate 'part (e.g. track) X of Y', and unary plus - returns the first value:: - - frame = TRCK('4/15') - track = +frame # track == 4 - """ - - _framespec = [ - EncodingSpec('encoding'), - MultiSpec('text', EncodedNumericPartTextSpec('text'), sep=u'\u0000'), - ] - - def __pos__(self): - return int(self.text[0].split("/")[0]) - - -@swap_to_string -class TimeStampTextFrame(TextFrame): - """A list of time stamps. - - The 'text' attribute in this frame is a list of ID3TimeStamp - objects, not a list of strings. - """ - - _framespec = [ - EncodingSpec('encoding'), - MultiSpec('text', TimeStampSpec('stamp'), sep=u','), - ] - - def __bytes__(self): - return text_type(self).encode('utf-8') - - def __str__(self): - return ','.join([stamp.text for stamp in self.text]) - - def _pprint(self): - return " / ".join([stamp.text for stamp in self.text]) - -@swap_to_string -class UrlFrame(Frame): - """A frame containing a URL string. - - The ID3 specification is silent about IRIs and normalized URL - forms. Mutagen assumes all URLs in files are encoded as Latin 1, - but string conversion of this frame returns a UTF-8 representation - for compatibility with other string conversions. - - The only sane way to handle URLs in MP3s is to restrict them to - ASCII. - """ - - _framespec = [Latin1TextSpec('url')] - - def __bytes__(self): - return self.url.encode('utf-8') - - def __str__(self): - return self.url - - def __eq__(self, other): - return self.url == other - - __hash__ = Frame.__hash__ - - def _pprint(self): - return self.url - - -class UrlFrameU(UrlFrame): - - @property - def HashKey(self): - return '%s:%s' % (self.FrameID, self.url) - - -class TALB(TextFrame): - "Album" - - -class TBPM(NumericTextFrame): - "Beats per minute" - - -class TCOM(TextFrame): - "Composer" - - -class TCON(TextFrame): - """Content type (Genre) - - ID3 has several ways genres can be represented; for convenience, - use the 'genres' property rather than the 'text' attribute. - """ - - from mutagen._constants import GENRES - GENRES = GENRES - - def __get_genres(self): - genres = [] - import re - genre_re = re.compile(r"((?:\((?P[0-9]+|RX|CR)\))*)(?P.+)?") - for value in self.text: - # 255 possible entries in id3v1 - if value.isdigit() and int(value) < 256: - try: - genres.append(self.GENRES[int(value)]) - except IndexError: - genres.append(u"Unknown") - elif value == "CR": - genres.append(u"Cover") - elif value == "RX": - genres.append(u"Remix") - elif value: - newgenres = [] - genreid, dummy, genrename = genre_re.match(value).groups() - - if genreid: - for gid in genreid[1:-1].split(")("): - if gid.isdigit() and int(gid) < len(self.GENRES): - gid = text_type(self.GENRES[int(gid)]) - newgenres.append(gid) - elif gid == "CR": - newgenres.append(u"Cover") - elif gid == "RX": - newgenres.append(u"Remix") - else: - newgenres.append(u"Unknown") - - if genrename: - # "Unescaping" the first parenthesis - if genrename.startswith("(("): - genrename = genrename[1:] - if genrename not in newgenres: - newgenres.append(genrename) - - genres.extend(newgenres) - - return genres - - def __set_genres(self, genres): - if isinstance(genres, string_types): - genres = [genres] - self.text = [self.__decode(g) for g in genres] - - def __decode(self, value): - if isinstance(value, bytes): - enc = EncodedTextSpec._encodings[self.encoding][0] - return value.decode(enc) - else: - return value - - genres = property(__get_genres, __set_genres, None, - "A list of genres parsed from the raw text data.") - - def _pprint(self): - return " / ".join(self.genres) - - -class TCOP(TextFrame): - "Copyright (c)" - - -class TCMP(NumericTextFrame): - "iTunes Compilation Flag" - - -class TDAT(TextFrame): - "Date of recording (DDMM)" - - -class TDEN(TimeStampTextFrame): - "Encoding Time" - - -class TDES(TextFrame): - "iTunes Podcast Description" - - -class TDOR(TimeStampTextFrame): - "Original Release Time" - - -class TDLY(NumericTextFrame): - "Audio Delay (ms)" - - -class TDRC(TimeStampTextFrame): - "Recording Time" - - -class TDRL(TimeStampTextFrame): - "Release Time" - - -class TDTG(TimeStampTextFrame): - "Tagging Time" - - -class TENC(TextFrame): - "Encoder" - - -class TEXT(TextFrame): - "Lyricist" - - -class TFLT(TextFrame): - "File type" - - -class TGID(TextFrame): - "iTunes Podcast Identifier" - - -class TIME(TextFrame): - "Time of recording (HHMM)" - - -class TIT1(TextFrame): - "Content group description" - - -class TIT2(TextFrame): - "Title" - - -class TIT3(TextFrame): - "Subtitle/Description refinement" - - -class TKEY(TextFrame): - "Starting Key" - - -class TLAN(TextFrame): - "Audio Languages" - - -class TLEN(NumericTextFrame): - "Audio Length (ms)" - - -class TMED(TextFrame): - "Source Media Type" - - -class TMOO(TextFrame): - "Mood" - - -class TOAL(TextFrame): - "Original Album" - - -class TOFN(TextFrame): - "Original Filename" - - -class TOLY(TextFrame): - "Original Lyricist" - - -class TOPE(TextFrame): - "Original Artist/Performer" - - -class TORY(NumericTextFrame): - "Original Release Year" - - -class TOWN(TextFrame): - "Owner/Licensee" - - -class TPE1(TextFrame): - "Lead Artist/Performer/Soloist/Group" - - -class TPE2(TextFrame): - "Band/Orchestra/Accompaniment" - - -class TPE3(TextFrame): - "Conductor" - - -class TPE4(TextFrame): - "Interpreter/Remixer/Modifier" - - -class TPOS(NumericPartTextFrame): - "Part of set" - - -class TPRO(TextFrame): - "Produced (P)" - - -class TPUB(TextFrame): - "Publisher" - - -class TRCK(NumericPartTextFrame): - "Track Number" - - -class TRDA(TextFrame): - "Recording Dates" - - -class TRSN(TextFrame): - "Internet Radio Station Name" - - -class TRSO(TextFrame): - "Internet Radio Station Owner" - - -class TSIZ(NumericTextFrame): - "Size of audio data (bytes)" - - -class TSO2(TextFrame): - "iTunes Album Artist Sort" - - -class TSOA(TextFrame): - "Album Sort Order key" - - -class TSOC(TextFrame): - "iTunes Composer Sort" - - -class TSOP(TextFrame): - "Perfomer Sort Order key" - - -class TSOT(TextFrame): - "Title Sort Order key" - - -class TSRC(TextFrame): - "International Standard Recording Code (ISRC)" - - -class TSSE(TextFrame): - "Encoder settings" - - -class TSST(TextFrame): - "Set Subtitle" - - -class TYER(NumericTextFrame): - "Year of recording" - - -class TXXX(TextFrame): - """User-defined text data. - - TXXX frames have a 'desc' attribute which is set to any Unicode - value (though the encoding of the text and the description must be - the same). Many taggers use this frame to store freeform keys. - """ - - _framespec = [ - EncodingSpec('encoding'), - EncodedTextSpec('desc'), - MultiSpec('text', EncodedTextSpec('text'), sep=u'\u0000'), - ] - - @property - def HashKey(self): - return '%s:%s' % (self.FrameID, self.desc) - - def _pprint(self): - return "%s=%s" % (self.desc, " / ".join(self.text)) - - -class WCOM(UrlFrameU): - "Commercial Information" - - -class WCOP(UrlFrame): - "Copyright Information" - - -class WFED(UrlFrame): - "iTunes Podcast Feed" - - -class WOAF(UrlFrame): - "Official File Information" - - -class WOAR(UrlFrameU): - "Official Artist/Performer Information" - - -class WOAS(UrlFrame): - "Official Source Information" - - -class WORS(UrlFrame): - "Official Internet Radio Information" - - -class WPAY(UrlFrame): - "Payment Information" - - -class WPUB(UrlFrame): - "Official Publisher Information" - - -class WXXX(UrlFrame): - """User-defined URL data. - - Like TXXX, this has a freeform description associated with it. - """ - - _framespec = [ - EncodingSpec('encoding'), - EncodedTextSpec('desc'), - Latin1TextSpec('url'), - ] - - @property - def HashKey(self): - return '%s:%s' % (self.FrameID, self.desc) - - -class PairedTextFrame(Frame): - """Paired text strings. - - Some ID3 frames pair text strings, to associate names with a more - specific involvement in the song. The 'people' attribute of these - frames contains a list of pairs:: - - [['trumpet', 'Miles Davis'], ['bass', 'Paul Chambers']] - - Like text frames, these frames also have an encoding attribute. - """ - - _framespec = [ - EncodingSpec('encoding'), - MultiSpec('people', - EncodedTextSpec('involvement'), - EncodedTextSpec('person')) - ] - - def __eq__(self, other): - return self.people == other - - __hash__ = Frame.__hash__ - - -class TIPL(PairedTextFrame): - "Involved People List" - - -class TMCL(PairedTextFrame): - "Musicians Credits List" - - -class IPLS(TIPL): - "Involved People List" - - -class BinaryFrame(Frame): - """Binary data - - The 'data' attribute contains the raw byte string. - """ - - _framespec = [BinaryDataSpec('data')] - - def __eq__(self, other): - return self.data == other - - __hash__ = Frame.__hash__ - - -class MCDI(BinaryFrame): - "Binary dump of CD's TOC" - - -class ETCO(Frame): - """Event timing codes.""" - - _framespec = [ - ByteSpec("format"), - KeyEventSpec("events"), - ] - - def __eq__(self, other): - return self.events == other - - __hash__ = Frame.__hash__ - - -class MLLT(Frame): - """MPEG location lookup table. - - This frame's attributes may be changed in the future based on - feedback from real-world use. - """ - - _framespec = [ - SizedIntegerSpec('frames', 2), - SizedIntegerSpec('bytes', 3), - SizedIntegerSpec('milliseconds', 3), - ByteSpec('bits_for_bytes'), - ByteSpec('bits_for_milliseconds'), - BinaryDataSpec('data'), - ] - - def __eq__(self, other): - return self.data == other - - __hash__ = Frame.__hash__ - - -class SYTC(Frame): - """Synchronised tempo codes. - - This frame's attributes may be changed in the future based on - feedback from real-world use. - """ - - _framespec = [ - ByteSpec("format"), - BinaryDataSpec("data"), - ] - - def __eq__(self, other): - return self.data == other - - __hash__ = Frame.__hash__ - - -class USLT(Frame): - """Unsynchronised lyrics/text transcription. - - Lyrics have a three letter ISO language code ('lang'), a - description ('desc'), and a block of plain text ('text'). - """ - - _framespec = [ - EncodingSpec('encoding'), - StringSpec('lang', 3), - EncodedTextSpec('desc'), - EncodedTextSpec('text'), - ] - - @property - def HashKey(self): - return '%s:%s:%r' % (self.FrameID, self.desc, self.lang) - - def __str__(self): - return self.text.encode('utf-8') - - def __unicode__(self): - return self.text - - def __eq__(self, other): - return self.text == other - - __hash__ = Frame.__hash__ - - -class SYLT(Frame): - """Synchronised lyrics/text.""" - - _framespec = [ - EncodingSpec('encoding'), - StringSpec('lang', 3), - ByteSpec('format'), - ByteSpec('type'), - EncodedTextSpec('desc'), - SynchronizedTextSpec('text'), - ] - - @property - def HashKey(self): - return '%s:%s:%r' % (self.FrameID, self.desc, self.lang) - - def __eq__(self, other): - return str(self) == other - - __hash__ = Frame.__hash__ - - def __str__(self): - return "".join([text for (text, time) in self.text]).encode('utf-8') - - -class COMM(TextFrame): - """User comment. - - User comment frames have a descrption, like TXXX, and also a three - letter ISO language code in the 'lang' attribute. - """ - - _framespec = [ - EncodingSpec('encoding'), - StringSpec('lang', 3), - EncodedTextSpec('desc'), - MultiSpec('text', EncodedTextSpec('text'), sep=u'\u0000'), - ] - - @property - def HashKey(self): - return '%s:%s:%r' % (self.FrameID, self.desc, self.lang) - - def _pprint(self): - return "%s=%r=%s" % (self.desc, self.lang, " / ".join(self.text)) - - -class RVA2(Frame): - """Relative volume adjustment (2). - - This frame is used to implemented volume scaling, and in - particular, normalization using ReplayGain. - - Attributes: - - * desc -- description or context of this adjustment - * channel -- audio channel to adjust (master is 1) - * gain -- a + or - dB gain relative to some reference level - * peak -- peak of the audio as a floating point number, [0, 1] - - When storing ReplayGain tags, use descriptions of 'album' and - 'track' on channel 1. - """ - - _framespec = [ - Latin1TextSpec('desc'), - ChannelSpec('channel'), - VolumeAdjustmentSpec('gain'), - VolumePeakSpec('peak'), - ] - - _channels = ["Other", "Master volume", "Front right", "Front left", - "Back right", "Back left", "Front centre", "Back centre", - "Subwoofer"] - - @property - def HashKey(self): - return '%s:%s' % (self.FrameID, self.desc) - - def __eq__(self, other): - try: - return ((str(self) == other) or - (self.desc == other.desc and - self.channel == other.channel and - self.gain == other.gain and - self.peak == other.peak)) - except AttributeError: - return False - - __hash__ = Frame.__hash__ - - def __str__(self): - return "%s: %+0.4f dB/%0.4f" % ( - self._channels[self.channel], self.gain, self.peak) - - -class EQU2(Frame): - """Equalisation (2). - - Attributes: - method -- interpolation method (0 = band, 1 = linear) - desc -- identifying description - adjustments -- list of (frequency, vol_adjustment) pairs - """ - - _framespec = [ - ByteSpec("method"), - Latin1TextSpec("desc"), - VolumeAdjustmentsSpec("adjustments"), - ] - - def __eq__(self, other): - return self.adjustments == other - - __hash__ = Frame.__hash__ - - @property - def HashKey(self): - return '%s:%s' % (self.FrameID, self.desc) - - -# class RVAD: unsupported -# class EQUA: unsupported - - -class RVRB(Frame): - """Reverb.""" - - _framespec = [ - SizedIntegerSpec('left', 2), - SizedIntegerSpec('right', 2), - ByteSpec('bounce_left'), - ByteSpec('bounce_right'), - ByteSpec('feedback_ltl'), - ByteSpec('feedback_ltr'), - ByteSpec('feedback_rtr'), - ByteSpec('feedback_rtl'), - ByteSpec('premix_ltr'), - ByteSpec('premix_rtl'), - ] - - def __eq__(self, other): - return (self.left, self.right) == other - - __hash__ = Frame.__hash__ - - -class APIC(Frame): - """Attached (or linked) Picture. - - Attributes: - - * encoding -- text encoding for the description - * mime -- a MIME type (e.g. image/jpeg) or '-->' if the data is a URI - * type -- the source of the image (3 is the album front cover) - * desc -- a text description of the image - * data -- raw image data, as a byte string - - Mutagen will automatically compress large images when saving tags. - """ - - _framespec = [ - EncodingSpec('encoding'), - Latin1TextSpec('mime'), - ByteSpec('type'), - EncodedTextSpec('desc'), - BinaryDataSpec('data'), - ] - - def __eq__(self, other): - return self.data == other - - __hash__ = Frame.__hash__ - - @property - def HashKey(self): - return '%s:%s' % (self.FrameID, self.desc) - - def _pprint(self): - return "%s (%s, %d bytes)" % ( - self.desc, self.mime, len(self.data)) - - -class PCNT(Frame): - """Play counter. - - The 'count' attribute contains the (recorded) number of times this - file has been played. - - This frame is basically obsoleted by POPM. - """ - - _framespec = [IntegerSpec('count')] - - def __eq__(self, other): - return self.count == other - - __hash__ = Frame.__hash__ - - def __pos__(self): - return self.count - - def _pprint(self): - return unicode(self.count) - - -class POPM(FrameOpt): - """Popularimeter. - - This frame keys a rating (out of 255) and a play count to an email - address. - - Attributes: - - * email -- email this POPM frame is for - * rating -- rating from 0 to 255 - * count -- number of times the files has been played (optional) - """ - - _framespec = [ - Latin1TextSpec('email'), - ByteSpec('rating'), - ] - - _optionalspec = [IntegerSpec('count')] - - @property - def HashKey(self): - return '%s:%s' % (self.FrameID, self.email) - - def __eq__(self, other): - return self.rating == other - - __hash__ = FrameOpt.__hash__ - - def __pos__(self): - return self.rating - - def _pprint(self): - return "%s=%r %r/255" % ( - self.email, getattr(self, 'count', None), self.rating) - - -class GEOB(Frame): - """General Encapsulated Object. - - A blob of binary data, that is not a picture (those go in APIC). - - Attributes: - - * encoding -- encoding of the description - * mime -- MIME type of the data or '-->' if the data is a URI - * filename -- suggested filename if extracted - * desc -- text description of the data - * data -- raw data, as a byte string - """ - - _framespec = [ - EncodingSpec('encoding'), - Latin1TextSpec('mime'), - EncodedTextSpec('filename'), - EncodedTextSpec('desc'), - BinaryDataSpec('data'), - ] - - @property - def HashKey(self): - return '%s:%s' % (self.FrameID, self.desc) - - def __eq__(self, other): - return self.data == other - - __hash__ = Frame.__hash__ - - -class RBUF(FrameOpt): - """Recommended buffer size. - - Attributes: - - * size -- recommended buffer size in bytes - * info -- if ID3 tags may be elsewhere in the file (optional) - * offset -- the location of the next ID3 tag, if any - - Mutagen will not find the next tag itself. - """ - - _framespec = [SizedIntegerSpec('size', 3)] - - _optionalspec = [ - ByteSpec('info'), - SizedIntegerSpec('offset', 4), - ] - - def __eq__(self, other): - return self.size == other - - __hash__ = FrameOpt.__hash__ - - def __pos__(self): - return self.size - - -class AENC(FrameOpt): - """Audio encryption. - - Attributes: - - * owner -- key identifying this encryption type - * preview_start -- unencrypted data block offset - * preview_length -- number of unencrypted blocks - * data -- data required for decryption (optional) - - Mutagen cannot decrypt files. - """ - - _framespec = [ - Latin1TextSpec('owner'), - SizedIntegerSpec('preview_start', 2), - SizedIntegerSpec('preview_length', 2), - ] - - _optionalspec = [BinaryDataSpec('data')] - - @property - def HashKey(self): - return '%s:%s' % (self.FrameID, self.owner) - - def __str__(self): - return self.owner.encode('utf-8') - - def __unicode__(self): - return self.owner - - def __eq__(self, other): - return self.owner == other - - __hash__ = FrameOpt.__hash__ - - -class LINK(FrameOpt): - """Linked information. - - Attributes: - - * frameid -- the ID of the linked frame - * url -- the location of the linked frame - * data -- further ID information for the frame - """ - - _framespec = [ - StringSpec('frameid', 4), - Latin1TextSpec('url'), - ] - - _optionalspec = [BinaryDataSpec('data')] - - @property - def HashKey(self): - try: - return "%s:%s:%s:%r" % ( - self.FrameID, self.frameid, self.url, self.data) - except AttributeError: - return "%s:%s:%s" % (self.FrameID, self.frameid, self.url) - - def __eq__(self, other): - try: - return (self.frameid, self.url, self.data) == other - except AttributeError: - return (self.frameid, self.url) == other - - __hash__ = FrameOpt.__hash__ - - -class POSS(Frame): - """Position synchronisation frame - - Attribute: - - * format -- format of the position attribute (frames or milliseconds) - * position -- current position of the file - """ - - _framespec = [ - ByteSpec('format'), - IntegerSpec('position'), - ] - - def __pos__(self): - return self.position - - def __eq__(self, other): - return self.position == other - - __hash__ = Frame.__hash__ - - -class UFID(Frame): - """Unique file identifier. - - Attributes: - - * owner -- format/type of identifier - * data -- identifier - """ - - _framespec = [ - Latin1TextSpec('owner'), - BinaryDataSpec('data'), - ] - - @property - def HashKey(self): - return '%s:%s' % (self.FrameID, self.owner) - - def __eq__(s, o): - if isinstance(o, UFI): - return s.owner == o.owner and s.data == o.data - else: - return s.data == o - - __hash__ = Frame.__hash__ - - def _pprint(self): - isascii = ord(max(self.data)) < 128 - if isascii: - return "%s=%s" % (self.owner, self.data) - else: - return "%s (%d bytes)" % (self.owner, len(self.data)) - - -class USER(Frame): - """Terms of use. - - Attributes: - - * encoding -- text encoding - * lang -- ISO three letter language code - * text -- licensing terms for the audio - """ - - _framespec = [ - EncodingSpec('encoding'), - StringSpec('lang', 3), - EncodedTextSpec('text'), - ] - - @property - def HashKey(self): - return '%s:%r' % (self.FrameID, self.lang) - - def __str__(self): - return self.text.encode('utf-8') - - def __unicode__(self): - return self.text - - def __eq__(self, other): - return self.text == other - - __hash__ = Frame.__hash__ - - def _pprint(self): - return "%r=%s" % (self.lang, self.text) - - -class OWNE(Frame): - """Ownership frame.""" - - _framespec = [ - EncodingSpec('encoding'), - Latin1TextSpec('price'), - StringSpec('date', 8), - EncodedTextSpec('seller'), - ] - - def __str__(self): - return self.seller.encode('utf-8') - - def __unicode__(self): - return self.seller - - def __eq__(self, other): - return self.seller == other - - __hash__ = Frame.__hash__ - - -class COMR(FrameOpt): - """Commercial frame.""" - - _framespec = [ - EncodingSpec('encoding'), - Latin1TextSpec('price'), - StringSpec('valid_until', 8), - Latin1TextSpec('contact'), - ByteSpec('format'), - EncodedTextSpec('seller'), - EncodedTextSpec('desc'), - ] - - _optionalspec = [ - Latin1TextSpec('mime'), - BinaryDataSpec('logo'), - ] - - @property - def HashKey(self): - return '%s:%s' % (self.FrameID, self._writeData()) - - def __eq__(self, other): - return self._writeData() == other._writeData() - - __hash__ = FrameOpt.__hash__ - - -class ENCR(Frame): - """Encryption method registration. - - The standard does not allow multiple ENCR frames with the same owner - or the same method. Mutagen only verifies that the owner is unique. - """ - - _framespec = [ - Latin1TextSpec('owner'), - ByteSpec('method'), - BinaryDataSpec('data'), - ] - - @property - def HashKey(self): - return "%s:%s" % (self.FrameID, self.owner) - - def __str__(self): - return self.data - - def __eq__(self, other): - return self.data == other - - __hash__ = Frame.__hash__ - - -class GRID(FrameOpt): - """Group identification registration.""" - - _framespec = [ - Latin1TextSpec('owner'), - ByteSpec('group'), - ] - - _optionalspec = [BinaryDataSpec('data')] - - @property - def HashKey(self): - return '%s:%s' % (self.FrameID, self.group) - - def __pos__(self): - return self.group - - def __str__(self): - return self.owner.encode('utf-8') - - def __unicode__(self): - return self.owner - - def __eq__(self, other): - return self.owner == other or self.group == other - - __hash__ = FrameOpt.__hash__ - - -class PRIV(Frame): - """Private frame.""" - - _framespec = [ - Latin1TextSpec('owner'), - BinaryDataSpec('data'), - ] - - @property - def HashKey(self): - return '%s:%s:%s' % ( - self.FrameID, self.owner, self.data.decode('latin1')) - - def __str__(self): - return self.data - - def __eq__(self, other): - return self.data == other - - def _pprint(self): - isascii = ord(max(self.data)) < 128 - if isascii: - return "%s=%s" % (self.owner, self.data) - else: - return "%s (%d bytes)" % (self.owner, len(self.data)) - - __hash__ = Frame.__hash__ - - -class SIGN(Frame): - """Signature frame.""" - - _framespec = [ - ByteSpec('group'), - BinaryDataSpec('sig'), - ] - - @property - def HashKey(self): - return '%s:%c:%s' % (self.FrameID, self.group, self.sig) - - def __str__(self): - return self.sig - - def __eq__(self, other): - return self.sig == other - - __hash__ = Frame.__hash__ - - -class SEEK(Frame): - """Seek frame. - - Mutagen does not find tags at seek offsets. - """ - - _framespec = [IntegerSpec('offset')] - - def __pos__(self): - return self.offset - - def __eq__(self, other): - return self.offset == other - - __hash__ = Frame.__hash__ - - -class ASPI(Frame): - """Audio seek point index. - - Attributes: S, L, N, b, and Fi. For the meaning of these, see - the ID3v2.4 specification. Fi is a list of integers. - """ - _framespec = [ - SizedIntegerSpec("S", 4), - SizedIntegerSpec("L", 4), - SizedIntegerSpec("N", 2), - ByteSpec("b"), - ASPIIndexSpec("Fi"), - ] - - def __eq__(self, other): - return self.Fi == other - - __hash__ = Frame.__hash__ - - -Frames = dict([(k, v) for (k, v) in globals().items() - if len(k) == 4 and isinstance(v, type) and - issubclass(v, Frame)]) -"""All supported ID3v2 frames, keyed by frame name.""" - -try: - del(k) - del(v) -except NameError: - pass - - -# ID3v2.2 frames -class UFI(UFID): - "Unique File Identifier" - - -class TT1(TIT1): - "Content group description" - - -class TT2(TIT2): - "Title" - - -class TT3(TIT3): - "Subtitle/Description refinement" - - -class TP1(TPE1): - "Lead Artist/Performer/Soloist/Group" - - -class TP2(TPE2): - "Band/Orchestra/Accompaniment" - - -class TP3(TPE3): - "Conductor" - - -class TP4(TPE4): - "Interpreter/Remixer/Modifier" - - -class TCM(TCOM): - "Composer" - - -class TXT(TEXT): - "Lyricist" - - -class TLA(TLAN): - "Audio Language(s)" - - -class TCO(TCON): - "Content Type (Genre)" - - -class TAL(TALB): - "Album" - - -class TPA(TPOS): - "Part of set" - - -class TRK(TRCK): - "Track Number" - - -class TRC(TSRC): - "International Standard Recording Code (ISRC)" - - -class TYE(TYER): - "Year of recording" - - -class TDA(TDAT): - "Date of recording (DDMM)" - - -class TIM(TIME): - "Time of recording (HHMM)" - - -class TRD(TRDA): - "Recording Dates" - - -class TMT(TMED): - "Source Media Type" - - -class TFT(TFLT): - "File Type" - - -class TBP(TBPM): - "Beats per minute" - - -class TCP(TCMP): - "iTunes Compilation Flag" - - -class TCR(TCOP): - "Copyright (C)" - - -class TPB(TPUB): - "Publisher" - - -class TEN(TENC): - "Encoder" - - -class TSS(TSSE): - "Encoder settings" - - -class TOF(TOFN): - "Original Filename" - - -class TLE(TLEN): - "Audio Length (ms)" - - -class TSI(TSIZ): - "Audio Data size (bytes)" - - -class TDY(TDLY): - "Audio Delay (ms)" - - -class TKE(TKEY): - "Starting Key" - - -class TOT(TOAL): - "Original Album" - - -class TOA(TOPE): - "Original Artist/Perfomer" - - -class TOL(TOLY): - "Original Lyricist" - - -class TOR(TORY): - "Original Release Year" - - -class TXX(TXXX): - "User-defined Text" - - -class WAF(WOAF): - "Official File Information" - - -class WAR(WOAR): - "Official Artist/Performer Information" - - -class WAS(WOAS): - "Official Source Information" - - -class WCM(WCOM): - "Commercial Information" - - -class WCP(WCOP): - "Copyright Information" - - -class WPB(WPUB): - "Official Publisher Information" - - -class WXX(WXXX): - "User-defined URL" - - -class IPL(IPLS): - "Involved people list" - - -class MCI(MCDI): - "Binary dump of CD's TOC" - - -class ETC(ETCO): - "Event timing codes" - - -class MLL(MLLT): - "MPEG location lookup table" - - -class STC(SYTC): - "Synced tempo codes" - - -class ULT(USLT): - "Unsychronised lyrics/text transcription" - - -class SLT(SYLT): - "Synchronised lyrics/text" - - -class COM(COMM): - "Comment" - - -#class RVA(RVAD) -#class EQU(EQUA) - - -class REV(RVRB): - "Reverb" - - -class PIC(APIC): - """Attached Picture. - - The 'mime' attribute of an ID3v2.2 attached picture must be either - 'PNG' or 'JPG'. - """ - _framespec = [EncodingSpec('encoding'), StringSpec('mime', 3), - ByteSpec('type'), EncodedTextSpec('desc'), - BinaryDataSpec('data')] - - -class GEO(GEOB): - "General Encapsulated Object" - - -class CNT(PCNT): - "Play counter" - - -class POP(POPM): - "Popularimeter" - - -class BUF(RBUF): - "Recommended buffer size" - - -class CRM(Frame): - """Encrypted meta frame""" - _framespec = [Latin1TextSpec('owner'), Latin1TextSpec('desc'), - BinaryDataSpec('data')] - - def __eq__(self, other): - return self.data == other - __hash__ = Frame.__hash__ - - -class CRA(AENC): - "Audio encryption" - - -class LNK(LINK): - """Linked information""" - _framespec = [StringSpec('frameid', 3), Latin1TextSpec('url')] - _optionalspec = [BinaryDataSpec('data')] - - -Frames_2_2 = dict([(k, v) for (k, v) in globals().items() - if len(k) == 3 and isinstance(v, type) and - issubclass(v, Frame)]) - -try: - del k - del v -except NameError: - pass diff -Nru mutagen-1.23/mutagen/id3.py mutagen-1.30/mutagen/id3.py --- mutagen-1.23/mutagen/id3.py 2014-05-02 17:16:01.000000000 +0000 +++ mutagen-1.30/mutagen/id3.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,937 +0,0 @@ -# id3 support for mutagen -# Copyright (C) 2005 Michael Urman -# 2006 Lukas Lalinsky -# 2013 Christoph Reiter -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of version 2 of the GNU General Public License as -# published by the Free Software Foundation. - -"""ID3v2 reading and writing. - -This is based off of the following references: - -* http://id3.org/id3v2.4.0-structure -* http://id3.org/id3v2.4.0-frames -* http://id3.org/id3v2.3.0 -* http://id3.org/id3v2-00 -* http://id3.org/ID3v1 - -Its largest deviation from the above (versions 2.3 and 2.2) is that it -will not interpret the / characters as a separator, and will almost -always accept null separators to generate multi-valued text frames. - -Because ID3 frame structure differs between frame types, each frame is -implemented as a different class (e.g. TIT2 as mutagen.id3.TIT2). Each -frame's documentation contains a list of its attributes. - -Since this file's documentation is a little unwieldy, you are probably -interested in the :class:`ID3` class to start with. -""" - -__all__ = ['ID3', 'ID3FileType', 'Frames', 'Open', 'delete'] - -import struct - -from struct import unpack, pack, error as StructError - -import mutagen -from mutagen._util import insert_bytes, delete_bytes, DictProxy -from ._compat import reraise, chr_ - -from mutagen._id3util import * -from mutagen._id3frames import * -from mutagen._id3specs import * - - -class ID3(DictProxy, mutagen.Metadata): - """A file with an ID3v2 tag. - - Attributes: - - * version -- ID3 tag version as a tuple - * unknown_frames -- raw frame data of any unknown frames found - * size -- the total size of the ID3 tag, including the header - """ - - PEDANTIC = True - version = (2, 4, 0) - - filename = None - size = 0 - __flags = 0 - __readbytes = 0 - __crc = None - __unknown_version = None - - _V24 = (2, 4, 0) - _V23 = (2, 3, 0) - _V22 = (2, 2, 0) - _V11 = (1, 1) - - def __init__(self, *args, **kwargs): - self.unknown_frames = [] - super(ID3, self).__init__(*args, **kwargs) - - def __fullread(self, size): - try: - if size < 0: - raise ValueError('Requested bytes (%s) less than zero' % size) - if size > self.__filesize: - raise EOFError('Requested %#x of %#x (%s)' % ( - int(size), int(self.__filesize), self.filename)) - except AttributeError: - pass - data = self._fileobj.read(size) - if len(data) != size: - raise EOFError - self.__readbytes += size - return data - - def load(self, filename, known_frames=None, translate=True, v2_version=4): - """Load tags from a filename. - - Keyword arguments: - - * filename -- filename to load tag data from - * known_frames -- dict mapping frame IDs to Frame objects - * translate -- Update all tags to ID3v2.3/4 internally. If you - intend to save, this must be true or you have to - call update_to_v23() / update_to_v24() manually. - * v2_version -- if update_to_v23 or update_to_v24 get called (3 or 4) - - Example of loading a custom frame:: - - my_frames = dict(mutagen.id3.Frames) - class XMYF(Frame): ... - my_frames["XMYF"] = XMYF - mutagen.id3.ID3(filename, known_frames=my_frames) - """ - - if not v2_version in (3, 4): - raise ValueError("Only 3 and 4 possible for v2_version") - - from os.path import getsize - - self.filename = filename - self.__known_frames = known_frames - self._fileobj = open(filename, 'rb') - self.__filesize = getsize(filename) - try: - try: - self._load_header() - except EOFError: - self.size = 0 - raise ID3NoHeaderError("%s: too small (%d bytes)" % ( - filename, self.__filesize)) - except (ID3NoHeaderError, ID3UnsupportedVersionError) as err: - self.size = 0 - import sys - stack = sys.exc_info()[2] - try: - self._fileobj.seek(-128, 2) - except EnvironmentError: - reraise(err, None, stack) - else: - frames = ParseID3v1(self._fileobj.read(128)) - if frames is not None: - self.version = self._V11 - for v in frames.values(): - self.add(v) - else: - reraise(type(err), None, stack) - else: - frames = self.__known_frames - if frames is None: - if self._V23 <= self.version: - frames = Frames - elif self._V22 <= self.version: - frames = Frames_2_2 - data = self.__fullread(self.size - 10) - for frame in self.__read_frames(data, frames=frames): - if isinstance(frame, Frame): - self.add(frame) - else: - self.unknown_frames.append(frame) - self.__unknown_version = self.version - finally: - self._fileobj.close() - del self._fileobj - del self.__filesize - if translate: - if v2_version == 3: - self.update_to_v23() - else: - self.update_to_v24() - - def getall(self, key): - """Return all frames with a given name (the list may be empty). - - This is best explained by examples:: - - id3.getall('TIT2') == [id3['TIT2']] - id3.getall('TTTT') == [] - id3.getall('TXXX') == [TXXX(desc='woo', text='bar'), - TXXX(desc='baz', text='quuuux'), ...] - - Since this is based on the frame's HashKey, which is - colon-separated, you can use it to do things like - ``getall('COMM:MusicMatch')`` or ``getall('TXXX:QuodLibet:')``. - """ - if key in self: - return [self[key]] - else: - key = key + ":" - return [v for s, v in self.items() if s.startswith(key)] - - def delall(self, key): - """Delete all tags of a given kind; see getall.""" - if key in self: - del(self[key]) - else: - key = key + ":" - for k in filter(lambda s: s.startswith(key), self.keys()): - del(self[k]) - - def setall(self, key, values): - """Delete frames of the given type and add frames in 'values'.""" - self.delall(key) - for tag in values: - self[tag.HashKey] = tag - - def pprint(self): - """Return tags in a human-readable format. - - "Human-readable" is used loosely here. The format is intended - to mirror that used for Vorbis or APEv2 output, e.g. - - ``TIT2=My Title`` - - However, ID3 frames can have multiple keys: - - ``POPM=user@example.org=3 128/255`` - """ - frames = list(map(Frame.pprint, self.values())) - frames.sort() - return "\n".join(frames) - - def loaded_frame(self, tag): - """Deprecated; use the add method.""" - # turn 2.2 into 2.3/2.4 tags - if len(type(tag).__name__) == 3: - tag = type(tag).__base__(tag) - self[tag.HashKey] = tag - - # add = loaded_frame (and vice versa) break applications that - # expect to be able to override loaded_frame (e.g. Quod Libet), - # as does making loaded_frame call add. - def add(self, frame): - """Add a frame to the tag.""" - return self.loaded_frame(frame) - - def _load_header(self): - fn = self.filename - data = self.__fullread(10) - id3, vmaj, vrev, flags, size = unpack('>3sBBB4s', data) - self.__flags = flags - self.size = BitPaddedInt(size) + 10 - self.version = (2, vmaj, vrev) - - if id3 != b'ID3': - raise ID3NoHeaderError("%r doesn't start with an ID3 tag" % fn) - if vmaj not in [2, 3, 4]: - raise ID3UnsupportedVersionError("%r ID3v2.%d not supported" - % (fn, vmaj)) - - if self.PEDANTIC: - if not BitPaddedInt.has_valid_padding(size): - raise ValueError("Header size not synchsafe") - - if self._V24 <= self.version and (flags & 0x0f): - raise ValueError("%r has invalid flags %#02x" % (fn, flags)) - elif self._V23 <= self.version < self._V24 and (flags & 0x1f): - raise ValueError("%r has invalid flags %#02x" % (fn, flags)) - - if self.f_extended: - extsize = self.__fullread(4) - if extsize in Frames: - # Some tagger sets the extended header flag but - # doesn't write an extended header; in this case, the - # ID3 data follows immediately. Since no extended - # header is going to be long enough to actually match - # a frame, and if it's *not* a frame we're going to be - # completely lost anyway, this seems to be the most - # correct check. - # http://code.google.com/p/quodlibet/issues/detail?id=126 - self.__flags ^= 0x40 - self.__extsize = 0 - self._fileobj.seek(-4, 1) - self.__readbytes -= 4 - elif self.version >= self._V24: - # "Where the 'Extended header size' is the size of the whole - # extended header, stored as a 32 bit synchsafe integer." - self.__extsize = BitPaddedInt(extsize) - 4 - if self.PEDANTIC: - if not BitPaddedInt.has_valid_padding(extsize): - raise ValueError("Extended header size not synchsafe") - else: - # "Where the 'Extended header size', currently 6 or 10 bytes, - # excludes itself." - self.__extsize = unpack('>L', extsize)[0] - if self.__extsize: - self.__extdata = self.__fullread(self.__extsize) - else: - self.__extdata = b"" - - def __determine_bpi(self, data, frames, EMPTY=b"\x00" * 10): - if self.version < self._V24: - return int - # have to special case whether to use bitpaddedints here - # spec says to use them, but iTunes has it wrong - - # count number of tags found as BitPaddedInt and how far past - o = 0 - asbpi = 0 - while o < len(data) - 10: - part = data[o:o + 10] - if part == EMPTY: - bpioff = -((len(data) - o) % 10) - break - name, size, flags = unpack('>4sLH', part) - size = BitPaddedInt(size) - o += 10 + size - if name in frames: - asbpi += 1 - else: - bpioff = o - len(data) - - # count number of tags found as int and how far past - o = 0 - asint = 0 - while o < len(data) - 10: - part = data[o:o + 10] - if part == EMPTY: - intoff = -((len(data) - o) % 10) - break - name, size, flags = unpack('>4sLH', part) - o += 10 + size - if name in frames: - asint += 1 - else: - intoff = o - len(data) - - # if more tags as int, or equal and bpi is past and int is not - if asint > asbpi or (asint == asbpi and (bpioff >= 1 and intoff <= 1)): - return int - return BitPaddedInt - - def __read_frames(self, data, frames): - if self.version < self._V24 and self.f_unsynch: - try: - data = unsynch.decode(data) - except ValueError: - pass - - if self._V23 <= self.version: - bpi = self.__determine_bpi(data, frames) - while data: - header = data[:10] - try: - name, size, flags = unpack('>4sLH', header) - except struct.error: - return # not enough header - if name.strip(b'\x00') == b'': - return - size = bpi(size) - framedata = data[10:10+size] - data = data[10+size:] - if size == 0: - continue # drop empty frames - try: - tag = frames[name] - except KeyError: - if is_valid_frame_id(name): - yield header + framedata - else: - try: - yield self.__load_framedata(tag, flags, framedata) - except NotImplementedError: - yield header + framedata - except ID3JunkFrameError: - pass - - elif self._V22 <= self.version: - while data: - header = data[0:6] - try: - name, size = unpack('>3s3s', header) - except struct.error: - return # not enough header - size, = struct.unpack('>L', b'\x00'+size) - if name.strip(b'\x00') == b'': - return - framedata = data[6:6+size] - data = data[6+size:] - if size == 0: - continue # drop empty frames - try: - tag = frames[name] - except KeyError: - if is_valid_frame_id(name): - yield header + framedata - else: - try: - yield self.__load_framedata(tag, 0, framedata) - except NotImplementedError: - yield header + framedata - except ID3JunkFrameError: - pass - - def __load_framedata(self, tag, flags, framedata): - return tag.fromData(self, flags, framedata) - - f_unsynch = property(lambda s: bool(s.__flags & 0x80)) - f_extended = property(lambda s: bool(s.__flags & 0x40)) - f_experimental = property(lambda s: bool(s.__flags & 0x20)) - f_footer = property(lambda s: bool(s.__flags & 0x10)) - - #f_crc = property(lambda s: bool(s.__extflags & 0x8000)) - - def _prepare_framedata(self, v2_version, v23_sep): - if v2_version == 3: - version = self._V23 - elif v2_version == 4: - version = self._V24 - else: - raise ValueError("Only 3 or 4 allowed for v2_version") - - # Sort frames by 'importance' - order = ["TIT2", "TPE1", "TRCK", "TALB", "TPOS", "TDRC", "TCON"] - order = dict(zip(order, range(len(order)))) - last = len(order) - frames = self.items() - frames.sort(key=lambda a: (order.get(a[0][:4], last), a[0])) - - framedata = [self.__save_frame(frame, version=version, v23_sep=v23_sep) - for (key, frame) in frames] - - # only write unknown frames if they were loaded from the version - # we are saving with or upgraded to it - if self.__unknown_version == version: - framedata.extend([data for data in self.unknown_frames - if len(data) > 10]) - - return b''.join(framedata) - - def _prepare_id3_header(self, original_header, framesize, v2_version): - try: - id3, vmaj, vrev, flags, insize = unpack('>3sBBB4s', original_header) - except struct.error: - id3, insize = b'', 0 - insize = BitPaddedInt(insize) - if id3 != b'ID3': - insize = -10 - - if insize >= framesize: - outsize = insize - else: - outsize = (framesize + 1023) & ~0x3FF - - framesize = BitPaddedInt.to_str(outsize, width=4) - header = pack('>3sBBB4s', b'ID3', v2_version, 0, 0, framesize) - - return (header, outsize, insize) - - def save(self, filename=None, v1=1, v2_version=4, v23_sep='/'): - """Save changes to a file. - - If no filename is given, the one most recently loaded is used. - - Keyword arguments: - v1 -- if 0, ID3v1 tags will be removed - if 1, ID3v1 tags will be updated but not added - if 2, ID3v1 tags will be created and/or updated - v2 -- version of ID3v2 tags (3 or 4). - - By default Mutagen saves ID3v2.4 tags. If you want to save ID3v2.3 - tags, you must call method update_to_v23 before saving the file. - - v23_sep -- the separator used to join multiple text values - if v2_version == 3. Defaults to '/' but if it's None - will be the ID3v2v2.4 null separator. - - The lack of a way to update only an ID3v1 tag is intentional. - """ - - framedata = self._prepare_framedata(v2_version, v23_sep) - framesize = len(framedata) - - if not framedata: - try: - self.delete(filename) - except EnvironmentError as err: - from errno import ENOENT - if err.errno != ENOENT: - raise - return - - if filename is None: - filename = self.filename - try: - f = open(filename, 'rb+') - except IOError as err: - from errno import ENOENT - if err.errno != ENOENT: - raise - f = open(filename, 'ab') # create, then reopen - f = open(filename, 'rb+') - try: - idata = f.read(10) - - header = self._prepare_id3_header(idata, framesize, v2_version) - header, outsize, insize = header - - data = header + framedata + (b'\x00' * (outsize - framesize)) - - if (insize < outsize): - insert_bytes(f, outsize-insize, insize+10) - f.seek(0) - f.write(data) - - try: - f.seek(-128, 2) - except IOError as err: - # If the file is too small, that's OK - it just means - # we're certain it doesn't have a v1 tag. - from errno import EINVAL - if err.errno != EINVAL: - # If we failed to see for some other reason, bail out. - raise - # Since we're sure this isn't a v1 tag, don't read it. - f.seek(0, 2) - - data = f.read(128) - try: - idx = data.index(b"TAG") - except ValueError: - offset = 0 - has_v1 = False - else: - offset = idx - len(data) - has_v1 = True - - f.seek(offset, 2) - if v1 == 1 and has_v1 or v1 == 2: - f.write(MakeID3v1(self)) - else: - f.truncate() - - finally: - f.close() - - def delete(self, filename=None, delete_v1=True, delete_v2=True): - """Remove tags from a file. - - If no filename is given, the one most recently loaded is used. - - Keyword arguments: - - * delete_v1 -- delete any ID3v1 tag - * delete_v2 -- delete any ID3v2 tag - """ - if filename is None: - filename = self.filename - delete(filename, delete_v1, delete_v2) - self.clear() - - def __save_frame(self, frame, name=None, version=_V24, v23_sep=None): - flags = 0 - if self.PEDANTIC and isinstance(frame, TextFrame): - if len(str(frame)) == 0: - return b'' - - if version == self._V23: - framev23 = frame._get_v23_frame(sep=v23_sep) - framedata = framev23._writeData() - else: - framedata = frame._writeData() - - usize = len(framedata) - if usize > 2048: - # Disabled as this causes iTunes and other programs - # to fail to find these frames, which usually includes - # e.g. APIC. - #framedata = BitPaddedInt.to_str(usize) + framedata.encode('zlib') - #flags |= Frame.FLAG24_COMPRESS | Frame.FLAG24_DATALEN - pass - - if version == self._V24: - bits = 7 - elif version == self._V23: - bits = 8 - else: - raise ValueError - - datasize = BitPaddedInt.to_str(len(framedata), width=4, bits=bits) - frame_name = type(frame).__name__.encode("ascii") - header = pack('>4s4sH', name or frame_name, datasize, flags) - return header + framedata - - def __update_common(self): - """Updates done by both v23 and v24 update""" - - if "TCON" in self: - # Get rid of "(xx)Foobr" format. - self["TCON"].genres = self["TCON"].genres - - if self.version < self._V23: - # ID3v2.2 PIC frames are slightly different. - pics = self.getall("APIC") - mimes = {"PNG": "image/png", "JPG": "image/jpeg"} - self.delall("APIC") - for pic in pics: - newpic = APIC( - encoding=pic.encoding, mime=mimes.get(pic.mime, pic.mime), - type=pic.type, desc=pic.desc, data=pic.data) - self.add(newpic) - - # ID3v2.2 LNK frames are just way too different to upgrade. - self.delall("LINK") - - def update_to_v24(self): - """Convert older tags into an ID3v2.4 tag. - - This updates old ID3v2 frames to ID3v2.4 ones (e.g. TYER to - TDRC). If you intend to save tags, you must call this function - at some point; it is called by default when loading the tag. - """ - - self.__update_common() - - if self.__unknown_version == (2, 3, 0): - # convert unknown 2.3 frames (flags/size) to 2.4 - converted = [] - for frame in self.unknown_frames: - try: - name, size, flags = unpack('>4sLH', frame[:10]) - frame = BinaryFrame.fromData(self, flags, frame[10:]) - except (struct.error, error): - continue - converted.append(self.__save_frame(frame, name=name)) - self.unknown_frames[:] = converted - self.__unknown_version = (2, 4, 0) - - # TDAT, TYER, and TIME have been turned into TDRC. - try: - if str(self.get("TYER", "")).strip("\x00"): - date = str(self.pop("TYER")) - if str(self.get("TDAT", "")).strip("\x00"): - dat = str(self.pop("TDAT")) - date = "%s-%s-%s" % (date, dat[2:], dat[:2]) - if str(self.get("TIME", "")).strip("\x00"): - time = str(self.pop("TIME")) - date += "T%s:%s:00" % (time[:2], time[2:]) - if "TDRC" not in self: - self.add(TDRC(encoding=0, text=date)) - except UnicodeDecodeError: - # Old ID3 tags have *lots* of Unicode problems, so if TYER - # is bad, just chuck the frames. - pass - - # TORY can be the first part of a TDOR. - if "TORY" in self: - f = self.pop("TORY") - if "TDOR" not in self: - try: - self.add(TDOR(encoding=0, text=str(f))) - except UnicodeDecodeError: - pass - - # IPLS is now TIPL. - if "IPLS" in self: - f = self.pop("IPLS") - if "TIPL" not in self: - self.add(TIPL(encoding=f.encoding, people=f.people)) - - # These can't be trivially translated to any ID3v2.4 tags, or - # should have been removed already. - for key in ["RVAD", "EQUA", "TRDA", "TSIZ", "TDAT", "TIME", "CRM"]: - if key in self: - del(self[key]) - - def update_to_v23(self): - """Convert older (and newer) tags into an ID3v2.3 tag. - - This updates incompatible ID3v2 frames to ID3v2.3 ones. If you - intend to save tags as ID3v2.3, you must call this function - at some point. - - If you want to to go off spec and include some v2.4 frames - in v2.3, remove them before calling this and add them back afterwards. - """ - - self.__update_common() - - # we could downgrade unknown v2.4 frames here, but given that - # the main reason to save v2.3 is compatibility and this - # might increase the chance of some parser breaking.. better not - - # TMCL, TIPL -> TIPL - if "TIPL" in self or "TMCL" in self: - people = [] - if "TIPL" in self: - f = self.pop("TIPL") - people.extend(f.people) - if "TMCL" in self: - f = self.pop("TMCL") - people.extend(f.people) - if "IPLS" not in self: - self.add(IPLS(encoding=f.encoding, people=people)) - - # TDOR -> TORY - if "TDOR" in self: - f = self.pop("TDOR") - if f.text: - d = f.text[0] - if d.year and "TORY" not in self: - self.add(TORY(encoding=f.encoding, text="%04d" % d.year)) - - # TDRC -> TYER, TDAT, TIME - if "TDRC" in self: - f = self.pop("TDRC") - if f.text: - d = f.text[0] - if d.year and "TYER" not in self: - self.add(TYER(encoding=f.encoding, text="%04d" % d.year)) - if d.month and d.day and "TDAT" not in self: - self.add(TDAT(encoding=f.encoding, - text="%02d%02d" % (d.day, d.month))) - if d.hour and d.minute and "TIME" not in self: - self.add(TIME(encoding=f.encoding, - text="%02d%02d" % (d.hour, d.minute))) - - # New frames added in v2.4 - v24_frames = [ - 'ASPI', 'EQU2', 'RVA2', 'SEEK', 'SIGN', 'TDEN', 'TDOR', - 'TDRC', 'TDRL', 'TDTG', 'TIPL', 'TMCL', 'TMOO', 'TPRO', - 'TSOA', 'TSOP', 'TSOT', 'TSST', - ] - - for key in v24_frames: - if key in self: - del(self[key]) - - -def delete(filename, delete_v1=True, delete_v2=True): - """Remove tags from a file. - - Keyword arguments: - - * delete_v1 -- delete any ID3v1 tag - * delete_v2 -- delete any ID3v2 tag - """ - - f = open(filename, 'rb+') - - if delete_v1: - try: - f.seek(-128, 2) - except IOError: - pass - else: - if f.read(3) == b"TAG": - f.seek(-128, 2) - f.truncate() - - # technically an insize=0 tag is invalid, but we delete it anyway - # (primarily because we used to write it) - if delete_v2: - f.seek(0, 0) - idata = f.read(10) - try: - id3, vmaj, vrev, flags, insize = unpack('>3sBBB4s', idata) - except struct.error: - id3, insize = '', -1 - insize = BitPaddedInt(insize) - if id3 == b'ID3' and insize >= 0: - delete_bytes(f, insize + 10, 0) - - -# support open(filename) as interface -Open = ID3 - - -# ID3v1.1 support. -def ParseID3v1(string): - """Parse an ID3v1 tag, returning a list of ID3v2.4 frames.""" - - try: - string = string[string.index(b"TAG"):] - except ValueError: - return None - if 128 < len(string) or len(string) < 124: - return None - - # Issue #69 - Previous versions of Mutagen, when encountering - # out-of-spec TDRC and TYER frames of less than four characters, - # wrote only the characters available - e.g. "1" or "" - into the - # year field. To parse those, reduce the size of the year field. - # Amazingly, "0s" works as a struct format string. - unpack_fmt = "3s30s30s30s%ds29sBB" % (len(string) - 124) - - try: - tag, title, artist, album, year, comment, track, genre = unpack( - unpack_fmt, string) - except StructError: - return None - - if tag != b"TAG": - return None - - def fix(string): - return string.split(b"\x00")[0].strip().decode('latin1') - - title, artist, album, year, comment = map( - fix, [title, artist, album, year, comment]) - - frames = {} - if title: - frames["TIT2"] = TIT2(encoding=0, text=title) - if artist: - frames["TPE1"] = TPE1(encoding=0, text=[artist]) - if album: - frames["TALB"] = TALB(encoding=0, text=album) - if year: - frames["TDRC"] = TDRC(encoding=0, text=year) - if comment: - frames["COMM"] = COMM( - encoding=0, lang="eng", desc="ID3v1 Comment", text=comment) - # Don't read a track number if it looks like the comment was - # padded with spaces instead of nulls (thanks, WinAmp). - if track and (track != 32 or string[-3] == b'\x00'): - frames["TRCK"] = TRCK(encoding=0, text=str(track)) - if genre != 255: - frames["TCON"] = TCON(encoding=0, text=str(genre)) - return frames - - -def MakeID3v1(id3): - """Return an ID3v1.1 tag string from a dict of ID3v2.4 frames.""" - - v1 = {} - - for v2id, name in {"TIT2": "title", "TPE1": "artist", - "TALB": "album"}.items(): - if v2id in id3: - text = id3[v2id].text[0].encode('latin1', 'replace')[:30] - else: - text = b"" - v1[name] = text + (b"\x00" * (30 - len(text))) - - if "COMM" in id3: - cmnt = id3["COMM"].text[0].encode('latin1', 'replace')[:28] - else: - cmnt = b"" - v1["comment"] = cmnt + (b"\x00" * (29 - len(cmnt))) - - if "TRCK" in id3: - try: - v1["track"] = chr_(+id3["TRCK"]) - except ValueError: - v1["track"] = b"\x00" - else: - v1["track"] = b"\x00" - - if "TCON" in id3: - try: - genre = id3["TCON"].genres[0] - except IndexError: - pass - else: - if genre in TCON.GENRES: - v1["genre"] = chr_(TCON.GENRES.index(genre)) - if "genre" not in v1: - v1["genre"] = b"\xff" - - if "TDRC" in id3: - year = bytes(id3["TDRC"]) - elif "TYER" in id3: - year = bytes(id3["TYER"]) - else: - year = b"" - v1["year"] = (year + b"\x00\x00\x00\x00")[:4] - - data = b"TAG" - data += v1["title"] - data += v1["artist"] - data += v1["album"] - data += v1["year"] - data += v1["comment"] - data += v1["track"] - data += v1["genre"] - return data - - -class ID3FileType(mutagen.FileType): - """An unknown type of file with ID3 tags.""" - - ID3 = ID3 - - class _Info(mutagen.StreamInfo): - length = 0 - - def __init__(self, fileobj, offset): - pass - - @staticmethod - def pprint(): - return "Unknown format with ID3 tag" - - @staticmethod - def score(filename, fileobj, header): - return header.startswith(b"ID3") - - def add_tags(self, ID3=None): - """Add an empty ID3 tag to the file. - - A custom tag reader may be used in instead of the default - mutagen.id3.ID3 object, e.g. an EasyID3 reader. - """ - if ID3 is None: - ID3 = self.ID3 - if self.tags is None: - self.ID3 = ID3 - self.tags = ID3() - else: - raise error("an ID3 tag already exists") - - def load(self, filename, ID3=None, **kwargs): - """Load stream and tag information from a file. - - A custom tag reader may be used in instead of the default - mutagen.id3.ID3 object, e.g. an EasyID3 reader. - """ - - if ID3 is None: - ID3 = self.ID3 - else: - # If this was initialized with EasyID3, remember that for - # when tags are auto-instantiated in add_tags. - self.ID3 = ID3 - self.filename = filename - try: - self.tags = ID3(filename, **kwargs) - except error: - self.tags = None - if self.tags is not None: - try: - offset = self.tags.size - except AttributeError: - offset = None - else: - offset = None - try: - fileobj = open(filename, "rb") - self.info = self._Info(fileobj, offset) - finally: - fileobj.close() diff -Nru mutagen-1.23/mutagen/_id3specs.py mutagen-1.30/mutagen/_id3specs.py --- mutagen-1.23/mutagen/_id3specs.py 2013-09-13 10:48:47.000000000 +0000 +++ mutagen-1.30/mutagen/_id3specs.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,502 +0,0 @@ -# Copyright (C) 2005 Michael Urman -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of version 2 of the GNU General Public License as -# published by the Free Software Foundation. - -import struct -from struct import unpack, pack -from warnings import warn - -from ._compat import text_type, chr_, PY3, swap_to_string, string_types -from mutagen._id3util import ID3JunkFrameError, ID3Warning, BitPaddedInt -from mutagen._util import total_ordering - - -class Spec(object): - def __init__(self, name): - self.name = name - - def __hash__(self): - raise TypeError("Spec objects are unhashable") - - def _validate23(self, frame, value, **kwargs): - """Return a possibly modified value which, if written, - results in valid id3v2.3 data. - """ - - return value - - -class ByteSpec(Spec): - def read(self, frame, data): - return bytearray(data)[0], data[1:] - - def write(self, frame, value): - return chr_(value) - - def validate(self, frame, value): - if value is not None: - chr_(value) - return value - - -class IntegerSpec(Spec): - def read(self, frame, data): - return int(BitPaddedInt(data, bits=8)), '' - - def write(self, frame, value): - return BitPaddedInt.to_str(value, bits=8, width=-1) - - def validate(self, frame, value): - return value - - -class SizedIntegerSpec(Spec): - def __init__(self, name, size): - self.name, self.__sz = name, size - - def read(self, frame, data): - return int(BitPaddedInt(data[:self.__sz], bits=8)), data[self.__sz:] - - def write(self, frame, value): - return BitPaddedInt.to_str(value, bits=8, width=self.__sz) - - def validate(self, frame, value): - return value - - -class EncodingSpec(ByteSpec): - def read(self, frame, data): - enc, data = super(EncodingSpec, self).read(frame, data) - if enc < 16: - return enc, data - else: - return 0, chr_(enc) + data - - def validate(self, frame, value): - if value is None: - return None - if 0 <= value <= 3: - return value - raise ValueError('Invalid Encoding: %r' % value) - - def _validate23(self, frame, value, **kwargs): - # only 0, 1 are valid in v2.3, default to utf-16 - return min(1, value) - - -class StringSpec(Spec): - def __init__(self, name, length): - super(StringSpec, self).__init__(name) - self.len = length - - def read(s, frame, data): - return data[:s.len], data[s.len:] - - def write(s, frame, value): - if value is None: - return b'\x00' * s.len - else: - return (bytes(value) + b'\x00' * s.len)[:s.len] - - def validate(s, frame, value): - if value is None: - return None - - if not isinstance(value, bytes): - value = value.encode("ascii") - - if len(value) == s.len: - return value - raise ValueError('Invalid StringSpec[%d] data: %r' % (s.len, value)) - - -class BinaryDataSpec(Spec): - def read(self, frame, data): - return data, b'' - - def write(self, frame, value): - if value is None: - return b"" - if isinstance(value, bytes): - return value - value = text_type(value).encode("ascii") - return value - - def validate(self, frame, value): - if isinstance(value, bytes): - return value - value = text_type(value).encode("ascii") - return value - - -class EncodedTextSpec(Spec): - # Okay, seriously. This is private and defined explicitly and - # completely by the ID3 specification. You can't just add - # encodings here however you want. - _encodings = ( - ('latin1', b'\x00'), - ('utf16', b'\x00\x00'), - ('utf_16_be', b'\x00\x00'), - ('utf8', b'\x00') - ) - - def read(self, frame, data): - enc, term = self._encodings[frame.encoding] - ret = b'' - if len(term) == 1: - if term in data: - data, ret = data.split(term, 1) - else: - offset = -1 - try: - while True: - offset = data.index(term, offset+1) - if offset & 1: - continue - data, ret = data[0:offset], data[offset+2:] - break - except ValueError: - pass - - if len(data) < len(term): - return u'', ret - return data.decode(enc), ret - - def write(self, frame, value): - enc, term = self._encodings[frame.encoding] - return value.encode(enc) + term - - def validate(self, frame, value): - return text_type(value) - - -class MultiSpec(Spec): - def __init__(self, name, *specs, **kw): - super(MultiSpec, self).__init__(name) - self.specs = specs - self.sep = kw.get('sep') - - def read(self, frame, data): - values = [] - while data: - record = [] - for spec in self.specs: - value, data = spec.read(frame, data) - record.append(value) - if len(self.specs) != 1: - values.append(record) - else: - values.append(record[0]) - return values, data - - def write(self, frame, value): - data = [] - if len(self.specs) == 1: - for v in value: - data.append(self.specs[0].write(frame, v)) - else: - for record in value: - for v, s in zip(record, self.specs): - data.append(s.write(frame, v)) - return b''.join(data) - - def validate(self, frame, value): - if value is None: - return [] - if self.sep and isinstance(value, string_types): - value = value.split(self.sep) - if isinstance(value, list): - if len(self.specs) == 1: - return [self.specs[0].validate(frame, v) for v in value] - else: - return [ - [s.validate(frame, v) for (v, s) in zip(val, self.specs)] - for val in value] - raise ValueError('Invalid MultiSpec data: %r' % value) - - def _validate23(self, frame, value, **kwargs): - if len(self.specs) != 1: - return [[s._validate23(frame, v, **kwargs) - for (v, s) in zip(val, self.specs)] - for val in value] - - spec = self.specs[0] - - # Merge single text spec multispecs only. - # (TimeStampSpec beeing the exception, but it's not a valid v2.3 frame) - if not isinstance(spec, EncodedTextSpec) or \ - isinstance(spec, TimeStampSpec): - return value - - value = [spec._validate23(frame, v, **kwargs) for v in value] - if kwargs.get("sep") is not None: - return [spec.validate(frame, kwargs["sep"].join(value))] - return value - - -class EncodedNumericTextSpec(EncodedTextSpec): - pass - - -class EncodedNumericPartTextSpec(EncodedTextSpec): - pass - - -class Latin1TextSpec(EncodedTextSpec): - def read(self, frame, data): - if b'\x00' in data: - data, ret = data.split(b'\x00', 1) - else: - ret = b'' - return data.decode('latin1'), ret - - def write(self, data, value): - return value.encode('latin1') + b'\x00' - - def validate(self, frame, value): - return text_type(value) - - -@swap_to_string -@total_ordering -class ID3TimeStamp(object): - """A time stamp in ID3v2 format. - - This is a restricted form of the ISO 8601 standard; time stamps - take the form of: - YYYY-MM-DD HH:MM:SS - Or some partial form (YYYY-MM-DD HH, YYYY, etc.). - - The 'text' attribute contains the raw text data of the time stamp. - """ - - import re - - def __init__(self, text): - if isinstance(text, ID3TimeStamp): - text = text.text - elif not isinstance(text, text_type): - if PY3: - raise TypeError("not a str") - text = text.decode("utf-8") - - self.text = text - - __formats = ['%04d'] + ['%02d'] * 5 - __seps = ['-', '-', ' ', ':', ':', 'x'] - - def get_text(self): - parts = [self.year, self.month, self.day, - self.hour, self.minute, self.second] - pieces = [] - for i, part in enumerate(parts): - if part is None: - break - pieces.append(self.__formats[i] % part + self.__seps[i]) - return u''.join(pieces)[:-1] - - def set_text(self, text, splitre=re.compile('[-T:/.]|\s+')): - year, month, day, hour, minute, second = \ - splitre.split(text + ':::::')[:6] - for a in 'year month day hour minute second'.split(): - try: - v = int(locals()[a]) - except ValueError: - v = None - setattr(self, a, v) - - text = property(get_text, set_text, doc="ID3v2.4 date and time.") - - def __str__(self): - return self.text - - def __bytes__(self): - return self.text.encode("utf-8") - - def __repr__(self): - return repr(self.text) - - def __eq__(self, other): - return self.text == other.text - - def __lt__(self, other): - return self.text < other.text - - __hash__ = object.__hash__ - - def encode(self, *args): - return self.text.encode(*args) - - -class TimeStampSpec(EncodedTextSpec): - def read(self, frame, data): - value, data = super(TimeStampSpec, self).read(frame, data) - return self.validate(frame, value), data - - def write(self, frame, data): - return super(TimeStampSpec, self).write(frame, - data.text.replace(' ', 'T')) - - def validate(self, frame, value): - try: - return ID3TimeStamp(value) - except TypeError: - raise ValueError("Invalid ID3TimeStamp: %r" % value) - - -class ChannelSpec(ByteSpec): - (OTHER, MASTER, FRONTRIGHT, FRONTLEFT, BACKRIGHT, BACKLEFT, FRONTCENTRE, - BACKCENTRE, SUBWOOFER) = range(9) - - -class VolumeAdjustmentSpec(Spec): - def read(self, frame, data): - value, = unpack('>h', data[0:2]) - return value/512.0, data[2:] - - def write(self, frame, value): - number = int(round(value * 512)) - # pack only fails in 2.7, do it manually in 2.6 - if not -32768 <= number <= 32767: - raise struct.error - return pack('>h', number) - - def validate(self, frame, value): - if value is not None: - try: - self.write(frame, value) - except struct.error: - raise ValueError("out of range") - return value - - -class VolumePeakSpec(Spec): - def read(self, frame, data): - # http://bugs.xmms.org/attachment.cgi?id=113&action=view - peak = 0 - bits = ord(data[0]) - bytes = min(4, (bits + 7) >> 3) - # not enough frame data - if bytes + 1 > len(data): - raise ID3JunkFrameError - shift = ((8 - (bits & 7)) & 7) + (4 - bytes) * 8 - for i in range(1, bytes+1): - peak *= 256 - peak += ord(data[i]) - peak *= 2 ** shift - return (float(peak) / (2**31-1)), data[1+bytes:] - - def write(self, frame, value): - number = int(round(value * 32768)) - # pack only fails in 2.7, do it manually in 2.6 - if not 0 <= number <= 65535: - raise struct.error - # always write as 16 bits for sanity. - return b"\x10" + pack('>H', number) - - def validate(self, frame, value): - if value is not None: - try: - self.write(frame, value) - except struct.error: - raise ValueError("out of range") - return value - - -class SynchronizedTextSpec(EncodedTextSpec): - def read(self, frame, data): - texts = [] - encoding, term = self._encodings[frame.encoding] - while data: - l = len(term) - try: - value_idx = data.index(term) - except ValueError: - raise ID3JunkFrameError - value = data[:value_idx].decode(encoding) - if len(data) < value_idx + l + 4: - raise ID3JunkFrameError - time, = struct.unpack(">I", data[value_idx+l:value_idx+l+4]) - texts.append((value, time)) - data = data[value_idx+l+4:] - return texts, "" - - def write(self, frame, value): - data = [] - encoding, term = self._encodings[frame.encoding] - for text, time in frame.text: - text = text.encode(encoding) + term - data.append(text + struct.pack(">I", time)) - return b"".join(data) - - def validate(self, frame, value): - return value - - -class KeyEventSpec(Spec): - def read(self, frame, data): - events = [] - while len(data) >= 5: - events.append(struct.unpack(">bI", data[:5])) - data = data[5:] - return events, data - - def write(self, frame, value): - return b"".join([struct.pack(">bI", *event) for event in value]) - - def validate(self, frame, value): - return value - - -class VolumeAdjustmentsSpec(Spec): - # Not to be confused with VolumeAdjustmentSpec. - def read(self, frame, data): - adjustments = {} - while len(data) >= 4: - freq, adj = struct.unpack(">Hh", data[:4]) - data = data[4:] - freq /= 2.0 - adj /= 512.0 - adjustments[freq] = adj - adjustments = adjustments.items() - adjustments.sort() - return adjustments, data - - def write(self, frame, value): - value.sort() - return b"".join([struct.pack(">Hh", int(freq * 2), int(adj * 512)) - for (freq, adj) in value]) - - def validate(self, frame, value): - return value - - -class ASPIIndexSpec(Spec): - def read(self, frame, data): - if frame.b == 16: - format = "H" - size = 2 - elif frame.b == 8: - format = "B" - size = 1 - else: - warn("invalid bit count in ASPI (%d)" % frame.b, ID3Warning) - return [], data - - indexes = data[:frame.N * size] - data = data[frame.N * size:] - return list(struct.unpack(">" + format * frame.N, indexes)), data - - def write(self, frame, values): - if frame.b == 16: - format = "H" - elif frame.b == 8: - format = "B" - else: - raise ValueError("frame.b must be 8 or 16") - return struct.pack(">" + format * frame.N, *values) - - def validate(self, frame, values): - return values diff -Nru mutagen-1.23/mutagen/_id3util.py mutagen-1.30/mutagen/_id3util.py --- mutagen-1.23/mutagen/_id3util.py 2013-09-12 17:26:56.000000000 +0000 +++ mutagen-1.30/mutagen/_id3util.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,178 +0,0 @@ -# Copyright (C) 2005 Michael Urman -# 2013 Christoph Reiter -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of version 2 of the GNU General Public License as -# published by the Free Software Foundation. - -from ._compat import long_, integer_types - - -class error(Exception): - pass - - -class ID3NoHeaderError(error, ValueError): - pass - - -class ID3BadUnsynchData(error, ValueError): - pass - - -class ID3BadCompressedData(error, ValueError): - pass - - -class ID3TagError(error, ValueError): - pass - - -class ID3UnsupportedVersionError(error, NotImplementedError): - pass - - -class ID3EncryptionUnsupportedError(error, NotImplementedError): - pass - - -class ID3JunkFrameError(error, ValueError): - pass - - -class ID3Warning(error, UserWarning): - pass - - -class unsynch(object): - @staticmethod - def decode(value): - output = bytearray() - safe = True - append = output.append - for val in bytearray(value): - if safe: - append(val) - safe = val != 0xFF - else: - if val >= 0xE0: - raise ValueError('invalid sync-safe string') - elif val != 0x00: - append(val) - safe = True - if not safe: - raise ValueError('string ended unsafe') - return bytes(output) - - @staticmethod - def encode(value): - output = bytearray() - safe = True - append = output.append - for val in bytearray(value): - if safe: - append(val) - if val == 0xFF: - safe = False - elif val == 0x00 or val >= 0xE0: - append(0x00) - append(val) - safe = val != 0xFF - else: - append(val) - safe = True - if not safe: - append(0x00) - return bytes(output) - - -class _BitPaddedMixin(object): - - def as_str(self, width=4, minwidth=4): - return self.to_str(self, self.bits, self.bigendian, width, minwidth) - - @staticmethod - def to_str(value, bits=7, bigendian=True, width=4, minwidth=4): - mask = (1 << bits) - 1 - - if width != -1: - index = 0 - bytes_ = bytearray(width) - try: - while value: - bytes_[index] = value & mask - value >>= bits - index += 1 - except IndexError: - raise ValueError('Value too wide (>%d bytes)' % width) - else: - # PCNT and POPM use growing integers - # of at least 4 bytes (=minwidth) as counters. - bytes_ = bytearray() - append = bytes_.append - while value: - append(value & mask) - value >>= bits - bytes_ = bytes_.ljust(minwidth, b"\x00") - - if bigendian: - bytes_.reverse() - return bytes(bytes_) - - @staticmethod - def has_valid_padding(value, bits=7): - """Whether the padding bits are all zero""" - - assert bits <= 8 - - mask = (((1 << (8 - bits)) - 1) << bits) - - if isinstance(value, integer_types): - while value: - if value & mask: - return False - value >>= 8 - elif isinstance(value, bytes): - for byte in bytearray(value): - if byte & mask: - return False - else: - raise TypeError - - return True - - -class BitPaddedInt(int, _BitPaddedMixin): - - def __new__(cls, value, bits=7, bigendian=True): - - mask = (1 << (bits)) - 1 - numeric_value = 0 - shift = 0 - - if isinstance(value, integer_types): - while value: - numeric_value += (value & mask) << shift - value >>= 8 - shift += bits - elif isinstance(value, bytes): - if bigendian: - value = reversed(value) - for byte in bytearray(value): - numeric_value += (byte & mask) << shift - shift += bits - else: - raise TypeError - - if isinstance(numeric_value, int): - self = int.__new__(BitPaddedInt, numeric_value) - else: - self = long_.__new__(BitPaddedLong, numeric_value) - - self.bits = bits - self.bigendian = bigendian - return self - - -class BitPaddedLong(long_, _BitPaddedMixin): - pass diff -Nru mutagen-1.23/mutagen/__init__.py mutagen-1.30/mutagen/__init__.py --- mutagen-1.23/mutagen/__init__.py 2014-05-14 13:29:01.000000000 +0000 +++ mutagen-1.30/mutagen/__init__.py 2015-08-22 17:53:42.000000000 +0000 @@ -1,4 +1,5 @@ -# mutagen aims to be an all purpose media tagging library +# -*- coding: utf-8 -*- + # Copyright (C) 2005 Michael Urman # # This program is free software; you can redistribute it and/or modify @@ -19,260 +20,22 @@ for certain keys, again depending on format. """ -version = (1, 23) +from mutagen._util import MutagenError +from mutagen._file import FileType, StreamInfo, File +from mutagen._tags import Metadata + +version = (1, 30) """Version tuple.""" version_string = ".".join(map(str, version)) """Version string.""" +MutagenError -import warnings - -import mutagen._util +FileType +StreamInfo -class Metadata(object): - """An abstract dict-like object. +File - Metadata is the base class for many of the tag objects in Mutagen. - """ - - def __init__(self, *args, **kwargs): - if args or kwargs: - self.load(*args, **kwargs) - - def load(self, *args, **kwargs): - raise NotImplementedError - - def save(self, filename=None): - """Save changes to a file.""" - - raise NotImplementedError - - def delete(self, filename=None): - """Remove tags from a file.""" - - raise NotImplementedError - - -class FileType(mutagen._util.DictMixin): - """An abstract object wrapping tags and audio stream information. - - Attributes: - - * info -- stream information (length, bitrate, sample rate) - * tags -- metadata tags, if any - - Each file format has different potential tags and stream - information. - - FileTypes implement an interface very similar to Metadata; the - dict interface, save, load, and delete calls on a FileType call - the appropriate methods on its tag data. - """ - - info = None - tags = None - filename = None - _mimes = ["application/octet-stream"] - - def __init__(self, filename=None, *args, **kwargs): - if filename is None: - warnings.warn("FileType constructor requires a filename", - DeprecationWarning) - else: - self.load(filename, *args, **kwargs) - - def load(self, filename, *args, **kwargs): - raise NotImplementedError - - def __getitem__(self, key): - """Look up a metadata tag key. - - If the file has no tags at all, a KeyError is raised. - """ - - if self.tags is None: - raise KeyError(key) - else: - return self.tags[key] - - def __setitem__(self, key, value): - """Set a metadata tag. - - If the file has no tags, an appropriate format is added (but - not written until save is called). - """ - - if self.tags is None: - self.add_tags() - self.tags[key] = value - - def __delitem__(self, key): - """Delete a metadata tag key. - - If the file has no tags at all, a KeyError is raised. - """ - - if self.tags is None: - raise KeyError(key) - else: - del(self.tags[key]) - - def keys(self): - """Return a list of keys in the metadata tag. - - If the file has no tags at all, an empty list is returned. - """ - - if self.tags is None: - return [] - else: - return self.tags.keys() - - def delete(self, filename=None): - """Remove tags from a file.""" - - if self.tags is not None: - if filename is None: - filename = self.filename - else: - warnings.warn( - "delete(filename=...) is deprecated, reload the file", - DeprecationWarning) - return self.tags.delete(filename) - - def save(self, filename=None, **kwargs): - """Save metadata tags.""" - - if filename is None: - filename = self.filename - else: - warnings.warn( - "save(filename=...) is deprecated, reload the file", - DeprecationWarning) - if self.tags is not None: - return self.tags.save(filename, **kwargs) - else: - raise ValueError("no tags in file") - - def pprint(self): - """Print stream information and comment key=value pairs.""" - - stream = "%s (%s)" % (self.info.pprint(), self.mime[0]) - try: - tags = self.tags.pprint() - except AttributeError: - return stream - else: - return stream + ((tags and "\n" + tags) or "") - - def add_tags(self): - """Adds new tags to the file. - - Raises if tags already exist. - """ - - raise NotImplementedError - - @property - def mime(self): - """A list of mime types""" - - mimes = [] - for Kind in type(self).__mro__: - for mime in getattr(Kind, '_mimes', []): - if mime not in mimes: - mimes.append(mime) - return mimes - - @staticmethod - def score(filename, fileobj, header): - raise NotImplementedError - - -class StreamInfo(object): - """Abstract stream information object. - - Provides attributes for length, bitrate, sample rate etc. - - See the implementations for details. - """ - - def pprint(self): - """Print stream information""" - - raise NotImplementedError - - -def File(filename, options=None, easy=False): - """Guess the type of the file and try to open it. - - The file type is decided by several things, such as the first 128 - bytes (which usually contains a file type identifier), the - filename extension, and the presence of existing tags. - - If no appropriate type could be found, None is returned. - - :param options: Sequence of :class:`FileType` implementations, defaults to - all included ones. - - :param easy: If the easy wrappers should be returnd if available. - For example :class:`EasyMP3 ` instead - of :class:`MP3 `. - """ - - if options is None: - from mutagen.asf import ASF - from mutagen.apev2 import APEv2File - from mutagen.flac import FLAC - if easy: - from mutagen.easyid3 import EasyID3FileType as ID3FileType - else: - from mutagen.id3 import ID3FileType - if easy: - from mutagen.mp3 import EasyMP3 as MP3 - else: - from mutagen.mp3 import MP3 - from mutagen.oggflac import OggFLAC - from mutagen.oggspeex import OggSpeex - from mutagen.oggtheora import OggTheora - from mutagen.oggvorbis import OggVorbis - from mutagen.oggopus import OggOpus - if easy: - from mutagen.trueaudio import EasyTrueAudio as TrueAudio - else: - from mutagen.trueaudio import TrueAudio - from mutagen.wavpack import WavPack - if easy: - from mutagen.easymp4 import EasyMP4 as MP4 - else: - from mutagen.mp4 import MP4 - from mutagen.musepack import Musepack - from mutagen.monkeysaudio import MonkeysAudio - from mutagen.optimfrog import OptimFROG - from mutagen.aiff import AIFF - options = [MP3, TrueAudio, OggTheora, OggSpeex, OggVorbis, OggFLAC, - FLAC, AIFF, APEv2File, MP4, ID3FileType, WavPack, - Musepack, MonkeysAudio, OptimFROG, ASF, OggOpus] - - if not options: - return None - - fileobj = open(filename, "rb") - try: - header = fileobj.read(128) - # Sort by name after score. Otherwise import order affects - # Kind sort order, which affects treatment of things with - # equals scores. - results = [(Kind.score(filename, fileobj, header), Kind.__name__) - for Kind in options] - finally: - fileobj.close() - results = list(zip(results, options)) - results.sort() - (score, name), Kind = results[-1] - if score > 0: - return Kind(filename) - else: - return None +Metadata diff -Nru mutagen-1.23/mutagen/m4a.py mutagen-1.30/mutagen/m4a.py --- mutagen-1.23/mutagen/m4a.py 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/mutagen/m4a.py 2015-04-25 08:46:40.000000000 +0000 @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # Copyright 2006 Joe Wreschnig # # This program is free software; you can redistribute it and/or modify @@ -33,10 +34,11 @@ from ._compat import reraise from mutagen import FileType, Metadata, StreamInfo from mutagen._constants import GENRES -from mutagen._util import cdata, insert_bytes, delete_bytes, DictProxy +from mutagen._util import cdata, insert_bytes, delete_bytes, DictProxy, \ + MutagenError -class error(IOError): +class error(IOError, MutagenError): pass diff -Nru mutagen-1.23/mutagen/monkeysaudio.py mutagen-1.30/mutagen/monkeysaudio.py --- mutagen-1.23/mutagen/monkeysaudio.py 2013-09-13 10:44:40.000000000 +0000 +++ mutagen-1.30/mutagen/monkeysaudio.py 2015-02-01 19:07:57.000000000 +0000 @@ -1,6 +1,6 @@ -# A Monkey's Audio (APE) reader/tagger -# -# Copyright 2006 Lukas Lalinsky +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Lukas Lalinsky # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as @@ -64,7 +64,7 @@ blocks_per_frame = 9216 self.version /= 1000.0 self.length = 0.0 - if self.sample_rate != 0 and total_frames > 0: + if (self.sample_rate != 0) and (total_frames > 0): total_blocks = ((total_frames - 1) * blocks_per_frame + final_frame_blocks) self.length = float(total_blocks) / self.sample_rate diff -Nru mutagen-1.23/mutagen/mp3.py mutagen-1.30/mutagen/mp3.py --- mutagen-1.23/mutagen/mp3.py 2014-01-09 14:48:43.000000000 +0000 +++ mutagen-1.30/mutagen/mp3.py 2015-08-17 19:23:26.000000000 +0000 @@ -1,5 +1,6 @@ -# MP3 stream header information support for Mutagen. -# Copyright 2006 Joe Wreschnig +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Joe Wreschnig # # This program is free software; you can redistribute it and/or modify # it under the terms of version 2 of the GNU General Public License as @@ -11,13 +12,15 @@ import struct from ._compat import endswith +from ._mp3util import XingHeader, XingHeaderError, VBRIHeader, VBRIHeaderError from mutagen import StreamInfo +from mutagen._util import MutagenError, enum from mutagen.id3 import ID3FileType, BitPaddedInt, delete __all__ = ["MP3", "Open", "delete", "MP3"] -class error(RuntimeError): +class error(RuntimeError, MutagenError): pass @@ -29,6 +32,45 @@ pass +@enum +class BitrateMode(object): + + UNKNOWN = 0 + """Probably a CBR file, but not sure""" + + CBR = 1 + """Constant Bitrate""" + + VBR = 2 + """Variable Bitrate""" + + ABR = 3 + """Average Bitrate (a variant of VBR)""" + + +def _guess_xing_bitrate_mode(xing): + + if xing.lame_header: + lame = xing.lame_header + if lame.vbr_method in (1, 8): + return BitrateMode.CBR + elif lame.vbr_method in (2, 9): + return BitrateMode.ABR + elif lame.vbr_method in (3, 4, 5, 6): + return BitrateMode.VBR + # everything else undefined, continue guessing + + # info tags get only written by lame for cbr files + if xing.is_info: + return BitrateMode.CBR + + # older lame and non-lame with some variant of vbr + if xing.vbr_scale != -1 or xing.lame_version: + return BitrateMode.VBR + + return BitrateMode.UNKNOWN + + # Mode values. STEREO, JOINTSTEREO, DUALCHANNEL, MONO = range(4) @@ -45,8 +87,18 @@ Useful attributes: * length -- audio length, in seconds + * channels -- number of audio channels * bitrate -- audio bitrate, in bits per second * sketchy -- if true, the file may not be valid MPEG audio + * encoder_info -- a string containing encoder name and possibly version. + In case a lame tag is present this will start with + ``"LAME "``, if unknown it is empty, otherwise the + text format is undefined. + * bitrate_mode -- a :class:`BitrateMode` + + * track_gain -- replaygain track gain (89db) or None + * track_peak -- replaygain track peak or None + * album_gain -- replaygain album gain (89db) or None Useless attributes: @@ -60,14 +112,15 @@ # Map (version, layer) tuples to bitrates. __BITRATE = { - (1, 1): range(0, 480, 32), + (1, 1): [0, 32, 64, 96, 128, 160, 192, 224, + 256, 288, 320, 352, 384, 416, 448], (1, 2): [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384], (1, 3): [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320], (2, 1): [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256], - (2, 2): [0, 8, 16, 24, 32, 40, 48, 56, 64, + (2, 2): [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160], } @@ -83,6 +136,9 @@ } sketchy = False + encoder_info = u"" + bitrate_mode = BitrateMode.UNKNOWN + track_gain = track_peak = album_gain = album_peak = None def __init__(self, fileobj, offset=None): """Parse MPEG stream information from a file-like object. @@ -106,7 +162,7 @@ try: id3, insize = struct.unpack('>3sxxx4s', idata) except struct.error: - id3, insize = '', 0 + id3, insize = b'', 0 insize = BitPaddedInt(insize) if id3 == b'ID3' and insize > 0: offset = insize + 10 @@ -141,9 +197,9 @@ data = fileobj.read(32768) frame_1 = data.find(b"\xff") - while 0 <= frame_1 <= len(data) - 4: + while 0 <= frame_1 <= (len(data) - 4): frame_data = struct.unpack(">I", data[frame_1:frame_1 + 4])[0] - if (frame_data >> 16) & 0xE0 != 0xE0: + if ((frame_data >> 16) & 0xE0) != 0xE0: frame_1 = data.find(b"\xff", frame_1 + 2) else: version = (frame_data >> 19) & 0x3 @@ -152,12 +208,12 @@ bitrate = (frame_data >> 12) & 0xF sample_rate = (frame_data >> 10) & 0x3 padding = (frame_data >> 9) & 0x1 - #private = (frame_data >> 8) & 0x1 + # private = (frame_data >> 8) & 0x1 self.mode = (frame_data >> 6) & 0x3 - #mode_extension = (frame_data >> 4) & 0x3 - #copyright = (frame_data >> 3) & 0x1 - #original = (frame_data >> 2) & 0x1 - #emphasis = (frame_data >> 0) & 0x3 + # mode_extension = (frame_data >> 4) & 0x3 + # copyright = (frame_data >> 3) & 0x1 + # original = (frame_data >> 2) & 0x1 + # emphasis = (frame_data >> 0) & 0x3 if (version == 1 or layer == 0 or sample_rate == 0x3 or bitrate == 0 or bitrate == 0xF): frame_1 = data.find(b"\xff", frame_1 + 2) @@ -166,6 +222,8 @@ else: raise HeaderNotFoundError("can't sync to an MPEG frame") + self.channels = 1 if self.mode == MONO else 2 + # There is a serious problem here, which is that many flags # in an MPEG header are backwards. self.version = [2.5, None, 2, 1][version] @@ -178,13 +236,14 @@ self.sample_rate = self.__RATES[self.version][sample_rate] if self.layer == 1: - frame_length = (12 * self.bitrate / self.sample_rate + padding) * 4 + frame_length = ( + (12 * self.bitrate // self.sample_rate) + padding) * 4 frame_size = 384 elif self.version >= 2 and self.layer == 3: - frame_length = 72 * self.bitrate / self.sample_rate + padding + frame_length = (72 * self.bitrate // self.sample_rate) + padding frame_size = 576 else: - frame_length = 144 * self.bitrate / self.sample_rate + padding + frame_length = (144 * self.bitrate // self.sample_rate) + padding frame_size = 1152 if check_second: @@ -196,51 +255,70 @@ ">H", data[possible:possible + 2])[0] except struct.error: raise HeaderNotFoundError("can't sync to second MPEG frame") - if frame_data & 0xFFE0 != 0xFFE0: + if (frame_data & 0xFFE0) != 0xFFE0: raise HeaderNotFoundError("can't sync to second MPEG frame") self.length = 8 * real_size / float(self.bitrate) # Try to find/parse the Xing header, which trumps the above length # and bitrate calculation. - fileobj.seek(offset, 0) - data = fileobj.read(32768) + + if self.layer != 3: + return + + # Xing + xing_offset = XingHeader.get_offset(self) + fileobj.seek(offset + frame_1 + xing_offset, 0) try: - xing = data[:-4].index(b"Xing") - except ValueError: - # Try to find/parse the VBRI header, which trumps the above length - # calculation. - try: - vbri = data[:-24].index(b"VBRI") - except ValueError: - pass - else: - # If a VBRI header was found, this is definitely MPEG audio. - self.sketchy = False - vbri_version = struct.unpack('>H', data[vbri + 4:vbri + 6])[0] - if vbri_version == 1: - frame_count = struct.unpack( - '>I', data[vbri + 14:vbri + 18])[0] - samples = float(frame_size * frame_count) - self.length = (samples / self.sample_rate) or self.length + xing = XingHeader(fileobj) + except XingHeaderError: + pass + else: + lame = xing.lame_header + self.sketchy = False + self.bitrate_mode = _guess_xing_bitrate_mode(xing) + if xing.frames != -1: + samples = frame_size * xing.frames + if lame is not None: + samples -= lame.encoder_delay_start + samples -= lame.encoder_padding_end + self.length = float(samples) / self.sample_rate + if xing.bytes != -1 and self.length: + self.bitrate = int((xing.bytes * 8) / self.length) + if xing.lame_version: + self.encoder_info = u"LAME %s" % xing.lame_version + if lame is not None: + self.track_gain = lame.track_gain_adjustment + self.track_peak = lame.track_peak + self.album_gain = lame.album_gain_adjustment + return + + # VBRI + vbri_offset = VBRIHeader.get_offset(self) + fileobj.seek(offset + frame_1 + vbri_offset, 0) + try: + vbri = VBRIHeader(fileobj) + except VBRIHeaderError: + pass else: - # If a Xing header was found, this is definitely MPEG audio. + self.bitrate_mode = BitrateMode.VBR + self.encoder_info = u"FhG" self.sketchy = False - flags = struct.unpack('>I', data[xing + 4:xing + 8])[0] - if flags & 0x1: - frame_count = struct.unpack('>I', data[xing + 8:xing + 12])[0] - samples = float(frame_size * frame_count) - self.length = (samples / self.sample_rate) or self.length - if flags & 0x2: - bytes = struct.unpack('>I', data[xing + 12:xing + 16])[0] - self.bitrate = int((bytes * 8) // self.length) + self.length = float(frame_size * vbri.frames) / self.sample_rate + if self.length: + self.bitrate = int((vbri.bytes * 8) / self.length) def pprint(self): - s = "MPEG %s layer %d, %d bps, %s Hz, %.2f seconds" % ( - self.version, self.layer, self.bitrate, self.sample_rate, - self.length) + info = str(self.bitrate_mode).split(".", 1)[-1] + if self.bitrate_mode == BitrateMode.UNKNOWN: + info = u"CBR?" + if self.encoder_info: + info += ", %s" % self.encoder_info + s = u"MPEG %s layer %d, %d bps (%s), %s Hz, %d chn, %.2f seconds" % ( + self.version, self.layer, self.bitrate, info, + self.sample_rate, self.channels, self.length) if self.sketchy: - s += " (sketchy)" + s += u" (sketchy)" return s @@ -261,10 +339,11 @@ return ["audio/mp%d" % l, "audio/x-mp%d" % l] + super(MP3, self).mime @staticmethod - def score(filename, fileobj, header): + def score(filename, fileobj, header_data): filename = filename.lower() - return (header.startswith(b"ID3") * 2 + endswith(filename, b".mp3") + + return (header_data.startswith(b"ID3") * 2 + + endswith(filename, b".mp3") + endswith(filename, b".mp2") + endswith(filename, b".mpg") + endswith(filename, b".mpeg")) diff -Nru mutagen-1.23/mutagen/_mp3util.py mutagen-1.30/mutagen/_mp3util.py --- mutagen-1.23/mutagen/_mp3util.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/mutagen/_mp3util.py 2015-08-18 11:01:16.000000000 +0000 @@ -0,0 +1,420 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +""" +http://www.codeproject.com/Articles/8295/MPEG-Audio-Frame-Header +http://wiki.hydrogenaud.io/index.php?title=MP3 +""" + +from functools import partial + +from ._util import cdata, BitReader +from ._compat import xrange, iterbytes, cBytesIO + + +class LAMEError(Exception): + pass + + +class LAMEHeader(object): + """http://gabriel.mp3-tech.org/mp3infotag.html""" + + vbr_method = 0 + """0: unknown, 1: CBR, 2: ABR, 3/4/5: VBR, others: see the docs""" + + lowpass_filter = 0 + """lowpass filter value in Hz. 0 means unknown""" + + quality = -1 + """Encoding quality: 0..9""" + + vbr_quality = -1 + """VBR quality: 0..9""" + + track_peak = None + """Peak signal amplitude as float. None if unknown.""" + + track_gain_origin = 0 + """see the docs""" + + track_gain_adjustment = None + """Track gain adjustment as float (for 89db replay gain) or None""" + + album_gain_origin = 0 + """see the docs""" + + album_gain_adjustment = None + """Album gain adjustment as float (for 89db replay gain) or None""" + + encoding_flags = 0 + """see docs""" + + ath_type = -1 + """see docs""" + + bitrate = -1 + """Bitrate in kbps. For VBR the minimum bitrate, for anything else + (CBR, ABR, ..) the target bitrate. + """ + + encoder_delay_start = 0 + """Encoder delay in samples""" + + encoder_padding_end = 0 + """Padding in samples added at the end""" + + source_sample_frequency_enum = -1 + """see docs""" + + unwise_setting_used = False + """see docs""" + + stereo_mode = 0 + """see docs""" + + noise_shaping = 0 + """see docs""" + + mp3_gain = 0 + """Applied MP3 gain -127..127. Factor is 2 ** (mp3_gain / 4)""" + + surround_info = 0 + """see docs""" + + preset_used = 0 + """lame preset""" + + music_length = 0 + """Length in bytes excluding any ID3 tags""" + + music_crc = -1 + """CRC16 of the data specified by music_length""" + + header_crc = -1 + """CRC16 of this header and everything before (not checked)""" + + def __init__(self, xing, fileobj): + """Raises LAMEError if parsing fails""" + + payload = fileobj.read(27) + if len(payload) != 27: + raise LAMEError("Not enough data") + + # extended lame header + r = BitReader(cBytesIO(payload)) + revision = r.bits(4) + if revision != 0: + raise LAMEError("unsupported header revision %d" % revision) + + self.vbr_method = r.bits(4) + self.lowpass_filter = r.bits(8) * 100 + + # these have a different meaning for lame; expose them again here + self.quality = (100 - xing.vbr_scale) % 10 + self.vbr_quality = (100 - xing.vbr_scale) // 10 + + track_peak_data = r.bytes(4) + if track_peak_data == b"\x00\x00\x00\x00": + self.track_peak = None + else: + # see PutLameVBR() in LAME's VbrTag.c + self.track_peak = ( + cdata.uint32_be(track_peak_data) - 0.5) / 2 ** 23 + track_gain_type = r.bits(3) + self.track_gain_origin = r.bits(3) + sign = r.bits(1) + gain_adj = r.bits(9) / 10.0 + if sign: + gain_adj *= -1 + if track_gain_type == 1: + self.track_gain_adjustment = gain_adj + else: + self.track_gain_adjustment = None + assert r.is_aligned() + + album_gain_type = r.bits(3) + self.album_gain_origin = r.bits(3) + sign = r.bits(1) + album_gain_adj = r.bits(9) / 10.0 + if album_gain_type == 2: + self.album_gain_adjustment = album_gain_adj + else: + self.album_gain_adjustment = None + + self.encoding_flags = r.bits(4) + self.ath_type = r.bits(4) + + self.bitrate = r.bits(8) + + self.encoder_delay_start = r.bits(12) + self.encoder_padding_end = r.bits(12) + + self.source_sample_frequency_enum = r.bits(2) + self.unwise_setting_used = r.bits(1) + self.stereo_mode = r.bits(3) + self.noise_shaping = r.bits(2) + + sign = r.bits(1) + mp3_gain = r.bits(7) + if sign: + mp3_gain *= -1 + self.mp3_gain = mp3_gain + + r.skip(2) + self.surround_info = r.bits(3) + self.preset_used = r.bits(11) + self.music_length = r.bits(32) + self.music_crc = r.bits(16) + + self.header_crc = r.bits(16) + assert r.is_aligned() + + @classmethod + def parse_version(cls, fileobj): + """Returns a version string and True if a LAMEHeader follows. + The passed file object will be positioned right before the + lame header if True. + + Raises LAMEError if there is no lame version info. + """ + + # http://wiki.hydrogenaud.io/index.php?title=LAME_version_string + + data = fileobj.read(20) + if len(data) != 20: + raise LAMEError("Not a lame header") + if not data.startswith((b"LAME", b"L3.99")): + raise LAMEError("Not a lame header") + + data = data.lstrip(b"EMAL") + major, data = data[0:1], data[1:].lstrip(b".") + minor = b"" + for c in iterbytes(data): + if not c.isdigit(): + break + minor += c + data = data[len(minor):] + + try: + major = int(major.decode("ascii")) + minor = int(minor.decode("ascii")) + except ValueError: + raise LAMEError + + # the extended header was added sometimes in the 3.90 cycle + # e.g. "LAME3.90 (alpha)" should still stop here. + # (I have seen such a file) + if (major, minor) < (3, 90) or ( + (major, minor) == (3, 90) and data[-11:-10] == b"("): + flag = data.strip(b"\x00").rstrip().decode("ascii") + return u"%d.%d%s" % (major, minor, flag), False + + if len(data) <= 11: + raise LAMEError("Invalid version: too long") + + flag = data[:-11].rstrip(b"\x00") + + flag_string = u"" + patch = u"" + if flag == b"a": + flag_string = u" (alpha)" + elif flag == b"b": + flag_string = u" (beta)" + elif flag == b"r": + patch = u".1+" + elif flag == b" ": + if (major, minor) > (3, 96): + patch = u".0" + else: + patch = u".0+" + elif flag == b"" or flag == b".": + patch = u".0+" + else: + flag_string = u" (?)" + + # extended header, seek back to 9 bytes for the caller + fileobj.seek(-11, 1) + + return u"%d.%d%s%s" % (major, minor, patch, flag_string), True + + +class XingHeaderError(Exception): + pass + + +class XingHeaderFlags(object): + FRAMES = 0x1 + BYTES = 0x2 + TOC = 0x4 + VBR_SCALE = 0x8 + + +class XingHeader(object): + + frames = -1 + """Number of frames, -1 if unknown""" + + bytes = -1 + """Number of bytes, -1 if unknown""" + + toc = [] + """List of 100 file offsets in percent encoded as 0-255. E.g. entry + 50 contains the file offset in percent at 50% play time. + Empty if unknown. + """ + + vbr_scale = -1 + """VBR quality indicator 0-100. -1 if unknown""" + + lame_header = None + """A LAMEHeader instance or None""" + + lame_version = u"" + """The version of the LAME encoder e.g. '3.99.0'. Empty if unknown""" + + is_info = False + """If the header started with 'Info' and not 'Xing'""" + + def __init__(self, fileobj): + """Parses the Xing header or raises XingHeaderError. + + The file position after this returns is undefined. + """ + + data = fileobj.read(8) + if len(data) != 8 or data[:4] not in (b"Xing", b"Info"): + raise XingHeaderError("Not a Xing header") + + self.is_info = (data[:4] == b"Info") + + flags = cdata.uint32_be_from(data, 4)[0] + + if flags & XingHeaderFlags.FRAMES: + data = fileobj.read(4) + if len(data) != 4: + raise XingHeaderError("Xing header truncated") + self.frames = cdata.uint32_be(data) + + if flags & XingHeaderFlags.BYTES: + data = fileobj.read(4) + if len(data) != 4: + raise XingHeaderError("Xing header truncated") + self.bytes = cdata.uint32_be(data) + + if flags & XingHeaderFlags.TOC: + data = fileobj.read(100) + if len(data) != 100: + raise XingHeaderError("Xing header truncated") + self.toc = list(bytearray(data)) + + if flags & XingHeaderFlags.VBR_SCALE: + data = fileobj.read(4) + if len(data) != 4: + raise XingHeaderError("Xing header truncated") + self.vbr_scale = cdata.uint32_be(data) + + try: + self.lame_version, has_header = LAMEHeader.parse_version(fileobj) + if has_header: + self.lame_header = LAMEHeader(self, fileobj) + except LAMEError: + pass + + @classmethod + def get_offset(cls, info): + """Calculate the offset to the Xing header from the start of the + MPEG header including sync based on the MPEG header's content. + """ + + assert info.layer == 3 + + if info.version == 1: + if info.mode != 3: + return 36 + else: + return 21 + else: + if info.mode != 3: + return 21 + else: + return 13 + + +class VBRIHeaderError(Exception): + pass + + +class VBRIHeader(object): + + version = 0 + """VBRI header version""" + + quality = 0 + """Quality indicator""" + + bytes = 0 + """Number of bytes""" + + frames = 0 + """Number of frames""" + + toc_scale_factor = 0 + """Scale factor of TOC entries""" + + toc_frames = 0 + """Number of frames per table entry""" + + toc = [] + """TOC""" + + def __init__(self, fileobj): + """Reads the VBRI header or raises VBRIHeaderError. + + The file position is undefined after this returns + """ + + data = fileobj.read(26) + if len(data) != 26 or not data.startswith(b"VBRI"): + raise VBRIHeaderError("Not a VBRI header") + + offset = 4 + self.version, offset = cdata.uint16_be_from(data, offset) + if self.version != 1: + raise VBRIHeaderError( + "Unsupported header version: %r" % self.version) + + offset += 2 # float16.. can't do + self.quality, offset = cdata.uint16_be_from(data, offset) + self.bytes, offset = cdata.uint32_be_from(data, offset) + self.frames, offset = cdata.uint32_be_from(data, offset) + + toc_num_entries, offset = cdata.uint16_be_from(data, offset) + self.toc_scale_factor, offset = cdata.uint16_be_from(data, offset) + toc_entry_size, offset = cdata.uint16_be_from(data, offset) + self.toc_frames, offset = cdata.uint16_be_from(data, offset) + toc_size = toc_entry_size * toc_num_entries + toc_data = fileobj.read(toc_size) + if len(toc_data) != toc_size: + raise VBRIHeaderError("VBRI header truncated") + + self.toc = [] + if toc_entry_size == 2: + unpack = partial(cdata.uint16_be_from, toc_data) + elif toc_entry_size == 4: + unpack = partial(cdata.uint32_be_from, toc_data) + else: + raise VBRIHeaderError("Invalid TOC entry size") + + self.toc = [unpack(i)[0] for i in xrange(0, toc_size, toc_entry_size)] + + @classmethod + def get_offset(cls, info): + """Offset in bytes from the start of the MPEG header including sync""" + + assert info.layer == 3 + + return 36 diff -Nru mutagen-1.23/mutagen/mp4/_as_entry.py mutagen-1.30/mutagen/mp4/_as_entry.py --- mutagen-1.23/mutagen/mp4/_as_entry.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/mutagen/mp4/_as_entry.py 2015-04-29 19:25:39.000000000 +0000 @@ -0,0 +1,542 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +from mutagen._compat import cBytesIO, xrange +from mutagen.aac import ProgramConfigElement +from mutagen._util import BitReader, BitReaderError, cdata +from mutagen._compat import text_type +from ._util import parse_full_atom +from ._atom import Atom, AtomError + + +class ASEntryError(Exception): + pass + + +class AudioSampleEntry(object): + """Parses an AudioSampleEntry atom. + + Private API. + + Attrs: + channels (int): number of channels + sample_size (int): sample size in bits + sample_rate (int): sample rate in Hz + bitrate (int): bits per second (0 means unknown) + codec (string): + audio codec, either 'mp4a[.*][.*]' (rfc6381) or 'alac' + codec_description (string): descriptive codec name e.g. "AAC LC+SBR" + + Can raise ASEntryError. + """ + + channels = 0 + sample_size = 0 + sample_rate = 0 + bitrate = 0 + codec = None + codec_description = None + + def __init__(self, atom, fileobj): + ok, data = atom.read(fileobj) + if not ok: + raise ASEntryError("too short %r atom" % atom.name) + + fileobj = cBytesIO(data) + r = BitReader(fileobj) + + try: + # SampleEntry + r.skip(6 * 8) # reserved + r.skip(2 * 8) # data_ref_index + + # AudioSampleEntry + r.skip(8 * 8) # reserved + self.channels = r.bits(16) + self.sample_size = r.bits(16) + r.skip(2 * 8) # pre_defined + r.skip(2 * 8) # reserved + self.sample_rate = r.bits(32) >> 16 + except BitReaderError as e: + raise ASEntryError(e) + + assert r.is_aligned() + + try: + extra = Atom(fileobj) + except AtomError as e: + raise ASEntryError(e) + + self.codec = atom.name.decode("latin-1") + self.codec_description = None + + if atom.name == b"mp4a" and extra.name == b"esds": + self._parse_esds(extra, fileobj) + elif atom.name == b"alac" and extra.name == b"alac": + self._parse_alac(extra, fileobj) + elif atom.name == b"ac-3" and extra.name == b"dac3": + self._parse_dac3(extra, fileobj) + + if self.codec_description is None: + self.codec_description = self.codec.upper() + + def _parse_dac3(self, atom, fileobj): + # ETSI TS 102 366 + + assert atom.name == b"dac3" + + ok, data = atom.read(fileobj) + if not ok: + raise ASEntryError("truncated %s atom" % atom.name) + fileobj = cBytesIO(data) + r = BitReader(fileobj) + + # sample_rate in AudioSampleEntry covers values in + # fscod2 and not just fscod, so ignore fscod here. + try: + r.skip(2 + 5 + 3) # fscod, bsid, bsmod + acmod = r.bits(3) + lfeon = r.bits(1) + bit_rate_code = r.bits(5) + r.skip(5) # reserved + except BitReaderError as e: + raise ASEntryError(e) + + self.channels = [2, 1, 2, 3, 3, 4, 4, 5][acmod] + lfeon + + try: + self.bitrate = [ + 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, + 224, 256, 320, 384, 448, 512, 576, 640][bit_rate_code] * 1000 + except IndexError: + pass + + def _parse_alac(self, atom, fileobj): + # https://alac.macosforge.org/trac/browser/trunk/ + # ALACMagicCookieDescription.txt + + assert atom.name == b"alac" + + ok, data = atom.read(fileobj) + if not ok: + raise ASEntryError("truncated %s atom" % atom.name) + + try: + version, flags, data = parse_full_atom(data) + except ValueError as e: + raise ASEntryError(e) + + if version != 0: + raise ASEntryError("Unsupported version %d" % version) + + fileobj = cBytesIO(data) + r = BitReader(fileobj) + + try: + # for some files the AudioSampleEntry values default to 44100/2chan + # and the real info is in the alac cookie, so prefer it + r.skip(32) # frameLength + compatibleVersion = r.bits(8) + if compatibleVersion != 0: + return + self.sample_size = r.bits(8) + r.skip(8 + 8 + 8) + self.channels = r.bits(8) + r.skip(16 + 32) + self.bitrate = r.bits(32) + self.sample_rate = r.bits(32) + except BitReaderError as e: + raise ASEntryError(e) + + def _parse_esds(self, esds, fileobj): + assert esds.name == b"esds" + + ok, data = esds.read(fileobj) + if not ok: + raise ASEntryError("truncated %s atom" % esds.name) + + try: + version, flags, data = parse_full_atom(data) + except ValueError as e: + raise ASEntryError(e) + + if version != 0: + raise ASEntryError("Unsupported version %d" % version) + + fileobj = cBytesIO(data) + r = BitReader(fileobj) + + try: + tag = r.bits(8) + if tag != ES_Descriptor.TAG: + raise ASEntryError("unexpected descriptor: %d" % tag) + assert r.is_aligned() + except BitReaderError as e: + raise ASEntryError(e) + + try: + decSpecificInfo = ES_Descriptor.parse(fileobj) + except DescriptorError as e: + raise ASEntryError(e) + dec_conf_desc = decSpecificInfo.decConfigDescr + + self.bitrate = dec_conf_desc.avgBitrate + self.codec += dec_conf_desc.codec_param + self.codec_description = dec_conf_desc.codec_desc + + decSpecificInfo = dec_conf_desc.decSpecificInfo + if decSpecificInfo is not None: + if decSpecificInfo.channels != 0: + self.channels = decSpecificInfo.channels + + if decSpecificInfo.sample_rate != 0: + self.sample_rate = decSpecificInfo.sample_rate + + +class DescriptorError(Exception): + pass + + +class BaseDescriptor(object): + + TAG = None + + @classmethod + def _parse_desc_length_file(cls, fileobj): + """May raise ValueError""" + + value = 0 + for i in xrange(4): + try: + b = cdata.uint8(fileobj.read(1)) + except cdata.error as e: + raise ValueError(e) + value = (value << 7) | (b & 0x7f) + if not b >> 7: + break + else: + raise ValueError("invalid descriptor length") + + return value + + @classmethod + def parse(cls, fileobj): + """Returns a parsed instance of the called type. + The file position is right after the descriptor after this returns. + + Raises DescriptorError + """ + + try: + length = cls._parse_desc_length_file(fileobj) + except ValueError as e: + raise DescriptorError(e) + pos = fileobj.tell() + instance = cls(fileobj, length) + left = length - (fileobj.tell() - pos) + if left < 0: + raise DescriptorError("descriptor parsing read too much data") + fileobj.seek(left, 1) + return instance + + +class ES_Descriptor(BaseDescriptor): + + TAG = 0x3 + + def __init__(self, fileobj, length): + """Raises DescriptorError""" + + r = BitReader(fileobj) + try: + self.ES_ID = r.bits(16) + self.streamDependenceFlag = r.bits(1) + self.URL_Flag = r.bits(1) + self.OCRstreamFlag = r.bits(1) + self.streamPriority = r.bits(5) + if self.streamDependenceFlag: + self.dependsOn_ES_ID = r.bits(16) + if self.URL_Flag: + URLlength = r.bits(8) + self.URLstring = r.bytes(URLlength) + if self.OCRstreamFlag: + self.OCR_ES_Id = r.bits(16) + + tag = r.bits(8) + except BitReaderError as e: + raise DescriptorError(e) + + if tag != DecoderConfigDescriptor.TAG: + raise DescriptorError("unexpected DecoderConfigDescrTag %d" % tag) + + assert r.is_aligned() + self.decConfigDescr = DecoderConfigDescriptor.parse(fileobj) + + +class DecoderConfigDescriptor(BaseDescriptor): + + TAG = 0x4 + + decSpecificInfo = None + """A DecoderSpecificInfo, optional""" + + def __init__(self, fileobj, length): + """Raises DescriptorError""" + + r = BitReader(fileobj) + + try: + self.objectTypeIndication = r.bits(8) + self.streamType = r.bits(6) + self.upStream = r.bits(1) + self.reserved = r.bits(1) + self.bufferSizeDB = r.bits(24) + self.maxBitrate = r.bits(32) + self.avgBitrate = r.bits(32) + + if (self.objectTypeIndication, self.streamType) != (0x40, 0x5): + return + + # all from here is optional + if length * 8 == r.get_position(): + return + + tag = r.bits(8) + except BitReaderError as e: + raise DescriptorError(e) + + if tag == DecoderSpecificInfo.TAG: + assert r.is_aligned() + self.decSpecificInfo = DecoderSpecificInfo.parse(fileobj) + + @property + def codec_param(self): + """string""" + + param = u".%X" % self.objectTypeIndication + info = self.decSpecificInfo + if info is not None: + param += u".%d" % info.audioObjectType + return param + + @property + def codec_desc(self): + """string or None""" + + info = self.decSpecificInfo + desc = None + if info is not None: + desc = info.description + return desc + + +class DecoderSpecificInfo(BaseDescriptor): + + TAG = 0x5 + + _TYPE_NAMES = [ + None, "AAC MAIN", "AAC LC", "AAC SSR", "AAC LTP", "SBR", + "AAC scalable", "TwinVQ", "CELP", "HVXC", None, None, "TTSI", + "Main synthetic", "Wavetable synthesis", "General MIDI", + "Algorithmic Synthesis and Audio FX", "ER AAC LC", None, "ER AAC LTP", + "ER AAC scalable", "ER Twin VQ", "ER BSAC", "ER AAC LD", "ER CELP", + "ER HVXC", "ER HILN", "ER Parametric", "SSC", "PS", "MPEG Surround", + None, "Layer-1", "Layer-2", "Layer-3", "DST", "ALS", "SLS", + "SLS non-core", "ER AAC ELD", "SMR Simple", "SMR Main", "USAC", + "SAOC", "LD MPEG Surround", "USAC" + ] + + _FREQS = [ + 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, + 12000, 11025, 8000, 7350, + ] + + @property + def description(self): + """string or None if unknown""" + + name = None + try: + name = self._TYPE_NAMES[self.audioObjectType] + except IndexError: + pass + if name is None: + return + if self.sbrPresentFlag == 1: + name += "+SBR" + if self.psPresentFlag == 1: + name += "+PS" + return text_type(name) + + @property + def sample_rate(self): + """0 means unknown""" + + if self.sbrPresentFlag == 1: + return self.extensionSamplingFrequency + elif self.sbrPresentFlag == 0: + return self.samplingFrequency + else: + # these are all types that support SBR + aot_can_sbr = (1, 2, 3, 4, 6, 17, 19, 20, 22) + if self.audioObjectType not in aot_can_sbr: + return self.samplingFrequency + # there shouldn't be SBR for > 48KHz + if self.samplingFrequency > 24000: + return self.samplingFrequency + # either samplingFrequency or samplingFrequency * 2 + return 0 + + @property + def channels(self): + """channel count or 0 for unknown""" + + # from ProgramConfigElement() + if hasattr(self, "pce_channels"): + return self.pce_channels + + conf = getattr( + self, "extensionChannelConfiguration", self.channelConfiguration) + + if conf == 1: + if self.psPresentFlag == -1: + return 0 + elif self.psPresentFlag == 1: + return 2 + else: + return 1 + elif conf == 7: + return 8 + elif conf > 7: + return 0 + else: + return conf + + def _get_audio_object_type(self, r): + """Raises BitReaderError""" + + audioObjectType = r.bits(5) + if audioObjectType == 31: + audioObjectTypeExt = r.bits(6) + audioObjectType = 32 + audioObjectTypeExt + return audioObjectType + + def _get_sampling_freq(self, r): + """Raises BitReaderError""" + + samplingFrequencyIndex = r.bits(4) + if samplingFrequencyIndex == 0xf: + samplingFrequency = r.bits(24) + else: + try: + samplingFrequency = self._FREQS[samplingFrequencyIndex] + except IndexError: + samplingFrequency = 0 + return samplingFrequency + + def __init__(self, fileobj, length): + """Raises DescriptorError""" + + r = BitReader(fileobj) + try: + self._parse(r, length) + except BitReaderError as e: + raise DescriptorError(e) + + def _parse(self, r, length): + """Raises BitReaderError""" + + def bits_left(): + return length * 8 - r.get_position() + + self.audioObjectType = self._get_audio_object_type(r) + self.samplingFrequency = self._get_sampling_freq(r) + self.channelConfiguration = r.bits(4) + + self.sbrPresentFlag = -1 + self.psPresentFlag = -1 + if self.audioObjectType in (5, 29): + self.extensionAudioObjectType = 5 + self.sbrPresentFlag = 1 + if self.audioObjectType == 29: + self.psPresentFlag = 1 + self.extensionSamplingFrequency = self._get_sampling_freq(r) + self.audioObjectType = self._get_audio_object_type(r) + if self.audioObjectType == 22: + self.extensionChannelConfiguration = r.bits(4) + else: + self.extensionAudioObjectType = 0 + + if self.audioObjectType in (1, 2, 3, 4, 6, 7, 17, 19, 20, 21, 22, 23): + try: + GASpecificConfig(r, self) + except NotImplementedError: + # unsupported, (warn?) + return + else: + # unsupported + return + + if self.audioObjectType in ( + 17, 19, 20, 21, 22, 23, 24, 25, 26, 27, 39): + epConfig = r.bits(2) + if epConfig in (2, 3): + # unsupported + return + + if self.extensionAudioObjectType != 5 and bits_left() >= 16: + syncExtensionType = r.bits(11) + if syncExtensionType == 0x2b7: + self.extensionAudioObjectType = self._get_audio_object_type(r) + + if self.extensionAudioObjectType == 5: + self.sbrPresentFlag = r.bits(1) + if self.sbrPresentFlag == 1: + self.extensionSamplingFrequency = \ + self._get_sampling_freq(r) + if bits_left() >= 12: + syncExtensionType = r.bits(11) + if syncExtensionType == 0x548: + self.psPresentFlag = r.bits(1) + + if self.extensionAudioObjectType == 22: + self.sbrPresentFlag = r.bits(1) + if self.sbrPresentFlag == 1: + self.extensionSamplingFrequency = \ + self._get_sampling_freq(r) + self.extensionChannelConfiguration = r.bits(4) + + +def GASpecificConfig(r, info): + """Reads GASpecificConfig which is needed to get the data after that + (there is no length defined to skip it) and to read program_config_element + which can contain channel counts. + + May raise BitReaderError on error or + NotImplementedError if some reserved data was set. + """ + + assert isinstance(info, DecoderSpecificInfo) + + r.skip(1) # frameLengthFlag + dependsOnCoreCoder = r.bits(1) + if dependsOnCoreCoder: + r.skip(14) + extensionFlag = r.bits(1) + if not info.channelConfiguration: + pce = ProgramConfigElement(r) + info.pce_channels = pce.channels + if info.audioObjectType == 6 or info.audioObjectType == 20: + r.skip(3) + if extensionFlag: + if info.audioObjectType == 22: + r.skip(5 + 11) + if info.audioObjectType in (17, 19, 20, 23): + r.skip(1 + 1 + 1) + extensionFlag3 = r.bits(1) + if extensionFlag3 != 0: + raise NotImplementedError("extensionFlag3 set") diff -Nru mutagen-1.23/mutagen/mp4/_atom.py mutagen-1.30/mutagen/mp4/_atom.py --- mutagen-1.23/mutagen/mp4/_atom.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/mutagen/mp4/_atom.py 2015-02-01 19:07:57.000000000 +0000 @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Joe Wreschnig +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +import struct + +from mutagen._compat import PY2 + +# This is not an exhaustive list of container atoms, but just the +# ones this module needs to peek inside. +_CONTAINERS = [b"moov", b"udta", b"trak", b"mdia", b"meta", b"ilst", + b"stbl", b"minf", b"moof", b"traf"] +_SKIP_SIZE = {b"meta": 4} + + +class AtomError(Exception): + pass + + +class Atom(object): + """An individual atom. + + Attributes: + children -- list child atoms (or None for non-container atoms) + length -- length of this atom, including length and name + name -- four byte name of the atom, as a str + offset -- location in the constructor-given fileobj of this atom + + This structure should only be used internally by Mutagen. + """ + + children = None + + def __init__(self, fileobj, level=0): + """May raise AtomError""" + + self.offset = fileobj.tell() + try: + self.length, self.name = struct.unpack(">I4s", fileobj.read(8)) + except struct.error: + raise AtomError("truncated data") + self._dataoffset = self.offset + 8 + if self.length == 1: + try: + self.length, = struct.unpack(">Q", fileobj.read(8)) + except struct.error: + raise AtomError("truncated data") + self._dataoffset += 8 + if self.length < 16: + raise AtomError( + "64 bit atom length can only be 16 and higher") + elif self.length == 0: + if level != 0: + raise AtomError( + "only a top-level atom can have zero length") + # Only the last atom is supposed to have a zero-length, meaning it + # extends to the end of file. + fileobj.seek(0, 2) + self.length = fileobj.tell() - self.offset + fileobj.seek(self.offset + 8, 0) + elif self.length < 8: + raise AtomError( + "atom length can only be 0, 1 or 8 and higher") + + if self.name in _CONTAINERS: + self.children = [] + fileobj.seek(_SKIP_SIZE.get(self.name, 0), 1) + while fileobj.tell() < self.offset + self.length: + self.children.append(Atom(fileobj, level + 1)) + else: + fileobj.seek(self.offset + self.length, 0) + + def read(self, fileobj): + """Return if all data could be read and the atom payload""" + + fileobj.seek(self._dataoffset, 0) + length = self.length - (self._dataoffset - self.offset) + data = fileobj.read(length) + return len(data) == length, data + + @staticmethod + def render(name, data): + """Render raw atom data.""" + # this raises OverflowError if Py_ssize_t can't handle the atom data + size = len(data) + 8 + if size <= 0xFFFFFFFF: + return struct.pack(">I4s", size, name) + data + else: + return struct.pack(">I4sQ", 1, name, size + 8) + data + + def findall(self, name, recursive=False): + """Recursively find all child atoms by specified name.""" + if self.children is not None: + for child in self.children: + if child.name == name: + yield child + if recursive: + for atom in child.findall(name, True): + yield atom + + def __getitem__(self, remaining): + """Look up a child atom, potentially recursively. + + e.g. atom['udta', 'meta'] => + """ + if not remaining: + return self + elif self.children is None: + raise KeyError("%r is not a container" % self.name) + for child in self.children: + if child.name == remaining[0]: + return child[remaining[1:]] + else: + raise KeyError("%r not found" % remaining[0]) + + def __repr__(self): + cls = self.__class__.__name__ + if self.children is None: + return "<%s name=%r length=%r offset=%r>" % ( + cls, self.name, self.length, self.offset) + else: + children = "\n".join([" " + line for child in self.children + for line in repr(child).splitlines()]) + return "<%s name=%r length=%r offset=%r\n%s>" % ( + cls, self.name, self.length, self.offset, children) + + +class Atoms(object): + """Root atoms in a given file. + + Attributes: + atoms -- a list of top-level atoms as Atom objects + + This structure should only be used internally by Mutagen. + """ + + def __init__(self, fileobj): + self.atoms = [] + fileobj.seek(0, 2) + end = fileobj.tell() + fileobj.seek(0) + while fileobj.tell() + 8 <= end: + self.atoms.append(Atom(fileobj)) + + def path(self, *names): + """Look up and return the complete path of an atom. + + For example, atoms.path('moov', 'udta', 'meta') will return a + list of three atoms, corresponding to the moov, udta, and meta + atoms. + """ + + path = [self] + for name in names: + path.append(path[-1][name, ]) + return path[1:] + + def __contains__(self, names): + try: + self[names] + except KeyError: + return False + return True + + def __getitem__(self, names): + """Look up a child atom. + + 'names' may be a list of atoms (['moov', 'udta']) or a string + specifying the complete path ('moov.udta'). + """ + + if PY2: + if isinstance(names, basestring): + names = names.split(b".") + else: + if isinstance(names, bytes): + names = names.split(b".") + + for child in self.atoms: + if child.name == names[0]: + return child[names[1:]] + else: + raise KeyError("%r not found" % names[0]) + + def __repr__(self): + return "\n".join([repr(child) for child in self.atoms]) diff -Nru mutagen-1.23/mutagen/mp4/__init__.py mutagen-1.30/mutagen/mp4/__init__.py --- mutagen-1.23/mutagen/mp4/__init__.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/mutagen/mp4/__init__.py 2015-08-20 11:27:05.000000000 +0000 @@ -0,0 +1,960 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Joe Wreschnig +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +"""Read and write MPEG-4 audio files with iTunes metadata. + +This module will read MPEG-4 audio information and metadata, +as found in Apple's MP4 (aka M4A, M4B, M4P) files. + +There is no official specification for this format. The source code +for TagLib, FAAD, and various MPEG specifications at + +* http://developer.apple.com/documentation/QuickTime/QTFF/ +* http://www.geocities.com/xhelmboyx/quicktime/formats/mp4-layout.txt +* http://standards.iso.org/ittf/PubliclyAvailableStandards/\ +c041828_ISO_IEC_14496-12_2005(E).zip +* http://wiki.multimedia.cx/index.php?title=Apple_QuickTime + +were all consulted. +""" + +import struct +import sys + +from mutagen import FileType, Metadata, StreamInfo +from mutagen._constants import GENRES +from mutagen._util import (cdata, insert_bytes, DictProxy, MutagenError, + hashable, enum) +from mutagen._compat import (reraise, PY2, string_types, text_type, chr_, + iteritems, PY3, cBytesIO) +from ._atom import Atoms, Atom, AtomError +from ._util import parse_full_atom +from ._as_entry import AudioSampleEntry, ASEntryError + + +class error(IOError, MutagenError): + pass + + +class MP4MetadataError(error): + pass + + +class MP4StreamInfoError(error): + pass + + +class MP4MetadataValueError(ValueError, MP4MetadataError): + pass + + +__all__ = ['MP4', 'Open', 'delete', 'MP4Cover', 'MP4FreeForm', 'AtomDataType'] + + +@enum +class AtomDataType(object): + """Enum for `dataformat` attribute of MP4FreeForm. + + .. versionadded:: 1.25 + """ + + IMPLICIT = 0 + """for use with tags for which no type needs to be indicated because + only one type is allowed""" + + UTF8 = 1 + """without any count or null terminator""" + + UTF16 = 2 + """also known as UTF-16BE""" + + SJIS = 3 + """deprecated unless it is needed for special Japanese characters""" + + HTML = 6 + """the HTML file header specifies which HTML version""" + + XML = 7 + """the XML header must identify the DTD or schemas""" + + UUID = 8 + """also known as GUID; stored as 16 bytes in binary (valid as an ID)""" + + ISRC = 9 + """stored as UTF-8 text (valid as an ID)""" + + MI3P = 10 + """stored as UTF-8 text (valid as an ID)""" + + GIF = 12 + """(deprecated) a GIF image""" + + JPEG = 13 + """a JPEG image""" + + PNG = 14 + """PNG image""" + + URL = 15 + """absolute, in UTF-8 characters""" + + DURATION = 16 + """in milliseconds, 32-bit integer""" + + DATETIME = 17 + """in UTC, counting seconds since midnight, January 1, 1904; + 32 or 64-bits""" + + GENRES = 18 + """a list of enumerated values""" + + INTEGER = 21 + """a signed big-endian integer with length one of { 1,2,3,4,8 } bytes""" + + RIAA_PA = 24 + """RIAA parental advisory; { -1=no, 1=yes, 0=unspecified }, + 8-bit ingteger""" + + UPC = 25 + """Universal Product Code, in text UTF-8 format (valid as an ID)""" + + BMP = 27 + """Windows bitmap image""" + + +@hashable +class MP4Cover(bytes): + """A cover artwork. + + Attributes: + + * imageformat -- format of the image (either FORMAT_JPEG or FORMAT_PNG) + """ + + FORMAT_JPEG = AtomDataType.JPEG + FORMAT_PNG = AtomDataType.PNG + + def __new__(cls, data, *args, **kwargs): + return bytes.__new__(cls, data) + + def __init__(self, data, imageformat=FORMAT_JPEG): + self.imageformat = imageformat + + __hash__ = bytes.__hash__ + + def __eq__(self, other): + if not isinstance(other, MP4Cover): + return bytes(self) == other + + return (bytes(self) == bytes(other) and + self.imageformat == other.imageformat) + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return "%s(%r, %r)" % ( + type(self).__name__, bytes(self), + AtomDataType(self.imageformat)) + + +@hashable +class MP4FreeForm(bytes): + """A freeform value. + + Attributes: + + * dataformat -- format of the data (see AtomDataType) + """ + + FORMAT_DATA = AtomDataType.IMPLICIT # deprecated + FORMAT_TEXT = AtomDataType.UTF8 # deprecated + + def __new__(cls, data, *args, **kwargs): + return bytes.__new__(cls, data) + + def __init__(self, data, dataformat=AtomDataType.UTF8, version=0): + self.dataformat = dataformat + self.version = version + + __hash__ = bytes.__hash__ + + def __eq__(self, other): + if not isinstance(other, MP4FreeForm): + return bytes(self) == other + + return (bytes(self) == bytes(other) and + self.dataformat == other.dataformat and + self.version == other.version) + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return "%s(%r, %r)" % ( + type(self).__name__, bytes(self), + AtomDataType(self.dataformat)) + + + +def _name2key(name): + if PY2: + return name + return name.decode("latin-1") + + +def _key2name(key): + if PY2: + return key + return key.encode("latin-1") + + +class MP4Tags(DictProxy, Metadata): + r"""Dictionary containing Apple iTunes metadata list key/values. + + Keys are four byte identifiers, except for freeform ('----') + keys. Values are usually unicode strings, but some atoms have a + special structure: + + Text values (multiple values per key are supported): + + * '\\xa9nam' -- track title + * '\\xa9alb' -- album + * '\\xa9ART' -- artist + * 'aART' -- album artist + * '\\xa9wrt' -- composer + * '\\xa9day' -- year + * '\\xa9cmt' -- comment + * 'desc' -- description (usually used in podcasts) + * 'purd' -- purchase date + * '\\xa9grp' -- grouping + * '\\xa9gen' -- genre + * '\\xa9lyr' -- lyrics + * 'purl' -- podcast URL + * 'egid' -- podcast episode GUID + * 'catg' -- podcast category + * 'keyw' -- podcast keywords + * '\\xa9too' -- encoded by + * 'cprt' -- copyright + * 'soal' -- album sort order + * 'soaa' -- album artist sort order + * 'soar' -- artist sort order + * 'sonm' -- title sort order + * 'soco' -- composer sort order + * 'sosn' -- show sort order + * 'tvsh' -- show name + + Boolean values: + + * 'cpil' -- part of a compilation + * 'pgap' -- part of a gapless album + * 'pcst' -- podcast (iTunes reads this only on import) + + Tuples of ints (multiple values per key are supported): + + * 'trkn' -- track number, total tracks + * 'disk' -- disc number, total discs + + Others: + + * 'tmpo' -- tempo/BPM, 16 bit int + * 'covr' -- cover artwork, list of MP4Cover objects (which are + tagged strs) + * 'gnre' -- ID3v1 genre. Not supported, use '\\xa9gen' instead. + + The freeform '----' frames use a key in the format '----:mean:name' + where 'mean' is usually 'com.apple.iTunes' and 'name' is a unique + identifier for this frame. The value is a str, but is probably + text that can be decoded as UTF-8. Multiple values per key are + supported. + + MP4 tag data cannot exist outside of the structure of an MP4 file, + so this class should not be manually instantiated. + + Unknown non-text tags and tags that failed to parse will be written + back as is. + """ + + def __init__(self, *args, **kwargs): + self._failed_atoms = {} + super(MP4Tags, self).__init__(*args, **kwargs) + + def load(self, atoms, fileobj): + try: + ilst = atoms[b"moov.udta.meta.ilst"] + except KeyError as key: + raise MP4MetadataError(key) + for atom in ilst.children: + ok, data = atom.read(fileobj) + if not ok: + raise MP4MetadataError("Not enough data") + + try: + if atom.name in self.__atoms: + info = self.__atoms[atom.name] + info[0](self, atom, data) + else: + # unknown atom, try as text + self.__parse_text(atom, data, implicit=False) + except MP4MetadataError: + # parsing failed, save them so we can write them back + key = _name2key(atom.name) + self._failed_atoms.setdefault(key, []).append(data) + + def __setitem__(self, key, value): + if not isinstance(key, str): + raise TypeError("key has to be str") + super(MP4Tags, self).__setitem__(key, value) + + @classmethod + def _can_load(cls, atoms): + return b"moov.udta.meta.ilst" in atoms + + @staticmethod + def _key_sort(item): + (key, v) = item + # iTunes always writes the tags in order of "relevance", try + # to copy it as closely as possible. + order = ["\xa9nam", "\xa9ART", "\xa9wrt", "\xa9alb", + "\xa9gen", "gnre", "trkn", "disk", + "\xa9day", "cpil", "pgap", "pcst", "tmpo", + "\xa9too", "----", "covr", "\xa9lyr"] + order = dict(zip(order, range(len(order)))) + last = len(order) + # If there's no key-based way to distinguish, order by length. + # If there's still no way, go by string comparison on the + # values, so we at least have something determinstic. + return (order.get(key[:4], last), len(repr(v)), repr(v)) + + def save(self, filename): + """Save the metadata to the given filename.""" + + values = [] + items = sorted(self.items(), key=self._key_sort) + for key, value in items: + atom_name = _key2name(key)[:4] + if atom_name in self.__atoms: + render_func = self.__atoms[atom_name][1] + else: + render_func = type(self).__render_text + + try: + values.append(render_func(self, key, value)) + except (TypeError, ValueError) as s: + reraise(MP4MetadataValueError, s, sys.exc_info()[2]) + + for key, failed in iteritems(self._failed_atoms): + # don't write atoms back if we have added a new one with + # the same name, this excludes freeform which can have + # multiple atoms with the same key (most parsers seem to be able + # to handle that) + if key in self: + assert _key2name(key) != b"----" + continue + for data in failed: + values.append(Atom.render(_key2name(key), data)) + + data = Atom.render(b"ilst", b"".join(values)) + + # Find the old atoms. + with open(filename, "rb+") as fileobj: + try: + atoms = Atoms(fileobj) + except AtomError as err: + reraise(error, err, sys.exc_info()[2]) + + try: + path = atoms.path(b"moov", b"udta", b"meta", b"ilst") + except KeyError: + self.__save_new(fileobj, atoms, data) + else: + self.__save_existing(fileobj, atoms, path, data) + + def __pad_ilst(self, data, length=None): + if length is None: + length = ((len(data) + 1023) & ~1023) - len(data) + return Atom.render(b"free", b"\x00" * length) + + def __save_new(self, fileobj, atoms, ilst): + hdlr = Atom.render(b"hdlr", b"\x00" * 8 + b"mdirappl" + b"\x00" * 9) + meta = Atom.render( + b"meta", b"\x00\x00\x00\x00" + hdlr + ilst + self.__pad_ilst(ilst)) + try: + path = atoms.path(b"moov", b"udta") + except KeyError: + # moov.udta not found -- create one + path = atoms.path(b"moov") + meta = Atom.render(b"udta", meta) + offset = path[-1].offset + 8 + insert_bytes(fileobj, len(meta), offset) + fileobj.seek(offset) + fileobj.write(meta) + self.__update_parents(fileobj, path, len(meta)) + self.__update_offsets(fileobj, atoms, len(meta), offset) + + def __save_existing(self, fileobj, atoms, path, data): + # Replace the old ilst atom. + ilst = path.pop() + offset = ilst.offset + length = ilst.length + + # Check for padding "free" atoms + meta = path[-1] + index = meta.children.index(ilst) + try: + prev = meta.children[index - 1] + if prev.name == b"free": + offset = prev.offset + length += prev.length + except IndexError: + pass + try: + next = meta.children[index + 1] + if next.name == b"free": + length += next.length + except IndexError: + pass + + delta = len(data) - length + if delta > 0 or (delta < 0 and delta > -8): + data += self.__pad_ilst(data) + delta = len(data) - length + insert_bytes(fileobj, delta, offset) + elif delta < 0: + data += self.__pad_ilst(data, -delta - 8) + delta = 0 + + fileobj.seek(offset) + fileobj.write(data) + self.__update_parents(fileobj, path, delta) + self.__update_offsets(fileobj, atoms, delta, offset) + + def __update_parents(self, fileobj, path, delta): + """Update all parent atoms with the new size.""" + for atom in path: + fileobj.seek(atom.offset) + size = cdata.uint_be(fileobj.read(4)) + if size == 1: # 64bit + # skip name (4B) and read size (8B) + size = cdata.ulonglong_be(fileobj.read(12)[4:]) + fileobj.seek(atom.offset + 8) + fileobj.write(cdata.to_ulonglong_be(size + delta)) + else: # 32bit + fileobj.seek(atom.offset) + fileobj.write(cdata.to_uint_be(size + delta)) + + def __update_offset_table(self, fileobj, fmt, atom, delta, offset): + """Update offset table in the specified atom.""" + if atom.offset > offset: + atom.offset += delta + fileobj.seek(atom.offset + 12) + data = fileobj.read(atom.length - 12) + fmt = fmt % cdata.uint_be(data[:4]) + offsets = struct.unpack(fmt, data[4:]) + offsets = [o + (0, delta)[offset < o] for o in offsets] + fileobj.seek(atom.offset + 16) + fileobj.write(struct.pack(fmt, *offsets)) + + def __update_tfhd(self, fileobj, atom, delta, offset): + if atom.offset > offset: + atom.offset += delta + fileobj.seek(atom.offset + 9) + data = fileobj.read(atom.length - 9) + flags = cdata.uint_be(b"\x00" + data[:3]) + if flags & 1: + o = cdata.ulonglong_be(data[7:15]) + if o > offset: + o += delta + fileobj.seek(atom.offset + 16) + fileobj.write(cdata.to_ulonglong_be(o)) + + def __update_offsets(self, fileobj, atoms, delta, offset): + """Update offset tables in all 'stco' and 'co64' atoms.""" + if delta == 0: + return + moov = atoms[b"moov"] + for atom in moov.findall(b'stco', True): + self.__update_offset_table(fileobj, ">%dI", atom, delta, offset) + for atom in moov.findall(b'co64', True): + self.__update_offset_table(fileobj, ">%dQ", atom, delta, offset) + try: + for atom in atoms[b"moof"].findall(b'tfhd', True): + self.__update_tfhd(fileobj, atom, delta, offset) + except KeyError: + pass + + def __parse_data(self, atom, data): + pos = 0 + while pos < atom.length - 8: + head = data[pos:pos + 12] + if len(head) != 12: + raise MP4MetadataError("truncated atom % r" % atom.name) + length, name = struct.unpack(">I4s", head[:8]) + version = ord(head[8:9]) + flags = struct.unpack(">I", b"\x00" + head[9:12])[0] + if name != b"data": + raise MP4MetadataError( + "unexpected atom %r inside %r" % (name, atom.name)) + + chunk = data[pos + 16:pos + length] + if len(chunk) != length - 16: + raise MP4MetadataError("truncated atom % r" % atom.name) + yield version, flags, chunk + pos += length + + def __add(self, key, value, single=False): + assert isinstance(key, str) + + if single: + self[key] = value + else: + self.setdefault(key, []).extend(value) + + def __render_data(self, key, version, flags, value): + return Atom.render(_key2name(key), b"".join([ + Atom.render( + b"data", struct.pack(">2I", version << 24 | flags, 0) + data) + for data in value])) + + def __parse_freeform(self, atom, data): + length = cdata.uint_be(data[:4]) + mean = data[12:length] + pos = length + length = cdata.uint_be(data[pos:pos + 4]) + name = data[pos + 12:pos + length] + pos += length + value = [] + while pos < atom.length - 8: + length, atom_name = struct.unpack(">I4s", data[pos:pos + 8]) + if atom_name != b"data": + raise MP4MetadataError( + "unexpected atom %r inside %r" % (atom_name, atom.name)) + + version = ord(data[pos + 8:pos + 8 + 1]) + flags = struct.unpack(">I", b"\x00" + data[pos + 9:pos + 12])[0] + value.append(MP4FreeForm(data[pos + 16:pos + length], + dataformat=flags, version=version)) + pos += length + + key = _name2key(atom.name + b":" + mean + b":" + name) + self.__add(key, value) + + def __render_freeform(self, key, value): + if isinstance(value, bytes): + value = [value] + + dummy, mean, name = _key2name(key).split(b":", 2) + mean = struct.pack(">I4sI", len(mean) + 12, b"mean", 0) + mean + name = struct.pack(">I4sI", len(name) + 12, b"name", 0) + name + + data = b"" + for v in value: + flags = AtomDataType.UTF8 + version = 0 + if isinstance(v, MP4FreeForm): + flags = v.dataformat + version = v.version + + data += struct.pack( + ">I4s2I", len(v) + 16, b"data", version << 24 | flags, 0) + data += v + + return Atom.render(b"----", mean + name + data) + + def __parse_pair(self, atom, data): + key = _name2key(atom.name) + values = [struct.unpack(">2H", d[2:6]) for + version, flags, d in self.__parse_data(atom, data)] + self.__add(key, values) + + def __render_pair(self, key, value): + data = [] + for (track, total) in value: + if 0 <= track < 1 << 16 and 0 <= total < 1 << 16: + data.append(struct.pack(">4H", 0, track, total, 0)) + else: + raise MP4MetadataValueError( + "invalid numeric pair %r" % ((track, total),)) + return self.__render_data(key, 0, AtomDataType.IMPLICIT, data) + + def __render_pair_no_trailing(self, key, value): + data = [] + for (track, total) in value: + if 0 <= track < 1 << 16 and 0 <= total < 1 << 16: + data.append(struct.pack(">3H", 0, track, total)) + else: + raise MP4MetadataValueError( + "invalid numeric pair %r" % ((track, total),)) + return self.__render_data(key, 0, AtomDataType.IMPLICIT, data) + + def __parse_genre(self, atom, data): + values = [] + for version, flags, data in self.__parse_data(atom, data): + # version = 0, flags = 0 + if len(data) != 2: + raise MP4MetadataValueError("invalid genre") + genre = cdata.short_be(data) + # Translate to a freeform genre. + try: + genre = GENRES[genre - 1] + except IndexError: + # this will make us write it back at least + raise MP4MetadataValueError("unknown genre") + values.append(genre) + key = _name2key(b"\xa9gen") + self.__add(key, values) + + def __parse_tempo(self, atom, data): + values = [] + for version, flags, data in self.__parse_data(atom, data): + # version = 0, flags = 0 or 21 + if len(data) != 2: + raise MP4MetadataValueError("invalid tempo") + values.append(cdata.ushort_be(data)) + key = _name2key(atom.name) + self.__add(key, values) + + def __render_tempo(self, key, value): + try: + if len(value) == 0: + return self.__render_data(key, 0, AtomDataType.INTEGER, b"") + + if (min(value) < 0) or (max(value) >= 2 ** 16): + raise MP4MetadataValueError( + "invalid 16 bit integers: %r" % value) + except TypeError: + raise MP4MetadataValueError( + "tmpo must be a list of 16 bit integers") + + values = [cdata.to_ushort_be(v) for v in value] + return self.__render_data(key, 0, AtomDataType.INTEGER, values) + + def __parse_bool(self, atom, data): + for version, flags, data in self.__parse_data(atom, data): + if len(data) != 1: + raise MP4MetadataValueError("invalid bool") + + value = bool(ord(data)) + key = _name2key(atom.name) + self.__add(key, value, single=True) + + def __render_bool(self, key, value): + return self.__render_data( + key, 0, AtomDataType.INTEGER, [chr_(bool(value))]) + + def __parse_cover(self, atom, data): + values = [] + pos = 0 + while pos < atom.length - 8: + length, name, imageformat = struct.unpack(">I4sI", + data[pos:pos + 12]) + if name != b"data": + if name == b"name": + pos += length + continue + raise MP4MetadataError( + "unexpected atom %r inside 'covr'" % name) + if imageformat not in (MP4Cover.FORMAT_JPEG, MP4Cover.FORMAT_PNG): + # Sometimes AtomDataType.IMPLICIT or simply wrong. + # In all cases it was jpeg, so default to it + imageformat = MP4Cover.FORMAT_JPEG + cover = MP4Cover(data[pos + 16:pos + length], imageformat) + values.append(cover) + pos += length + + key = _name2key(atom.name) + self.__add(key, values) + + def __render_cover(self, key, value): + atom_data = [] + for cover in value: + try: + imageformat = cover.imageformat + except AttributeError: + imageformat = MP4Cover.FORMAT_JPEG + atom_data.append(Atom.render( + b"data", struct.pack(">2I", imageformat, 0) + cover)) + return Atom.render(_key2name(key), b"".join(atom_data)) + + def __parse_text(self, atom, data, implicit=True): + # implicit = False, for parsing unknown atoms only take utf8 ones. + # For known ones we can assume the implicit are utf8 too. + values = [] + for version, flags, atom_data in self.__parse_data(atom, data): + if implicit: + if flags not in (AtomDataType.IMPLICIT, AtomDataType.UTF8): + raise MP4MetadataError( + "Unknown atom type %r for %r" % (flags, atom.name)) + else: + if flags != AtomDataType.UTF8: + raise MP4MetadataError( + "%r is not text, ignore" % atom.name) + + try: + text = atom_data.decode("utf-8") + except UnicodeDecodeError as e: + raise MP4MetadataError("%s: %s" % (_name2key(atom.name), e)) + + values.append(text) + + key = _name2key(atom.name) + self.__add(key, values) + + def __render_text(self, key, value, flags=AtomDataType.UTF8): + if isinstance(value, string_types): + value = [value] + + encoded = [] + for v in value: + if not isinstance(v, text_type): + if PY3: + raise TypeError("%r not str" % v) + v = v.decode("utf-8") + encoded.append(v.encode("utf-8")) + + return self.__render_data(key, 0, flags, encoded) + + def delete(self, filename): + """Remove the metadata from the given filename.""" + + self._failed_atoms.clear() + self.clear() + self.save(filename) + + __atoms = { + b"----": (__parse_freeform, __render_freeform), + b"trkn": (__parse_pair, __render_pair), + b"disk": (__parse_pair, __render_pair_no_trailing), + b"gnre": (__parse_genre, None), + b"tmpo": (__parse_tempo, __render_tempo), + b"cpil": (__parse_bool, __render_bool), + b"pgap": (__parse_bool, __render_bool), + b"pcst": (__parse_bool, __render_bool), + b"covr": (__parse_cover, __render_cover), + b"purl": (__parse_text, __render_text), + b"egid": (__parse_text, __render_text), + } + + # these allow implicit flags and parse as text + for name in [b"\xa9nam", b"\xa9alb", b"\xa9ART", b"aART", b"\xa9wrt", + b"\xa9day", b"\xa9cmt", b"desc", b"purd", b"\xa9grp", + b"\xa9gen", b"\xa9lyr", b"catg", b"keyw", b"\xa9too", + b"cprt", b"soal", b"soaa", b"soar", b"sonm", b"soco", + b"sosn", b"tvsh"]: + __atoms[name] = (__parse_text, __render_text) + + def pprint(self): + + def to_line(key, value): + assert isinstance(key, text_type) + if isinstance(value, text_type): + return u"%s=%s" % (key, value) + return u"%s=%r" % (key, value) + + values = [] + for key, value in sorted(iteritems(self)): + if not isinstance(key, text_type): + key = key.decode("latin-1") + if key == "covr": + values.append(u"%s=%s" % (key, u", ".join( + [u"[%d bytes of data]" % len(data) for data in value]))) + elif isinstance(value, list): + for v in value: + values.append(to_line(key, v)) + else: + values.append(to_line(key, value)) + return u"\n".join(values) + + +class MP4Info(StreamInfo): + """MPEG-4 stream information. + + Attributes: + + * bitrate -- bitrate in bits per second, as an int + * length -- file length in seconds, as a float + * channels -- number of audio channels + * sample_rate -- audio sampling rate in Hz + * bits_per_sample -- bits per sample + * codec (string): + * if starting with ``"mp4a"`` uses an mp4a audio codec + (see the codec parameter in rfc6381 for details e.g. ``"mp4a.40.2"``) + * for everything else see a list of possible values at + http://www.mp4ra.org/codecs.html + + e.g. ``"mp4a"``, ``"alac"``, ``"mp4a.40.2"``, ``"ac-3"`` etc. + * codec_description (string): + Name of the codec used (ALAC, AAC LC, AC-3...). Values might change in + the future, use for display purposes only. + """ + + bitrate = 0 + channels = 0 + sample_rate = 0 + bits_per_sample = 0 + codec = u"" + codec_name = u"" + + def __init__(self, atoms, fileobj): + try: + moov = atoms[b"moov"] + except KeyError: + raise MP4StreamInfoError("not a MP4 file") + + for trak in moov.findall(b"trak"): + hdlr = trak[b"mdia", b"hdlr"] + ok, data = hdlr.read(fileobj) + if not ok: + raise MP4StreamInfoError("Not enough data") + if data[8:12] == b"soun": + break + else: + raise MP4StreamInfoError("track has no audio data") + + mdhd = trak[b"mdia", b"mdhd"] + ok, data = mdhd.read(fileobj) + if not ok: + raise MP4StreamInfoError("Not enough data") + + try: + version, flags, data = parse_full_atom(data) + except ValueError as e: + raise MP4StreamInfoError(e) + + if version == 0: + offset = 8 + fmt = ">2I" + elif version == 1: + offset = 16 + fmt = ">IQ" + else: + raise MP4StreamInfoError("Unknown mdhd version %d" % version) + + end = offset + struct.calcsize(fmt) + unit, length = struct.unpack(fmt, data[offset:end]) + try: + self.length = float(length) / unit + except ZeroDivisionError: + self.length = 0 + + try: + atom = trak[b"mdia", b"minf", b"stbl", b"stsd"] + except KeyError: + pass + else: + self._parse_stsd(atom, fileobj) + + def _parse_stsd(self, atom, fileobj): + """Sets channels, bits_per_sample, sample_rate and optionally bitrate. + + Can raise MP4StreamInfoError. + """ + + assert atom.name == b"stsd" + + ok, data = atom.read(fileobj) + if not ok: + raise MP4StreamInfoError("Invalid stsd") + + try: + version, flags, data = parse_full_atom(data) + except ValueError as e: + raise MP4StreamInfoError(e) + + if version != 0: + raise MP4StreamInfoError("Unsupported stsd version") + + try: + num_entries, offset = cdata.uint32_be_from(data, 0) + except cdata.error as e: + raise MP4StreamInfoError(e) + + if num_entries == 0: + return + + # look at the first entry if there is one + entry_fileobj = cBytesIO(data[offset:]) + try: + entry_atom = Atom(entry_fileobj) + except AtomError as e: + raise MP4StreamInfoError(e) + + try: + entry = AudioSampleEntry(entry_atom, entry_fileobj) + except ASEntryError as e: + raise MP4StreamInfoError(e) + else: + self.channels = entry.channels + self.bits_per_sample = entry.sample_size + self.sample_rate = entry.sample_rate + self.bitrate = entry.bitrate + self.codec = entry.codec + self.codec_description = entry.codec_description + + def pprint(self): + return "MPEG-4 audio (%s), %.2f seconds, %d bps" % ( + self.codec_description, self.length, self.bitrate) + + +class MP4(FileType): + """An MPEG-4 audio file, probably containing AAC. + + If more than one track is present in the file, the first is used. + Only audio ('soun') tracks will be read. + + :ivar info: :class:`MP4Info` + :ivar tags: :class:`MP4Tags` + """ + + MP4Tags = MP4Tags + + _mimes = ["audio/mp4", "audio/x-m4a", "audio/mpeg4", "audio/aac"] + + def load(self, filename): + self.filename = filename + with open(filename, "rb") as fileobj: + try: + atoms = Atoms(fileobj) + except AtomError as err: + reraise(error, err, sys.exc_info()[2]) + + try: + self.info = MP4Info(atoms, fileobj) + except error: + raise + except Exception as err: + reraise(MP4StreamInfoError, err, sys.exc_info()[2]) + + if not MP4Tags._can_load(atoms): + self.tags = None + else: + try: + self.tags = self.MP4Tags(atoms, fileobj) + except error: + raise + except Exception as err: + reraise(MP4MetadataError, err, sys.exc_info()[2]) + + def add_tags(self): + if self.tags is None: + self.tags = self.MP4Tags() + else: + raise error("an MP4 tag already exists") + + @staticmethod + def score(filename, fileobj, header_data): + return (b"ftyp" in header_data) + (b"mp4" in header_data) + + +Open = MP4 + + +def delete(filename): + """Remove tags from a file.""" + + MP4(filename).delete() diff -Nru mutagen-1.23/mutagen/mp4/_util.py mutagen-1.30/mutagen/mp4/_util.py --- mutagen-1.23/mutagen/mp4/_util.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/mutagen/mp4/_util.py 2015-02-01 19:07:57.000000000 +0000 @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +from mutagen._util import cdata + + +def parse_full_atom(data): + """Some atoms are versioned. Split them up in (version, flags, payload). + Can raise ValueError. + """ + + if len(data) < 4: + raise ValueError("not enough data") + + version = ord(data[0:1]) + flags = cdata.uint_be(b"\x00" + data[1:4]) + return version, flags, data[4:] diff -Nru mutagen-1.23/mutagen/mp4.py mutagen-1.30/mutagen/mp4.py --- mutagen-1.23/mutagen/mp4.py 2013-10-05 17:12:33.000000000 +0000 +++ mutagen-1.30/mutagen/mp4.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,837 +0,0 @@ -# Copyright 2006 Joe Wreschnig -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 as -# published by the Free Software Foundation. - -"""Read and write MPEG-4 audio files with iTunes metadata. - -This module will read MPEG-4 audio information and metadata, -as found in Apple's MP4 (aka M4A, M4B, M4P) files. - -There is no official specification for this format. The source code -for TagLib, FAAD, and various MPEG specifications at - -* http://developer.apple.com/documentation/QuickTime/QTFF/ -* http://www.geocities.com/xhelmboyx/quicktime/formats/mp4-layout.txt -* http://standards.iso.org/ittf/PubliclyAvailableStandards/\ -c041828_ISO_IEC_14496-12_2005(E).zip -* http://wiki.multimedia.cx/index.php?title=Apple_QuickTime - -were all consulted. -""" - -import struct -import sys - -from mutagen import FileType, Metadata, StreamInfo -from mutagen._constants import GENRES -from mutagen._util import cdata, insert_bytes, DictProxy, utf8 -from mutagen._compat import reraise, PY2, string_types, text_type, chr_ - - -class error(IOError): - pass - - -class MP4MetadataError(error): - pass - - -class MP4StreamInfoError(error): - pass - - -class MP4MetadataValueError(ValueError, MP4MetadataError): - pass - - -# This is not an exhaustive list of container atoms, but just the -# ones this module needs to peek inside. -_CONTAINERS = [b"moov", b"udta", b"trak", b"mdia", b"meta", b"ilst", - b"stbl", b"minf", b"moof", b"traf"] -_SKIP_SIZE = {b"meta": 4} - -__all__ = ['MP4', 'Open', 'delete', 'MP4Cover', 'MP4FreeForm'] - - -class MP4Cover(bytes): - """A cover artwork. - - Attributes: - - * imageformat -- format of the image (either FORMAT_JPEG or FORMAT_PNG) - """ - FORMAT_JPEG = 0x0D - FORMAT_PNG = 0x0E - - def __new__(cls, data, *args, **kwargs): - return bytes.__new__(cls, data) - - def __init__(self, data, imageformat=FORMAT_JPEG): - self.imageformat = imageformat - try: - self.format - except AttributeError: - self.format = imageformat - - -class MP4FreeForm(bytes): - """A freeform value. - - Attributes: - - * dataformat -- format of the data (either FORMAT_TEXT or FORMAT_DATA) - """ - - FORMAT_DATA = 0x0 - FORMAT_TEXT = 0x1 - - def __new__(cls, data, *args, **kwargs): - return bytes.__new__(cls, data) - - def __init__(self, data, dataformat=FORMAT_TEXT): - self.dataformat = dataformat - - -class Atom(object): - """An individual atom. - - Attributes: - children -- list child atoms (or None for non-container atoms) - length -- length of this atom, including length and name - name -- four byte name of the atom, as a str - offset -- location in the constructor-given fileobj of this atom - - This structure should only be used internally by Mutagen. - """ - - children = None - - def __init__(self, fileobj, level=0): - self.offset = fileobj.tell() - self.length, self.name = struct.unpack(">I4s", fileobj.read(8)) - if self.length == 1: - self.length, = struct.unpack(">Q", fileobj.read(8)) - if self.length < 16: - raise MP4MetadataError( - "64 bit atom length can only be 16 and higher") - elif self.length == 0: - if level != 0: - raise MP4MetadataError( - "only a top-level atom can have zero length") - # Only the last atom is supposed to have a zero-length, meaning it - # extends to the end of file. - fileobj.seek(0, 2) - self.length = fileobj.tell() - self.offset - fileobj.seek(self.offset + 8, 0) - elif self.length < 8: - raise MP4MetadataError( - "atom length can only be 0, 1 or 8 and higher") - - if self.name in _CONTAINERS: - self.children = [] - fileobj.seek(_SKIP_SIZE.get(self.name, 0), 1) - while fileobj.tell() < self.offset + self.length: - self.children.append(Atom(fileobj, level + 1)) - else: - fileobj.seek(self.offset + self.length, 0) - - @staticmethod - def render(name, data): - """Render raw atom data.""" - # this raises OverflowError if Py_ssize_t can't handle the atom data - size = len(data) + 8 - if size <= 0xFFFFFFFF: - return struct.pack(">I4s", size, name) + data - else: - return struct.pack(">I4sQ", 1, name, size + 8) + data - - def findall(self, name, recursive=False): - """Recursively find all child atoms by specified name.""" - if self.children is not None: - for child in self.children: - if child.name == name: - yield child - if recursive: - for atom in child.findall(name, True): - yield atom - - def __getitem__(self, remaining): - """Look up a child atom, potentially recursively. - - e.g. atom['udta', 'meta'] => - """ - if not remaining: - return self - elif self.children is None: - raise KeyError("%r is not a container" % self.name) - for child in self.children: - if child.name == remaining[0]: - return child[remaining[1:]] - else: - raise KeyError("%r not found" % remaining[0]) - - def __repr__(self): - klass = self.__class__.__name__ - if self.children is None: - return "<%s name=%r length=%r offset=%r>" % ( - klass, self.name, self.length, self.offset) - else: - children = "\n".join([" " + line for child in self.children - for line in repr(child).splitlines()]) - return "<%s name=%r length=%r offset=%r\n%s>" % ( - klass, self.name, self.length, self.offset, children) - - -class Atoms(object): - """Root atoms in a given file. - - Attributes: - atoms -- a list of top-level atoms as Atom objects - - This structure should only be used internally by Mutagen. - """ - - def __init__(self, fileobj): - self.atoms = [] - fileobj.seek(0, 2) - end = fileobj.tell() - fileobj.seek(0) - while fileobj.tell() + 8 <= end: - self.atoms.append(Atom(fileobj)) - - def path(self, *names): - """Look up and return the complete path of an atom. - - For example, atoms.path('moov', 'udta', 'meta') will return a - list of three atoms, corresponding to the moov, udta, and meta - atoms. - """ - - path = [self] - for name in names: - path.append(path[-1][name, ]) - return path[1:] - - def __contains__(self, names): - try: - self[names] - except KeyError: - return False - return True - - def __getitem__(self, names): - """Look up a child atom. - - 'names' may be a list of atoms (['moov', 'udta']) or a string - specifying the complete path ('moov.udta'). - """ - - if PY2: - if isinstance(names, basestring): - names = names.split(b".") - else: - if isinstance(names, bytes): - names = names.split(b".") - - for child in self.atoms: - if child.name == names[0]: - return child[names[1:]] - else: - raise KeyError("%s not found" % names[0]) - - def __repr__(self): - return "\n".join([repr(child) for child in self.atoms]) - - -class MP4Tags(DictProxy, Metadata): - r"""Dictionary containing Apple iTunes metadata list key/values. - - Keys are four byte identifiers, except for freeform ('----') - keys. Values are usually unicode strings, but some atoms have a - special structure: - - Text values (multiple values per key are supported): - - * '\\xa9nam' -- track title - * '\\xa9alb' -- album - * '\\xa9ART' -- artist - * 'aART' -- album artist - * '\\xa9wrt' -- composer - * '\\xa9day' -- year - * '\\xa9cmt' -- comment - * 'desc' -- description (usually used in podcasts) - * 'purd' -- purchase date - * '\\xa9grp' -- grouping - * '\\xa9gen' -- genre - * '\\xa9lyr' -- lyrics - * 'purl' -- podcast URL - * 'egid' -- podcast episode GUID - * 'catg' -- podcast category - * 'keyw' -- podcast keywords - * '\\xa9too' -- encoded by - * 'cprt' -- copyright - * 'soal' -- album sort order - * 'soaa' -- album artist sort order - * 'soar' -- artist sort order - * 'sonm' -- title sort order - * 'soco' -- composer sort order - * 'sosn' -- show sort order - * 'tvsh' -- show name - - Boolean values: - - * 'cpil' -- part of a compilation - * 'pgap' -- part of a gapless album - * 'pcst' -- podcast (iTunes reads this only on import) - - Tuples of ints (multiple values per key are supported): - - * 'trkn' -- track number, total tracks - * 'disk' -- disc number, total discs - - Others: - - * 'tmpo' -- tempo/BPM, 16 bit int - * 'covr' -- cover artwork, list of MP4Cover objects (which are - tagged strs) - * 'gnre' -- ID3v1 genre. Not supported, use '\\xa9gen' instead. - - The freeform '----' frames use a key in the format '----:mean:name' - where 'mean' is usually 'com.apple.iTunes' and 'name' is a unique - identifier for this frame. The value is a str, but is probably - text that can be decoded as UTF-8. Multiple values per key are - supported. - - MP4 tag data cannot exist outside of the structure of an MP4 file, - so this class should not be manually instantiated. - - Unknown non-text tags are removed. - """ - - def load(self, atoms, fileobj): - try: - ilst = atoms[b"moov.udta.meta.ilst"] - except KeyError as key: - raise MP4MetadataError(key) - for atom in ilst.children: - fileobj.seek(atom.offset + 8) - data = fileobj.read(atom.length - 8) - if len(data) != atom.length - 8: - raise MP4MetadataError("Not enough data") - - if atom.name in self.__atoms: - info = self.__atoms[atom.name] - info[0](self, atom, data, *info[2:]) - else: - # unknown atom, try as text and skip if it fails - # FIXME: keep them somehow - try: - self.__parse_text(atom, data) - except MP4MetadataError: - continue - - @classmethod - def _can_load(cls, atoms): - return b"moov.udta.meta.ilst" in atoms - - @staticmethod - def __key_sort(item): - (key, v) = item - # iTunes always writes the tags in order of "relevance", try - # to copy it as closely as possible. - order = [b"\xa9nam", b"\xa9ART", b"\xa9wrt", b"\xa9alb", - b"\xa9gen", b"gnre", b"trkn", b"disk", - b"\xa9day", b"cpil", b"pgap", b"pcst", b"tmpo", - b"\xa9too", b"----", b"covr", b"\xa9lyr"] - order = dict(zip(order, range(len(order)))) - last = len(order) - # If there's no key-based way to distinguish, order by length. - # If there's still no way, go by string comparison on the - # values, so we at least have something determinstic. - return (order.get(key[:4], last), len(repr(v)), repr(v)) - - def save(self, filename): - """Save the metadata to the given filename.""" - - values = [] - items = self.items() - items.sort(key=self.__key_sort) - for key, value in items: - - if not PY2 and not isinstance(key, bytes): - raise MP4MetadataValueError("keys have to be bytes") - - info = self.__atoms.get(key[:4], (None, type(self).__render_text)) - try: - values.append(info[1](self, key, value, *info[2:])) - except (TypeError, ValueError) as s: - reraise(MP4MetadataValueError, s, sys.exc_info()[2]) - data = Atom.render(b"ilst", b"".join(values)) - - # Find the old atoms. - fileobj = open(filename, "rb+") - try: - atoms = Atoms(fileobj) - try: - path = atoms.path(b"moov", b"udta", b"meta", b"ilst") - except KeyError: - self.__save_new(fileobj, atoms, data) - else: - self.__save_existing(fileobj, atoms, path, data) - finally: - fileobj.close() - - def __pad_ilst(self, data, length=None): - if length is None: - length = ((len(data) + 1023) & ~1023) - len(data) - return Atom.render(b"free", b"\x00" * length) - - def __save_new(self, fileobj, atoms, ilst): - hdlr = Atom.render(b"hdlr", b"\x00" * 8 + b"mdirappl" + b"\x00" * 9) - meta = Atom.render( - b"meta", b"\x00\x00\x00\x00" + hdlr + ilst + self.__pad_ilst(ilst)) - try: - path = atoms.path(b"moov", b"udta") - except KeyError: - # moov.udta not found -- create one - path = atoms.path(b"moov") - meta = Atom.render(b"udta", meta) - offset = path[-1].offset + 8 - insert_bytes(fileobj, len(meta), offset) - fileobj.seek(offset) - fileobj.write(meta) - self.__update_parents(fileobj, path, len(meta)) - self.__update_offsets(fileobj, atoms, len(meta), offset) - - def __save_existing(self, fileobj, atoms, path, data): - # Replace the old ilst atom. - ilst = path.pop() - offset = ilst.offset - length = ilst.length - - # Check for padding "free" atoms - meta = path[-1] - index = meta.children.index(ilst) - try: - prev = meta.children[index-1] - if prev.name == b"free": - offset = prev.offset - length += prev.length - except IndexError: - pass - try: - next = meta.children[index+1] - if next.name == b"free": - length += next.length - except IndexError: - pass - - delta = len(data) - length - if delta > 0 or (delta < 0 and delta > -8): - data += self.__pad_ilst(data) - delta = len(data) - length - insert_bytes(fileobj, delta, offset) - elif delta < 0: - data += self.__pad_ilst(data, -delta - 8) - delta = 0 - - fileobj.seek(offset) - fileobj.write(data) - self.__update_parents(fileobj, path, delta) - self.__update_offsets(fileobj, atoms, delta, offset) - - def __update_parents(self, fileobj, path, delta): - """Update all parent atoms with the new size.""" - for atom in path: - fileobj.seek(atom.offset) - size = cdata.uint_be(fileobj.read(4)) - if size == 1: # 64bit - # skip name (4B) and read size (8B) - size = cdata.ulonglong_be(fileobj.read(12)[4:]) - fileobj.seek(atom.offset + 8) - fileobj.write(cdata.to_ulonglong_be(size + delta)) - else: # 32bit - fileobj.seek(atom.offset) - fileobj.write(cdata.to_uint_be(size + delta)) - - def __update_offset_table(self, fileobj, fmt, atom, delta, offset): - """Update offset table in the specified atom.""" - if atom.offset > offset: - atom.offset += delta - fileobj.seek(atom.offset + 12) - data = fileobj.read(atom.length - 12) - fmt = fmt % cdata.uint_be(data[:4]) - offsets = struct.unpack(fmt, data[4:]) - offsets = [o + (0, delta)[offset < o] for o in offsets] - fileobj.seek(atom.offset + 16) - fileobj.write(struct.pack(fmt, *offsets)) - - def __update_tfhd(self, fileobj, atom, delta, offset): - if atom.offset > offset: - atom.offset += delta - fileobj.seek(atom.offset + 9) - data = fileobj.read(atom.length - 9) - flags = cdata.uint_be(b"\x00" + data[:3]) - if flags & 1: - o = cdata.ulonglong_be(data[7:15]) - if o > offset: - o += delta - fileobj.seek(atom.offset + 16) - fileobj.write(cdata.to_ulonglong_be(o)) - - def __update_offsets(self, fileobj, atoms, delta, offset): - """Update offset tables in all 'stco' and 'co64' atoms.""" - if delta == 0: - return - moov = atoms[b"moov"] - for atom in moov.findall(b'stco', True): - self.__update_offset_table(fileobj, ">%dI", atom, delta, offset) - for atom in moov.findall(b'co64', True): - self.__update_offset_table(fileobj, ">%dQ", atom, delta, offset) - try: - for atom in atoms[b"moof"].findall(b'tfhd', True): - self.__update_tfhd(fileobj, atom, delta, offset) - except KeyError: - pass - - def __parse_data(self, atom, data): - pos = 0 - while pos < atom.length - 8: - length, name, flags = struct.unpack(">I4sI", data[pos:pos+12]) - if name != b"data": - raise MP4MetadataError( - "unexpected atom %r inside %r" % (name, atom.name)) - yield flags, data[pos+16:pos+length] - pos += length - - def __render_data(self, key, flags, value): - return Atom.render(key, b"".join([ - Atom.render(b"data", struct.pack(">2I", flags, 0) + data) - for data in value])) - - def __parse_freeform(self, atom, data): - length = cdata.uint_be(data[:4]) - mean = data[12:length] - pos = length - length = cdata.uint_be(data[pos:pos+4]) - name = data[pos+12:pos+length] - pos += length - value = [] - while pos < atom.length - 8: - length, atom_name = struct.unpack(">I4s", data[pos:pos+8]) - if atom_name != b"data": - raise MP4MetadataError( - "unexpected atom %r inside %r" % (atom_name, atom.name)) - - version = ord(data[pos+8:pos+8+1]) - if version != 0: - raise MP4MetadataError("Unsupported version: %r" % version) - - flags = struct.unpack(">I", b"\x00" + data[pos+9:pos+12])[0] - value.append(MP4FreeForm(data[pos+16:pos+length], - dataformat=flags)) - pos += length - if value: - self[atom.name + b":" + mean + b":" + name] = value - - def __render_freeform(self, key, value): - dummy, mean, name = key.split(b":", 2) - mean = struct.pack(">I4sI", len(mean) + 12, b"mean", 0) + mean - name = struct.pack(">I4sI", len(name) + 12, b"name", 0) + name - if isinstance(value, bytes): - value = [value] - data = b"" - for v in value: - flags = MP4FreeForm.FORMAT_TEXT - if isinstance(v, MP4FreeForm): - flags = v.dataformat - data += struct.pack(">I4s2I", len(v) + 16, b"data", flags, 0) - data += v - return Atom.render(b"----", mean + name + data) - - def __parse_pair(self, atom, data): - self[atom.name] = [struct.unpack(">2H", d[2:6]) for - flags, d in self.__parse_data(atom, data)] - - def __render_pair(self, key, value): - data = [] - for (track, total) in value: - if 0 <= track < 1 << 16 and 0 <= total < 1 << 16: - data.append(struct.pack(">4H", 0, track, total, 0)) - else: - raise MP4MetadataValueError( - "invalid numeric pair %r" % ((track, total),)) - return self.__render_data(key, 0, data) - - def __render_pair_no_trailing(self, key, value): - data = [] - for (track, total) in value: - if 0 <= track < 1 << 16 and 0 <= total < 1 << 16: - data.append(struct.pack(">3H", 0, track, total)) - else: - raise MP4MetadataValueError( - "invalid numeric pair %r" % ((track, total),)) - return self.__render_data(key, 0, data) - - def __parse_genre(self, atom, data): - # Translate to a freeform genre. - genre = cdata.short_be(data[16:18]) - if b"\xa9gen" not in self: - try: - self[b"\xa9gen"] = [GENRES[genre - 1]] - except IndexError: - pass - - def __parse_tempo(self, atom, data): - self[atom.name] = [cdata.ushort_be(value[1]) for - value in self.__parse_data(atom, data)] - - def __render_tempo(self, key, value): - try: - if len(value) == 0: - return self.__render_data(key, 0x15, b"") - - if min(value) < 0 or max(value) >= 2**16: - raise MP4MetadataValueError( - "invalid 16 bit integers: %r" % value) - except TypeError: - raise MP4MetadataValueError( - "tmpo must be a list of 16 bit integers") - - values = list(map(cdata.to_ushort_be, value)) - return self.__render_data(key, 0x15, values) - - def __parse_bool(self, atom, data): - try: - self[atom.name] = bool(ord(data[16:17])) - except TypeError: - self[atom.name] = False - - def __render_bool(self, key, value): - return self.__render_data(key, 0x15, [chr_(bool(value))]) - - def __parse_cover(self, atom, data): - self[atom.name] = [] - pos = 0 - while pos < atom.length - 8: - length, name, imageformat = struct.unpack(">I4sI", - data[pos:pos+12]) - if name != b"data": - if name == b"name": - pos += length - continue - raise MP4MetadataError( - "unexpected atom %r inside 'covr'" % name) - if imageformat not in (MP4Cover.FORMAT_JPEG, MP4Cover.FORMAT_PNG): - imageformat = MP4Cover.FORMAT_JPEG - cover = MP4Cover(data[pos+16:pos+length], imageformat) - self[atom.name].append(cover) - pos += length - - def __render_cover(self, key, value): - atom_data = [] - for cover in value: - try: - imageformat = cover.imageformat - except AttributeError: - imageformat = MP4Cover.FORMAT_JPEG - atom_data.append(Atom.render( - b"data", struct.pack(">2I", imageformat, 0) + cover)) - return Atom.render(key, b"".join(atom_data)) - - def __parse_text(self, atom, data, expected_flags=1): - value = [text.decode('utf-8', 'replace') for flags, text - in self.__parse_data(atom, data) - if flags == expected_flags] - if value: - self[atom.name] = value - - def __render_text(self, key, value, flags=1): - if isinstance(value, string_types): - value = [value] - return self.__render_data( - key, flags, [utf8(v) for v in value]) - - def delete(self, filename): - """Remove the metadata from the given filename.""" - - self.clear() - self.save(filename) - - __atoms = { - b"----": (__parse_freeform, __render_freeform), - b"trkn": (__parse_pair, __render_pair), - b"disk": (__parse_pair, __render_pair_no_trailing), - b"gnre": (__parse_genre, None), - b"tmpo": (__parse_tempo, __render_tempo), - b"cpil": (__parse_bool, __render_bool), - b"pgap": (__parse_bool, __render_bool), - b"pcst": (__parse_bool, __render_bool), - b"covr": (__parse_cover, __render_cover), - b"purl": (__parse_text, __render_text, 0), - b"egid": (__parse_text, __render_text, 0), - } - - # the text atoms we know about which should make loading fail if parsing - # any of them fails - for name in [b"\xa9nam", b"\xa9alb", b"\xa9ART", b"aART", b"\xa9wrt", - b"\xa9day", b"\xa9cmt", b"desc", b"purd", b"\xa9grp", - b"\xa9gen", b"\xa9lyr", b"catg", b"keyw", b"\xa9too", - b"cprt", b"soal", b"soaa", b"soar", b"sonm", b"soco", - b"sosn", b"tvsh"]: - __atoms[name] = (__parse_text, __render_text) - - def pprint(self): - values = [] - for key, value in self.iteritems(): - key = key.decode('latin1', "replace") - if key == "covr": - values.append("%s=%s" % (key, ", ".join( - ["[%d bytes of data]" % len(data) for data in value]))) - elif isinstance(value, list): - values.append("%s=%s" % - (key, " / ".join(map(text_type, value)))) - else: - values.append("%s=%s" % (key, value)) - return "\n".join(values) - - -class MP4Info(StreamInfo): - """MPEG-4 stream information. - - Attributes: - - * bitrate -- bitrate in bits per second, as an int - * length -- file length in seconds, as a float - * channels -- number of audio channels - * sample_rate -- audio sampling rate in Hz - * bits_per_sample -- bits per sample - """ - - bitrate = 0 - channels = 0 - sample_rate = 0 - bits_per_sample = 0 - - def __init__(self, atoms, fileobj): - for trak in list(atoms[b"moov"].findall(b"trak")): - hdlr = trak[b"mdia", b"hdlr"] - fileobj.seek(hdlr.offset) - data = fileobj.read(hdlr.length) - if data[16:20] == b"soun": - break - else: - raise MP4StreamInfoError("track has no audio data") - - mdhd = trak[b"mdia", b"mdhd"] - fileobj.seek(mdhd.offset) - data = fileobj.read(mdhd.length) - if ord(data[8:9]) == 0: - offset = 20 - fmt = ">2I" - else: - offset = 28 - fmt = ">IQ" - end = offset + struct.calcsize(fmt) - unit, length = struct.unpack(fmt, data[offset:end]) - self.length = float(length) / unit - - try: - atom = trak[b"mdia", b"minf", b"stbl", b"stsd"] - fileobj.seek(atom.offset) - data = fileobj.read(atom.length) - if data[20:24] == b"mp4a": - length = cdata.uint_be(data[16:20]) - (self.channels, self.bits_per_sample, _, - self.sample_rate) = struct.unpack(">3HI", data[40:50]) - # ES descriptor type - if data[56:60] == b"esds" and ord(data[64:65]) == 0x03: - pos = 65 - # skip extended descriptor type tag, length, ES ID - # and stream priority - if data[pos:pos+3] == b"\x80\x80\x80": - pos += 3 - pos += 4 - # decoder config descriptor type - if ord(data[pos:pos+1]) == 0x04: - pos += 1 - # skip extended descriptor type tag, length, - # object type ID, stream type, buffer size - # and maximum bitrate - if data[pos:pos+3] == b"\x80\x80\x80": - pos += 3 - pos += 10 - # average bitrate - self.bitrate = cdata.uint_be(data[pos:pos+4]) - except (ValueError, KeyError): - # stsd atoms are optional - pass - - def pprint(self): - return "MPEG-4 audio, %.2f seconds, %d bps" % ( - self.length, self.bitrate) - - -class MP4(FileType): - """An MPEG-4 audio file, probably containing AAC. - - If more than one track is present in the file, the first is used. - Only audio ('soun') tracks will be read. - - :ivar info: :class:`MP4Info` - :ivar tags: :class:`MP4Tags` - """ - - MP4Tags = MP4Tags - - _mimes = ["audio/mp4", "audio/x-m4a", "audio/mpeg4", "audio/aac"] - - def load(self, filename): - self.filename = filename - fileobj = open(filename, "rb") - try: - atoms = Atoms(fileobj) - - # ftyp is always the first atom in a valid MP4 file - if not atoms.atoms or atoms.atoms[0].name != b"ftyp": - raise error("Not a MP4 file") - - try: - self.info = MP4Info(atoms, fileobj) - except error: - raise - except Exception as err: - reraise(MP4StreamInfoError, err, sys.exc_info()[2]) - - if not MP4Tags._can_load(atoms): - self.tags = None - else: - try: - self.tags = self.MP4Tags(atoms, fileobj) - except error: - raise - except Exception as err: - reraise(MP4MetadataError, err, sys.exc_info()[2]) - finally: - fileobj.close() - - def add_tags(self): - if self.tags is None: - self.tags = self.MP4Tags() - else: - raise error("an MP4 tag already exists") - - @staticmethod - def score(filename, fileobj, header): - return (b"ftyp" in header) + (b"mp4" in header) - - -Open = MP4 - - -def delete(filename): - """Remove tags from a file.""" - - MP4(filename).delete() diff -Nru mutagen-1.23/mutagen/musepack.py mutagen-1.30/mutagen/musepack.py --- mutagen-1.23/mutagen/musepack.py 2013-10-07 13:32:17.000000000 +0000 +++ mutagen-1.30/mutagen/musepack.py 2015-02-01 19:07:57.000000000 +0000 @@ -1,7 +1,7 @@ -# A Musepack reader/tagger -# -# Copyright 2006 Lukas Lalinsky -# Copyright 2012 Christoph Reiter +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Lukas Lalinsky +# Copyright (C) 2012 Christoph Reiter # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as @@ -19,12 +19,11 @@ import struct -from ._compat import endswith +from ._compat import endswith, xrange from mutagen import StreamInfo from mutagen.apev2 import APEv2File, error, delete from mutagen.id3 import BitPaddedInt from mutagen._util import cdata -from ._compat import xrange class MusepackHeaderError(error): @@ -49,8 +48,9 @@ c = fileobj.read(1) if len(c) != 1: raise EOFError - num = (num << 7) | (ord(c) & 0x7F) - if not ord(c) & 0x80: + c = bytearray(c) + num = (num << 7) | (c[0] & 0x7F) + if not c[0] & 0x80: return num, i + 1 if limit > 0: raise ValueError @@ -114,13 +114,14 @@ self.bitrate = int(round(fileobj.tell() * 8 / self.length)) def __parse_sv8(self, fileobj): - #SV8 http://trac.musepack.net/trac/wiki/SV8Specification + # SV8 http://trac.musepack.net/trac/wiki/SV8Specification key_size = 2 mandatory_packets = [b"SH", b"RG"] def check_frame_key(key): - if len(frame_type) != key_size or not b'AA' <= frame_type <= b'ZZ': + if ((len(frame_type) != key_size) or + (not b'AA' <= frame_type <= b'ZZ')): raise MusepackHeaderError("Invalid frame key.") frame_type = fileobj.read(key_size) @@ -132,6 +133,7 @@ except (EOFError, ValueError): raise MusepackHeaderError("Invalid packet size.") data_size = frame_size - key_size - slen + # packets can be at maximum data_size big and are padded with zeros if frame_type == b"SH": mandatory_packets.remove(frame_type) @@ -153,30 +155,36 @@ self.bitrate = 0 def __parse_stream_header(self, fileobj, data_size): + # skip CRC fileobj.seek(4, 1) + remaining_size = data_size - 4 + try: - self.version = ord(fileobj.read(1)) + self.version = bytearray(fileobj.read(1))[0] except TypeError: raise MusepackHeaderError("SH packet ended unexpectedly.") + + remaining_size -= 1 + try: samples, l1 = _parse_sv8_int(fileobj) samples_skip, l2 = _parse_sv8_int(fileobj) except (EOFError, ValueError): raise MusepackHeaderError( "SH packet: Invalid sample counts.") - left_size = data_size - 5 - l1 - l2 - if left_size != 2: - raise MusepackHeaderError("Invalid SH packet size.") - data = fileobj.read(left_size) - if len(data) != left_size: - raise MusepackHeaderError("SH packet ended unexpectedly.") - self.sample_rate = RATES[ord(data[-2:-1]) >> 5] - self.channels = (ord(data[-1:]) >> 4) + 1 + self.samples = samples - samples_skip + remaining_size -= l1 + l2 + + data = fileobj.read(remaining_size) + if len(data) != remaining_size: + raise MusepackHeaderError("SH packet ended unexpectedly.") + self.sample_rate = RATES[bytearray(data)[0] >> 5] + self.channels = (bytearray(data)[1] >> 4) + 1 def __parse_replaygain_packet(self, fileobj, data_size): data = fileobj.read(data_size) - if data_size != 9: + if data_size < 9: raise MusepackHeaderError("Invalid RG packet size.") if len(data) != data_size: raise MusepackHeaderError("RG packet ended unexpectedly.") @@ -201,7 +209,7 @@ # SV7 if header.startswith(b"MP+"): - self.version = ord(header[3:4]) & 0xF + self.version = bytearray(header)[3] & 0xF if self.version < 7: raise MusepackHeaderError("not a Musepack file") frames = cdata.uint_le(header[4:8]) @@ -253,8 +261,10 @@ @staticmethod def score(filename, fileobj, header): + filename = filename.lower() + return (header.startswith(b"MP+") + header.startswith(b"MPCK") + - endswith(filename.lower(), b".mpc")) + endswith(filename, b".mpc")) Open = Musepack diff -Nru mutagen-1.23/mutagen/oggflac.py mutagen-1.30/mutagen/oggflac.py --- mutagen-1.23/mutagen/oggflac.py 2013-09-13 09:43:26.000000000 +0000 +++ mutagen-1.30/mutagen/oggflac.py 2015-02-01 19:07:57.000000000 +0000 @@ -1,6 +1,6 @@ -# Ogg FLAC support. -# -# Copyright 2006 Joe Wreschnig +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Joe Wreschnig # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as @@ -21,8 +21,7 @@ from ._compat import cBytesIO -from mutagen import flac -from mutagen.flac import VCFLACDict, StrictFileObject +from mutagen.flac import StreamInfo, VCFLACDict, StrictFileObject from mutagen.ogg import OggPage, OggFileType, error as OggError @@ -34,7 +33,7 @@ pass -class OggFLACStreamInfo(flac.StreamInfo): +class OggFLACStreamInfo(StreamInfo): """Ogg FLAC general header and stream info. This encompasses the Ogg wrapper for the FLAC STREAMINFO metadata diff -Nru mutagen-1.23/mutagen/oggopus.py mutagen-1.30/mutagen/oggopus.py --- mutagen-1.23/mutagen/oggopus.py 2013-09-10 16:01:04.000000000 +0000 +++ mutagen-1.30/mutagen/oggopus.py 2015-02-01 19:07:57.000000000 +0000 @@ -1,4 +1,6 @@ -# Copyright 2012, 2013 Christoph Reiter +# -*- coding: utf-8 -*- + +# Copyright (C) 2012, 2013 Christoph Reiter # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as @@ -17,6 +19,7 @@ import struct from mutagen import StreamInfo +from mutagen._compat import BytesIO from mutagen._vorbis import VCommentDict from mutagen.ogg import OggPage, OggFileType, error as OggError @@ -57,7 +60,7 @@ self.__pre_skip = pre_skip # only the higher 4 bits change on incombatible changes - major, minor = version >> 4, version & 0xF + major = version >> 4 if major != 0: raise OggOpusHeaderError("version %r unsupported" % major) @@ -75,8 +78,8 @@ def __get_comment_pages(self, fileobj, info): # find the first tags page with the right serial page = OggPage(fileobj) - while info.serial != page.serial or \ - not page.packets[0].startswith(b"OpusTags"): + while ((info.serial != page.serial) or + not page.packets[0].startswith(b"OpusTags")): page = OggPage(fileobj) # get all comment pages @@ -91,7 +94,16 @@ def __init__(self, fileobj, info): pages = self.__get_comment_pages(fileobj, info) data = OggPage.to_packets(pages)[0][8:] # Strip OpusTags - super(OggOpusVComment, self).__init__(data, framing=False) + fileobj = BytesIO(data) + super(OggOpusVComment, self).__init__(fileobj, framing=False) + + # in case the LSB of the first byte after v-comment is 1, preserve the + # following data + padding_flag = fileobj.read(1) + if padding_flag and ord(padding_flag) & 0x1: + self._pad_data = padding_flag + fileobj.read() + else: + self._pad_data = b"" def _inject(self, fileobj): fileobj.seek(0) @@ -99,7 +111,7 @@ old_pages = self.__get_comment_pages(fileobj, info) packets = OggPage.to_packets(old_pages) - packets[0] = b"OpusTags" + self.write(framing=False) + packets[0] = b"OpusTags" + self.write(framing=False) + self._pad_data new_pages = OggPage.from_packets(packets, old_pages[0].sequence) OggPage.replace(fileobj, old_pages, new_pages) diff -Nru mutagen-1.23/mutagen/ogg.py mutagen-1.30/mutagen/ogg.py --- mutagen-1.23/mutagen/ogg.py 2013-09-10 15:56:18.000000000 +0000 +++ mutagen-1.30/mutagen/ogg.py 2015-02-01 19:07:57.000000000 +0000 @@ -1,4 +1,6 @@ -# Copyright 2006 Joe Wreschnig +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Joe Wreschnig # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as @@ -19,11 +21,11 @@ import zlib from mutagen import FileType -from mutagen._util import cdata, insert_bytes, delete_bytes +from mutagen._util import cdata, insert_bytes, delete_bytes, MutagenError from ._compat import cBytesIO, reraise, chr_ -class error(IOError): +class error(IOError, MutagenError): """Ogg stream parsing errors.""" pass @@ -77,9 +79,9 @@ raise EOFError try: - (oggs, self.version, self.__type_flags, self.position, - self.serial, self.sequence, crc, segments) = struct.unpack( - "<4sBBqIIiB", header) + (oggs, self.version, self.__type_flags, + self.position, self.serial, self.sequence, + crc, segments) = struct.unpack("<4sBBqIIiB", header) except struct.error: raise error("unable to read full header; got %r" % header) @@ -195,8 +197,8 @@ lambda self, v: self.__set_flag(2, v), doc="This is the last page of a logical bitstream.") - @classmethod - def renumber(klass, fileobj, serial, start): + @staticmethod + def renumber(fileobj, serial, start): """Renumber pages belonging to a specified logical stream. fileobj must be opened with mode r+b or w+b. @@ -234,8 +236,8 @@ fileobj.seek(page.offset + page.size, 0) number += 1 - @classmethod - def to_packets(klass, pages, strict=False): + @staticmethod + def to_packets(pages, strict=False): """Construct a list of packet data from a list of Ogg pages. If strict is true, the first page must start a new packet, @@ -266,13 +268,13 @@ packets[-1].append(page.packets[0]) else: packets.append([page.packets[0]]) - packets.extend([[p] for p in page.packets[1:]]) + packets.extend([p] for p in page.packets[1:]) return [b"".join(p) for p in packets] - @classmethod - def from_packets(klass, packets, sequence=0, - default_size=4096, wiggle_room=2048): + @staticmethod + def from_packets(packets, sequence=0, default_size=4096, + wiggle_room=2048): """Construct a list of Ogg pages from a list of packet data. The algorithm will generate pages of approximately @@ -332,7 +334,7 @@ return pages @classmethod - def replace(klass, fileobj, old_pages, new_pages): + def replace(cls, fileobj, old_pages, new_pages): """Replace old_pages with new_pages within fileobj. old_pages must have come from reading fileobj originally. @@ -360,7 +362,7 @@ if not new_pages[-1].complete and len(new_pages[-1].packets) == 1: new_pages[-1].position = -1 - new_data = b"".join(map(klass.write, new_pages)) + new_data = b"".join(cls.write(p) for p in new_pages) # Make room in the file for the new data. delta = len(new_data) @@ -385,10 +387,10 @@ fileobj.seek(new_data_end, 0) serial = new_pages[-1].serial sequence = new_pages[-1].sequence + 1 - klass.renumber(fileobj, serial, sequence) + cls.renumber(fileobj, serial, sequence) - @classmethod - def find_last(klass, fileobj, serial): + @staticmethod + def find_last(fileobj, serial): """Find the last page of the stream 'serial'. If the file is not multiplexed this function is fast. If it is, @@ -400,7 +402,7 @@ # For non-muxed streams, look at the last page. try: - fileobj.seek(-256*256, 2) + fileobj.seek(-256 * 256, 2) except IOError: # The file is less than 64k in length. fileobj.seek(0) @@ -409,10 +411,10 @@ index = data.rindex(b"OggS") except ValueError: raise error("unable to find final Ogg header") - stringobj = cBytesIO(data[index:]) + bytesobj = cBytesIO(data[index:]) best_page = None try: - page = OggPage(stringobj) + page = OggPage(bytesobj) except error: pass else: diff -Nru mutagen-1.23/mutagen/oggspeex.py mutagen-1.30/mutagen/oggspeex.py --- mutagen-1.23/mutagen/oggspeex.py 2013-09-10 16:21:14.000000000 +0000 +++ mutagen-1.30/mutagen/oggspeex.py 2015-02-01 19:07:57.000000000 +0000 @@ -1,5 +1,5 @@ -# Ogg Speex support. -# +# -*- coding: utf-8 -*- + # Copyright 2006 Joe Wreschnig # # This program is free software; you can redistribute it and/or modify diff -Nru mutagen-1.23/mutagen/oggtheora.py mutagen-1.30/mutagen/oggtheora.py --- mutagen-1.23/mutagen/oggtheora.py 2013-09-10 16:20:21.000000000 +0000 +++ mutagen-1.30/mutagen/oggtheora.py 2015-02-01 19:07:57.000000000 +0000 @@ -1,5 +1,5 @@ -# Ogg Theora support. -# +# -*- coding: utf-8 -*- + # Copyright 2006 Joe Wreschnig # # This program is free software; you can redistribute it and/or modify @@ -119,7 +119,7 @@ @staticmethod def score(filename, fileobj, header): return (header.startswith(b"OggS") * - ((b"\x80theora" in header) + (b"\x81theora" in header))) + ((b"\x80theora" in header) + (b"\x81theora" in header)) * 2) Open = OggTheora diff -Nru mutagen-1.23/mutagen/oggvorbis.py mutagen-1.30/mutagen/oggvorbis.py --- mutagen-1.23/mutagen/oggvorbis.py 2013-09-10 16:18:09.000000000 +0000 +++ mutagen-1.30/mutagen/oggvorbis.py 2015-02-01 19:07:57.000000000 +0000 @@ -1,5 +1,5 @@ -# Ogg Vorbis support. -# +# -*- coding: utf-8 -*- + # Copyright 2006 Joe Wreschnig # # This program is free software; you can redistribute it and/or modify @@ -74,7 +74,8 @@ self.length = page.position / float(self.sample_rate) def pprint(self): - return u"Ogg Vorbis, %.2f seconds, %d bps" % (self.length, self.bitrate) + return u"Ogg Vorbis, %.2f seconds, %d bps" % ( + self.length, self.bitrate) class OggVCommentDict(VCommentDict): diff -Nru mutagen-1.23/mutagen/optimfrog.py mutagen-1.30/mutagen/optimfrog.py --- mutagen-1.23/mutagen/optimfrog.py 2013-09-09 11:18:34.000000000 +0000 +++ mutagen-1.30/mutagen/optimfrog.py 2015-02-01 19:07:57.000000000 +0000 @@ -1,6 +1,6 @@ -# OptimFROG reader/tagger -# -# Copyright 2006 Lukas Lalinsky +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Lukas Lalinsky # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as diff -Nru mutagen-1.23/mutagen/_tags.py mutagen-1.30/mutagen/_tags.py --- mutagen-1.23/mutagen/_tags.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/mutagen/_tags.py 2015-04-25 08:46:30.000000000 +0000 @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2005 Michael Urman +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + + +class Metadata(object): + """An abstract dict-like object. + + Metadata is the base class for many of the tag objects in Mutagen. + """ + + __module__ = "mutagen" + + def __init__(self, *args, **kwargs): + if args or kwargs: + self.load(*args, **kwargs) + + def load(self, *args, **kwargs): + raise NotImplementedError + + def save(self, filename=None): + """Save changes to a file.""" + + raise NotImplementedError + + def delete(self, filename=None): + """Remove tags from a file.""" + + raise NotImplementedError diff -Nru mutagen-1.23/mutagen/_toolsutil.py mutagen-1.30/mutagen/_toolsutil.py --- mutagen-1.23/mutagen/_toolsutil.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/mutagen/_toolsutil.py 2015-04-29 20:59:36.000000000 +0000 @@ -0,0 +1,217 @@ +# -*- coding: utf-8 -*- + +# Copyright 2015 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +import os +import sys +import signal +import locale +import contextlib +import optparse + +from ._compat import text_type, PY2, PY3, iterbytes + + +def split_escape(string, sep, maxsplit=None, escape_char="\\"): + """Like unicode/str/bytes.split but allows for the separator to be escaped + + If passed unicode/str/bytes will only return list of unicode/str/bytes. + """ + + assert len(sep) == 1 + assert len(escape_char) == 1 + + if isinstance(string, bytes): + if isinstance(escape_char, text_type): + escape_char = escape_char.encode("ascii") + iter_ = iterbytes + else: + iter_ = iter + + if maxsplit is None: + maxsplit = len(string) + + empty = string[:0] + result = [] + current = empty + escaped = False + for char in iter_(string): + if escaped: + if char != escape_char and char != sep: + current += escape_char + current += char + escaped = False + else: + if char == escape_char: + escaped = True + elif char == sep and len(result) < maxsplit: + result.append(current) + current = empty + else: + current += char + result.append(current) + return result + + +class SignalHandler(object): + + def __init__(self): + self._interrupted = False + self._nosig = False + self._init = False + + def init(self): + signal.signal(signal.SIGINT, self._handler) + signal.signal(signal.SIGTERM, self._handler) + if os.name != "nt": + signal.signal(signal.SIGHUP, self._handler) + + def _handler(self, signum, frame): + self._interrupted = True + if not self._nosig: + raise SystemExit("Aborted...") + + @contextlib.contextmanager + def block(self): + """While this context manager is active any signals for aborting + the process will be queued and exit the program once the context + is left. + """ + + self._nosig = True + yield + self._nosig = False + if self._interrupted: + raise SystemExit("Aborted...") + + +def get_win32_unicode_argv(): + """Returns a unicode argv under Windows and standard sys.argv otherwise""" + + if os.name != "nt" or not PY2: + return sys.argv + + import ctypes + from ctypes import cdll, windll, wintypes + + GetCommandLineW = cdll.kernel32.GetCommandLineW + GetCommandLineW.argtypes = [] + GetCommandLineW.restype = wintypes.LPCWSTR + + CommandLineToArgvW = windll.shell32.CommandLineToArgvW + CommandLineToArgvW.argtypes = [ + wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)] + CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR) + + LocalFree = windll.kernel32.LocalFree + LocalFree.argtypes = [wintypes.HLOCAL] + LocalFree.restype = wintypes.HLOCAL + + argc = ctypes.c_int() + argv = CommandLineToArgvW(GetCommandLineW(), ctypes.byref(argc)) + if not argv: + return + + res = argv[max(0, argc.value - len(sys.argv)):argc.value] + + LocalFree(argv) + + return res + + +def fsencoding(): + """The encoding used for paths, argv, environ, stdout and stdin""" + + if os.name == "nt": + return "" + + return locale.getpreferredencoding() or "utf-8" + + +def fsnative(text=u""): + """Returns the passed text converted to the preferred path type + for each platform. + """ + + assert isinstance(text, text_type) + + if os.name == "nt" or PY3: + return text + else: + return text.encode(fsencoding(), "replace") + return text + + +def is_fsnative(arg): + """If the passed value is of the preferred path type for each platform. + Note that on Python3+linux, paths can be bytes or str but this returns + False for bytes there. + """ + + if PY3 or os.name == "nt": + return isinstance(arg, text_type) + else: + return isinstance(arg, bytes) + + +def print_(*objects, **kwargs): + """A print which supports bytes and str+surrogates under python3. + + Needed so we can print anything passed to us through argv and environ. + Under Windows only text_type is allowed. + + Arguments: + objects: one or more bytes/text + linesep (bool): whether a line separator should be appended + sep (bool): whether objects should be printed separated by spaces + """ + + linesep = kwargs.pop("linesep", True) + sep = kwargs.pop("sep", True) + file_ = kwargs.pop("file", None) + if file_ is None: + file_ = sys.stdout + + if os.name == "nt": + encoding = getattr(sys.stdout, "encoding", None) or "utf-8" + else: + encoding = fsencoding() + + if linesep: + objects = list(objects) + [os.linesep] + + parts = [] + for text in objects: + if isinstance(text, text_type): + if PY3: + try: + text = text.encode(encoding, 'surrogateescape') + except UnicodeEncodeError: + text = text.encode(encoding, 'replace') + else: + text = text.encode(encoding, 'replace') + parts.append(text) + + data = (b" " if sep else b"").join(parts) + try: + fileno = file_.fileno() + except (AttributeError, OSError, ValueError): + # for tests when stdout is replaced + try: + file_.write(data) + except TypeError: + file_.write(data.decode(encoding, "replace")) + else: + file_.flush() + os.write(fileno, data) + + +class OptionParser(optparse.OptionParser): + """OptionParser subclass which supports printing Unicode under Windows""" + + def print_help(self, file=None): + print_(self.format_help(), file=file) diff -Nru mutagen-1.23/mutagen/trueaudio.py mutagen-1.30/mutagen/trueaudio.py --- mutagen-1.23/mutagen/trueaudio.py 2013-09-12 16:51:59.000000000 +0000 +++ mutagen-1.30/mutagen/trueaudio.py 2015-02-01 19:07:57.000000000 +0000 @@ -1,5 +1,6 @@ -# True Audio support for Mutagen -# Copyright 2006 Joe Wreschnig +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Joe Wreschnig # # This program is free software; you can redistribute it and/or modify # it under the terms of version 2 of the GNU General Public License as @@ -19,10 +20,10 @@ from ._compat import endswith from mutagen import StreamInfo from mutagen.id3 import ID3FileType, delete -from mutagen._util import cdata +from mutagen._util import cdata, MutagenError -class error(RuntimeError): +class error(RuntimeError, MutagenError): pass diff -Nru mutagen-1.23/mutagen/_util.py mutagen-1.30/mutagen/_util.py --- mutagen-1.23/mutagen/_util.py 2013-09-13 10:36:24.000000000 +0000 +++ mutagen-1.30/mutagen/_util.py 2015-08-17 10:42:51.000000000 +0000 @@ -1,4 +1,6 @@ -# Copyright 2006 Joe Wreschnig +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Joe Wreschnig # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as @@ -11,15 +13,23 @@ """ import struct +import codecs from fnmatch import fnmatchcase -from ._compat import chr_, text_type, PY2, iteritems +from ._compat import chr_, PY2, iteritems, iterbytes, integer_types, xrange + + +class MutagenError(Exception): + """Base class for all custom exceptions in mutagen + + .. versionadded:: 1.25 + """ def total_ordering(cls): - assert hasattr(cls, "__eq__") - assert hasattr(cls, "__lt__") + assert "__eq__" in cls.__dict__ + assert "__lt__" in cls.__dict__ cls.__le__ = lambda self, other: self == other or self < other cls.__gt__ = lambda self, other: not (self == other or self < other) @@ -29,6 +39,53 @@ return cls +def hashable(cls): + """Makes sure the class is hashable. + + Needs a working __eq__ and __hash__ and will add a __ne__. + """ + + # py2 + assert "__hash__" in cls.__dict__ + # py3 + assert cls.__dict__["__hash__"] is not None + assert "__eq__" in cls.__dict__ + + cls.__ne__ = lambda self, other: not self.__eq__(other) + + return cls + + +def enum(cls): + assert cls.__bases__ == (object,) + + d = dict(cls.__dict__) + new_type = type(cls.__name__, (int,), d) + new_type.__module__ = cls.__module__ + + map_ = {} + for key, value in iteritems(d): + if key.upper() == key and isinstance(value, integer_types): + value_instance = new_type(value) + setattr(new_type, key, value_instance) + map_[value] = key + + def str_(self): + if self in map_: + return "%s.%s" % (type(self).__name__, map_[self]) + return "%d" % int(self) + + def repr_(self): + if self in map_: + return "<%s.%s: %d>" % (type(self).__name__, map_[self], int(self)) + return "%d" % int(self) + + setattr(new_type, "__repr__", repr_) + setattr(new_type, "__str__", str_) + + return new_type + + @total_ordering class DictMixin(object): """Implement the dict API using keys() and __*item__ methods. @@ -61,17 +118,20 @@ __contains__ = __has_key - iterkeys = lambda self: iter(self.keys()) + if PY2: + iterkeys = lambda self: iter(self.keys()) def values(self): return [self[k] for k in self.keys()] - itervalues = lambda self: iter(self.values()) + if PY2: + itervalues = lambda self: iter(self.values()) def items(self): return list(zip(self.keys(), self.values())) - iteritems = lambda s: iter(s.items()) + if PY2: + iteritems = lambda s: iter(s.items()) def clear(self): for key in list(self.keys()): @@ -155,59 +215,61 @@ return self.__dict.keys() -class cdata(object): - """C character buffer to Python numeric type conversions.""" - - from struct import error - error = error - - short_le = staticmethod(lambda data: struct.unpack('h', data)[0]) - ushort_be = staticmethod(lambda data: struct.unpack('>H', data)[0]) - - int_le = staticmethod(lambda data: struct.unpack('i', data)[0]) - uint_be = staticmethod(lambda data: struct.unpack('>I', data)[0]) + funcs = {} + for key, name in [("b", "char"), ("h", "short"), + ("i", "int"), ("q", "longlong")]: + for echar, esuffix in [("<", "le"), (">", "be")]: + esuffix = "_" + esuffix + for unsigned in [True, False]: + s = struct.Struct(echar + (key.upper() if unsigned else key)) + get_wrapper = lambda f: lambda *a, **k: f(*a, **k)[0] + unpack = get_wrapper(s.unpack) + unpack_from = get_wrapper(s.unpack_from) + + def get_unpack_from(s): + def unpack_from(data, offset=0): + return s.unpack_from(data, offset)[0], offset + s.size + return unpack_from + + unpack_from = get_unpack_from(s) + pack = s.pack + + prefix = "u" if unsigned else "" + if s.size == 1: + esuffix = "" + bits = str(s.size * 8) + funcs["%s%s%s" % (prefix, name, esuffix)] = unpack + funcs["%sint%s%s" % (prefix, bits, esuffix)] = unpack + funcs["%s%s%s_from" % (prefix, name, esuffix)] = unpack_from + funcs["%sint%s%s_from" % (prefix, bits, esuffix)] = unpack_from + funcs["to_%s%s%s" % (prefix, name, esuffix)] = pack + funcs["to_%sint%s%s" % (prefix, bits, esuffix)] = pack - longlong_le = staticmethod(lambda data: struct.unpack('q', data)[0]) - ulonglong_be = staticmethod(lambda data: struct.unpack('>Q', data)[0]) - to_short_le = staticmethod(lambda data: struct.pack('h', data)) - to_ushort_be = staticmethod(lambda data: struct.pack('>H', data)) + For each size/sign/endianness: + uint32_le(data)/to_uint32_le(num)/uint32_le_from(data, offset=0) + """ - to_int_le = staticmethod(lambda data: struct.pack('i', data)) - to_uint_be = staticmethod(lambda data: struct.pack('>I', data)) + bitswap = b''.join( + chr_(sum(((val >> i) & 1) << (7 - i) for i in range(8))) + for val in range(256)) - to_longlong_le = staticmethod(lambda data: struct.pack('> n) & 1)) - to_longlong_be = staticmethod(lambda data: struct.pack('>q', data)) - to_ulonglong_be = staticmethod(lambda data: struct.pack('>Q', data)) - bitswap = b''.join([chr_(sum([((val >> i) & 1) << (7-i) - for i in range(8)])) - for val in range(256)]) - - try: - del(i) - del(val) - except NameError: - pass - - test_bit = staticmethod(lambda value, n: bool((value >> n) & 1)) +_fill_cdata(cdata) def lock(fileobj): @@ -251,7 +313,7 @@ fcntl.lockf(fileobj, fcntl.LOCK_UN) -def insert_bytes(fobj, size, offset, BUFFER_SIZE=2**16): +def insert_bytes(fobj, size, offset, BUFFER_SIZE=2 ** 16): """Insert size bytes of empty space starting at offset. fobj must be an open file object, open rb+ or @@ -270,11 +332,11 @@ try: try: import mmap - map = mmap.mmap(fobj.fileno(), filesize + size) + file_map = mmap.mmap(fobj.fileno(), filesize + size) try: - map.move(offset + size, offset, movesize) + file_map.move(offset + size, offset, movesize) finally: - map.close() + file_map.close() except (ValueError, EnvironmentError, ImportError): # handle broken mmap scenarios locked = lock(fobj) @@ -313,7 +375,7 @@ unlock(fobj) -def delete_bytes(fobj, size, offset, BUFFER_SIZE=2**16): +def delete_bytes(fobj, size, offset, BUFFER_SIZE=2 ** 16): """Delete size bytes of empty space starting at offset. fobj must be an open file object, open rb+ or @@ -333,11 +395,11 @@ fobj.flush() try: import mmap - map = mmap.mmap(fobj.fileno(), filesize) + file_map = mmap.mmap(fobj.fileno(), filesize) try: - map.move(offset, offset + size, movesize) + file_map.move(offset, offset + size, movesize) finally: - map.close() + file_map.close() except (ValueError, EnvironmentError, ImportError): # handle broken mmap scenarios locked = lock(fobj) @@ -356,22 +418,149 @@ unlock(fobj) -def utf8(data): - """Convert a basestring to a valid UTF-8 str.""" - - if isinstance(data, bytes): - return data.decode("utf-8", "replace").encode("utf-8") - elif isinstance(data, text_type): - return data.encode("utf-8") - else: - raise TypeError("only unicode/str types can be converted to UTF-8") - - def dict_match(d, key, default=None): - try: + """Like __getitem__ but works as if the keys() are all filename patterns. + Returns the value of any dict key that matches the passed key. + """ + + if key in d and "[" not in key: return d[key] - except KeyError: + else: for pattern, value in iteritems(d): if fnmatchcase(key, pattern): return value return default + + +def decode_terminated(data, encoding, strict=True): + """Returns the decoded data until the first NULL terminator + and all data after it. + + In case the data can't be decoded raises UnicodeError. + In case the encoding is not found raises LookupError. + In case the data isn't null terminated (even if it is encoded correctly) + raises ValueError except if strict is False, then the decoded string + will be returned anyway. + """ + + codec_info = codecs.lookup(encoding) + + # normalize encoding name so we can compare by name + encoding = codec_info.name + + # fast path + if encoding in ("utf-8", "iso8859-1"): + index = data.find(b"\x00") + if index == -1: + # make sure we raise UnicodeError first, like in the slow path + res = data.decode(encoding), b"" + if strict: + raise ValueError("not null terminated") + else: + return res + return data[:index].decode(encoding), data[index + 1:] + + # slow path + decoder = codec_info.incrementaldecoder() + r = [] + for i, b in enumerate(iterbytes(data)): + c = decoder.decode(b) + if c == u"\x00": + return u"".join(r), data[i + 1:] + r.append(c) + else: + # make sure the decoder is finished + r.append(decoder.decode(b"", True)) + if strict: + raise ValueError("not null terminated") + return u"".join(r), b"" + + +class BitReaderError(Exception): + pass + + +class BitReader(object): + + def __init__(self, fileobj): + self._fileobj = fileobj + self._buffer = 0 + self._bits = 0 + self._pos = fileobj.tell() + + def bits(self, count): + """Reads `count` bits and returns an uint, MSB read first. + + May raise BitReaderError if not enough data could be read or + IOError by the underlying file object. + """ + + if count < 0: + raise ValueError + + if count > self._bits: + n_bytes = (count - self._bits + 7) // 8 + data = self._fileobj.read(n_bytes) + if len(data) != n_bytes: + raise BitReaderError("not enough data") + for b in bytearray(data): + self._buffer = (self._buffer << 8) | b + self._bits += n_bytes * 8 + + self._bits -= count + value = self._buffer >> self._bits + self._buffer &= (1 << self._bits) - 1 + assert self._bits < 8 + return value + + def bytes(self, count): + """Returns a bytearray of length `count`. Works unaligned.""" + + if count < 0: + raise ValueError + + # fast path + if self._bits == 0: + data = self._fileobj.read(count) + if len(data) != count: + raise BitReaderError("not enough data") + return data + + return bytes(bytearray(self.bits(8) for _ in xrange(count))) + + def skip(self, count): + """Skip `count` bits. + + Might raise BitReaderError if there wasn't enough data to skip, + but might also fail on the next bits() instead. + """ + + if count < 0: + raise ValueError + + if count <= self._bits: + self.bits(count) + else: + count -= self.align() + n_bytes = count // 8 + self._fileobj.seek(n_bytes, 1) + count -= n_bytes * 8 + self.bits(count) + + def get_position(self): + """Returns the amount of bits read or skipped so far""" + + return (self._fileobj.tell() - self._pos) * 8 - self._bits + + def align(self): + """Align to the next byte, returns the amount of bits skipped""" + + bits = self._bits + self._buffer = 0 + self._bits = 0 + return bits + + def is_aligned(self): + """If we are currently aligned to bytes and nothing is buffered""" + + return self._bits == 0 diff -Nru mutagen-1.23/mutagen/_vorbis.py mutagen-1.30/mutagen/_vorbis.py --- mutagen-1.23/mutagen/_vorbis.py 2013-09-10 16:11:49.000000000 +0000 +++ mutagen-1.30/mutagen/_vorbis.py 2015-02-01 19:07:57.000000000 +0000 @@ -1,6 +1,7 @@ -# Vorbis comment support for Mutagen -# Copyright 2005-2006 Joe Wreschnig -# 2013 Christoph Reiter +# -*- coding: utf-8 -*- + +# Copyright (C) 2005-2006 Joe Wreschnig +# 2013 Christoph Reiter # # This program is free software; you can redistribute it and/or modify # it under the terms of version 2 of the GNU General Public License as @@ -32,7 +33,7 @@ """ if PY3 and isinstance(key, bytes): - raise ValueError + raise TypeError("needs to be str not bytes") for c in key: if c < " " or c > "}" or c == "=": @@ -127,7 +128,8 @@ tag = tag.decode("ascii") if is_valid_key(tag): self.append((tag, value)) - if framing and not ord(fileobj.read(1)) & 0x01: + + if framing and not bytearray(fileobj.read(1))[0] & 0x01: raise VorbisUnsetFrameError("framing bit was unset") except (cdata.error, TypeError): raise error("file is not a valid Vorbis comment") @@ -142,17 +144,10 @@ In Python 3 all keys and values have to be a string. """ - # be stricter in Python 3 - if PY3: - if not isinstance(self.vendor, text_type): - raise ValueError - for key, value in self: - if not isinstance(key, text_type): - raise ValueError - if not isinstance(value, text_type): - raise ValueError - if not isinstance(self.vendor, text_type): + if PY3: + raise ValueError("vendor needs to be str") + try: self.vendor.decode('utf-8') except UnicodeDecodeError: @@ -162,16 +157,19 @@ try: if not is_valid_key(key): raise ValueError - except: + except TypeError: raise ValueError("%r is not a valid key" % key) if not isinstance(value, text_type): + if PY3: + raise ValueError("%r needs to be str" % key) + try: - value.encode("utf-8") + value.decode("utf-8") except: raise ValueError("%r is not a valid value" % value) - else: - return True + + return True def clear(self): """Clear all keys from the comment.""" @@ -244,6 +242,10 @@ work. """ + # PY3 only + if isinstance(key, slice): + return VComment.__getitem__(self, key) + if not is_valid_key(key): raise ValueError @@ -258,6 +260,10 @@ def __delitem__(self, key): """Delete all values associated with the key.""" + # PY3 only + if isinstance(key, slice): + return VComment.__delitem__(self, key) + if not is_valid_key(key): raise ValueError @@ -290,6 +296,10 @@ string. """ + # PY3 only + if isinstance(key, slice): + return VComment.__setitem__(self, key, values) + if not is_valid_key(key): raise ValueError diff -Nru mutagen-1.23/mutagen/wavpack.py mutagen-1.30/mutagen/wavpack.py --- mutagen-1.23/mutagen/wavpack.py 2013-09-11 13:45:52.000000000 +0000 +++ mutagen-1.30/mutagen/wavpack.py 2015-02-01 19:07:57.000000000 +0000 @@ -1,6 +1,7 @@ -# A WavPack reader/tagger -# +# -*- coding: utf-8 -*- + # Copyright 2006 Joe Wreschnig +# 2014 Christoph Reiter # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as @@ -9,7 +10,11 @@ """WavPack reading and writing. WavPack is a lossless format that uses APEv2 tags. Read -http://www.wavpack.com/ for more information. + +* http://www.wavpack.com/ +* http://www.wavpack.com/file_format.txt + +for more information. """ __all__ = ["WavPack", "Open", "delete"] @@ -26,6 +31,45 @@ 48000, 64000, 88200, 96000, 192000] +class _WavPackHeader(object): + + def __init__(self, block_size, version, track_no, index_no, total_samples, + block_index, block_samples, flags, crc): + + self.block_size = block_size + self.version = version + self.track_no = track_no + self.index_no = index_no + self.total_samples = total_samples + self.block_index = block_index + self.block_samples = block_samples + self.flags = flags + self.crc = crc + + @classmethod + def from_fileobj(cls, fileobj): + """A new _WavPackHeader or raises WavPackHeaderError""" + + header = fileobj.read(32) + if len(header) != 32 or not header.startswith(b"wvpk"): + raise WavPackHeaderError("not a WavPack header: %r" % header) + + block_size = cdata.uint_le(header[4:8]) + version = cdata.ushort_le(header[8:10]) + track_no = ord(header[10:11]) + index_no = ord(header[11:12]) + samples = cdata.uint_le(header[12:16]) + if samples == 2 ** 32 - 1: + samples = -1 + block_index = cdata.uint_le(header[16:20]) + block_samples = cdata.uint_le(header[20:24]) + flags = cdata.uint_le(header[24:28]) + crc = cdata.uint_le(header[28:32]) + + return _WavPackHeader(block_size, version, track_no, index_no, + samples, block_index, block_samples, flags, crc) + + class WavPackInfo(StreamInfo): """WavPack stream information. @@ -38,14 +82,30 @@ """ def __init__(self, fileobj): - header = fileobj.read(28) - if len(header) != 28 or not header.startswith(b"wvpk"): + try: + header = _WavPackHeader.from_fileobj(fileobj) + except WavPackHeaderError: raise WavPackHeaderError("not a WavPack file") - samples = cdata.uint_le(header[12:16]) - flags = cdata.uint_le(header[24:28]) - self.version = cdata.short_le(header[8:10]) - self.channels = bool(flags & 4) or 2 - self.sample_rate = RATES[(flags >> 23) & 0xF] + + self.version = header.version + self.channels = bool(header.flags & 4) or 2 + self.sample_rate = RATES[(header.flags >> 23) & 0xF] + + if header.total_samples == -1 or header.block_index != 0: + # TODO: we could make this faster by using the tag size + # and search backwards for the last block, then do + # last.block_index + last.block_samples - initial.block_index + samples = header.block_samples + while 1: + fileobj.seek(header.block_size - 32 + 8, 1) + try: + header = _WavPackHeader.from_fileobj(fileobj) + except WavPackHeaderError: + break + samples += header.block_samples + else: + samples = header.total_samples + self.length = float(samples) / self.sample_rate def pprint(self): diff -Nru mutagen-1.23/NEWS mutagen-1.30/NEWS --- mutagen-1.23/NEWS 2014-05-14 13:26:05.000000000 +0000 +++ mutagen-1.30/NEWS 2015-08-22 17:34:53.000000000 +0000 @@ -1,344 +1,677 @@ +1.30 - 2015.08.22 +----------------- + +* FLAC: + + * Fix :meth:`flac.FLAC.save` in case the source contained a too large + (invalid but recovered) image block :bug:`226` + +* MP3: + + * Improved length and bitrate accuracy: + + * Read lame "Info" tags for improved bitrate/length accuracy + * Use bytes info of VBRI headers for improved bitrate accuracy + * Subtract encoder delay/padding from length for improved length accuracy + (especially for short tracks) + * Fix rare false identification of Xing headers :bug:`182` + + * New :class:`mp3.MPEGInfo`.encoder_info attribute containing the encoder + name and version :bug:`66` + * New :class:`mp3.MPEGInfo`.bitrate_mode attribute exposing if the file is + VBR, ABR or CBR :bug:`24` :bug:`66` + * New :class:`mp3.MPEGInfo`.channels attribute providing the channel count + * New :class:`mp3.MPEGInfo`.track_gain/track_peak/album_gain values exposing + the replaygain info provided by the lame header :bug:`36` + +* ID3: + + * New :class:`id3.PictureType` enum for the picture type used in APIC frames :bug:`222` + +* MP4: + + * Fix MP4FreeForm.__eq__ and MP4Cover.__eq__ when comparing with bytes + :bug:`218` + +* Don't raise on :meth:`FileType.save` if there are no tags. :bug:`227` +* Minor fixes: :bug:`228` + + +1.29 - 2015.05.09 +----------------- + +* mid3v2: Fix an error under Python 3 with files without tags :bug:`219` +* mid3v2: Various Windows+Python2+Unicode fixes :bug:`214` +* Don't emit warnings during loading (ID3Warning) :bug:`223` +* py.test support + + +1.28 - 2015.03.06 +----------------- + +* Various minor fixes to make mutagen behave the same under Python3 as under + Python2. +* Update gpl text :bug:`205` +* Documentation: Add example for how to create a new flac.Picture :bug:`209` + +* ID3: + + * Various error handling fixes (:bug:`110`, :bug:`211`, ...) + * Don't hide ID3 loading errors with ID3FileType. + * In case a synch safe marked frame isn't sync safe, only warn :bug:`210` + * Removed PEDANTIC mode + +* Tools: + + * Add signal handling :bug:`170` + * mid3cp: Make it work under Windows. + * mutagen-inspect: Make it work under Windows+Python3 :bug:`216` + * Support unicode file paths under Windows+Python2 :bug:`214` + * Support file paths with invalid encoding under Unix+Python3. + + +1.27 - 2014.11.28 +----------------- + +* MP4: + + * New ``MP4Info.codec`` for identifying the contained audio codec + e.g. ``"mp4a"``, ``"alac"``, ``"mp4a.40.2"``, ``"ac-3"`` etc. :pr:`6` + * New ``MP4Info.codec_description``: name of the audio codec + e.g. ``"ALAC"``, ``"AAC LC"``, ``"AC-3"`` + +* OggOpus: + + * Preserve data after vorbis comment ( + See https://tools.ietf.org/html/draft-ietf-codec-oggopus-05#section-5.2) + :bug:`202` + +* AAC: + + * New AAC FileType. Supports loading ADTS/ADIF AAC files. :bug:`15` + + +1.26 - 2014.11.10 +----------------- + +* MP4: + + * Parse channels/sample_rate/bits_per_sample/bitrate for ALAC files + :bug:`199` :pr:`5` (Adrian Sampson, Christoph Reiter) + +* ASF: + + * Support writing multiple values for + Author/Title/Copyright/Description/Rating :bug:`151` + * Fix read order for multi value tags + * Various Python3 fixes + +* EasyID3: Add more tag mappings :bug:`136` (Ben Ockmore) + +* MPC/SV8: Fix parsing of SH packets with padding :bug:`198` + +* docs: + + * New logo :pr:`4` (Samuel Messner) + * Add examples for handling cover art in vorbiscomment :bug:`200` + * Add examples for id3v2.3 + + +1.25.1 - 2014.10.13 +------------------- + +* ID3: Fix parsing of some files with Python 3 :bug:`194` + + +1.25 - 2014.10.03 +----------------- + +* Python 3 support (Ben Ockmore et al) :bug:`27` + Supported: Python 2.6, 2.7, 3.3, 3.4 (CPython and PyPy) +* All custom exceptions now have a common mutagen.MutagenError base class +* mutagen.File: prefer theora over vorbis/flac streams in ogg :bug:`184` +* New mid3cp script for copying id3 tags :bug:`178` + (Marcus Sundman, Ben Ockmore) + +* ID3: + + * Parse 2.3/4 frames with 2.2 names :bug:`177` + * Try to detect apev2 tags when looking for id3v1 tags :bug:`122` + * New id3.Encoding, id3.ID3v1SaveOptions enums :bug:`190` + +* ASF: + + * Raise a proper exception on invalid utf-16 :bug:`127` + +* APEv2: + + * Fix UnicodeDecodeError during parsing :bug:`174` + +* MP4: + + * Fix struct.error exception during parsing :bug:`119` + * New AtomDataType enum for MP4FreeForm.dataformat values + * Read some previously ignored purl/egit atoms + * Read multi value reverse DNS tags written by foobar2000 + * Read multi value atoms written by MusicBee :bug:`165` + * Write back unknown atoms and ones that failed to parse. + + +1.24 - 2014.08.13 +----------------- + +* Moved to Bitbucket: https://bitbucket.org/lazka/mutagen +* ID3: + + * Parse utf-16 text frames with wrong termination :bug:`169` + * Fix parsing of utf-16 SYLT frames :bug:`173` + +* WavPack: + + * Fix length calculation if sample count is missing in the header :bug:`180` + +* setup.py: Don't install leftover files produced by the test suite :bug:`179` +* tests: Fix error with POSIX locale :bug:`181` + 1.23 - 2014.05.14 - * tools: Don't crash in misconfigured envs, fall back to utf-8. - * mp3: Return correct mimetype for MP2 files. (#163) - * id3: deterministic sorting of frames. (#166) - * AIFF support (#146, Evan Purkhiser) +----------------- + +* tools: Don't crash in misconfigured envs, fall back to utf-8. +* mp3: Return correct mimetype for MP2 files. :bug:`163` +* id3: deterministic sorting of frames. :bug:`166` +* AIFF support :bug:`146` (Evan Purkhiser) 1.22 - 2013.09.08 - * Minimum required Python version is now 2.6 - * Online API reference at https://mutagen.readthedocs.org/ - * EasyID3: - * Fix crash with empty TXXX values. (#135) - * ID3: - * id3v2.3 writing support (#85) - * Add iTunes podcast frames (TGID, TDES, WFED) (#141) - * Updated id3v1 genre list - * MP4: - * add_tags() will not replace existing tags. (#101) - * Don't ignore tags if parsing unknown atoms fails. - * Raise on invalid 64bit atom size (#132, Sidnei da Silva) - * APEv2: - * Handle invalid tag item count. (#145, Dawid Zamirski) - * Ogg: - * Faster parsing of files with large packets. - * VComment: - * Preserve text case for field names added through the dict interface (#152) - * mid3v2: - * New -e,--escape switch to enable interpretation of escape sequences and - makes escaping of the colon separator possible. (#159) - * mid3iconv: - * Convert COMM frames (#128) +----------------- + +* Minimum required Python version is now 2.6 +* Online API reference at https://mutagen.readthedocs.org/ +* EasyID3: + + * Fix crash with empty TXXX values. :bug:`135` + +* ID3: + + * id3v2.3 writing support :bug:`85` + * Add iTunes podcast frames (TGID, TDES, WFED) :bug:`141` + * Updated id3v1 genre list + +* MP4: + + * add_tags() will not replace existing tags. :bug:`101` + * Don't ignore tags if parsing unknown atoms fails. + * Raise on invalid 64bit atom size :bug:`132` (Sidnei da Silva) + +* APEv2: + + * Handle invalid tag item count. :bug:`145` (Dawid Zamirski) + +* Ogg: + + * Faster parsing of files with large packets. + +* VComment: + + * Preserve text case for field names added through the dict interface + :bug:`152` + +* mid3v2: + + * New -e,--escape switch to enable interpretation of escape sequences and + makes escaping of the colon separator possible. :bug:`159` + +* mid3iconv: + + * Convert COMM frames :bug:`128` 1.21 - 2013.01.30 - * Fix Python 2.3 compatibility (broken in 1.19). - * Fix many warnings triggered by -3. (#27) - * mid3v2: - * Add --TXXX support. (#62, Tim Phipps) - * Add --POPM support. (#71) - * Allow setting multiple COMM or TXXX frames with one command line. - * FLAC: - * Try to handle corrupt Vorbis comment block sizes. (#52) - * Try to handle corrupt Picture block sizes (#106, Christoph Reiter) - * Don't leak file handle with PyPy (#111, Marien Zwart) - * ID3: - * MakeID3v1: Do not generate bad tags when given short dates. (#69) - * ParseID3v1: Parse short (< 128 byte) tags generated by old Mutagen - implementations of MakeID3v1, and tags with garbage on the front. - * pprint: Sort frames by name. - * Upgrade unknown 2.3 frames (#97, Christoph Reiter) - * Fix handling of invalid SYLT frames (#105, Christoph Reiter) - * MP3: - * Fix error when loading extremely small MP3s. (#72) - * Fix rounding error in CBR length calculation (#93, Christoph Reiter) - * Use 'open' rather than 'file' everywhere. (#74, Dan Callahan) - * mid3iconv: - * Accurately copy QL-style frame encoding behavior. (#75) - * Skip unopenable files. (#79) - * ID3FileType: - * Remember which tag type load() was called with even if the file - doesn't yet have any ID3 tags. (#89) - * VComment: - * Prevent MemoryError when parsing invalid header (#112, Jyrki Pulliainen) - * ASF: - * Don't corrupt files on the second save() call (#81, Christoph Reiter) - * Always store GUID objects in the MetadataLibraryBlock (#81) - * OggTheora: Fix length/bitrate calculation. (#99, Christoph Reiter) - * MP4: - * Less strict MP4 covr atom parsing. (#86, Lukáš Lalinský) - * Support atoms that extend to the end of the file. (#109, Sidnei da Silva) - * Preserve freeform format flags (#103, Christoph Reiter) - * OggOpus support. (#115, Christoph Reiter) - * Musepack: - * Fix SV7 bitrate calculation (#7, Christoph Reiter) - * Support SV8 (#7, Christoph Reiter) +----------------- + +* Fix Python 2.3 compatibility (broken in 1.19). +* Fix many warnings triggered by -3. :bug:`27` +* mid3v2: + + * Add --TXXX support. :bug:`62` (Tim Phipps) + * Add --POPM support. :bug:`71` + * Allow setting multiple COMM or TXXX frames with one command line. + +* FLAC: + + * Try to handle corrupt Vorbis comment block sizes. :bug:`52` + * Try to handle corrupt Picture block sizes :bug:`106` (Christoph Reiter) + * Don't leak file handle with PyPy :bug:`111` (Marien Zwart) + +* ID3: + + * MakeID3v1: Do not generate bad tags when given short dates. :bug:`69` + * ParseID3v1: Parse short (< 128 byte) tags generated by old Mutagen + implementations of MakeID3v1, and tags with garbage on the front. + * pprint: Sort frames by name. + * Upgrade unknown 2.3 frames :bug:`97` (Christoph Reiter) + * Fix handling of invalid SYLT frames :bug:`105` (Christoph Reiter) + +* MP3: + + * Fix error when loading extremely small MP3s. :bug:`72` + * Fix rounding error in CBR length calculation :bug:`93` (Christoph Reiter) + +* Use 'open' rather than 'file' everywhere. :bug:`74` (Dan Callahan) +* mid3iconv: + + * Accurately copy QL-style frame encoding behavior. :bug:`75` + * Skip unopenable files. :bug:`79` + +* ID3FileType: + + * Remember which tag type load() was called with even if the file + doesn't yet have any ID3 tags. :bug:`89` + +* VComment: + + * Prevent MemoryError when parsing invalid header :bug:`112` + (Jyrki Pulliainen) + +* ASF: + + * Don't corrupt files on the second save() call :bug:`81` (Christoph Reiter) + * Always store GUID objects in the MetadataLibraryBlock :bug:`81` + +* OggTheora: Fix length/bitrate calculation. :bug:`99` (Christoph Reiter) +* MP4: + + * Less strict MP4 covr atom parsing. :bug:`86` (Lukáš Lalinský) + * Support atoms that extend to the end of the file. :bug:`109` + (Sidnei da Silva) + * Preserve freeform format flags :bug:`103` (Christoph Reiter) + +* OggOpus support. :bug:`115` (Christoph Reiter) +* Musepack: + + * Fix SV7 bitrate calculation :bug:`7` (Christoph Reiter) + * Support SV8 :bug:`7` (Christoph Reiter) 1.20 - 2010.08.04 - * ASF: Don't store blocks over 64K in the MetadataObject block; - use the MetadataLibraryBlock instead. (#60, Lukáš Lalinský) - * ID3: Faster parsing of files with lots of padding. (#65, Christoph Reiter) - * FLAC: Correct check for audio data start. (#67) +----------------- + +* ASF: Don't store blocks over 64K in the MetadataObject block; + use the MetadataLibraryBlock instead. :bug:`60` (Lukáš Lalinský) +* ID3: Faster parsing of files with lots of padding. :bug:`65` + (Christoph Reiter) +* FLAC: Correct check for audio data start. :bug:`67` 1.19 - 2010.02.18 - * ID3: - * POPM: 'count' is optional; the attribute may not exist. (#33) - * TimeStampTextFrame: Fix a TypeError in unicode comparisons. (#43) - * MakeID3v1: Translate TYER into ID3v1 year if TDRC is not present. (#42) - * mid3v2: - * Allow --delete followed by --frame, and --genre 1 --genre 2. (#37) - * Add --quiet and --verbose flags. (#40) - * moggsplit: --m3u option to write an M3U playlist of the new files. (#39) - * mid3iconv: Fix crash when processing TCML or TIPL frames. (#41) - * VCommentDict: Correctly normalize key names for .keys() iterator. (#45) - * MP3: Correct length calculation for MPEG-2 files. (#46) - * oggflac: Fix typo in docstring. (#53) - * EasyID3: Force UTF-8 encoding. (#54) - * EasyMP4: Fix 'genre' translation. (#56) +----------------- + +* ID3: + + * POPM: 'count' is optional; the attribute may not exist. :bug:`33` + * TimeStampTextFrame: Fix a TypeError in unicode comparisons. :bug:`43` + * MakeID3v1: Translate TYER into ID3v1 year if TDRC is not present. :bug:`42` + +* mid3v2: + + * Allow --delete followed by --frame, and --genre 1 --genre 2. :bug:`37` + * Add --quiet and --verbose flags. :bug:`40` + +* moggsplit: --m3u option to write an M3U playlist of the new files. :bug:`39` +* mid3iconv: Fix crash when processing TCML or TIPL frames. :bug:`41` +* VCommentDict: Correctly normalize key names for .keys() iterator. :bug:`45` +* MP3: Correct length calculation for MPEG-2 files. :bug:`46` +* oggflac: Fix typo in docstring. :bug:`53` +* EasyID3: Force UTF-8 encoding. :bug:`54` +* EasyMP4: Fix 'genre' translation. :bug:`56` 1.18 - 2009.10.22 - * ASF: - * Distinguish between empty and absent tag values in - ContentDescriptionObjects. (#29) - * mid3iconv: - * Fix a crash when processing empty (invalid) text frames. - * MAJOR API INCOMPATIBILITY!!!! - * EasyID3FileType is now in mutagen.easyid3, not mutagen.id3. This - change was necessary to restore API compatibility with 1.16, as - 1.17 accidentally contained a circular import preventing - mutagen.easyid3 from importing by itself. (#32) +----------------- + +* ASF: + + * Distinguish between empty and absent tag values in + ContentDescriptionObjects. :bug:`29` + +* mid3iconv: + + * Fix a crash when processing empty (invalid) text frames. + +* MAJOR API INCOMPATIBILITY!!!! + + * EasyID3FileType is now in mutagen.easyid3, not mutagen.id3. This + change was necessary to restore API compatibility with 1.16, as + 1.17 accidentally contained a circular import preventing + mutagen.easyid3 from importing by itself. :bug:`32` 1.17 - 2009.10.07 - * ID3: - * Support for the iTunes non-standard TSO2 and TSOC frames. - * Attempt to recover from bad SYLT frames. (#2) - * Attempt to recover from faulty extended header flags. (#4, #21) - * Fix a bug in ID3v2.4 footer flag detection, (#5) - * MP4: - * Don't fail or double-encode UTF-8 strings when given a str. - * Don't corrupt 64 bit atom sizes when resizing atoms. (#17) - * EasyID3: - * Extension API for defining new "easy" tags at runtime. - * Support for many, many more tags. - * OggVorbis, OggSpeex: Handle bitrates below 0 as per the spec. (#30) - * EasyMP4: Like EasyID3, but for iTunes MPEG-4 files. - * mutagen.File: New 'easy=True' argument to create new EasyMP3, EasyMP4, - EasyTrueAudio, and EasyID3FileType instances. +----------------- + +* ID3: + + * Support for the iTunes non-standard TSO2 and TSOC frames. + * Attempt to recover from bad SYLT frames. :bug:`2` + * Attempt to recover from faulty extended header flags. :bug:`4` :bug:`21` + * Fix a bug in ID3v2.4 footer flag detection, :bug:`5` + +* MP4: + + * Don't fail or double-encode UTF-8 strings when given a str. + * Don't corrupt 64 bit atom sizes when resizing atoms. :bug:`17` + +* EasyID3: + + * Extension API for defining new "easy" tags at runtime. + * Support for many, many more tags. + +* OggVorbis, OggSpeex: Handle bitrates below 0 as per the spec. :bug:`30` +* EasyMP4: Like EasyID3, but for iTunes MPEG-4 files. +* mutagen.File: New 'easy=True' argument to create new EasyMP3, EasyMP4, + EasyTrueAudio, and EasyID3FileType instances. 1.16 - 2009.06.15 - * Website / code repository move. - * Bug Fixes: - * EasyID3: Invalid keys now raise KeyError (and ValueError). - * mutagen.File: .flac files with an ID3 tag will be opened as FLAC. - * MAJOR API INCOMPATIBILITY!!!! - * Python 2.6 has required us to rename the .format attribute of M4A/MP4 - cover atoms, because it conflicts with the new str.format method. - It has been renamed .imageformat. +----------------- + +* Website / code repository move. +* Bug Fixes: + + * EasyID3: Invalid keys now raise KeyError (and ValueError). + * mutagen.File: .flac files with an ID3 tag will be opened as FLAC. + +* MAJOR API INCOMPATIBILITY!!!! + + * Python 2.6 has required us to rename the .format attribute of M4A/MP4 + cover atoms, because it conflicts with the new str.format method. + It has been renamed .imageformat. 1.15 - 2008.12.01 - * Bug Fixes: - * mutagen.File: Import order no longer affects what type is returned. - * mutagen.id3: Compression of frames is now disabled. - * mutagen.flac.StreamInfo: Fix channel mask (support channels > 2). [35] - * mutagen.mp3: Ignore Xing headers if they are obviously wrong. +----------------- + +* Bug Fixes: + + * mutagen.File: Import order no longer affects what type is returned. + * mutagen.id3: Compression of frames is now disabled. + * mutagen.flac.StreamInfo: Fix channel mask (support channels > 2). :bug:`35` + * mutagen.mp3: Ignore Xing headers if they are obviously wrong. 1.14 - 2008.05.31 - * Bug Fixes: - * MP4/M4A: Fixed saving of atoms with 64-bit size on 64-bit platforms. - * MP4: Conversion of 'gnre' atoms to '\xa9gen' text atoms now correctly - produces a list of string values, not just a single value. - * ID3: Broken RVA2 frames are now discarded. (Vladislav Naumov) - * ID3: Use long integers when appropriate. +----------------- + +* Bug Fixes: + + * MP4/M4A: Fixed saving of atoms with 64-bit size on 64-bit platforms. + * MP4: Conversion of 'gnre' atoms to '\xa9gen' text atoms now correctly + produces a list of string values, not just a single value. + * ID3: Broken RVA2 frames are now discarded. (Vladislav Naumov) + * ID3: Use long integers when appropriate. * VCommentDict: Raise UnicodeEncodeErrors when trying to use a Unicode key that is not valid ASCII; keys are also normalized to ASCII str objects. (Forest Bond) - * Tests: - * FLAC: Use 2**64 instead of 2**32 to test overflow behavior. + +* Tests: + * FLAC: Use 2**64 instead of 2**32 to test overflow behavior. 1.13 - 2007.12.03 - * Bug Fixes: - * FLAC: Raise IOError, instead of UnboundLocalError, when trying - to open a non-existant file. (Lukáš Lalinský, Debian #448734) - * Throw out invalid frames when upgrading from 2.3 to 2.4. - * Fixed reading of Unicode strings from ASF files on big-endian - platforms. - * TCP/TCMP support. (Debian #452231) - * Faster implementation of file-writing when mmap fails, and - exclusive advisory locking when available. - * Test cases to ensure Mutagen is not vulnerable to CVE-2007-4619. - It is not now, nor was it ever. - * Use VBRI header to calculate length of VBR MP3 files if the Xing - header is not found. +----------------- + +* Bug Fixes: + + * FLAC: Raise IOError, instead of UnboundLocalError, when trying + to open a non-existant file. (Lukáš Lalinský, Debian #448734) + * Throw out invalid frames when upgrading from 2.3 to 2.4. + * Fixed reading of Unicode strings from ASF files on big-endian + platforms. + +* TCP/TCMP support. (Debian #452231) +* Faster implementation of file-writing when mmap fails, and + exclusive advisory locking when available. +* Test cases to ensure Mutagen is not vulnerable to CVE-2007-4619. + It is not now, nor was it ever. +* Use VBRI header to calculate length of VBR MP3 files if the Xing + header is not found. 1.12 - 2007.08.04 - * Write important ID3v2 frames near the start. (Lukáš Lalinský) - * Clean up distutils functions. +----------------- + +* Write important ID3v2 frames near the start. (Lukáš Lalinský) +* Clean up distutils functions. 1.11 - 2007.04.26 - * New Features: - * mid3v2 can now set URL frames. (Vladislav Naumov) - * Musepack: Skip ID3v2 tags. (Lukáš Lalinský) - * Bug Fixes: - * mid3iconv: Skip all timestamp frames. (Lukáš Lalinský) - * WavPack: More accurate length calculation. ('ak') - * PairedTextFrame: Fix typo in documentation. (Lukáš Lalinský) - * ID3: Fixed incorrect TDAT conversion. The format is DDMM, not - MMDD. (Lukáš Lalinský) - * API: - * Metadata no longer inherits from dict. - * Relatedly, the MRO has changed on several types. - * More documentation for MP4 atoms. (Lukáš Lalinský) - * Prefer MP3 for files with unknown extensions and ID3 tags. +----------------- + +* New Features: + + * mid3v2 can now set URL frames. (Vladislav Naumov) + * Musepack: Skip ID3v2 tags. (Lukáš Lalinský) + +* Bug Fixes: + + * mid3iconv: Skip all timestamp frames. (Lukáš Lalinský) + * WavPack: More accurate length calculation. ('ak') + * PairedTextFrame: Fix typo in documentation. (Lukáš Lalinský) + * ID3: Fixed incorrect TDAT conversion. The format is DDMM, not + MMDD. (Lukáš Lalinský) + +* API: + + * Metadata no longer inherits from dict. + * Relatedly, the MRO has changed on several types. + * More documentation for MP4 atoms. (Lukáš Lalinský) + * Prefer MP3 for files with unknown extensions and ID3 tags. 1.10.1 - 2007.01.23 - * Bug Fixes: - * Documentation mentions ASF support. - * APEv2 flags and valid keys are fixed. - * Tests pass on Python 2.3 again. +------------------- + +* Bug Fixes: + + * Documentation mentions ASF support. + * APEv2 flags and valid keys are fixed. + * Tests pass on Python 2.3 again. 1.10 - 2007.01.21 - * New Features: - * FLAC: Skip ID3 tags. Added option to delete them on save. - * EncodedTextSpec: Make private members more private. - * Corrupted Oggs generated by GStreamer (e.g. Sound Juicer) can be read. - * FileTypes have a .mime attribute which is a list of likely MIME types - for the file. - * ASF (WMA/WMV) support. - * Bug Fixes: - * ID3: Fixed reading of v2.3 tags with unsynchronized data. - * ID3: The data length indicator for compressed tags is written - as a synch-safe integer. +----------------- + +* New Features: + + * FLAC: Skip ID3 tags. Added option to delete them on save. + * EncodedTextSpec: Make private members more private. + * Corrupted Oggs generated by GStreamer (e.g. Sound Juicer) can be read. + * FileTypes have a .mime attribute which is a list of likely MIME types + for the file. + * ASF (WMA/WMV) support. + +* Bug Fixes: + + * ID3: Fixed reading of v2.3 tags with unsynchronized data. + * ID3: The data length indicator for compressed tags is written + as a synch-safe integer. 1.9 - 2006.12.09 - * New Features: - * OptimFROG support. - * New mutagen.mp4 module with support for multiple data fields per atom - and more compatible tag saving implementation. - * Support for embedded pictures in FLAC files (new in FLAC 1.1.3). - * mutagen.m4a is deprecated in favor of mutagen.mp4. +---------------- + +* New Features: + + * OptimFROG support. + * New mutagen.mp4 module with support for multiple data fields per atom + and more compatible tag saving implementation. + * Support for embedded pictures in FLAC files (new in FLAC 1.1.3). + +* mutagen.m4a is deprecated in favor of mutagen.mp4. 1.8 - 2006.10.02 - * New Features: - * MonkeysAudio support. (#851, Lukáš Lalinský) - * APEv2 support on Python 2.5; see API-NOTES. (#852) +---------------- + +* New Features: + + * MonkeysAudio support. (#851, Lukáš Lalinský) + * APEv2 support on Python 2.5; see API-NOTES. (#852) 1.7.1 - 2006.09.24 - * Bug Fixes: - * Expose full ID3 tag size as .size. (#848) +------------------ + +* Bug Fixes: + + * Expose full ID3 tag size as .size. (#848) - * New Features: - * Musepack Replay Gain data is available in SV7 files. +* New Features: + + * Musepack Replay Gain data is available in SV7 files. 1.7 - 2006.09.15 - * Bug Fixes: - * Trying to save an empty tag deletes it. (#813) - * The semi-public API removal mentioned in 1.6's API-NOTES happened. - * Stricter frame ID validation. (#830, Lukáš Lalinský) - * Use os.path.devnull on Win32/Mac OS X. (#831, Lukáš Lalinský) - - * New Features: - * FLAC cuesheet and seektable support. (#791, Nuutti Kotivuori) - * Kwargs can be passed to ID3 constructors. (#824, Lukáš Lalinský) - * mutagen.musepack: Read/tag Musepack files. (#825, Lukáš Lalinský) +---------------- + +* Bug Fixes: + + * Trying to save an empty tag deletes it. (#813) + * The semi-public API removal mentioned in 1.6's API-NOTES happened. + * Stricter frame ID validation. (#830, Lukáš Lalinský) + * Use os.path.devnull on Win32/Mac OS X. (#831, Lukáš Lalinský) - * Tools: - * mutagen-inspect responds immediately to keyboard interrupts. +* New Features: + + * FLAC cuesheet and seektable support. (#791, Nuutti Kotivuori) + * Kwargs can be passed to ID3 constructors. (#824, Lukáš Lalinský) + * mutagen.musepack: Read/tag Musepack files. (#825, Lukáš Lalinský) + +* Tools: + + * mutagen-inspect responds immediately to keyboard interrupts. 1.6 - 2006.08.09 - * Bug Fixes: - * IOError rather than NameError is raised when File succeeds in - typefinding but fails in stream parsing. - * errors= kwarg is correctly interpreted for FLAC tags now. - * Handle struct.pack API change in Python 2.5b2. (SF #1530559) - * Metadata 'load' methods always reset in-memory tags. - * Metadata 'delete' methods always clear in-memory tags. - - * New Features: - * Vorbis comment vendor strings include the Mutagen version. - * mutagen.id3: Read ASPI, ETCO, SYTC, MLLT, EQU2, and LINK frames. - * mutagen.m4a: Read/tag MPEG-4 AAC audio files with iTunes tags. (#681) - * mutagen.oggspeex: Read/tag Ogg Speex files. - * mutagen.trueaudio: Read/tag True Audio files. - * mutagen.wavpack: Read/tag WavPack files. +---------------- + +* Bug Fixes: + + * IOError rather than NameError is raised when File succeeds in + typefinding but fails in stream parsing. + * errors= kwarg is correctly interpreted for FLAC tags now. + * Handle struct.pack API change in Python 2.5b2. (SF #1530559) + * Metadata 'load' methods always reset in-memory tags. + * Metadata 'delete' methods always clear in-memory tags. + +* New Features: - * Tools: - * mid3v2: --delete-frames. (#635) + * Vorbis comment vendor strings include the Mutagen version. + * mutagen.id3: Read ASPI, ETCO, SYTC, MLLT, EQU2, and LINK frames. + * mutagen.m4a: Read/tag MPEG-4 AAC audio files with iTunes tags. (#681) + * mutagen.oggspeex: Read/tag Ogg Speex files. + * mutagen.trueaudio: Read/tag True Audio files. + * mutagen.wavpack: Read/tag WavPack files. + +* Tools: + + * mid3v2: --delete-frames. (#635) 1.5.1 - 2006.06.26 - * Bug Fixes: - * Handle ENODEV from mmap (e.g. on fuse+sshfs). - * Reduce test rerun time. +------------------ + +* Bug Fixes: + + * Handle ENODEV from mmap (e.g. on fuse+sshfs). + * Reduce test rerun time. 1.5 - 2006.06.20 - * Bug Fixes: - * APEv2 - * Invalid Lyrics3v2 tags are ignored/overwritten. - * Binary values are autodetected as documented. - * OggVorbis, OggFLAC: - * Write when the setup packet spans multiple pages. - * Zero granule position for header packets. - - * New Features: - * mutagen.oggtheora: Read/tag Ogg Theora files. - * Test Ogg formats with ogginfo, if present. +---------------- + +* Bug Fixes: + + * APEv2 + + * Invalid Lyrics3v2 tags are ignored/overwritten. + * Binary values are autodetected as documented. + + * OggVorbis, OggFLAC: + + * Write when the setup packet spans multiple pages. + * Zero granule position for header packets. + +* New Features: + + * mutagen.oggtheora: Read/tag Ogg Theora files. + * Test Ogg formats with ogginfo, if present. 1.4 - 2006.06.03 - * Bug Fixes: - * EasyID3: Fix tag["key"] = "string" handler. (#693) - * APEv2: - * Skip Lyrics3v2 tags. (Miguel Angel Alvarez) - * Avoid infinite loop on malformed tags at the start of the file. - * Proper ANSI semantics for file positioning. (#707) - - * New Features: - * VComment: Handle malformed Vorbis comments when errors='ignore' or - errors='replace' is passed to VComment#load. - (Bastian Kleineidam, #696) - * Test running is now controlled through setup.py (./setup.py test). - * Test coverage data can be generated (./setup.py coverage). - * Considerably more test coverage. +---------------- + +* Bug Fixes: + + * EasyID3: Fix tag["key"] = "string" handler. (#693) + * APEv2: + + * Skip Lyrics3v2 tags. (Miguel Angel Alvarez) + * Avoid infinite loop on malformed tags at the start of the file. + + * Proper ANSI semantics for file positioning. (#707) + +* New Features: + + * VComment: Handle malformed Vorbis comments when errors='ignore' or + errors='replace' is passed to VComment.load. + (Bastian Kleineidam, #696) + * Test running is now controlled through setup.py (./setup.py test). + * Test coverage data can be generated (./setup.py coverage). + * Considerably more test coverage. 1.3 - 2006.05.29 - * New Features: - * mutagen.File: Automatic file type detection. - * mutagen.ogg: Generic Ogg stream parsing. (#612) - * mutagen.oggflac: Read/tag Ogg FLAC files. - * mutagen.oggvorbis no longer depends on pyvorbis. - * ID3: SYLT support. (#672) +---------------- + +* New Features: + + * mutagen.File: Automatic file type detection. + * mutagen.ogg: Generic Ogg stream parsing. (#612) + * mutagen.oggflac: Read/tag Ogg FLAC files. + * mutagen.oggvorbis no longer depends on pyvorbis. + * ID3: SYLT support. (#672) 1.2 - 2006.04.23 - * Bug Fixes: - * MP3: Load files with zeroed Xing headers. (#626) - * ID3: Upgrade ID3v2.2 PIC tags to ID3v2.4 APIC tags properly. - * Tests exit with non-zero status if any have failed. - * Full dict protocol support for VCommentDict, FileType, and APEv2 objects. - - * New features: - * mutagen.oggvorbis gives pyvorbis a Mutagen-like API. - * mutagen.easyid3 makes simple ID3 tag changes easier. - * A brief TUTORIAL was added. +---------------- + +* Bug Fixes: + + * MP3: Load files with zeroed Xing headers. (#626) + * ID3: Upgrade ID3v2.2 PIC tags to ID3v2.4 APIC tags properly. + * Tests exit with non-zero status if any have failed. + * Full dict protocol support for VCommentDict, FileType, and APEv2 objects. + +* New features: + + * mutagen.oggvorbis gives pyvorbis a Mutagen-like API. + * mutagen.easyid3 makes simple ID3 tag changes easier. + * A brief TUTORIAL was added. - * Tools: - * mid3iconv, a clone of id3iconv, was added by Emfox Zhou. (#605) +* Tools: + + * mid3iconv, a clone of id3iconv, was added by Emfox Zhou. (#605) 1.1 - 2006.04.04 - * ID3: +---------------- + +* ID3: + * Frame and Spec objects are not hashable. * COMM, USER: Accept non-ASCII (completely invalid) language codes. * Enable redundant data length bit for compressed frames. 1.0 - 2006.03.13 - * mutagen.FileType, an abstract container for tags and stream information. - * MP3: A new FileType subclass for MPEG audio files. - * FLAC: - * Add FLAC#delete. +---------------- + +* mutagen.FileType, an abstract container for tags and stream information. +* MP3: A new FileType subclass for MPEG audio files. +* FLAC: + + * Add FLAC.delete. * Raise correct exception when saving to a non-FLAC file. - * FLAC#vc is deprecated in favor of FLAC#tags. - * VComment (used by FLAC): - * VComment#clear to clear all tags. - * VComment#as_dict to return a dict of the tags. - * ID3: - * Fix typos in PRIV#_pprint, OWNE#_pprint, UFID#_pprint. - * mutagen-pony: Try finding lengths as well as tags. - * mutagen-inspect: Output stream information with tags. + * FLAC.vc is deprecated in favor of FLAC.tags. + +* VComment (used by FLAC): + + * VComment.clear to clear all tags. + * VComment.as_dict to return a dict of the tags. + +* ID3: + + * Fix typos in PRIV._pprint, OWNE._pprint, UFID._pprint. + +* mutagen-pony: Try finding lengths as well as tags. +* mutagen-inspect: Output stream information with tags. 0.9 - 2006.02.21 - * Initial release. +---------------- + +* Initial release. diff -Nru mutagen-1.23/PKG-INFO mutagen-1.30/PKG-INFO --- mutagen-1.23/PKG-INFO 2014-05-14 13:31:25.000000000 +0000 +++ mutagen-1.30/PKG-INFO 2015-08-22 17:54:33.000000000 +0000 @@ -1,8 +1,8 @@ Metadata-Version: 1.1 Name: mutagen -Version: 1.23 +Version: 1.30 Summary: read and write audio tags for many formats -Home-page: http://code.google.com/p/mutagen/ +Home-page: https://bitbucket.org/lazka/mutagen Author: Michael Urman Author-email: quod-libet-development@groups.google.com License: GNU GPL v2 @@ -19,6 +19,10 @@ Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 2.6 +Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.3 +Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: License :: OSI Approved :: GNU General Public License v2 (GPLv2) diff -Nru mutagen-1.23/README mutagen-1.30/README --- mutagen-1.23/README 2014-05-02 22:02:04.000000000 +0000 +++ mutagen-1.30/README 1970-01-01 00:00:00.000000000 +0000 @@ -1,58 +0,0 @@ -Mutagen -======= - -Mutagen is a Python module to handle audio metadata. It supports ASF, FLAC, -M4A, Monkey's Audio, MP3, Musepack, Ogg Opus, Ogg FLAC, Ogg Speex, Ogg -Theora, Ogg Vorbis, True Audio, WavPack, OptimFROG, and AIFF audio files. -All versions of ID3v2 are supported, and all standard ID3v2.4 frames are -parsed. It can read Xing headers to accurately calculate the bitrate and -length of MP3s. ID3 and APEv2 tags can be edited regardless of audio -format. It can also manipulate Ogg streams on an individual packet/page -level. - -Mutagen works on Python 2.6+ / PyPy and has no dependencies outside the -CPython standard library. - - -Installing ----------- - - $ ./setup.py build - $ su -c "./setup.py install" - - -Documentation -------------- - -The primary documentation for Mutagen is the doc strings found in -the source code and the sphinx documentation in the docs/ directory. - -To build the docs (needs sphinx): - - $ ./setup.py build_sphinx - -The tools/ directory contains several useful examples. - -The docs are also hosted on readthedocs.org: - - http://mutagen.readthedocs.org - - -Testing the Module ------------------- - -To test Mutagen's MP3 reading support, run - $ tools/mutagen-pony -Mutagen will try to load all of them, and report any errors. - -To look at the tags in files, run - $ tools/mutagen-inspect filename ... - -To run our test suite, - $ ./setup.py test - - -Compatibility/Bugs ------------------- - -See docs/bugs.rst diff -Nru mutagen-1.23/README.rst mutagen-1.30/README.rst --- mutagen-1.23/README.rst 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/README.rst 2015-02-01 19:07:57.000000000 +0000 @@ -0,0 +1,58 @@ +Mutagen +======= + +Mutagen is a Python module to handle audio metadata. It supports ASF, FLAC, +M4A, Monkey's Audio, MP3, Musepack, Ogg Opus, Ogg FLAC, Ogg Speex, Ogg +Theora, Ogg Vorbis, True Audio, WavPack, OptimFROG, and AIFF audio files. +All versions of ID3v2 are supported, and all standard ID3v2.4 frames are +parsed. It can read Xing headers to accurately calculate the bitrate and +length of MP3s. ID3 and APEv2 tags can be edited regardless of audio +format. It can also manipulate Ogg streams on an individual packet/page +level. + +Mutagen works on Python 2.6, 2.7, 3.3, 3.4 (CPython and PyPy) and has no +dependencies outside the Python standard library. + + +Installing +---------- + + $ ./setup.py build + $ su -c "./setup.py install" + + +Documentation +------------- + +The primary documentation for Mutagen is the doc strings found in +the source code and the sphinx documentation in the docs/ directory. + +To build the docs (needs sphinx): + + $ ./setup.py build_sphinx + +The tools/ directory contains several useful examples. + +The docs are also hosted on readthedocs.org: + + http://mutagen.readthedocs.org + + +Testing the Module +------------------ + +To test Mutagen's MP3 reading support, run + $ tools/mutagen-pony +Mutagen will try to load all of them, and report any errors. + +To look at the tags in files, run + $ tools/mutagen-inspect filename ... + +To run our test suite, + $ ./setup.py test + + +Compatibility/Bugs +------------------ + +See docs/bugs.rst diff -Nru mutagen-1.23/setup.py mutagen-1.30/setup.py --- mutagen-1.23/setup.py 2014-03-06 10:27:39.000000000 +0000 +++ mutagen-1.30/setup.py 2015-04-29 15:55:52.000000000 +0000 @@ -1,4 +1,5 @@ #!/usr/bin/env python +# -*- coding: utf-8 -*- # Copyright 2005-2009,2011 Joe Wreschnig # # This program is free software; you can redistribute it and/or modify @@ -10,11 +11,13 @@ import shutil import sys import subprocess +import tarfile from distutils.core import setup, Command +from distutils import dir_util from distutils.command.clean import clean as distutils_clean -from distutils.command.sdist import sdist as distutils_sdist +from distutils.command.sdist import sdist class clean(distutils_clean): @@ -48,11 +51,10 @@ shutil.rmtree(path) -class sdist(distutils_sdist): - def run(self): - self.run_command("test") +class distcheck(sdist): - distutils_sdist.run(self) + def _check_manifest(self): + assert self.get_archive_files() # make sure MANIFEST.in includes all tracked files if subprocess.call(["hg", "status"], @@ -60,9 +62,11 @@ stderr=subprocess.PIPE) == 0: # contains the packaged files after run() is finished included_files = self.filelist.files + assert included_files process = subprocess.Popen(["hg", "locate"], - stdout=subprocess.PIPE) + stdout=subprocess.PIPE, + universal_newlines=True) out, err = process.communicate() assert process.returncode == 0 @@ -70,8 +74,40 @@ for ignore in [".hgignore", ".hgtags"]: tracked_files.remove(ignore) - assert not set(tracked_files) - set(included_files), \ - "Not all tracked files included in tarball, update MANIFEST.in" + diff = set(tracked_files) - set(included_files) + assert not diff, ( + "Not all tracked files included in tarball, check MANIFEST.in", + diff) + + def _check_dist(self): + assert self.get_archive_files() + + distcheck_dir = os.path.join(self.dist_dir, "distcheck") + if os.path.exists(distcheck_dir): + dir_util.remove_tree(distcheck_dir) + self.mkpath(distcheck_dir) + + archive = self.get_archive_files()[0] + tfile = tarfile.open(archive, "r:gz") + tfile.extractall(distcheck_dir) + tfile.close() + + name = self.distribution.get_fullname() + extract_dir = os.path.join(distcheck_dir, name) + + old_pwd = os.getcwd() + os.chdir(extract_dir) + self.spawn([sys.executable, "setup.py", "test"]) + self.spawn([sys.executable, "setup.py", "build"]) + self.spawn([sys.executable, "setup.py", "build_sphinx"]) + self.spawn([sys.executable, "setup.py", "install", + "--prefix", "../prefix", "--record", "../log.txt"]) + os.chdir(old_pwd) + + def run(self): + sdist.run(self) + self._check_manifest() + self._check_dist() class build_sphinx(Command): @@ -96,21 +132,41 @@ description = "run automated tests" user_options = [ ("to-run=", None, "list of tests to run (default all)"), - ("quick", None, "don't run slow mmap-failing tests"), + ("exitfirst", "x", "stop after first failing test"), ] def initialize_options(self): self.to_run = [] - self.quick = False + self.exitfirst = False def finalize_options(self): if self.to_run: self.to_run = self.to_run.split(",") + self.exitfirst = bool(self.exitfirst) + + def run(self): + import tests + + count, failures = tests.unit(self.to_run, self.exitfirst) + if failures: + print("%d out of %d failed" % (failures, count)) + raise SystemExit("Test failures are listed above.") + + +class quality_cmd(Command): + description = "run pyflakes/pep8 tests" + user_options = [] + + def initialize_options(self): + pass + + def finalize_options(self): + pass def run(self): import tests - count, failures = tests.unit(self.to_run, self.quick) + count, failures = tests.check() if failures: print("%d out of %d failed" % (failures, count)) raise SystemExit("Test failures are listed above.") @@ -118,15 +174,13 @@ class coverage_cmd(Command): description = "generate test coverage data" - user_options = [ - ("quick", None, "don't run slow mmap-failing tests"), - ] + user_options = [] def initialize_options(self): - self.quick = None + pass def finalize_options(self): - self.quick = bool(self.quick) + pass def run(self): try: @@ -145,7 +199,6 @@ cov.start() cmd = self.reinitialize_command("test") - cmd.quick = self.quick cmd.ensure_finalized() cmd.run() @@ -171,14 +224,15 @@ cmd_classes = { "clean": clean, "test": test_cmd, + "quality": quality_cmd, "coverage": coverage_cmd, - "sdist": sdist, + "distcheck": distcheck, "build_sphinx": build_sphinx, } setup(cmdclass=cmd_classes, name="mutagen", version=version_string, - url="http://code.google.com/p/mutagen/", + url="https://bitbucket.org/lazka/mutagen", description="read and write audio tags for many formats", author="Michael Urman", author_email="quod-libet-development@groups.google.com", @@ -187,14 +241,25 @@ 'Operating System :: OS Independent', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', 'License :: OSI Approved :: GNU General Public License v2 (GPLv2)', 'Topic :: Multimedia :: Sound/Audio', ], - packages=["mutagen"], + packages=["mutagen", "mutagen.id3", "mutagen.mp4"], data_files=data_files, - scripts=glob.glob("tools/m*[!~]"), + scripts=[os.path.join("tools", name) for name in [ + "mid3cp", + "mid3iconv", + "mid3v2", + "moggsplit", + "mutagen-inspect", + "mutagen-pony", + ]], long_description="""\ Mutagen is a Python module to handle audio metadata. It supports ASF, FLAC, M4A, Monkey's Audio, MP3, Musepack, Ogg FLAC, Ogg Speex, Ogg Binary files /tmp/YUJl6SpwjE/mutagen-1.23/tests/data/adif.aac and /tmp/To4rziyjFJ/mutagen-1.30/tests/data/adif.aac differ Binary files /tmp/YUJl6SpwjE/mutagen-1.23/tests/data/alac.m4a and /tmp/To4rziyjFJ/mutagen-1.30/tests/data/alac.m4a differ Binary files /tmp/YUJl6SpwjE/mutagen-1.23/tests/data/empty.aac and /tmp/To4rziyjFJ/mutagen-1.30/tests/data/empty.aac differ Binary files /tmp/YUJl6SpwjE/mutagen-1.23/tests/data/lame.mp3 and /tmp/To4rziyjFJ/mutagen-1.30/tests/data/lame.mp3 differ Binary files /tmp/YUJl6SpwjE/mutagen-1.23/tests/data/lame-peak.mp3 and /tmp/To4rziyjFJ/mutagen-1.30/tests/data/lame-peak.mp3 differ Binary files /tmp/YUJl6SpwjE/mutagen-1.23/tests/data/no_length.wv and /tmp/To4rziyjFJ/mutagen-1.30/tests/data/no_length.wv differ diff -Nru mutagen-1.23/tests/__init__.py mutagen-1.30/tests/__init__.py --- mutagen-1.23/tests/__init__.py 2013-09-11 11:08:16.000000000 +0000 +++ mutagen-1.30/tests/__init__.py 2015-05-09 12:36:11.000000000 +0000 @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + from __future__ import division, print_function import re @@ -7,10 +9,23 @@ import unittest from unittest import TestCase as BaseTestCase -suites = [] -add = suites.append -from mutagen._compat import cmp +from mutagen._compat import PY3 +from mutagen._toolsutil import fsencoding, is_fsnative + + +DATA_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "data") +if os.name == "nt" and not PY3: + DATA_DIR = DATA_DIR.decode("ascii") +assert is_fsnative(DATA_DIR) + + +if os.name != "nt": + try: + u"öäü".encode(fsencoding()) + except ValueError: + raise RuntimeError("This test suite needs a unicode locale encoding. " + "Try setting LANG=C.UTF-8") class TestCase(BaseTestCase): @@ -40,8 +55,9 @@ self.assertTrue(b == a) self.assertFalse(a != b) self.assertFalse(b != a) - self.assertEqual(0, cmp(a, b)) - self.assertEqual(0, cmp(b, a)) + if not PY3: + self.assertEqual(0, cmp(a, b)) + self.assertEqual(0, cmp(b, a)) def assertReallyNotEqual(self, a, b): self.assertNotEqual(a, b) @@ -50,17 +66,38 @@ self.assertFalse(b == a) self.assertTrue(a != b) self.assertTrue(b != a) - self.assertNotEqual(0, cmp(a, b)) - self.assertNotEqual(0, cmp(b, a)) + if not PY3: + self.assertNotEqual(0, cmp(a, b)) + self.assertNotEqual(0, cmp(b, a)) + + +def import_tests(): + tests = [] + + for name in glob.glob( + os.path.join(os.path.dirname(__file__), "test_*.py")): + # skip m4a in py3k + if sys.version_info[0] != 2 and "test_m4a" in name: + continue + module_name = "tests." + os.path.basename(name) + mod = __import__(module_name[:-3], {}, {}, []) + mod = getattr(mod, os.path.basename(name)[:-3]) + + tests.extend(get_tests_from_mod(mod)) + + return list(set(tests)) + + +def get_tests_from_mod(mod): + tests = [] + for name in dir(mod): + obj = getattr(mod, name) + if isinstance(obj, type) and issubclass(obj, BaseTestCase) and \ + obj is not TestCase: + tests.append(obj) + return tests -for name in glob.glob(os.path.join(os.path.dirname(__file__), "test_*.py")): - # skip m4a in py3k - if sys.version_info[0] != 2 and "test_m4a" in name: - continue - module = "tests." + os.path.basename(name) - __import__(module[:-3], {}, {}, []) - class Result(unittest.TestResult): separator1 = '=' * 70 @@ -93,6 +130,7 @@ sys.stdout.write(self.separator2 + "\n") sys.stdout.write("%s\n" % err) + class Runner(object): def run(self, test): suite = unittest.makeSuite(test) @@ -104,76 +142,32 @@ return bool(result.failures + result.errors) -def unit(run=[], quick=False): - import mmap +def check(): + from tests.quality import test_pep8 + from tests.quality import test_pyflakes + + tests = get_tests_from_mod(test_pep8) + tests += get_tests_from_mod(test_pyflakes) runner = Runner() failures = 0 - count = 0 - tests = [t for t in suites if not run or t.__name__ in run] - - # normal run, trace mmap calls - orig_mmap = mmap.mmap - uses_mmap = [] - print("Running tests with real mmap.") - for test in tests: - def new_mmap(*args, **kwargs): - if test not in uses_mmap: - uses_mmap.append(test) - return orig_mmap(*args, **kwargs) - mmap.mmap = new_mmap + for test in sorted(tests, key=lambda c: c.__name__): failures += runner.run(test) - mmap.mmap = orig_mmap - count += len(tests) - # make sure the above works - if not run: - assert len(uses_mmap) > 1 + return len(tests), failures - if quick: - return count, failures - # run mmap using tests with mocked lockf - try: - import fcntl - except ImportError: - print("Unable to run mocked fcntl.lockf tests.") - else: - def MockLockF(*args, **kwargs): - raise IOError - lockf = fcntl.lockf - fcntl.lockf = MockLockF - print("Running tests with mocked failing fcntl.lockf.") - for test in uses_mmap: - failures += runner.run(test) - fcntl.lockf = lockf - count += len(uses_mmap) - - # failing mmap.move - class MockMMap(object): - def __init__(self, *args, **kwargs): - pass - - def move(self, dest, src, count): - raise ValueError - - def close(self): - pass - - print("Running tests with mocked failing mmap.move.") - mmap.mmap = MockMMap - for test in uses_mmap: - failures += runner.run(test) - count += len(uses_mmap) +def unit(run=[], exitfirst=False): + tests = import_tests() + + runner = Runner() + failures = 0 + filtered = [t for t in tests if not run or t.__name__ in run] + + for test in sorted(filtered, key=lambda c: c.__name__): + if failures and exitfirst: + break - # failing mmap.mmap - def MockMMap2(*args, **kwargs): - raise EnvironmentError - - mmap.mmap = MockMMap2 - print("Running tests with mocked failing mmap.mmap.") - for test in uses_mmap: failures += runner.run(test) - count += len(uses_mmap) - return count, failures + return len(filtered), failures diff -Nru mutagen-1.23/tests/quality/__init__.py mutagen-1.30/tests/quality/__init__.py --- mutagen-1.23/tests/quality/__init__.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/tests/quality/__init__.py 2015-04-25 08:50:11.000000000 +0000 @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation diff -Nru mutagen-1.23/tests/quality/test_pep8.py mutagen-1.30/tests/quality/test_pep8.py --- mutagen-1.23/tests/quality/test_pep8.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/tests/quality/test_pep8.py 2015-08-17 10:42:51.000000000 +0000 @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# Copyright 2013,2014 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation + +import os +import glob +import subprocess + +from tests import TestCase + +PEP8_NAME = "pep8" + +has_pep8 = True +try: + subprocess.check_output([PEP8_NAME, "--version"], stderr=subprocess.STDOUT) +except OSError: + has_pep8 = False + + +class TPEP8(TestCase): + IGNORE = ["E12", "W601", "E402", "E731"] + + def _run(self, path, ignore=None): + if ignore is None: + ignore = [] + ignore += self.IGNORE + + p = subprocess.Popen( + [PEP8_NAME, "--ignore=" + ",".join(ignore), path], + stderr=subprocess.PIPE, stdout=subprocess.PIPE) + + class Future(object): + + def __init__(self, p): + self.p = p + + def result(self): + if self.p.wait() != 0: + return self.p.communicate() + + return Future(p) + + def _run_package(self, mod, ignore=None): + path = mod.__path__[0] + files = glob.glob(os.path.join(path, "*.py")) + assert files + futures = [] + for file_ in files: + futures.append(self._run(file_, ignore)) + + errors = [] + for future in futures: + status = future.result() + if status is not None: + errors.append(status[0].decode("utf-8")) + + if errors: + raise Exception("\n".join(errors)) + + def test_main_package(self): + import mutagen + self._run_package(mutagen) + + def test_id3_package(self): + import mutagen.id3 + self._run_package(mutagen.id3) + + def test_tests(self): + import tests + self._run_package(tests) + + +if not has_pep8: + del TPEP8 diff -Nru mutagen-1.23/tests/quality/test_pyflakes.py mutagen-1.30/tests/quality/test_pyflakes.py --- mutagen-1.23/tests/quality/test_pyflakes.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/tests/quality/test_pyflakes.py 2015-04-25 08:50:05.000000000 +0000 @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# Copyright 2013,2014 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation + +import os +import re +import sys + +from tests import TestCase + +from mutagen import _compat + + +os.environ["PYFLAKES_NODOCTEST"] = "1" +try: + from pyflakes.scripts import pyflakes +except ImportError: + pyflakes = None + + +class Error(object): + IMPORT_UNUSED = "imported but unused" + REDEF_FUNCTION = "redefinition of function" + UNABLE_DETECT_UNDEF = "unable to detect undefined names" + UNDEFINED_PY2_NAME = \ + "undefined name '(unicode|long|basestring|xrange|cmp)'" + + +class FakeStream(object): + # skip these by default + BL = [Error.UNABLE_DETECT_UNDEF] + if _compat.PY3: + BL.append(Error.UNDEFINED_PY2_NAME) + + def __init__(self, blacklist=None): + self.lines = [] + if blacklist is None: + blacklist = [] + self.bl = self.BL[:] + blacklist + + def write(self, text): + for p in self.bl: + if re.search(p, text): + return + text = text.strip() + if not text: + return + self.lines.append(text) + + def check(self): + if self.lines: + raise Exception("\n" + "\n".join(self.lines)) + + +class TPyFlakes(TestCase): + + def _run(self, path, **kwargs): + old_stdout = sys.stdout + stream = FakeStream(**kwargs) + try: + sys.stdout = stream + for dirpath, dirnames, filenames in os.walk(path): + for filename in filenames: + if _compat.PY3 and filename in ("m4a.py", "test_m4a.py"): + continue + if filename.endswith('.py'): + pyflakes.checkPath(os.path.join(dirpath, filename)) + finally: + sys.stdout = old_stdout + stream.check() + + def _run_package(self, mod, *args, **kwargs): + path = mod.__path__[0] + self._run(path, *args, **kwargs) + + def test_main(self): + import mutagen + self._run_package(mutagen) + + def test_tests(self): + import tests + self._run_package(tests) + + +if not pyflakes: + del TPyFlakes diff -Nru mutagen-1.23/tests/test_aac.py mutagen-1.30/tests/test_aac.py --- mutagen-1.23/tests/test_aac.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/tests/test_aac.py 2015-05-09 12:17:13.000000000 +0000 @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- + +import os +from tempfile import mkstemp +import shutil + +from mutagen.id3 import ID3, TIT1 +from mutagen.aac import AAC, AACError +from tests import TestCase, DATA_DIR + + +class TADTS(TestCase): + + def setUp(self): + original = os.path.join(DATA_DIR, "empty.aac") + fd, self.filename = mkstemp(suffix='.aac') + os.close(fd) + shutil.copy(original, self.filename) + tag = ID3() + tag.add(TIT1(text=[u"a" * 5000], encoding=3)) + tag.save(self.filename) + + self.aac = AAC(original) + self.aac_id3 = AAC(self.filename) + + def tearDown(self): + os.remove(self.filename) + + def test_channels(self): + self.failUnlessEqual(self.aac.info.channels, 2) + self.failUnlessEqual(self.aac_id3.info.channels, 2) + + def test_bitrate(self): + self.failUnlessEqual(self.aac.info.bitrate, 3159) + self.failUnlessEqual(self.aac_id3.info.bitrate, 3159) + + def test_sample_rate(self): + self.failUnlessEqual(self.aac.info.sample_rate, 44100) + self.failUnlessEqual(self.aac_id3.info.sample_rate, 44100) + + def test_length(self): + self.failUnlessAlmostEqual(self.aac.info.length, 3.70, 2) + self.failUnlessAlmostEqual(self.aac_id3.info.length, 3.70, 2) + + def test_not_my_file(self): + self.failUnlessRaises( + AACError, AAC, + os.path.join(DATA_DIR, "empty.ogg")) + + self.failUnlessRaises( + AACError, AAC, + os.path.join(DATA_DIR, "silence-44-s.mp3")) + + def test_pprint(self): + self.assertEqual(self.aac.pprint(), self.aac_id3.pprint()) + self.assertTrue("ADTS" in self.aac.pprint()) + + +class TADIF(TestCase): + + def setUp(self): + original = os.path.join(DATA_DIR, "adif.aac") + fd, self.filename = mkstemp(suffix='.aac') + os.close(fd) + shutil.copy(original, self.filename) + tag = ID3() + tag.add(TIT1(text=[u"a" * 5000], encoding=3)) + tag.save(self.filename) + + self.aac = AAC(original) + self.aac_id3 = AAC(self.filename) + + def tearDown(self): + os.remove(self.filename) + + def test_channels(self): + self.failUnlessEqual(self.aac.info.channels, 2) + self.failUnlessEqual(self.aac_id3.info.channels, 2) + + def test_bitrate(self): + self.failUnlessEqual(self.aac.info.bitrate, 128000) + self.failUnlessEqual(self.aac_id3.info.bitrate, 128000) + + def test_sample_rate(self): + self.failUnlessEqual(self.aac.info.sample_rate, 48000) + self.failUnlessEqual(self.aac_id3.info.sample_rate, 48000) + + def test_length(self): + self.failUnlessAlmostEqual(self.aac.info.length, 0.25, 2) + self.failUnlessAlmostEqual(self.aac_id3.info.length, 0.25, 2) + + def test_not_my_file(self): + self.failUnlessRaises( + AACError, AAC, + os.path.join(DATA_DIR, "empty.ogg")) + + self.failUnlessRaises( + AACError, AAC, + os.path.join(DATA_DIR, "silence-44-s.mp3")) + + def test_pprint(self): + self.assertEqual(self.aac.pprint(), self.aac_id3.pprint()) + self.assertTrue("ADIF" in self.aac.pprint()) diff -Nru mutagen-1.23/tests/test_aiff.py mutagen-1.30/tests/test_aiff.py --- mutagen-1.23/tests/test_aiff.py 2014-05-02 17:16:01.000000000 +0000 +++ mutagen-1.30/tests/test_aiff.py 2015-08-18 10:55:54.000000000 +0000 @@ -1,22 +1,24 @@ +# -*- coding: utf-8 -*- + import os import shutil -from tests import TestCase +from tests import TestCase, DATA_DIR from mutagen._compat import cBytesIO -from tests import add from mutagen.aiff import AIFF, AIFFInfo, delete, IFFFile, IFFChunk from mutagen.aiff import error as AIFFError from tempfile import mkstemp + class TAIFF(TestCase): - silence_1 = os.path.join('tests', 'data', '11k-1ch-2s-silence.aif') - silence_2 = os.path.join('tests', 'data', '48k-2ch-s16-silence.aif') - silence_3 = os.path.join('tests', 'data', '8k-1ch-1s-silence.aif') - silence_4 = os.path.join('tests', 'data', '8k-1ch-3.5s-silence.aif') - silence_5 = os.path.join('tests', 'data', '8k-4ch-1s-silence.aif') + silence_1 = os.path.join(DATA_DIR, '11k-1ch-2s-silence.aif') + silence_2 = os.path.join(DATA_DIR, '48k-2ch-s16-silence.aif') + silence_3 = os.path.join(DATA_DIR, '8k-1ch-1s-silence.aif') + silence_4 = os.path.join(DATA_DIR, '8k-1ch-3.5s-silence.aif') + silence_5 = os.path.join(DATA_DIR, '8k-4ch-1s-silence.aif') - has_tags = os.path.join('tests', 'data', 'with-id3.aif') - no_tags = os.path.join('tests', 'data', '8k-1ch-1s-silence.aif') + has_tags = os.path.join(DATA_DIR, 'with-id3.aif') + no_tags = os.path.join(DATA_DIR, '8k-1ch-1s-silence.aif') def setUp(self): fd, self.filename_1 = mkstemp(suffix='.aif') @@ -27,7 +29,7 @@ os.close(fd) shutil.copy(self.no_tags, self.filename_2) - self.aiff_tmp_id3 = AIFF(self.filename_1) + self.aiff_tmp_id3 = AIFF(self.filename_1) self.aiff_tmp_no_id3 = AIFF(self.filename_2) self.aiff_1 = AIFF(self.silence_1) @@ -72,7 +74,8 @@ self.failUnlessEqual(self.aiff_5.info.sample_size, 16) def test_notaiff(self): - self.failUnlessRaises(AIFFError, AIFF, "README") + self.failUnlessRaises( + AIFFError, AIFF, os.path.join(DATA_DIR, 'empty.ofr')) def test_pprint(self): self.failUnless(self.aiff_1.pprint()) @@ -97,7 +100,8 @@ def test_save_no_tags(self): self.aiff_tmp_id3.tags = None - self.failUnlessRaises(ValueError, self.aiff_tmp_id3.save) + self.aiff_tmp_id3.save() + self.assertTrue(self.aiff_tmp_id3.tags is None) def test_add_tags_already_there(self): self.failUnless(self.aiff_tmp_id3.tags) @@ -117,7 +121,7 @@ self.failUnlessEqual(new["TIT2"], ["AIFF title"]) def test_save_tags(self): - from mutagen._id3frames import TIT1 + from mutagen.id3 import TIT1 tags = self.aiff_tmp_id3.tags tags.add(TIT1(encoding=3, text="foobar")) tags.save() @@ -126,23 +130,30 @@ self.failUnlessEqual(new["TIT1"], ["foobar"]) def test_save_with_ID3_chunk(self): - from mutagen._id3frames import TIT1 + from mutagen.id3 import TIT1 self.aiff_tmp_id3["TIT1"] = TIT1(encoding=3, text="foobar") self.aiff_tmp_id3.save() self.failUnless(AIFF(self.filename_1)["TIT1"] == "foobar") self.failUnless(self.aiff_tmp_id3["TIT2"] == "AIFF title") def test_save_without_ID3_chunk(self): - from mutagen._id3frames import TIT1 + from mutagen.id3 import TIT1 self.aiff_tmp_no_id3["TIT1"] = TIT1(encoding=3, text="foobar") self.aiff_tmp_no_id3.save() self.failUnless(AIFF(self.filename_2)["TIT1"] == "foobar") + def test_corrupt_tag(self): + with open(self.filename_1, "r+b") as h: + chunk = IFFFile(h)[u'ID3'] + h.seek(chunk.data_offset) + h.seek(4, 1) + h.write(b"\xff\xff") + self.assertRaises(AIFFError, AIFF, self.filename_1) + def tearDown(self): os.unlink(self.filename_1) os.unlink(self.filename_2) -add(TAIFF) class TAIFFInfo(TestCase): @@ -150,33 +161,32 @@ fileobj = cBytesIO(b"") self.failUnlessRaises(IOError, AIFFInfo, fileobj) -add(TAIFFInfo) class TIFFFile(TestCase): - has_tags = os.path.join('tests', 'data', 'with-id3.aif') - no_tags = os.path.join('tests', 'data', '8k-1ch-1s-silence.aif') + has_tags = os.path.join(DATA_DIR, 'with-id3.aif') + no_tags = os.path.join(DATA_DIR, '8k-1ch-1s-silence.aif') def setUp(self): self.file_1 = open(self.has_tags, 'rb') - self.iff_1 = IFFFile(self.file_1) + self.iff_1 = IFFFile(self.file_1) self.file_2 = open(self.no_tags, 'rb') - self.iff_2 = IFFFile(self.file_2) + self.iff_2 = IFFFile(self.file_2) fd_1, tmp_1_name = mkstemp(suffix='.aif') shutil.copy(self.has_tags, tmp_1_name) self.file_1_tmp = open(tmp_1_name, 'rb+') - self.iff_1_tmp = IFFFile(self.file_1_tmp) + self.iff_1_tmp = IFFFile(self.file_1_tmp) fd_2, tmp_2_name = mkstemp(suffix='.aif') shutil.copy(self.no_tags, tmp_2_name) self.file_2_tmp = open(tmp_2_name, 'rb+') - self.iff_2_tmp = IFFFile(self.file_2_tmp) + self.iff_2_tmp = IFFFile(self.file_2_tmp) def test_has_chunks(self): self.failUnless('FORM' in self.iff_1) self.failUnless('COMM' in self.iff_1) self.failUnless('SSND' in self.iff_1) - self.failUnless('ID3' in self.iff_1) + self.failUnless('ID3' in self.iff_1) self.failUnless('FORM' in self.iff_2) self.failUnless('COMM' in self.iff_2) @@ -186,7 +196,7 @@ self.failUnless(isinstance(self.iff_1['FORM'], IFFChunk)) self.failUnless(isinstance(self.iff_1['COMM'], IFFChunk)) self.failUnless(isinstance(self.iff_1['SSND'], IFFChunk)) - self.failUnless(isinstance(self.iff_1['ID3'], IFFChunk)) + self.failUnless(isinstance(self.iff_1['ID3'], IFFChunk)) def test_chunk_size(self): self.failUnlessEqual(self.iff_1['FORM'].size, 17096) @@ -233,5 +243,3 @@ self.file_2.close() self.file_1_tmp.close() self.file_2_tmp.close() - -add(TIFFFile) diff -Nru mutagen-1.23/tests/test_apev2.py mutagen-1.30/tests/test_apev2.py --- mutagen-1.23/tests/test_apev2.py 2013-09-12 16:21:58.000000000 +0000 +++ mutagen-1.30/tests/test_apev2.py 2015-05-09 12:19:42.000000000 +0000 @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # FIXME: This test suite is a mess, a lot of it dates from PyMusepack so # it doesn't match the other Mutagen test conventions/quality. @@ -6,19 +7,19 @@ from tempfile import mkstemp -from tests import TestCase, add +from tests import TestCase, DATA_DIR import mutagen.apev2 from mutagen._compat import PY3, text_type -from mutagen.apev2 import APEv2File, APEv2, is_valid_apev2_key +from mutagen.apev2 import APEv2File, APEv2, is_valid_apev2_key, APEBadItemError -DIR = os.path.dirname(__file__) -SAMPLE = os.path.join(DIR, "data", "click.mpc") -OLD = os.path.join(DIR, "data", "oldtag.apev2") -BROKEN = os.path.join(DIR, "data", "brokentag.apev2") -LYRICS2 = os.path.join(DIR, "data", "apev2-lyricsv2.mp3") -INVAL_ITEM_COUNT = os.path.join(DIR, "data", "145-invalid-item-count.apev2") +SAMPLE = os.path.join(DATA_DIR, "click.mpc") +OLD = os.path.join(DATA_DIR, "oldtag.apev2") +BROKEN = os.path.join(DATA_DIR, "brokentag.apev2") +LYRICS2 = os.path.join(DATA_DIR, "apev2-lyricsv2.mp3") +INVAL_ITEM_COUNT = os.path.join(DATA_DIR, "145-invalid-item-count.apev2") + class Tis_valid_apev2_key(TestCase): @@ -34,8 +35,6 @@ def test_py3(self): self.assertRaises(TypeError, is_valid_apev2_key, b"abc") -add(Tis_valid_apev2_key) - class TAPEInvalidItemCount(TestCase): # http://code.google.com/p/mutagen/issues/detail?id=145 @@ -44,8 +43,6 @@ x = mutagen.apev2.APEv2(INVAL_ITEM_COUNT) self.failUnlessEqual(len(x.keys()), 17) -add(TAPEInvalidItemCount) - class TAPEWriter(TestCase): offset = 0 @@ -68,7 +65,7 @@ self.tag = mutagen.apev2.APEv2(SAMPLE + ".new") def test_changed(self): - size = os.path.getsize(SAMPLE + ".new") + size = os.path.getsize(SAMPLE + ".new") self.tag.save() self.failUnlessEqual( os.path.getsize(SAMPLE + ".new"), size - self.offset) @@ -80,7 +77,7 @@ tag = mutagen.apev2.APEv2(BROKEN) tag.save(BROKEN + ".new") self.failUnlessEqual( - os.path.getsize(OLD), os.path.getsize(BROKEN+".new")) + os.path.getsize(OLD), os.path.getsize(BROKEN + ".new")) def test_readback(self): for k, v in self.tag.items(): @@ -103,7 +100,7 @@ def test_empty(self): self.failUnlessRaises( IOError, mutagen.apev2.APEv2, - os.path.join("tests", "data", "emptyfile.mp3")) + os.path.join(DATA_DIR, "emptyfile.mp3")) def test_tag_at_start(self): filename = SAMPLE + ".tag_at_start" @@ -150,7 +147,6 @@ os.unlink(SAMPLE + ".justtag") os.unlink(SAMPLE + ".tag_at_start") -add(TAPEWriter) class TAPEv2ThenID3v1Writer(TAPEWriter): offset = 128 @@ -170,7 +166,6 @@ def test_tag_at_start_write(self): pass -add(TAPEv2ThenID3v1Writer) class TAPEv2(TestCase): @@ -214,14 +209,14 @@ def test_module_delete_empty(self): from mutagen.apev2 import delete - delete(os.path.join("tests", "data", "emptyfile.mp3")) + delete(os.path.join(DATA_DIR, "emptyfile.mp3")) def test_invalid(self): self.failUnlessRaises(IOError, mutagen.apev2.APEv2, "dne") def test_no_tag(self): self.failUnlessRaises(IOError, mutagen.apev2.APEv2, - os.path.join("tests", "data", "empty.mp3")) + os.path.join(DATA_DIR, "empty.mp3")) def test_cases(self): self.failUnlessEqual(self.audio["artist"], self.audio["ARTIST"]) @@ -276,7 +271,6 @@ def tearDown(self): os.unlink(self.filename) -add(TAPEv2) class TAPEv2ThenID3v1(TAPEv2): @@ -287,7 +281,6 @@ f.close() self.audio = APEv2(self.filename) -add(TAPEv2ThenID3v1) class TAPEv2WithLyrics2(TestCase): @@ -299,7 +292,6 @@ self.failUnlessEqual(self.tag["REPLAYGAIN_TRACK_GAIN"], "-4.080000 dB") self.failUnlessEqual(self.tag["REPLAYGAIN_TRACK_PEAK"], "1.008101") -add(TAPEv2WithLyrics2) class TAPEBinaryValue(TestCase): @@ -308,7 +300,7 @@ def setUp(self): self.sample = b"\x12\x45\xde" - self.value = mutagen.apev2.APEValue(self.sample,mutagen.apev2.BINARY) + self.value = mutagen.apev2.APEValue(self.sample, mutagen.apev2.BINARY) def test_type(self): self.failUnless(isinstance(self.value, self.BV)) @@ -320,13 +312,12 @@ repr(self.value) def test_pprint(self): - self.value.pprint() + self.assertEqual(self.value.pprint(), "[3 bytes]") - def test_type(self): + def test_type2(self): self.assertRaises(TypeError, mutagen.apev2.APEValue, u"abc", mutagen.apev2.BINARY) -add(TAPEBinaryValue) class TAPETextValue(TestCase): @@ -338,9 +329,18 @@ self.value = mutagen.apev2.APEValue( "\0".join(self.sample), mutagen.apev2.TEXT) + def test_parse(self): + self.assertRaises(APEBadItemError, self.TV._new, b"\xff") + def test_type(self): self.failUnless(isinstance(self.value, self.TV)) + def test_construct(self): + self.assertEqual(text_type(self.TV(u"foo")), u"foo") + if not PY3: + self.assertEqual(text_type(self.TV(b"foo")), u"foo") + self.assertRaises(ValueError, self.TV, b"\xff") + def test_list(self): self.failUnlessEqual(self.sample, list(self.value)) @@ -354,8 +354,21 @@ for i in range(len(self.value)): self.failUnlessEqual(self.sample[i], self.value[i]) - if PY3: - def test_py3(self): + def test_delitem(self): + del self.sample[1] + self.assertEqual(list(self.sample), ["foo", "baz"]) + del self.sample[1:] + self.assertEqual(list(self.sample), ["foo"]) + + def test_insert(self): + self.sample.insert(0, "a") + self.assertEqual(len(self.sample), 4) + self.assertEqual(self.sample[0], "a") + if PY3: + self.assertRaises(TypeError, self.value.insert, 2, b"abc") + + def test_types(self): + if PY3: self.assertRaises(TypeError, self.value.__setitem__, 2, b"abc") self.assertRaises( TypeError, mutagen.apev2.APEValue, b"abc", mutagen.apev2.TEXT) @@ -363,7 +376,12 @@ def test_repr(self): repr(self.value) -add(TAPETextValue) + def test_str(self): + self.assertEqual(text_type(self.value), u"foo\x00bar\x00baz") + + def test_pprint(self): + self.assertEqual(self.value.pprint(), "foo / bar / baz") + class TAPEExtValue(TestCase): @@ -391,14 +409,17 @@ mutagen.apev2.EXTERNAL) def test_pprint(self): - self.value.pprint() + self.assertEqual(self.value.pprint(), "[External] http://foo") -add(TAPEExtValue) class TAPEv2File(TestCase): def setUp(self): - self.audio = APEv2File("tests/data/click.mpc") + self.audio = APEv2File(os.path.join(DATA_DIR, "click.mpc")) + + def test_empty(self): + f = APEv2File(os.path.join(DATA_DIR, "xing.mp3")) + self.assertFalse(f.items()) def test_add_tags(self): self.failUnless(self.audio.tags is None) @@ -409,5 +430,3 @@ def test_unknown_info(self): info = self.audio.info info.pprint() - -add(TAPEv2File) diff -Nru mutagen-1.23/tests/test_asf.py mutagen-1.30/tests/test_asf.py --- mutagen-1.23/tests/test_asf.py 2013-10-06 12:27:20.000000000 +0000 +++ mutagen-1.30/tests/test_asf.py 2015-05-09 12:20:56.000000000 +0000 @@ -1,38 +1,38 @@ +# -*- coding: utf-8 -*- + import os import shutil from tempfile import mkstemp -from tests import TestCase, add +from tests import TestCase, DATA_DIR + +from mutagen._compat import PY3, text_type, PY2 from mutagen.asf import ASF, ASFHeaderError, ASFValue, UNICODE, DWORD, QWORD from mutagen.asf import BOOL, WORD, BYTEARRAY, GUID +from mutagen.asf import ASFUnicodeAttribute, ASFError, ASFByteArrayAttribute, \ + ASFBoolAttribute, ASFDWordAttribute, ASFQWordAttribute, ASFWordAttribute, \ + ASFGUIDAttribute + class TASFFile(TestCase): def test_not_my_file(self): self.failUnlessRaises( ASFHeaderError, ASF, - os.path.join("tests", "data", "empty.ogg")) + os.path.join(DATA_DIR, "empty.ogg")) self.failUnlessRaises( ASFHeaderError, ASF, - os.path.join("tests", "data", "click.mpc")) - -add(TASFFile) + os.path.join(DATA_DIR, "click.mpc")) -try: sorted -except NameError: - def sorted(l): - n = list(l) - n.sort() - return n class TASFInfo(TestCase): def setUp(self): # WMA 9.1 64kbps CBR 48khz - self.wma1 = ASF(os.path.join("tests", "data", "silence-1.wma")) + self.wma1 = ASF(os.path.join(DATA_DIR, "silence-1.wma")) # WMA 9.1 Professional 192kbps VBR 44khz - self.wma2 = ASF(os.path.join("tests", "data", "silence-2.wma")) + self.wma2 = ASF(os.path.join(DATA_DIR, "silence-2.wma")) # WMA 9.1 Lossless 44khz - self.wma3 = ASF(os.path.join("tests", "data", "silence-3.wma")) + self.wma3 = ASF(os.path.join(DATA_DIR, "silence-3.wma")) def test_length(self): self.failUnlessAlmostEqual(self.wma1.info.length, 3.7, 1) @@ -54,7 +54,6 @@ self.failUnlessEqual(self.wma2.info.channels, 2) self.failUnlessEqual(self.wma3.info.channels, 2) -add(TASFInfo) class TASF(TestCase): @@ -67,6 +66,9 @@ def tearDown(self): os.unlink(self.filename) + +class TASFMixin(object): + def test_pprint(self): self.failUnless(self.audio.pprint()) @@ -85,6 +87,20 @@ else: self.failUnlessEqual(self.audio[key], result or value) + def test_slice(self): + tags = self.audio.tags + tags.clear() + tags["Author"] = [u"Foo", u"Bar"] + self.assertEqual(tags[:], [("Author", "Foo"), ("Author", "Bar")]) + del tags[:] + self.assertEqual(tags[:], []) + tags[:] = [("Author", "Baz")] + self.assertEqual(tags.items(), [("Author", ["Baz"])]) + + def test_iter(self): + self.assertEqual(next(iter(self.audio.tags)), ("Title", "test")) + self.assertEqual(list(self.audio.tags)[0], ("Title", "test")) + def test_contains(self): self.failUnlessEqual("notatag" in self.audio.tags, False) @@ -98,6 +114,22 @@ value = ASFValue(b'\x9eZl}\x89\xa2\xb5D\xb8\xa30\xfe', GUID) self.set_key(u"WM/WMCollectionGroupID", value, [value]) + def test_py3_bytes(self): + if PY3: + value = ASFValue(b'\xff\x00', BYTEARRAY) + self.set_key(u"QL/Something", [b'\xff\x00'], [value]) + + def test_set_invalid(self): + setitem = self.audio.__setitem__ + if PY2: + self.assertRaises(ValueError, setitem, u"QL/Something", [b"\xff"]) + self.assertRaises(TypeError, setitem, u"QL/Something", [object()]) + + # don't delete on error + setitem(u"QL/Foobar", [u"ok"]) + self.assertRaises(TypeError, setitem, u"QL/Foobar", [object()]) + self.assertEqual(self.audio[u"QL/Foobar"], [u"ok"]) + def test_auto_unicode(self): self.set_key(u"WM/AlbumTitle", u"foo", [ASFValue(u"foo", UNICODE)]) @@ -161,8 +193,8 @@ self.audio.save() self.audio = ASF(self.audio.filename) self.failUnlessEqual(self.audio["QL/NoStream"][0].stream, None) - self.failUnlessEqual(self.audio["QL/OneHasStream"][0].stream, 2) - self.failUnlessEqual(self.audio["QL/OneHasStream"][1].stream, None) + self.failUnlessEqual(self.audio["QL/OneHasStream"][1].stream, 2) + self.failUnlessEqual(self.audio["QL/OneHasStream"][0].stream, None) self.failUnlessEqual(self.audio["QL/AllHaveStream"][0].stream, 1) self.failUnlessEqual(self.audio["QL/AllHaveStream"][1].stream, 2) @@ -181,8 +213,8 @@ self.audio.save() self.audio = ASF(self.audio.filename) self.failUnlessEqual(self.audio["QL/NoLang"][0].language, None) - self.failUnlessEqual(self.audio["QL/OneHasLang"][0].language, 2) - self.failUnlessEqual(self.audio["QL/OneHasLang"][1].language, None) + self.failUnlessEqual(self.audio["QL/OneHasLang"][1].language, 2) + self.failUnlessEqual(self.audio["QL/OneHasLang"][0].language, None) self.failUnlessEqual(self.audio["QL/AllHaveLang"][0].language, 1) self.failUnlessEqual(self.audio["QL/AllHaveLang"][1].language, 2) @@ -195,33 +227,154 @@ ] self.audio.save() self.audio = ASF(self.audio.filename) + # order not preserved here because they end up in different objects. + self.failUnlessEqual(self.audio["QL/Mix"][1].language, None) + self.failUnlessEqual(self.audio["QL/Mix"][1].stream, 1) + self.failUnlessEqual(self.audio["QL/Mix"][2].language, 2) + self.failUnlessEqual(self.audio["QL/Mix"][2].stream, 0) + self.failUnlessEqual(self.audio["QL/Mix"][3].language, 4) + self.failUnlessEqual(self.audio["QL/Mix"][3].stream, 3) self.failUnlessEqual(self.audio["QL/Mix"][0].language, None) - self.failUnlessEqual(self.audio["QL/Mix"][0].stream, 1) - self.failUnlessEqual(self.audio["QL/Mix"][1].language, 2) - self.failUnlessEqual(self.audio["QL/Mix"][1].stream, 0) - self.failUnlessEqual(self.audio["QL/Mix"][2].language, 4) - self.failUnlessEqual(self.audio["QL/Mix"][2].stream, 3) - self.failUnlessEqual(self.audio["QL/Mix"][3].language, None) - self.failUnlessEqual(self.audio["QL/Mix"][3].stream, None) + self.failUnlessEqual(self.audio["QL/Mix"][0].stream, None) def test_data_size(self): v = ASFValue("", UNICODE, data=b'4\xd8\x1e\xdd\x00\x00') self.failUnlessEqual(v.data_size(), len(v._render())) -class TASFTags1(TASF): - original = os.path.join("tests", "data", "silence-1.wma") -add(TASFTags1) - -class TASFTags2(TASF): - original = os.path.join("tests", "data", "silence-2.wma") -add(TASFTags2) - -class TASFTags3(TASF): - original = os.path.join("tests", "data", "silence-3.wma") -add(TASFTags3) + +class TASFAttributes(TestCase): + + def test_ASFUnicodeAttribute(self): + if PY3: + self.assertRaises(TypeError, ASFUnicodeAttribute, b"\xff") + else: + self.assertRaises(ValueError, ASFUnicodeAttribute, b"\xff") + val = u'\xf6\xe4\xfc' + self.assertEqual(ASFUnicodeAttribute(val.encode("utf-8")), val) + + self.assertRaises(ASFError, ASFUnicodeAttribute, data=b"\x00") + self.assertEqual(ASFUnicodeAttribute(u"foo").value, u"foo") + + def test_ASFUnicodeAttribute_dunder(self): + attr = ASFUnicodeAttribute(u"foo") + + self.assertEqual(bytes(attr), b"f\x00o\x00o\x00") + self.assertEqual(text_type(attr), u"foo") + if PY3: + self.assertEqual(repr(attr), "ASFUnicodeAttribute('foo')") + else: + self.assertEqual(repr(attr), "ASFUnicodeAttribute(u'foo')") + self.assertRaises(TypeError, int, attr) + + def test_ASFByteArrayAttribute(self): + self.assertRaises(TypeError, ASFByteArrayAttribute, u"foo") + self.assertEqual(ASFByteArrayAttribute(data=b"\xff").value, b"\xff") + + def test_ASFByteArrayAttribute_dunder(self): + attr = ASFByteArrayAttribute(data=b"\xff") + self.assertEqual(bytes(attr), b"\xff") + self.assertEqual(text_type(attr), u"[binary data (1 bytes)]") + if PY3: + self.assertEqual(repr(attr), r"ASFByteArrayAttribute(b'\xff')") + else: + self.assertEqual(repr(attr), r"ASFByteArrayAttribute('\xff')") + self.assertRaises(TypeError, int, attr) + + def test_ASFByteArrayAttribute_compat(self): + ba = ASFByteArrayAttribute() + ba.value = b"\xff" + self.assertEqual(ba._render(), b"\xff") + + def test_ASFGUIDAttribute(self): + self.assertEqual(ASFGUIDAttribute(data=b"\xff").value, b"\xff") + self.assertRaises(TypeError, ASFGUIDAttribute, u"foo") + + def test_ASFGUIDAttribute_dunder(self): + attr = ASFGUIDAttribute(data=b"\xff") + self.assertEqual(bytes(attr), b"\xff") + if PY3: + self.assertEqual(text_type(attr), u"b'\\xff'") + self.assertEqual(repr(attr), "ASFGUIDAttribute(b'\\xff')") + else: + self.assertEqual(text_type(attr), u"'\\xff'") + self.assertEqual(repr(attr), "ASFGUIDAttribute('\\xff')") + self.assertRaises(TypeError, int, attr) + + def test_ASFBoolAttribute(self): + self.assertEqual( + ASFBoolAttribute(data=b"\x01\x00\x00\x00").value, True) + self.assertEqual( + ASFBoolAttribute(data=b"\x00\x00\x00\x00").value, False) + self.assertEqual(ASFBoolAttribute(False).value, False) + + def test_ASFBoolAttribute_dunder(self): + attr = ASFBoolAttribute(False) + self.assertEqual(bytes(attr), b"False") + self.assertEqual(text_type(attr), u"False") + self.assertEqual(repr(attr), "ASFBoolAttribute(False)") + self.assertRaises(TypeError, int, attr) + + def test_ASFWordAttribute(self): + self.assertEqual( + ASFWordAttribute(data=b"\x00" * 2).value, 0) + self.assertEqual( + ASFWordAttribute(data=b"\xff" * 2).value, 2 ** 16 - 1) + self.assertRaises(ValueError, ASFWordAttribute, -1) + self.assertRaises(ValueError, ASFWordAttribute, 2 ** 16) + + def test_ASFWordAttribute_dunder(self): + attr = ASFWordAttribute(data=b"\x00" * 2) + self.assertEqual(bytes(attr), b"0") + self.assertEqual(text_type(attr), u"0") + self.assertEqual(repr(attr), "ASFWordAttribute(0)") + self.assertEqual(int(attr), 0) + + def test_ASFDWordAttribute(self): + self.assertEqual( + ASFDWordAttribute(data=b"\x00" * 4).value, 0) + self.assertEqual( + ASFDWordAttribute(data=b"\xff" * 4).value, 2 ** 32 - 1) + self.assertRaises(ValueError, ASFDWordAttribute, -1) + self.assertRaises(ValueError, ASFDWordAttribute, 2 ** 32) + + def test_ASFDWordAttribute_dunder(self): + attr = ASFDWordAttribute(data=b"\x00" * 4) + self.assertEqual(bytes(attr), b"0") + self.assertEqual(text_type(attr), u"0") + self.assertEqual(repr(attr).replace("0L", "0"), "ASFDWordAttribute(0)") + self.assertEqual(int(attr), 0) + + def test_ASFQWordAttribute(self): + self.assertEqual( + ASFQWordAttribute(data=b"\x00" * 8).value, 0) + self.assertEqual( + ASFQWordAttribute(data=b"\xff" * 8).value, 2 ** 64 - 1) + self.assertRaises(ValueError, ASFQWordAttribute, -1) + self.assertRaises(ValueError, ASFQWordAttribute, 2 ** 64) + + def test_ASFQWordAttribute_dunder(self): + attr = ASFQWordAttribute(data=b"\x00" * 8) + self.assertEqual(bytes(attr), b"0") + self.assertEqual(text_type(attr), u"0") + self.assertEqual(repr(attr).replace("0L", "0"), "ASFQWordAttribute(0)") + self.assertEqual(int(attr), 0) + + +class TASFTags1(TASF, TASFMixin): + original = os.path.join(DATA_DIR, "silence-1.wma") + + +class TASFTags2(TASF, TASFMixin): + original = os.path.join(DATA_DIR, "silence-2.wma") + + +class TASFTags3(TASF, TASFMixin): + original = os.path.join(DATA_DIR, "silence-3.wma") + class TASFIssue29(TestCase): - original = os.path.join("tests", "data", "issue_29.wma") + original = os.path.join(DATA_DIR, "issue_29.wma") + def setUp(self): fd, self.filename = mkstemp(suffix='wma') os.close(fd) @@ -231,6 +384,9 @@ def tearDown(self): os.unlink(self.filename) + def test_pprint(self): + self.audio.pprint() + def test_issue_29_description(self): self.audio["Description"] = "Hello" self.audio.save() @@ -242,11 +398,89 @@ audio.save() audio = ASF(self.filename) self.failIf("Description" in audio) -add(TASFIssue29) + + +class TASFAttrDest(TestCase): + + original = os.path.join(DATA_DIR, "silence-1.wma") + + def setUp(self): + fd, self.filename = mkstemp(suffix='wma') + os.close(fd) + shutil.copy(self.original, self.filename) + audio = ASF(self.filename) + audio.clear() + audio.save() + + def tearDown(self): + os.unlink(self.filename) + + def test_author(self): + audio = ASF(self.filename) + values = [u"Foo", u"Bar", u"Baz"] + audio["Author"] = values + audio.save() + self.assertEqual( + list(audio.to_content_description.items()), [(u"Author", u"Foo")]) + self.assertEqual( + audio.to_metadata_library, + [(u"Author", u"Bar"), (u"Author", u"Baz")]) + + new = ASF(self.filename) + self.assertEqual(new["Author"], values) + + def test_author_long(self): + audio = ASF(self.filename) + # 2 ** 16 - 2 bytes encoded text + 2 bytes termination + just_small_enough = u"a" * (((2 ** 16) // 2) - 2) + audio["Author"] = [just_small_enough] + audio.save() + self.assertTrue(audio.to_content_description) + self.assertFalse(audio.to_metadata_library) + + audio["Author"] = [just_small_enough + u"a"] + audio.save() + self.assertFalse(audio.to_content_description) + self.assertTrue(audio.to_metadata_library) + + def test_multi_order(self): + audio = ASF(self.filename) + audio["Author"] = [u"a", u"b", u"c"] + audio.save() + audio = ASF(self.filename) + self.assertEqual(audio["Author"], [u"a", u"b", u"c"]) + + def test_multi_order_extended(self): + audio = ASF(self.filename) + audio["WM/Composer"] = [u"a", u"b", u"c"] + audio.save() + audio = ASF(self.filename) + self.assertEqual(audio["WM/Composer"], [u"a", u"b", u"c"]) + + def test_non_text_type(self): + audio = ASF(self.filename) + audio["Author"] = [42] + audio.save() + self.assertFalse(audio.to_content_description) + new = ASF(self.filename) + self.assertEqual(new["Author"], [42]) + + def test_empty(self): + audio = ASF(self.filename) + audio["Author"] = [u"", u""] + audio["Title"] = [u""] + audio["Copyright"] = [] + audio.save() + + new = ASF(self.filename) + self.assertEqual(new["Author"], [u"", u""]) + self.assertEqual(new["Title"], [u""]) + self.assertFalse("Copyright" in new) + class TASFLargeValue(TestCase): - original = os.path.join("tests", "data", "silence-1.wma") + original = os.path.join(DATA_DIR, "silence-1.wma") def setUp(self): fd, self.filename = mkstemp(suffix='wma') @@ -260,7 +494,8 @@ audio = ASF(self.filename) audio["QL/LargeObject"] = [ASFValue(b"." * 0xFFFF, BYTEARRAY)] audio.save() - self.failIf("QL/LargeObject" not in audio.to_extended_content_description) + self.failIf( + "QL/LargeObject" not in audio.to_extended_content_description) self.failIf("QL/LargeObject" in audio.to_metadata) self.failIf("QL/LargeObject" in dict(audio.to_metadata_library)) @@ -276,7 +511,8 @@ audio = ASF(self.filename) audio["QL/LargeObject"] = [ASFValue("." * (0x7FFF - 1), UNICODE)] audio.save() - self.failIf("QL/LargeObject" not in audio.to_extended_content_description) + self.failIf( + "QL/LargeObject" not in audio.to_extended_content_description) self.failIf("QL/LargeObject" in audio.to_metadata) self.failIf("QL/LargeObject" in dict(audio.to_metadata_library)) @@ -291,25 +527,24 @@ def test_save_guid(self): # http://code.google.com/p/mutagen/issues/detail?id=81 audio = ASF(self.filename) - audio["QL/GuidObject"] = [ASFValue(b" "*16, GUID)] + audio["QL/GuidObject"] = [ASFValue(b" " * 16, GUID)] audio.save() self.failIf("QL/GuidObject" in audio.to_extended_content_description) self.failIf("QL/GuidObject" in audio.to_metadata) self.failIf("QL/GuidObject" not in dict(audio.to_metadata_library)) -add(TASFLargeValue) -# http://code.google.com/p/mutagen/issues/detail?id=81#c4 class TASFUpdateSize(TestCase): + # http://code.google.com/p/mutagen/issues/detail?id=81#c4 - original = os.path.join("tests", "data", "silence-1.wma") + original = os.path.join(DATA_DIR, "silence-1.wma") def setUp(self): fd, self.filename = mkstemp(suffix='wma') os.close(fd) shutil.copy(self.original, self.filename) audio = ASF(self.filename) - audio["large_value1"] = "#"*50000 + audio["large_value1"] = "#" * 50000 audio.save() def tearDown(self): @@ -320,5 +555,3 @@ for tag in audio.keys(): del(audio[tag]) audio.save() - -add(TASFUpdateSize) diff -Nru mutagen-1.23/tests/test_easyid3.py mutagen-1.30/tests/test_easyid3.py --- mutagen-1.23/tests/test_easyid3.py 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/tests/test_easyid3.py 2015-05-09 12:23:25.000000000 +0000 @@ -1,22 +1,26 @@ +# -*- coding: utf-8 -*- + import os import shutil import pickle -from tests import add, TestCase -from mutagen.id3 import ID3FileType +from tests import TestCase, DATA_DIR +from mutagen.id3 import ID3FileType, ID3 from mutagen.easyid3 import EasyID3, error as ID3Error +from mutagen._compat import PY3 from tempfile import mkstemp + class TEasyID3(TestCase): def setUp(self): fd, self.filename = mkstemp('.mp3') os.close(fd) - empty = os.path.join('tests', 'data', 'emptyfile.mp3') + empty = os.path.join(DATA_DIR, 'emptyfile.mp3') shutil.copy(empty, self.filename) self.id3 = EasyID3() def test_remember_ctr(self): - empty = os.path.join('tests', 'data', 'emptyfile.mp3') + empty = os.path.join(DATA_DIR, 'emptyfile.mp3') mp3 = ID3FileType(empty, ID3=EasyID3) self.failIf(mp3.tags) mp3["artist"] = ["testing"] @@ -24,6 +28,11 @@ mp3.pprint() self.failUnless(isinstance(mp3.tags, EasyID3)) + def test_ignore_23(self): + self.id3["date"] = "2004" + self.id3.save(self.filename, v2_version=3) + self.assertEqual(ID3(self.filename).version, (2, 4, 0)) + def test_delete(self): self.id3["artist"] = "foobar" self.id3.save(self.filename) @@ -36,20 +45,24 @@ self.id3["artist"] = "baz" self.id3.pprint() - def test_has_key(self): - self.failIf(self.id3.has_key("foo")) + def test_in(self): + self.failIf("foo" in self.id3) + + if not PY3: + def test_has_key(self): + self.failIf(self.id3.has_key("foo")) def test_empty_file(self): - empty = os.path.join('tests', 'data', 'emptyfile.mp3') + empty = os.path.join(DATA_DIR, 'emptyfile.mp3') self.assertRaises(ID3Error, EasyID3, filename=empty) def test_nonexistent_file(self): - empty = os.path.join('tests', 'data', 'does', 'not', 'exist') + empty = os.path.join(DATA_DIR, 'does', 'not', 'exist') self.assertRaises(IOError, EasyID3, filename=empty) def test_write_single(self): for key in EasyID3.valid_keys: - if key == "date": + if (key == "date") or (key == "originaldate"): continue elif key.startswith("replaygain_"): continue @@ -72,7 +85,7 @@ def test_write_double(self): for key in EasyID3.valid_keys: - if key == "date": + if (key == "date") or (key == "originaldate"): continue elif key.startswith("replaygain_"): continue @@ -82,13 +95,15 @@ self.id3[key] = ["a test", "value"] self.id3.save(self.filename) id3 = EasyID3(self.filename) - self.failUnlessEqual(id3.get(key), ["a test", "value"]) + # some keys end up in multiple frames and ID3.getall returns + # them in undefined order + self.failUnlessEqual(sorted(id3.get(key)), ["a test", "value"]) self.failUnlessEqual(id3.keys(), [key]) self.id3[key] = ["a test", "value"] self.id3.save(self.filename) id3 = EasyID3(self.filename) - self.failUnlessEqual(id3.get(key), ["a test", "value"]) + self.failUnlessEqual(sorted(id3.get(key)), ["a test", "value"]) self.failUnlessEqual(id3.keys(), [key]) del(self.id3[key]) @@ -109,7 +124,7 @@ self.failUnlessEqual(self.id3["date"], ["2004"]) del(self.id3["date"]) self.failIf("date" in self.id3.keys()) - + def test_write_date_double(self): self.id3["date"] = ["2004", "2005"] self.id3.save(self.filename) @@ -121,6 +136,34 @@ id3 = EasyID3(self.filename) self.failUnlessEqual(id3["date"], ["2004", "2005"]) + def test_write_original_date(self): + self.id3["originaldate"] = "2004" + self.id3.save(self.filename) + id3 = EasyID3(self.filename) + self.failUnlessEqual(id3["originaldate"], ["2004"]) + + self.id3["originaldate"] = "2004" + self.id3.save(self.filename) + id3 = EasyID3(self.filename) + self.failUnlessEqual(id3["originaldate"], ["2004"]) + + def test_original_date_delete(self): + self.id3["originaldate"] = "2004" + self.failUnlessEqual(self.id3["originaldate"], ["2004"]) + del(self.id3["originaldate"]) + self.failIf("originaldate" in self.id3.keys()) + + def test_write_original_date_double(self): + self.id3["originaldate"] = ["2004", "2005"] + self.id3.save(self.filename) + id3 = EasyID3(self.filename) + self.failUnlessEqual(id3["originaldate"], ["2004", "2005"]) + + self.id3["originaldate"] = ["2004", "2005"] + self.id3.save(self.filename) + id3 = EasyID3(self.filename) + self.failUnlessEqual(id3["originaldate"], ["2004", "2005"]) + def test_write_invalid(self): self.failUnlessRaises(ValueError, self.id3.__getitem__, "notvalid") self.failUnlessRaises(ValueError, self.id3.__delitem__, "notvalid") @@ -192,31 +235,33 @@ def test_gain_bad_key(self): self.failIf("replaygain_foo_gain" in self.id3) self.failIf(self.id3._EasyID3__id3.getall("RVA2")) - + def test_gain_bad_value(self): self.failUnlessRaises( ValueError, self.id3.__setitem__, "replaygain_foo_gain", []) self.failUnlessRaises( ValueError, self.id3.__setitem__, "replaygain_foo_gain", ["foo"]) self.failUnlessRaises( - ValueError, self.id3.__setitem__, "replaygain_foo_gain", ["1", "2"]) + ValueError, + self.id3.__setitem__, "replaygain_foo_gain", ["1", "2"]) self.failIf(self.id3._EasyID3__id3.getall("RVA2")) - + def test_peak_bad_key(self): self.failIf("replaygain_foo_peak" in self.id3) self.failIf(self.id3._EasyID3__id3.getall("RVA2")) - + def test_peak_bad_value(self): self.failUnlessRaises( ValueError, self.id3.__setitem__, "replaygain_foo_peak", []) self.failUnlessRaises( ValueError, self.id3.__setitem__, "replaygain_foo_peak", ["foo"]) self.failUnlessRaises( - ValueError, self.id3.__setitem__, "replaygain_foo_peak", ["1", "1"]) + ValueError, + self.id3.__setitem__, "replaygain_foo_peak", ["1", "1"]) self.failUnlessRaises( ValueError, self.id3.__setitem__, "replaygain_foo_peak", ["3"]) self.failIf(self.id3._EasyID3__id3.getall("RVA2")) - + def test_gain_peak_get(self): self.id3["replaygain_foo_gain"] = "+3.5 dB" self.id3["replaygain_bar_peak"] = "0.5" @@ -304,5 +349,3 @@ def tearDown(self): os.unlink(self.filename) - -add(TEasyID3) diff -Nru mutagen-1.23/tests/test_easymp4.py mutagen-1.30/tests/test_easymp4.py --- mutagen-1.23/tests/test_easymp4.py 2013-10-05 17:07:56.000000000 +0000 +++ mutagen-1.30/tests/test_easymp4.py 2015-05-09 12:24:43.000000000 +0000 @@ -1,15 +1,18 @@ +# -*- coding: utf-8 -*- + import os import shutil -from tests import add, TestCase +from tests import TestCase, DATA_DIR from mutagen.easymp4 import EasyMP4, error as MP4Error from tempfile import mkstemp + class TEasyMP4(TestCase): def setUp(self): fd, self.filename = mkstemp('.mp4') os.close(fd) - empty = os.path.join('tests', 'data', 'has-tags.m4a') + empty = os.path.join(DATA_DIR, 'has-tags.m4a') shutil.copy(empty, self.filename) self.mp4 = EasyMP4(self.filename) self.mp4.delete() @@ -22,11 +25,11 @@ self.failIf("foo" in self.mp4) def test_empty_file(self): - empty = os.path.join('tests', 'data', 'emptyfile.mp3') + empty = os.path.join(DATA_DIR, 'emptyfile.mp3') self.assertRaises(MP4Error, EasyMP4, filename=empty) def test_nonexistent_file(self): - empty = os.path.join('tests', 'data', 'does', 'not', 'exist') + empty = os.path.join(DATA_DIR, 'does', 'not', 'exist') self.assertRaises(IOError, EasyMP4, filename=empty) def test_write_single(self): @@ -85,7 +88,7 @@ self.failUnlessEqual(self.mp4["date"], ["2004"]) del(self.mp4["date"]) self.failIf("date" in self.mp4) - + def test_write_date_double(self): self.mp4["date"] = ["2004", "2005"] self.mp4.save(self.filename) @@ -145,5 +148,3 @@ def tearDown(self): os.unlink(self.filename) - -add(TEasyMP4) diff -Nru mutagen-1.23/tests/test_encoding.py mutagen-1.30/tests/test_encoding.py --- mutagen-1.23/tests/test_encoding.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/tests/test_encoding.py 2015-04-29 14:01:09.000000000 +0000 @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation + +import os +import re + +from tests import TestCase + +import mutagen + + +class TSourceEncoding(TestCase): + """Enforce utf-8 source encoding everywhere. + Plus give helpful message for fixing it. + """ + + def _check_encoding(self, path): + with open(path, "r") as h: + match = None + for i, line in enumerate(h): + # https://www.python.org/dev/peps/pep-0263/ + match = match or re.search("coding[:=]\s*([-\w.]+)", line) + if i >= 2: + break + if match: + match = match.group(1) + self.assertEqual(match, "utf-8", + msg="%s has no utf-8 source encoding set\n" + "Insert:\n# -*- coding: utf-8 -*-" % path) + + def test_main(self): + root = os.path.dirname(mutagen.__path__[0]) + + skip = [os.path.join(root, "docs")] + for dirpath, dirnames, filenames in os.walk(root): + if any((dirpath.startswith(s + os.sep) or s == dirpath) + for s in skip): + continue + + for filename in filenames: + if filename.endswith('.py'): + path = os.path.join(dirpath, filename) + self._check_encoding(path) diff -Nru mutagen-1.23/tests/test_flac.py mutagen-1.30/tests/test_flac.py --- mutagen-1.23/tests/test_flac.py 2013-09-10 16:49:35.000000000 +0000 +++ mutagen-1.30/tests/test_flac.py 2015-08-17 10:42:51.000000000 +0000 @@ -1,15 +1,25 @@ +# -*- coding: utf-8 -*- + import shutil import os -from os.path import devnull +import subprocess +from tempfile import mkstemp -from tests import TestCase, add +from tests import TestCase, DATA_DIR from mutagen.id3 import ID3, TIT2, ID3NoHeaderError from mutagen.flac import to_int_be, Padding, VCFLACDict, MetadataBlock, error from mutagen.flac import StreamInfo, SeekTable, CueSheet, FLAC, delete, Picture +from mutagen._compat import PY3 from tests.test__vorbis import TVCommentDict, VComment +def call_flac(*args): + with open(os.devnull, 'wb') as null: + return subprocess.call( + ["flac"] + list(args), stdout=null, stderr=subprocess.STDOUT) + + class Tto_int_be(TestCase): def test_empty(self): @@ -25,8 +35,7 @@ self.failUnlessEqual(to_int_be(b"\x01\x00"), 256) def test_long(self): - self.failUnlessEqual(to_int_be(b"\x01\x00\x00\x00\x00"), 2**32) -add(Tto_int_be) + self.failUnlessEqual(to_int_be(b"\x01\x00\x00\x00\x00"), 2 ** 32) class TVCFLACDict(TVCommentDict): @@ -35,7 +44,6 @@ def test_roundtrip_vc(self): self.failUnlessEqual(self.c, VComment(self.c.write() + b"\x01")) -add(TVCFLACDict) class TMetadataBlock(TestCase): @@ -67,7 +75,6 @@ length2 = len(MetadataBlock.writeblocks(blocks)) self.failUnlessEqual(length1, length2) self.failUnlessEqual(len(blocks), 2) -add(TMetadataBlock) class TStreamInfo(TestCase): @@ -117,11 +124,10 @@ def test_roundtrip(self): self.failUnlessEqual(StreamInfo(self.i.write()), self.i) -add(TStreamInfo) class TSeekTable(TestCase): - SAMPLE = os.path.join("tests", "data", "silence-44-s.flac") + SAMPLE = os.path.join(DATA_DIR, "silence-44-s.flac") def setUp(self): self.flac = FLAC(self.SAMPLE) @@ -147,18 +153,17 @@ def test_roundtrip(self): self.failUnlessEqual(SeekTable(self.st.write()), self.st) -add(TSeekTable) class TCueSheet(TestCase): - SAMPLE = os.path.join("tests", "data", "silence-44-s.flac") + SAMPLE = os.path.join(DATA_DIR, "silence-44-s.flac") def setUp(self): self.flac = FLAC(self.SAMPLE) self.cs = self.flac.cuesheet def test_cuesheet(self): - self.failUnlessEqual(self.cs.media_catalog_number, "1234567890123") + self.failUnlessEqual(self.cs.media_catalog_number, b"1234567890123") self.failUnlessEqual(self.cs.lead_in_samples, 88200) self.failUnlessEqual(self.cs.compact_disc, True) self.failUnlessEqual(len(self.cs.tracks), 4) @@ -166,7 +171,7 @@ def test_first_track(self): self.failUnlessEqual(self.cs.tracks[0].track_number, 1) self.failUnlessEqual(self.cs.tracks[0].start_offset, 0) - self.failUnlessEqual(self.cs.tracks[0].isrc, '123456789012') + self.failUnlessEqual(self.cs.tracks[0].isrc, b'123456789012') self.failUnlessEqual(self.cs.tracks[0].type, 0) self.failUnlessEqual(self.cs.tracks[0].pre_emphasis, False) self.failUnlessEqual(self.cs.tracks[0].indexes, [(1, 0)]) @@ -174,7 +179,7 @@ def test_second_track(self): self.failUnlessEqual(self.cs.tracks[1].track_number, 2) self.failUnlessEqual(self.cs.tracks[1].start_offset, 44100) - self.failUnlessEqual(self.cs.tracks[1].isrc, '') + self.failUnlessEqual(self.cs.tracks[1].isrc, b'') self.failUnlessEqual(self.cs.tracks[1].type, 1) self.failUnlessEqual(self.cs.tracks[1].pre_emphasis, True) self.failUnlessEqual(self.cs.tracks[1].indexes, [(1, 0), @@ -183,7 +188,7 @@ def test_lead_out(self): self.failUnlessEqual(self.cs.tracks[-1].track_number, 170) self.failUnlessEqual(self.cs.tracks[-1].start_offset, 162496) - self.failUnlessEqual(self.cs.tracks[-1].isrc, '') + self.failUnlessEqual(self.cs.tracks[-1].isrc, b'') self.failUnlessEqual(self.cs.tracks[-1].type, 0) self.failUnlessEqual(self.cs.tracks[-1].pre_emphasis, False) self.failUnlessEqual(self.cs.tracks[-1].indexes, []) @@ -204,11 +209,10 @@ def test_roundtrip(self): self.failUnlessEqual(CueSheet(self.cs.write()), self.cs) -add(TCueSheet) class TPicture(TestCase): - SAMPLE = os.path.join("tests", "data", "silence-44-s.flac") + SAMPLE = os.path.join(DATA_DIR, "silence-44-s.flac") def setUp(self): self.flac = FLAC(self.SAMPLE) @@ -238,7 +242,6 @@ def test_roundtrip(self): self.failUnlessEqual(Picture(self.p.write()), self.p) -add(TPicture) class TPadding(TestCase): @@ -261,19 +264,20 @@ def test_change(self): self.b.length = 20 self.failUnlessEqual(self.b.write(), b"\x00" * 20) -add(TPadding) class TFLAC(TestCase): - SAMPLE = os.path.join("tests", "data", "silence-44-s.flac") - NEW = SAMPLE + ".new" + SAMPLE = os.path.join(DATA_DIR, "silence-44-s.flac") def setUp(self): + fd, self.NEW = mkstemp(".flac") + os.close(fd) shutil.copy(self.SAMPLE, self.NEW) - self.failUnlessEqual(open(self.SAMPLE, "rb").read(), - open(self.NEW, "rb").read()) self.flac = FLAC(self.NEW) + def tearDown(self): + os.unlink(self.NEW) + def test_delete(self): self.failUnless(self.flac.tags) self.flac.delete() @@ -290,13 +294,16 @@ self.failUnlessAlmostEqual(FLAC(self.NEW).info.length, 3.7, 1) def test_keys(self): - self.failUnlessEqual(self.flac.keys(), self.flac.tags.keys()) + self.failUnlessEqual( + list(self.flac.keys()), list(self.flac.tags.keys())) def test_values(self): - self.failUnlessEqual(self.flac.values(), self.flac.tags.values()) + self.failUnlessEqual( + list(self.flac.values()), list(self.flac.tags.values())) def test_items(self): - self.failUnlessEqual(self.flac.items(), self.flac.tags.items()) + self.failUnlessEqual( + list(self.flac.items()), list(self.flac.tags.items())) def test_vc(self): self.failUnlessEqual(self.flac['title'][0], 'Silence') @@ -309,24 +316,35 @@ def test_write_changetitle(self): f = FLAC(self.NEW) - f["title"] = "A New Title" - f.save() - f = FLAC(self.NEW) - self.failUnlessEqual(f["title"][0], "A New Title") + if PY3: + self.assertRaises( + TypeError, f.__setitem__, b'title', b"A New Title") + else: + f[b"title"] = b"A New Title" + f.save() + f = FLAC(self.NEW) + self.failUnlessEqual(f[b"title"][0], b"A New Title") def test_write_changetitle_unicode_value(self): f = FLAC(self.NEW) - f["title"] = u"A Unicode Title \u2022" - f.save() - f = FLAC(self.NEW) - self.failUnlessEqual(f["title"][0], u"A Unicode Title \u2022") + if PY3: + self.assertRaises( + TypeError, f.__setitem__, b'title', u"A Unicode Title \u2022") + else: + f[b"title"] = u"A Unicode Title \u2022" + f.save() + f = FLAC(self.NEW) + self.failUnlessEqual(f[b"title"][0], u"A Unicode Title \u2022") def test_write_changetitle_unicode_key(self): f = FLAC(self.NEW) - f[u"title"] = "A New Title" - f.save() - f = FLAC(self.NEW) - self.failUnlessEqual(f[u"title"][0], "A New Title") + f[u"title"] = b"A New Title" + if PY3: + self.assertRaises(ValueError, f.save) + else: + f.save() + f = FLAC(self.NEW) + self.failUnlessEqual(f[u"title"][0], b"A New Title") def test_write_changetitle_unicode_key_and_value(self): f = FLAC(self.NEW) @@ -351,14 +369,14 @@ self.failUnlessEqual(f["faketag"], ["foo"]) def test_add_vc(self): - f = FLAC(os.path.join("tests", "data", "no-tags.flac")) + f = FLAC(os.path.join(DATA_DIR, "no-tags.flac")) self.failIf(f.tags) f.add_tags() self.failUnless(f.tags == []) self.failUnlessRaises(ValueError, f.add_tags) def test_add_vc_implicit(self): - f = FLAC(os.path.join("tests", "data", "no-tags.flac")) + f = FLAC(os.path.join(DATA_DIR, "no-tags.flac")) self.failIf(f.tags) f["foo"] = "bar" self.failUnless(f.tags == [("foo", "bar")]) @@ -367,7 +385,7 @@ def test_ooming_vc_header(self): # issue 112: Malformed FLAC Vorbis header causes out of memory error # http://code.google.com/p/mutagen/issues/detail?id=112 - self.assertRaises(IOError, FLAC, os.path.join('tests', 'data', + self.assertRaises(IOError, FLAC, os.path.join(DATA_DIR, 'ooming-header.flac')) def test_with_real_flac(self): @@ -375,9 +393,7 @@ return self.flac["faketag"] = "foobar" * 1000 self.flac.save() - badval = os.system("tools/notarealprogram 2> %s" % devnull) - value = os.system("flac -t %s 2> %s" % (self.flac.filename, devnull)) - self.failIf(value and value != badval) + self.failIf(call_flac("-t", self.flac.filename) != 0) def test_save_unknown_block(self): block = MetadataBlock(b"test block data") @@ -404,11 +420,11 @@ def test_load_invalid_flac(self): self.failUnlessRaises( - IOError, FLAC, os.path.join("tests", "data", "xing.mp3")) + IOError, FLAC, os.path.join(DATA_DIR, "xing.mp3")) def test_save_invalid_flac(self): self.failUnlessRaises( - IOError, self.flac.save, os.path.join("tests", "data", "xing.mp3")) + IOError, self.flac.save, os.path.join(DATA_DIR, "xing.mp3")) def test_pprint(self): self.failUnless(self.flac.pprint()) @@ -471,14 +487,10 @@ self.failUnless("audio/x-flac" in self.flac.mime) def test_variable_block_size(self): - FLAC(os.path.join("tests", "data", "variable-block.flac")) + FLAC(os.path.join(DATA_DIR, "variable-block.flac")) def test_load_flac_with_application_block(self): - FLAC(os.path.join("tests", "data", "flac_application.flac")) - - def tearDown(self): - os.unlink(self.NEW) -add(TFLAC) + FLAC(os.path.join(DATA_DIR, "flac_application.flac")) class TFLACFile(TestCase): @@ -486,18 +498,15 @@ def test_open_nonexistant(self): """mutagen 1.2 raises UnboundLocalError, then it tries to open non-existant FLAC files""" - filename = os.path.join("tests", "data", "doesntexist.flac") + filename = os.path.join(DATA_DIR, "doesntexist.flac") self.assertRaises(IOError, FLAC, filename) -add(TFLACFile) - class TFLACBadBlockSize(TestCase): - TOO_SHORT = os.path.join("tests", "data", "52-too-short-block-size.flac") - TOO_SHORT_2 = os.path.join("tests", "data", - "106-short-picture-block-size.flac") - OVERWRITTEN = os.path.join("tests", "data", "52-overwritten-metadata.flac") - INVAL_INFO = os.path.join("tests", "data", "106-invalid-streaminfo.flac") + TOO_SHORT = os.path.join(DATA_DIR, "52-too-short-block-size.flac") + TOO_SHORT_2 = os.path.join(DATA_DIR, "106-short-picture-block-size.flac") + OVERWRITTEN = os.path.join(DATA_DIR, "52-overwritten-metadata.flac") + INVAL_INFO = os.path.join(DATA_DIR, "106-invalid-streaminfo.flac") def test_too_short_read(self): flac = FLAC(self.TOO_SHORT) @@ -513,16 +522,19 @@ def test_inval_streaminfo(self): self.assertRaises(error, FLAC, self.INVAL_INFO) -add(TFLACBadBlockSize) class TFLACBadBlockSizeWrite(TestCase): - TOO_SHORT = os.path.join("tests", "data", "52-too-short-block-size.flac") - NEW = TOO_SHORT + ".new" + TOO_SHORT = os.path.join(DATA_DIR, "52-too-short-block-size.flac") def setUp(self): + fd, self.NEW = mkstemp(".flac") + os.close(fd) shutil.copy(self.TOO_SHORT, self.NEW) + def tearDown(self): + os.unlink(self.NEW) + def test_write_reread(self): flac = FLAC(self.NEW) del(flac["artist"]) @@ -532,10 +544,48 @@ data = open(self.NEW, "rb").read(1024) self.failIf(b"Tunng" in data) + +class TFLACBadBlockSizeOverflow(TestCase): + + def setUp(self): + fd, self.filename = mkstemp(".flac") + os.close(fd) + shutil.copy(os.path.join(DATA_DIR, "silence-44-s.flac"), self.filename) + def tearDown(self): - os.unlink(self.NEW) + os.unlink(self.filename) + + def test_largest_valid(self): + f = FLAC(self.filename) + pic = Picture() + pic.data = b"\x00" * (2 ** 24 - 32) + self.assertEqual(len(pic.write()), 2 ** 24) + f.add_picture(pic) + f.save() + + def test_smallest_invalid(self): + f = FLAC(self.filename) + pic = Picture() + pic.data = b"\x00" * (2 ** 24 - 31) + f.add_picture(pic) + self.assertRaises(error, f.save) + + def test_invalid_overflow_recover_and_save_back(self): + # save a picture which is too large for flac, but still write it + # with a wrong block size + f = FLAC(self.filename) + f.clear_pictures() + pic = Picture() + pic.data = b"\x00" * (2 ** 24 - 31) + pic._invalid_overflow_size = 42 + f.add_picture(pic) + f.save() -add(TFLACBadBlockSizeWrite) + # make sure we can read it and save it again + f = FLAC(self.filename) + self.assertTrue(f.pictures) + self.assertEqual(len(f.pictures[0].data), 2 ** 24 - 31) + f.save() class CVE20074619(TestCase): @@ -548,8 +598,11 @@ # "Editing any Metadata Block Size value to a large value such # as 0xFFFFFFFF may result in a heap based overflow in the # decoding software." - filename = os.path.join("tests", "data", "CVE-2007-4619-1.flac") + filename = os.path.join(DATA_DIR, "CVE-2007-4619-1.flac") self.failUnlessRaises(IOError, FLAC, filename) + # work around https://bitbucket.org/pypy/pypy/issue/1988 + import gc + gc.collect() def test_2(self): # "The second vulnerability lies within the parsing of any @@ -557,8 +610,11 @@ # an overly large size, such as 0xFFFFFFF, could also result # in another heap-based overflow allowing arbitrary code to # execute in the content of the decoding program." - filename = os.path.join("tests", "data", "CVE-2007-4619-2.flac") + filename = os.path.join(DATA_DIR, "CVE-2007-4619-2.flac") self.failUnlessRaises(IOError, FLAC, filename) + # work around https://bitbucket.org/pypy/pypy/issue/1988 + import gc + gc.collect() # "By inserting an overly long VORBIS Comment data string along # with an large VORBIS Comment data string size value (such as @@ -589,11 +645,11 @@ # impossible to store in a FLAC file. def test_12_read(self): - filename = os.path.join("tests", "data", "CVE-2007-4619-12.flac") + filename = os.path.join(DATA_DIR, "CVE-2007-4619-12.flac") self.failUnlessRaises(IOError, FLAC, filename) def test_12_write_too_big(self): - filename = os.path.join("tests", "data", "silence-44-s.flac") + filename = os.path.join(DATA_DIR, "silence-44-s.flac") f = FLAC(filename) # This size is too big to be an integer. f.metadata_blocks[-1].length = 0xFFFFFFFFFFFFFFFF @@ -601,7 +657,7 @@ def test_12_write_too_big_for_flac(self): from mutagen.flac import MetadataBlock - filename = os.path.join("tests", "data", "silence-44-s.flac") + filename = os.path.join(DATA_DIR, "silence-44-s.flac") f = FLAC(filename) # This size is too big to be in a FLAC block but is overwise fine. f.metadata_blocks[-1].length = 0x1FFFFFF @@ -611,11 +667,10 @@ # Vulnerability 13 and 14 are specific to libFLAC and C/C++ memory # management schemes. -add(CVE20074619) - -NOTFOUND = os.system("tools/notarealprogram 2> %s" % devnull) have_flac = True -if os.system("flac 2> %s > %s" % (devnull, devnull)) == NOTFOUND: +try: + call_flac() +except OSError: have_flac = False print("WARNING: Skipping FLAC reference tests.") diff -Nru mutagen-1.23/tests/test__id3frames.py mutagen-1.30/tests/test__id3frames.py --- mutagen-1.23/tests/test__id3frames.py 2013-10-07 10:28:13.000000000 +0000 +++ mutagen-1.30/tests/test__id3frames.py 2015-04-25 08:49:26.000000000 +0000 @@ -1,15 +1,207 @@ -from tests import TestCase, add +# -*- coding: utf-8 -*- -from mutagen.id3 import Frames, Frames_2_2, ID3 +from tests import TestCase + +from mutagen.id3 import Frames, Frames_2_2, ID3, ID3Header from mutagen._compat import text_type -_22 = ID3(); _22.version = (2,2,0) -_23 = ID3(); _23.version = (2,3,0) -_24 = ID3(); _24.version = (2,4,0) +_22 = ID3() +_22._header = ID3Header() +_22._header.version = (2, 2, 0) + +_23 = ID3() +_23._header = ID3Header() +_23._header.version = (2, 3, 0) + +_24 = ID3() +_24._header = ID3Header() +_24._header.version = (2, 4, 0) class FrameSanityChecks(TestCase): + def test_CRA_upgrade(self): + from mutagen.id3 import CRA, AENC + frame = CRA(owner="a", preview_start=1, preview_length=2, data=b"foo") + new = AENC(frame) + self.assertEqual(new.owner, "a") + self.assertEqual(new.preview_start, 1) + self.assertEqual(new.preview_length, 2) + self.assertEqual(new.data, b"foo") + + frame = CRA(owner="a", preview_start=1, preview_length=2) + new = AENC(frame) + self.assertFalse(hasattr(new, "data")) + + def test_PIC_upgrade(self): + from mutagen.id3 import PIC, APIC + frame = PIC(encoding=0, mime="PNG", desc="bla", type=3, data=b"\x00") + new = APIC(frame) + self.assertEqual(new.encoding, 0) + self.assertEqual(new.mime, "PNG") + self.assertEqual(new.desc, "bla") + self.assertEqual(new.data, b"\x00") + + frame = PIC(encoding=0, mime="foo", + desc="bla", type=3, data=b"\x00") + self.assertEqual(frame.mime, "foo") + new = APIC(frame) + self.assertEqual(new.mime, "foo") + + def test_SIGN(self): + from mutagen.id3 import SIGN + frame = SIGN(group=1, sig=b"foo") + self.assertEqual(frame.HashKey, "SIGN:1:foo") + frame._pprint() + + def test_PRIV(self): + from mutagen.id3 import PRIV + frame = PRIV(owner="foo", data=b"foo") + self.assertEqual(frame.HashKey, "PRIV:foo:foo") + frame._pprint() + + frame = PRIV(owner="foo", data=b"\x00\xff") + self.assertEqual(frame.HashKey, u"PRIV:foo:\x00\xff") + frame._pprint() + + def test_GRID(self): + from mutagen.id3 import GRID + + frame = GRID(owner="foo", group=42) + self.assertEqual(frame.HashKey, "GRID:42") + frame._pprint() + + def test_ENCR(self): + from mutagen.id3 import ENCR + + frame = ENCR(owner="foo", method=42, data=b"\xff") + self.assertEqual(frame.HashKey, "ENCR:foo") + frame._pprint() + + def test_COMR(self): + from mutagen.id3 import COMR + + frame = COMR( + encoding=0, price="p", valid_until="v" * 8, contact="c", + format=42, seller="s", desc="d", mime="m", logo=b"\xff") + self.assertEqual( + frame.HashKey, u"COMR:\x00p\x00vvvvvvvvc\x00*s\x00d\x00m\x00\xff") + frame._pprint() + + def test_USER(self): + from mutagen.id3 import USER + + frame = USER(encoding=0, lang="foo", text="bla") + self.assertEqual(frame.HashKey, "USER:foo") + frame._pprint() + + def test_UFID(self): + from mutagen.id3 import UFID + + frame = UFID(owner="foo", data=b"\x42") + self.assertEqual(frame.HashKey, "UFID:foo") + frame._pprint() + + def test_LINK(self): + from mutagen.id3 import LINK + + frame = LINK(frameid="TPE1", url="http://foo.bar", data=b"\x42") + self.assertEqual(frame.HashKey, "LINK:TPE1:http://foo.bar:B") + frame._pprint() + + frame = LINK(frameid="TPE1", url="http://foo.bar") + self.assertEqual(frame.HashKey, "LINK:TPE1:http://foo.bar") + + def test_AENC(self): + from mutagen.id3 import AENC + + frame = AENC( + owner="foo", preview_start=1, preview_length=2, data=b"\x42") + self.assertEqual(frame.HashKey, "AENC:foo") + frame._pprint() + + def test_GEOB(self): + from mutagen.id3 import GEOB + + frame = GEOB( + encoding=0, mtime="m", filename="f", desc="d", data=b"\x42") + self.assertEqual(frame.HashKey, "GEOB:d") + frame._pprint() + + def test_POPM(self): + from mutagen.id3 import POPM + + frame = POPM(email="e", rating=42) + self.assertEqual(frame.HashKey, "POPM:e") + frame._pprint() + + def test_APIC(self): + from mutagen.id3 import APIC + + frame = APIC(encoding=0, mime="m", type=3, desc="d", data=b"\x42") + self.assertEqual(frame.HashKey, "APIC:d") + frame._pprint() + + def test_EQU2(self): + from mutagen.id3 import EQU2 + + frame = EQU2(method=42, desc="d", adjustments=[(0, 0)]) + self.assertEqual(frame.HashKey, "EQU2:d") + frame._pprint() + + def test_RVA2(self): + from mutagen.id3 import RVA2 + + frame = RVA2(method=42, desc="d", channel=1, gain=1, peak=1) + self.assertEqual(frame.HashKey, "RVA2:d") + frame._pprint() + + def test_COMM(self): + from mutagen.id3 import COMM + + frame = COMM(encoding=0, lang="foo", desc="d") + self.assertEqual(frame.HashKey, "COMM:d:foo") + frame._pprint() + + def test_SYLT(self): + from mutagen.id3 import SYLT + + frame = SYLT(encoding=0, lang="foo", format=1, type=2, + desc="d", text=[("t", 0)]) + self.assertEqual(frame.HashKey, "SYLT:d:foo") + frame._pprint() + + def test_USLT(self): + from mutagen.id3 import USLT + + frame = USLT(encoding=0, lang="foo", desc="d", text="t") + self.assertEqual(frame.HashKey, "USLT:d:foo") + frame._pprint() + + def test_WXXX(self): + from mutagen.id3 import WXXX + + self.assert_(isinstance(WXXX(url='durl'), WXXX)) + + frame = WXXX(encoding=0, desc="d", url="u") + self.assertEqual(frame.HashKey, "WXXX:d") + frame._pprint() + + def test_TXXX(self): + from mutagen.id3 import TXXX + self.assert_(isinstance(TXXX(desc='d', text='text'), TXXX)) + + frame = TXXX(encoding=0, desc="d", text=[]) + self.assertEqual(frame.HashKey, "TXXX:d") + frame._pprint() + + def test_WCOM(self): + from mutagen.id3 import WCOM + + frame = WCOM(url="u") + self.assertEqual(frame.HashKey, "WCOM:u") + frame._pprint() + def test_TF(self): from mutagen.id3 import TextFrame self.assert_(isinstance(TextFrame(text='text'), TextFrame)) @@ -18,10 +210,6 @@ from mutagen.id3 import UrlFrame self.assert_(isinstance(UrlFrame('url'), UrlFrame)) - def test_WXXX(self): - from mutagen.id3 import WXXX - self.assert_(isinstance(WXXX(url='durl'), WXXX)) - def test_NTF(self): from mutagen.id3 import NumericTextFrame self.assert_(isinstance(NumericTextFrame(text='1'), NumericTextFrame)) @@ -33,45 +221,45 @@ def test_MTF(self): from mutagen.id3 import TextFrame - self.assert_(isinstance(TextFrame(text=['a','b']), TextFrame)) - - def test_TXXX(self): - from mutagen.id3 import TXXX - self.assert_(isinstance(TXXX(desc='d',text='text'), TXXX)) + self.assert_(isinstance(TextFrame(text=['a', 'b']), TextFrame)) def test_22_uses_direct_ints(self): data = b'TT1\x00\x00\x83\x00' + (b'123456789abcdef' * 16) tag = list(_22._ID3__read_frames(data, Frames_2_2))[0] - self.assertEquals(data[7:7+0x82].decode('latin1'), tag.text[0]) + self.assertEquals(data[7:7 + 0x82].decode('latin1'), tag.text[0]) def test_frame_too_small(self): - self.assertEquals([], list(_24._ID3__read_frames(b'012345678', Frames))) - self.assertEquals([], list(_23._ID3__read_frames(b'012345678', Frames))) - self.assertEquals([], list(_22._ID3__read_frames(b'01234', Frames_2_2))) self.assertEquals( - [], list(_22._ID3__read_frames(b'TT1'+b'\x00'*3, Frames_2_2))) + [], list(_24._ID3__read_frames(b'012345678', Frames))) + self.assertEquals( + [], list(_23._ID3__read_frames(b'012345678', Frames))) + self.assertEquals( + [], list(_22._ID3__read_frames(b'01234', Frames_2_2))) + self.assertEquals( + [], list(_22._ID3__read_frames(b'TT1' + b'\x00' * 3, Frames_2_2))) def test_unknown_22_frame(self): data = b'XYZ\x00\x00\x01\x00' self.assertEquals([data], list(_22._ID3__read_frames(data, {}))) - def test_zlib_latin1(self): from mutagen.id3 import TPE1 - tag = TPE1.fromData(_24, 0x9, b'\x00\x00\x00\x0f' - b'x\x9cc(\xc9\xc8,V\x00\xa2D\xfd\x92\xd4\xe2\x12\x00&\x7f\x05%') + tag = TPE1._fromData( + _24._header, 0x9, b'\x00\x00\x00\x0f' + b'x\x9cc(\xc9\xc8,V\x00\xa2D\xfd\x92\xd4\xe2\x12\x00&\x7f\x05%' + ) self.assertEquals(tag.encoding, 0) self.assertEquals(tag, ['this is a/test']) def test_datalen_but_not_compressed(self): from mutagen.id3 import TPE1 - tag = TPE1.fromData(_24, 0x01, b'\x00\x00\x00\x06\x00A test') + tag = TPE1._fromData(_24._header, 0x01, b'\x00\x00\x00\x06\x00A test') self.assertEquals(tag.encoding, 0) self.assertEquals(tag, ['A test']) def test_utf8(self): from mutagen.id3 import TPE1 - tag = TPE1.fromData(_23, 0x00, b'\x03this is a test') + tag = TPE1._fromData(_23._header, 0x00, b'\x03this is a test') self.assertEquals(tag.encoding, 3) self.assertEquals(tag, 'this is a test') @@ -79,20 +267,22 @@ from mutagen.id3 import TPE1 data = (b'\x00\x00\x00\x1fx\x9cc\xfc\xff\xaf\x84!\x83!\x93\xa1\x98A' b'\x01J&2\xe83\x940\xa4\x02\xd9%\x0c\x00\x87\xc6\x07#') - tag = TPE1.fromData(_23, 0x80, data) + tag = TPE1._fromData(_23._header, 0x80, data) self.assertEquals(tag.encoding, 1) self.assertEquals(tag, ['this is a/test']) - tag = TPE1.fromData(_24, 0x08, data) + tag = TPE1._fromData(_24._header, 0x08, data) self.assertEquals(tag.encoding, 1) self.assertEquals(tag, ['this is a/test']) def test_load_write(self): from mutagen.id3 import TPE1, Frames - artists= [s.decode('utf8') for s in - [b'\xc2\xb5', b'\xe6\x97\xa5\xe6\x9c\xac']] + artists = [s.decode('utf8') for s in + [b'\xc2\xb5', b'\xe6\x97\xa5\xe6\x9c\xac']] artist = TPE1(encoding=3, text=artists) id3 = ID3() + id3._header = ID3Header() + id3._header.version = (2, 4, 0) tag = list(id3._ID3__read_frames( id3._ID3__save_frame(artist), Frames))[0] self.assertEquals('TPE1', type(tag).__name__) @@ -137,7 +327,7 @@ def test_multi_APIC(self): from mutagen.id3 import APIC - self.assertEquals(APIC(data="1").HashKey, APIC(data="2").HashKey) + self.assertEquals(APIC(data=b"1").HashKey, APIC(data=b"2").HashKey) self.assertNotEquals(APIC(desc="a").HashKey, APIC(desc="b").HashKey) def test_multi_POPM(self): @@ -147,12 +337,12 @@ def test_multi_GEOB(self): from mutagen.id3 import GEOB - self.assertEquals(GEOB(data="1").HashKey, GEOB(data="2").HashKey) + self.assertEquals(GEOB(data=b"1").HashKey, GEOB(data=b"2").HashKey) self.assertNotEquals(GEOB(desc="a").HashKey, GEOB(desc="b").HashKey) def test_multi_UFID(self): from mutagen.id3 import UFID - self.assertEquals(UFID(data="1").HashKey, UFID(data="2").HashKey) + self.assertEquals(UFID(data=b"1").HashKey, UFID(data=b"2").HashKey) self.assertNotEquals(UFID(owner="a").HashKey, UFID(owner="b").HashKey) def test_multi_USER(self): @@ -161,8 +351,6 @@ self.assertNotEquals( USER(lang="abc").HashKey, USER(lang="def").HashKey) -add(FrameSanityChecks) - class Genres(TestCase): @@ -171,9 +359,11 @@ from mutagen._constants import GENRES GENRES = GENRES - def _g(self, s): return self.TCON(text=s).genres + def _g(self, s): + return self.TCON(text=s).genres - def test_empty(self): self.assertEquals(self._g(""), []) + def test_empty(self): + self.assertEquals(self._g(""), []) def test_num(self): for i in range(len(self.GENRES)): @@ -215,8 +405,8 @@ def test_crazy(self): self.assertEquals( self._g("(20)(CR)\x0030\x00\x00Another\x00(51)Hooray"), - ['Alternative', 'Cover', 'Fusion', 'Another', - 'Techno-Industrial', 'Hooray']) + ['Alternative', 'Cover', 'Fusion', 'Another', + 'Techno-Industrial', 'Hooray']) def test_repeat(self): self.assertEquals(self._g("(20)Alternative"), ["Alternative"]) @@ -239,8 +429,6 @@ gen.genres = gen.genres self.assertEquals(gen.genres, [u"Unknown", u"genre"]) -add(Genres) - class TimeStamp(TestCase): @@ -309,8 +497,6 @@ self.assert_(t < s < u) self.assert_(u > s > t) -add(TimeStamp) - class NoHashFrame(TestCase): @@ -319,8 +505,6 @@ self.failUnlessRaises( TypeError, {}.__setitem__, TIT1(encoding=0, text="foo"), None) -add(NoHashFrame) - class FrameIDValidate(TestCase): @@ -334,8 +518,6 @@ self.failIf(is_valid_frame_id("MP3e")) self.failIf(is_valid_frame_id("+ABC")) -add(FrameIDValidate) - class TimeStampTextFrame(TestCase): @@ -346,8 +528,6 @@ frame = self.Frame(encoding=0, text=[u'1987', u'1988']) self.failUnlessEqual(frame, text_type(frame)) -add(TimeStampTextFrame) - class TTextFrame(TestCase): @@ -359,8 +539,6 @@ frame.extend(["b", "c"]) self.assertEqual(frame.text, ["a", "b", "c"]) -add(TTextFrame) - class TRVA2(TestCase): @@ -369,5 +547,3 @@ r = RVA2(gain=1, channel=1, peak=1) self.assertEqual(r, r) self.assertNotEqual(r, 42) - -add(TRVA2) diff -Nru mutagen-1.23/tests/test_id3.py mutagen-1.30/tests/test_id3.py --- mutagen-1.23/tests/test_id3.py 2014-05-02 17:16:01.000000000 +0000 +++ mutagen-1.30/tests/test_id3.py 2015-08-17 10:42:51.000000000 +0000 @@ -1,16 +1,26 @@ -import os; from os.path import join +# -*- coding: utf-8 -*- + +import os +from os.path import join import shutil -from tests import TestCase -from tests import add +from tests import TestCase, DATA_DIR from mutagen import id3 -from mutagen.id3 import ID3, COMR, Frames, Frames_2_2, ID3Warning, ID3JunkFrameError +from mutagen.apev2 import APEv2 +from mutagen.id3 import ID3, COMR, Frames, Frames_2_2, ID3Warning, \ + ID3JunkFrameError, ID3Header, ID3UnsupportedVersionError, _fullread +from mutagen.id3._util import BitPaddedInt, error as ID3Error from mutagen._compat import cBytesIO, PY2, iteritems, integer_types import warnings +from tempfile import mkstemp warnings.simplefilter('error', ID3Warning) -_22 = ID3(); _22.version = (2,2,0) -_23 = ID3(); _23.version = (2,3,0) -_24 = ID3(); _24.version = (2,4,0) +_22 = ID3Header() +_22.version = (2, 2, 0) +_23 = ID3Header() +_23.version = (2, 3, 0) +_24 = ID3Header() +_24.version = (2, 4, 0) + class ID3GetSetDel(TestCase): @@ -47,138 +57,123 @@ self.assert_("FOOB:az" not in self.i) def test_setone(self): - class TEST(object): HashKey = "FOOB:ar" + class TEST(object): + HashKey = "FOOB:ar" t = TEST() self.i.setall("FOOB", [t]) self.assertEquals(self.i["FOOB:ar"], t) self.assertEquals(self.i.getall("FOOB"), [t]) def test_settwo(self): - class TEST(object): HashKey = "FOOB:ar" + class TEST(object): + HashKey = "FOOB:ar" t = TEST() - t2 = TEST(); t2.HashKey = "FOOB:az" + t2 = TEST() + t2.HashKey = "FOOB:az" self.i.setall("FOOB", [t, t2]) self.assertEquals(self.i["FOOB:ar"], t) self.assertEquals(self.i["FOOB:az"], t2) self.assert_(self.i.getall("FOOB") in [[t, t2], [t2, t]]) + class ID3Loading(TestCase): - empty = join('tests', 'data', 'emptyfile.mp3') - silence = join('tests', 'data', 'silence-44-s.mp3') - unsynch = join('tests', 'data', 'id3v23_unsynch.id3') + empty = join(DATA_DIR, 'emptyfile.mp3') + silence = join(DATA_DIR, 'silence-44-s.mp3') + unsynch = join(DATA_DIR, 'id3v23_unsynch.id3') def test_empty_file(self): name = self.empty - self.assertRaises(ValueError, ID3, filename=name) - #from_name = ID3(name) - #obj = open(name, 'rb') - #from_obj = ID3(fileobj=obj) - #self.assertEquals(from_name, from_explicit_name) - #self.assertEquals(from_name, from_obj) + self.assertRaises(ID3Error, ID3, filename=name) def test_nonexistent_file(self): - name = join('tests', 'data', 'does', 'not', 'exist') + name = join(DATA_DIR, 'does', 'not', 'exist') self.assertRaises(EnvironmentError, ID3, name) def test_header_empty(self): - id3 = ID3() - id3._fileobj = open(self.empty, 'rb') - self.assertRaises(EOFError, id3._load_header) + fileobj = open(self.empty, 'rb') + self.assertRaises(ID3Error, ID3Header, fileobj) def test_header_silence(self): - id3 = ID3() - id3._fileobj = open(self.silence, 'rb') - id3._load_header() - self.assertEquals(id3.version, (2,3,0)) - self.assertEquals(id3.size, 1314) + fileobj = open(self.silence, 'rb') + header = ID3Header(fileobj) + self.assertEquals(header.version, (2, 3, 0)) + self.assertEquals(header.size, 1314) def test_header_2_4_invalid_flags(self): - id3 = ID3() - id3._fileobj = cBytesIO(b'ID3\x04\x00\x1f\x00\x00\x00\x00') - self.assertRaises(ValueError, id3._load_header) + fileobj = cBytesIO(b'ID3\x04\x00\x1f\x00\x00\x00\x00') + self.assertRaises(ID3Error, ID3Header, fileobj) def test_header_2_4_unsynch_size(self): - id3 = ID3() - id3._fileobj = cBytesIO(b'ID3\x04\x00\x10\x00\x00\x00\xFF') - self.assertRaises(ValueError, id3._load_header) + fileobj = cBytesIO(b'ID3\x04\x00\x10\x00\x00\x00\xFF') + self.assertRaises(ID3Error, ID3Header, fileobj) def test_header_2_4_allow_footer(self): - id3 = ID3() - id3._fileobj = cBytesIO(b'ID3\x04\x00\x10\x00\x00\x00\x00') - id3._load_header() + fileobj = cBytesIO(b'ID3\x04\x00\x10\x00\x00\x00\x00') + self.assertTrue(ID3Header(fileobj).f_footer) def test_header_2_3_invalid_flags(self): - id3 = ID3() - id3._fileobj = cBytesIO(b'ID3\x03\x00\x1f\x00\x00\x00\x00') - self.assertRaises(ValueError, id3._load_header) - id3._fileobj = cBytesIO(b'ID3\x03\x00\x0f\x00\x00\x00\x00') - self.assertRaises(ValueError, id3._load_header) + fileobj = cBytesIO(b'ID3\x03\x00\x1f\x00\x00\x00\x00') + self.assertRaises(ID3Error, ID3Header, fileobj) + + fileobj = cBytesIO(b'ID3\x03\x00\x0f\x00\x00\x00\x00') + self.assertRaises(ID3Error, ID3Header, fileobj) def test_header_2_2(self): - id3 = ID3() - id3._fileobj = cBytesIO(b'ID3\x02\x00\x00\x00\x00\x00\x00') - id3._load_header() - self.assertEquals(id3.version, (2,2,0)) + fileobj = cBytesIO(b'ID3\x02\x00\x00\x00\x00\x00\x00') + header = ID3Header(fileobj) + self.assertEquals(header.version, (2, 2, 0)) def test_header_2_1(self): - id3 = ID3() - id3._fileobj = cBytesIO(b'ID3\x01\x00\x00\x00\x00\x00\x00') - self.assertRaises(NotImplementedError, id3._load_header) + fileobj = cBytesIO(b'ID3\x01\x00\x00\x00\x00\x00\x00') + self.assertRaises(ID3UnsupportedVersionError, ID3Header, fileobj) def test_header_too_small(self): - id3 = ID3() - id3._fileobj = cBytesIO(b'ID3\x01\x00\x00\x00\x00\x00') - self.assertRaises(EOFError, id3._load_header) + fileobj = cBytesIO(b'ID3\x01\x00\x00\x00\x00\x00') + self.assertRaises(ID3Error, ID3Header, fileobj) def test_header_2_4_extended(self): - id3 = ID3() - id3._fileobj = cBytesIO( + fileobj = cBytesIO( b'ID3\x04\x00\x40\x00\x00\x00\x00\x00\x00\x00\x05\x5a') - id3._load_header() - self.assertEquals(id3._ID3__extsize, 1) - self.assertEquals(id3._ID3__extdata, '\x5a') + header = ID3Header(fileobj) + self.assertEquals(header._extdata, b'\x5a') def test_header_2_4_extended_unsynch_size(self): - id3 = ID3() - id3._fileobj = cBytesIO( + fileobj = cBytesIO( b'ID3\x04\x00\x40\x00\x00\x00\x00\x00\x00\x00\xFF\x5a') - self.assertRaises(ValueError, id3._load_header) + self.assertRaises(ID3Error, ID3Header, fileobj) def test_header_2_4_extended_but_not(self): - id3 = ID3() - id3._fileobj = cBytesIO( + fileobj = cBytesIO( b'ID3\x04\x00\x40\x00\x00\x00\x00TIT1\x00\x00\x00\x01a') - id3._load_header() - self.assertEquals(id3._ID3__extsize, 0) - self.assertEquals(id3._ID3__extdata, '') + header = ID3Header(fileobj) + self.assertEquals(header._extdata, b'') def test_header_2_4_extended_but_not_but_not_tag(self): - id3 = ID3() - id3._fileobj = cBytesIO( - 'ID3\x04\x00\x40\x00\x00\x00\x00TIT9') - self.failUnlessRaises(EOFError, id3._load_header) + fileobj = cBytesIO(b'ID3\x04\x00\x40\x00\x00\x00\x00TIT9') + self.failUnlessRaises(ID3Error, ID3Header, fileobj) def test_header_2_3_extended(self): - id3 = ID3() - id3._fileobj = cBytesIO( + fileobj = cBytesIO( b'ID3\x03\x00\x40\x00\x00\x00\x00\x00\x00\x00\x06' b'\x00\x00\x56\x78\x9a\xbc') - id3._load_header() - self.assertEquals(id3._ID3__extsize, 6) - self.assertEquals(id3._ID3__extdata, b'\x00\x00\x56\x78\x9a\xbc') + header = ID3Header(fileobj) + self.assertEquals(header._extdata, b'\x00\x00\x56\x78\x9a\xbc') def test_unsynch(self): - id3 = ID3() - id3.version = (2,4,0) - id3._ID3__flags = 0x80 + header = ID3Header() + header.version = (2, 4, 0) + header._flags = 0x80 badsync = b'\x00\xff\x00ab\x00' + + self.assertEquals( + Frames["TPE2"]._fromData(header, 0, badsync), [u"\xffab"]) + + header._flags = 0x00 self.assertEquals( - id3._ID3__load_framedata(Frames["TPE2"], 0, badsync), [u"\xffab"]) - id3._ID3__flags = 0x00 - self.assertEquals(id3._ID3__load_framedata( - Frames["TPE2"], 0x02, badsync), [u"\xffab"]) - tag = id3._ID3__load_framedata(Frames["TPE2"], 0, badsync) + Frames["TPE2"]._fromData(header, 0x02, badsync), [u"\xffab"]) + + tag = Frames["TPE2"]._fromData(header, 0, badsync) self.assertEquals(tag, [u"\xff", u"ab"]) def test_load_v23_unsynch(self): @@ -186,10 +181,10 @@ self.assertEquals(id3["TPE1"], ["Nina Simone"]) def test_insane__ID3__fullread(self): - id3 = ID3() - id3._ID3__filesize = 0 - self.assertRaises(ValueError, id3._ID3__fullread, -3) - self.assertRaises(EOFError, id3._ID3__fullread, 3) + fileobj = cBytesIO() + self.assertRaises(ValueError, _fullread, fileobj, -3) + self.assertRaises(EOFError, _fullread, fileobj, 3) + class Issue21(TestCase): @@ -197,7 +192,7 @@ # Ensure the extended header is turned off, and the frames are # read. def setUp(self): - self.id3 = ID3(join('tests', 'data', 'issue_21.id3')) + self.id3 = ID3(join(DATA_DIR, 'issue_21.id3')) def test_no_ext(self): self.failIf(self.id3.f_extended) @@ -208,20 +203,26 @@ def test_tit2_value(self): self.failUnlessEqual(self.id3["TIT2"].text, [u"Punk To Funk"]) -add(Issue21) + class ID3Tags(TestCase): def setUp(self): - self.silence = join('tests', 'data', 'silence-44-s.mp3') + self.silence = join(DATA_DIR, 'silence-44-s.mp3') def test_None(self): id3 = ID3(self.silence, known_frames={}) self.assertEquals(0, len(id3.keys())) self.assertEquals(9, len(id3.unknown_frames)) + def test_unknown_reset(self): + id3 = ID3(self.silence, known_frames={}) + self.assertEquals(9, len(id3.unknown_frames)) + id3.load(self.silence, known_frames={}) + self.assertEquals(9, len(id3.unknown_frames)) + def test_has_docs(self): - for Kind in Frames.values() + Frames_2_2.values(): + for Kind in (list(Frames.values()) + list(Frames_2_2.values())): self.failUnless(Kind.__doc__, "%s has no docstring" % Kind) def test_23(self): @@ -233,7 +234,7 @@ self.assertEquals('Silence', str(id3['TIT1'])) self.assertEquals('Silence', str(id3['TIT2'])) self.assertEquals(3000, +id3['TLEN']) - self.assertNotEquals(['piman','jzig'], id3['TPE1']) + self.assertNotEquals(['piman', 'jzig'], id3['TPE1']) self.assertEquals('02/10', id3['TRCK']) self.assertEquals(2, +id3['TRCK']) self.assertEquals('2004', id3['TDRC']) @@ -242,8 +243,10 @@ class ID3hack(ID3): "Override 'correct' behavior with desired behavior" def loaded_frame(self, tag): - if tag.HashKey in self: self[tag.HashKey].extend(tag[:]) - else: self[tag.HashKey] = tag + if tag.HashKey in self: + self[tag.HashKey].extend(tag[:]) + else: + self[tag.HashKey] = tag id3 = ID3hack(self.silence) self.assertEquals(8, len(id3.keys())) @@ -253,66 +256,74 @@ self.assertEquals('Silence', str(id3['TIT1'])) self.assertEquals('Silence', str(id3['TIT2'])) self.assertEquals(3000, +id3['TLEN']) - self.assertEquals(['piman','jzig'], id3['TPE1']) + self.assertEquals(['piman', 'jzig'], id3['TPE1']) self.assertEquals('02/10', id3['TRCK']) self.assertEquals(2, +id3['TRCK']) self.assertEquals('2004', id3['TDRC']) def test_badencoding(self): - self.assertRaises(IndexError, Frames["TPE1"].fromData, _24, 0, b"\x09ab") + self.assertRaises( + ID3JunkFrameError, Frames["TPE1"]._fromData, _24, 0, b"\x09ab") self.assertRaises(ValueError, Frames["TPE1"], encoding=9, text="ab") def test_badsync(self): - self.assertRaises( - ValueError, Frames["TPE1"].fromData, _24, 0x02, "\x00\xff\xfe") + frame = Frames["TPE1"]._fromData(_24, 0x02, b"\x00\xff\xfe") + self.assertEqual(frame.text, [u'\xff\xfe']) def test_noencrypt(self): self.assertRaises( - NotImplementedError, Frames["TPE1"].fromData, _24, 0x04, b"\x00") + NotImplementedError, Frames["TPE1"]._fromData, _24, 0x04, b"\x00") self.assertRaises( - NotImplementedError, Frames["TPE1"].fromData, _23, 0x40, "\x00") + NotImplementedError, Frames["TPE1"]._fromData, _23, 0x40, b"\x00") def test_badcompress(self): self.assertRaises( - ValueError, Frames["TPE1"].fromData, _24, 0x08, b"\x00\x00\x00\x00#") + ValueError, Frames["TPE1"]._fromData, _24, 0x08, + b"\x00\x00\x00\x00#") self.assertRaises( - ValueError, Frames["TPE1"].fromData, _23, 0x80, b"\x00\x00\x00\x00#") + ValueError, Frames["TPE1"]._fromData, _23, 0x80, + b"\x00\x00\x00\x00#") def test_junkframe(self): - self.assertRaises(ValueError, Frames["TPE1"].fromData, _24, 0, "") + self.assertRaises(ValueError, Frames["TPE1"]._fromData, _24, 0, b"") def test_bad_sylt(self): self.assertRaises( - ID3JunkFrameError, Frames["SYLT"].fromData, _24, 0x0, + ID3JunkFrameError, Frames["SYLT"]._fromData, _24, 0x0, b"\x00eng\x01description\x00foobar") self.assertRaises( - ID3JunkFrameError, Frames["SYLT"].fromData, _24, 0x0, + ID3JunkFrameError, Frames["SYLT"]._fromData, _24, 0x0, b"\x00eng\x01description\x00foobar\x00\xFF\xFF\xFF") def test_extradata(self): from mutagen.id3 import RVRB, RBUF - self.assertRaises(ID3Warning, - RVRB()._readData, b'L1R1BBFFFFPP#xyz') - self.assertRaises(ID3Warning, - RBUF()._readData, b'\x00\x01\x00\x01\x00\x00\x00\x00#xyz') + self.assertEqual(RVRB()._readData(b'L1R1BBFFFFPP#xyz'), b'#xyz') + self.assertEqual( + RBUF()._readData(b'\x00\x01\x00\x01\x00\x00\x00\x00#xyz'), b'#xyz') + class ID3v1Tags(TestCase): def setUp(self): - self.silence = join('tests', 'data', 'silence-44-s-v1.mp3') + self.silence = join(DATA_DIR, 'silence-44-s-v1.mp3') self.id3 = ID3(self.silence) def test_album(self): self.assertEquals('Quod Libet Test Data', self.id3['TALB']) + def test_genre(self): self.assertEquals('Darkwave', self.id3['TCON'].genres[0]) + def test_title(self): self.assertEquals('Silence', str(self.id3['TIT2'])) + def test_artist(self): self.assertEquals(['piman'], self.id3['TPE1']) + def test_track(self): self.assertEquals('2', self.id3['TRCK']) self.assertEquals(2, +self.id3['TRCK']) + def test_year(self): self.assertEquals('2004', self.id3['TDRC']) @@ -323,28 +334,28 @@ self.failUnless(32, ParseID3v1(tag)["TRCK"]) del(self.id3["TRCK"]) tag = MakeID3v1(self.id3) - tag = tag[:125] + ' ' + tag[-1] + tag = tag[:125] + b' ' + tag[-1:] self.failIf("TRCK" in ParseID3v1(tag)) def test_nulls(self): from mutagen.id3 import ParseID3v1 s = u'TAG%(title)30s%(artist)30s%(album)30s%(year)4s%(cmt)29s\x03\x01' s = s % dict(artist=u'abcd\00fg', title=u'hijklmn\x00p', - album='qrst\x00v', cmt='wxyz', year='1224') + album=u'qrst\x00v', cmt=u'wxyz', year=u'1224') tags = ParseID3v1(s.encode("ascii")) - self.assertEquals('abcd'.decode('latin1'), tags['TPE1']) - self.assertEquals('hijklmn'.decode('latin1'), tags['TIT2']) - self.assertEquals('qrst'.decode('latin1'), tags['TALB']) + self.assertEquals(b'abcd'.decode('latin1'), tags['TPE1']) + self.assertEquals(b'hijklmn'.decode('latin1'), tags['TIT2']) + self.assertEquals(b'qrst'.decode('latin1'), tags['TALB']) def test_nonascii(self): from mutagen.id3 import ParseID3v1 s = u'TAG%(title)30s%(artist)30s%(album)30s%(year)4s%(cmt)29s\x03\x01' s = s % dict(artist=u'abcd\xe9fg', title=u'hijklmn\xf3p', - album=u'qrst\xfcv', cmt='wxyz', year='1234') + album=u'qrst\xfcv', cmt=u'wxyz', year=u'1234') tags = ParseID3v1(s.encode("latin-1")) - self.assertEquals('abcd\xe9fg'.decode('latin1'), tags['TPE1']) - self.assertEquals('hijklmn\xf3p'.decode('latin1'), tags['TIT2']) - self.assertEquals('qrst\xfcv'.decode('latin1'), tags['TALB']) + self.assertEquals(b'abcd\xe9fg'.decode('latin1'), tags['TPE1']) + self.assertEquals(b'hijklmn\xf3p'.decode('latin1'), tags['TIT2']) + self.assertEquals(b'qrst\xfcv'.decode('latin1'), tags['TALB']) self.assertEquals('wxyz', tags['COMM']) self.assertEquals("3", tags['TRCK']) self.assertEquals("1234", tags['TDRC']) @@ -375,7 +386,7 @@ def test_invalid(self): from mutagen.id3 import ParseID3v1 - self.failUnless(ParseID3v1("") is None) + self.failUnless(ParseID3v1(b"") is None) def test_invalid_track(self): from mutagen.id3 import ParseID3v1, MakeID3v1, TRCK @@ -391,10 +402,11 @@ v1tag = MakeID3v1(tag) self.failUnlessEqual(ParseID3v1(v1tag)["TCON"].genres, ["Pop"]) + class TestWriteID3v1(TestCase): - SILENCE = os.path.join("tests", "data", "silence-44-s.mp3") + SILENCE = os.path.join(DATA_DIR, "silence-44-s.mp3") + def setUp(self): - from tempfile import mkstemp fd, self.filename = mkstemp(suffix='.mp3') os.close(fd) shutil.copy(self.SILENCE, self.filename) @@ -403,12 +415,12 @@ def failIfV1(self): fileobj = open(self.filename, "rb") fileobj.seek(-128, 2) - self.failIf(fileobj.read(3) == "TAG") + self.failIf(fileobj.read(3) == b"TAG") def failUnlessV1(self): fileobj = open(self.filename, "rb") fileobj.seek(-128, 2) - self.failUnless(fileobj.read(3) == "TAG") + self.failUnless(fileobj.read(3) == b"TAG") def test_save_delete(self): self.audio.save(v1=0) @@ -431,318 +443,412 @@ def tearDown(self): os.unlink(self.filename) -add(TestWriteID3v1) class TestV22Tags(TestCase): def setUp(self): - filename = os.path.join("tests", "data", "id3v22-test.mp3") + filename = os.path.join(DATA_DIR, "id3v22-test.mp3") self.tags = ID3(filename) def test_tags(self): self.failUnless(self.tags["TRCK"].text == ["3/11"]) self.failUnless(self.tags["TPE1"].text == ["Anais Mitchell"]) -add(TestV22Tags) -def TestReadTags(): - tests = [ - ['TALB', b'\x00a/b', 'a/b', '', dict(encoding=0)], - ['TBPM', b'\x00120', '120', 120, dict(encoding=0)], - ['TCMP', b'\x001', '1', 1, dict(encoding=0)], - ['TCMP', b'\x000', '0', 0, dict(encoding=0)], - ['TCOM', b'\x00a/b', 'a/b', '', dict(encoding=0)], - ['TCON', b'\x00(21)Disco', '(21)Disco', '', dict(encoding=0)], - ['TCOP', b'\x001900 c', '1900 c', '', dict(encoding=0)], - ['TDAT', b'\x00a/b', 'a/b', '', dict(encoding=0)], - ['TDEN', b'\x001987', '1987', '', dict(encoding=0, year=[1987])], - ['TDOR', b'\x001987-12', '1987-12', '', - dict(encoding=0, year=[1987], month=[12])], - ['TDRC', b'\x001987\x00', '1987', '', dict(encoding=0, year=[1987])], - ['TDRL', b'\x001987\x001988', '1987,1988', '', - dict(encoding=0, year=[1987,1988])], - ['TDTG', b'\x001987', '1987', '', dict(encoding=0, year=[1987])], - ['TDLY', b'\x001205', '1205', 1205, dict(encoding=0)], - ['TENC', b'\x00a b/c d', 'a b/c d', '', dict(encoding=0)], - ['TEXT', b'\x00a b\x00c d', ['a b', 'c d'], '', dict(encoding=0)], - ['TFLT', b'\x00MPG/3', 'MPG/3', '', dict(encoding=0)], - ['TIME', b'\x001205', '1205', '', dict(encoding=0)], - ['TIPL', b'\x02\x00a\x00\x00\x00b', [["a", "b"]], '', dict(encoding=2)], - ['TIT1', b'\x00a/b', 'a/b', '', dict(encoding=0)], - # TIT2 checks misaligned terminator '\x00\x00' across crosses utf16 chars - ['TIT2', b'\x01\xff\xfe\x38\x00\x00\x38', u'8\u3800', '', dict(encoding=1)], - ['TIT3', b'\x00a/b', 'a/b', '', dict(encoding=0)], - ['TKEY', b'\x00A#m', 'A#m', '', dict(encoding=0)], - ['TLAN', b'\x006241', '6241', '', dict(encoding=0)], - ['TLEN', b'\x006241', '6241', 6241, dict(encoding=0)], - ['TMCL', b'\x02\x00a\x00\x00\x00b', [["a", "b"]], '', dict(encoding=2)], - ['TMED', b'\x00med', 'med', '', dict(encoding=0)], - ['TMOO', b'\x00moo', 'moo', '', dict(encoding=0)], - ['TOAL', b'\x00alb', 'alb', '', dict(encoding=0)], - ['TOFN', b'\x0012 : bar', '12 : bar', '', dict(encoding=0)], - ['TOLY', b'\x00lyr', 'lyr', '', dict(encoding=0)], - ['TOPE', b'\x00own/lic', 'own/lic', '', dict(encoding=0)], - ['TORY', b'\x001923', '1923', 1923, dict(encoding=0)], - ['TOWN', b'\x00own/lic', 'own/lic', '', dict(encoding=0)], - ['TPE1', b'\x00ab', ['ab'], '', dict(encoding=0)], - ['TPE2', b'\x00ab\x00cd\x00ef', ['ab','cd','ef'], '', dict(encoding=0)], - ['TPE3', b'\x00ab\x00cd', ['ab','cd'], '', dict(encoding=0)], - ['TPE4', b'\x00ab\x00', ['ab'], '', dict(encoding=0)], - ['TPOS', b'\x0008/32', '08/32', 8, dict(encoding=0)], - ['TPRO', b'\x00pro', 'pro', '', dict(encoding=0)], - ['TPUB', b'\x00pub', 'pub', '', dict(encoding=0)], - ['TRCK', b'\x004/9', '4/9', 4, dict(encoding=0)], - ['TRDA', b'\x00Sun Jun 12', 'Sun Jun 12', '', dict(encoding=0)], - ['TRSN', b'\x00ab/cd', 'ab/cd', '', dict(encoding=0)], - ['TRSO', b'\x00ab', 'ab', '', dict(encoding=0)], - ['TSIZ', b'\x0012345', '12345', 12345, dict(encoding=0)], - ['TSOA', b'\x00ab', 'ab', '', dict(encoding=0)], - ['TSOP', b'\x00ab', 'ab', '', dict(encoding=0)], - ['TSOT', b'\x00ab', 'ab', '', dict(encoding=0)], - ['TSO2', b'\x00ab', 'ab', '', dict(encoding=0)], - ['TSOC', b'\x00ab', 'ab', '', dict(encoding=0)], - ['TSRC', b'\x0012345', '12345', '', dict(encoding=0)], - ['TSSE', b'\x0012345', '12345', '', dict(encoding=0)], - ['TSST', b'\x0012345', '12345', '', dict(encoding=0)], - ['TYER', b'\x002004', '2004', 2004, dict(encoding=0)], - - ['TXXX', b'\x00usr\x00a/b\x00c', ['a/b','c'], '', - dict(encoding=0, desc='usr')], - - ['WCOM', b'http://foo', 'http://foo', '', {}], - ['WCOP', b'http://bar', 'http://bar', '', {}], - ['WOAF', b'http://baz', 'http://baz', '', {}], - ['WOAR', b'http://bar', 'http://bar', '', {}], - ['WOAS', b'http://bar', 'http://bar', '', {}], - ['WORS', b'http://bar', 'http://bar', '', {}], - ['WPAY', b'http://bar', 'http://bar', '', {}], - ['WPUB', b'http://bar', 'http://bar', '', {}], - - ['WXXX', b'\x00usr\x00http', 'http', '', dict(encoding=0, desc='usr')], - - ['IPLS', b'\x00a\x00A\x00b\x00B\x00', [['a','A'],['b','B']], '', - dict(encoding=0)], - - ['MCDI', b'\x01\x02\x03\x04', '\x01\x02\x03\x04', '', {}], - - ['ETCO', b'\x01\x12\x00\x00\x7f\xff', [(18, 32767)], '', dict(format=1)], - - ['COMM', b'\x00ENUT\x00Com', 'Com', '', - dict(desc='T', lang='ENU', encoding=0)], - # found in a real MP3 - ['COMM', b'\x00\x00\xcc\x01\x00 ', ' ', '', - dict(desc=u'', lang='\x00\xcc\x01', encoding=0)], - - ['APIC', b'\x00-->\x00\x03cover\x00cover.jpg', 'cover.jpg', '', - dict(mime='-->', type=3, desc='cover', encoding=0)], - ['USER', b'\x00ENUCom', 'Com', '', dict(lang='ENU', encoding=0)], - - ['RVA2', b'testdata\x00\x01\xfb\x8c\x10\x12\x23', - 'Master volume: -2.2266 dB/0.1417', '', - dict(desc='testdata', channel=1, gain=-2.22656, peak=0.14169)], - - ['RVA2', b'testdata\x00\x01\xfb\x8c\x24\x01\x22\x30\x00\x00', - 'Master volume: -2.2266 dB/0.1417', '', - dict(desc='testdata', channel=1, gain=-2.22656, peak=0.14169)], - - ['RVA2', b'testdata2\x00\x01\x04\x01\x00', - 'Master volume: +2.0020 dB/0.0000', '', - dict(desc='testdata2', channel=1, gain=2.001953125, peak=0)], - - ['PCNT', b'\x00\x00\x00\x11', 17, 17, dict(count=17)], - ['POPM', 'foo@bar.org\x00\xde\x00\x00\x00\x11', 222, 222, - dict(email="foo@bar.org", rating=222, count=17)], - ['POPM', b'foo@bar.org\x00\xde\x00', 222, 222, - dict(email="foo@bar.org", rating=222, count=0)], - # Issue #33 - POPM may have no playcount at all. - ['POPM', b'foo@bar.org\x00\xde', 222, 222, - dict(email="foo@bar.org", rating=222)], - - ['UFID', b'own\x00data', 'data', '', dict(data='data', owner='own')], - ['UFID', b'own\x00\xdd', '\xdd', '', dict(data='\xdd', owner='own')], - - ['GEOB', b'\x00mime\x00name\x00desc\x00data', 'data', '', - dict(encoding=0, mime='mime', filename='name', desc='desc')], - - ['USLT', b'\x00engsome lyrics\x00woo\nfun', 'woo\nfun', '', - dict(encoding=0, lang='eng', desc='some lyrics', text='woo\nfun')], - - ['SYLT', (b'\x00eng\x02\x01some lyrics\x00foo\x00\x00\x00\x00\x01bar' - b'\x00\x00\x00\x00\x10'), "foobar", '', - dict(encoding=0, lang='eng', type=1, format=2, desc='some lyrics')], - - ['POSS', b'\x01\x0f', 15, 15, dict(format=1, position=15)], - ['OWNE', b'\x00USD10.01\x0020041010CDBaby', 'CDBaby', 'CDBaby', - dict(encoding=0, price="USD10.01", date='20041010', seller='CDBaby')], - - ['PRIV', b'a@b.org\x00random data', 'random data', 'random data', - dict(owner='a@b.org', data='random data')], - ['PRIV', b'a@b.org\x00\xdd', '\xdd', '\xdd', - dict(owner='a@b.org', data='\xdd')], - - ['SIGN', b'\x92huh?', 'huh?', 'huh?', dict(group=0x92, sig='huh?')], - ['ENCR', b'a@b.org\x00\x92Data!', 'Data!', 'Data!', - dict(owner='a@b.org', method=0x92, data='Data!')], - ['SEEK', b'\x00\x12\x00\x56', 0x12*256*256+0x56, 0x12*256*256+0x56, - dict(offset=0x12*256*256+0x56)], - - ['SYTC', "\x01\x10obar", '\x10obar', '', dict(format=1, data='\x10obar')], - - ['RBUF', b'\x00\x12\x00', 0x12*256, 0x12*256, dict(size=0x12*256)], - ['RBUF', b'\x00\x12\x00\x01', 0x12*256, 0x12*256, - dict(size=0x12*256, info=1)], - ['RBUF', b'\x00\x12\x00\x01\x00\x00\x00\x23', 0x12*256, 0x12*256, - dict(size=0x12*256, info=1, offset=0x23)], - - ['RVRB', b'\x12\x12\x23\x23\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11', - (0x12*256+0x12, 0x23*256+0x23), '', - dict(left=0x12*256+0x12, right=0x23*256+0x23) ], - - ['AENC', b'a@b.org\x00\x00\x12\x00\x23', 'a@b.org', 'a@b.org', - dict(owner='a@b.org', preview_start=0x12, preview_length=0x23)], - ['AENC', b'a@b.org\x00\x00\x12\x00\x23!', 'a@b.org', 'a@b.org', - dict(owner='a@b.org', preview_start=0x12, preview_length=0x23, data='!')], - - ['GRID', b'a@b.org\x00\x99', 'a@b.org', 0x99, - dict(owner='a@b.org', group=0x99)], - ['GRID', b'a@b.org\x00\x99data', 'a@b.org', 0x99, - dict(owner='a@b.org', group=0x99, data='data')], - - ['COMR', b'\x00USD10.00\x0020051010ql@sc.net\x00\x09Joe\x00A song\x00' - b'x-image/fake\x00some data', - COMR(encoding=0, price="USD10.00", valid_until="20051010", - contact="ql@sc.net", format=9, seller="Joe", desc="A song", - mime='x-image/fake', logo='some data'), '', - dict( - encoding=0, price="USD10.00", valid_until="20051010", - contact="ql@sc.net", format=9, seller="Joe", desc="A song", - mime='x-image/fake', logo='some data')], - - ['COMR', b'\x00USD10.00\x0020051010ql@sc.net\x00\x09Joe\x00A song\x00', - COMR(encoding=0, price="USD10.00", valid_until="20051010", - contact="ql@sc.net", format=9, seller="Joe", desc="A song"), '', - dict( - encoding=0, price="USD10.00", valid_until="20051010", - contact="ql@sc.net", format=9, seller="Joe", desc="A song")], - - ['MLLT', b'\x00\x01\x00\x00\x02\x00\x00\x03\x04\x08foobar', 'foobar', '', - dict(frames=1, bytes=2, milliseconds=3, bits_for_bytes=4, - bits_for_milliseconds=8, data='foobar')], - - ['EQU2', b'\x00Foobar\x00\x01\x01\x04\x00', [(128.5, 2.0)], '', - dict(method=0, desc="Foobar")], - - ['ASPI', b'\x00\x00\x00\x00\x00\x00\x00\x10\x00\x03\x08\x01\x02\x03', - [1, 2, 3], '', dict(S=0, L=16, N=3, b=8)], - - ['ASPI', b'\x00\x00\x00\x00\x00\x00\x00\x10\x00\x03\x10' - b'\x00\x01\x00\x02\x00\x03', [1, 2, 3], '', dict(S=0, L=16, N=3, b=16)], - - ['LINK', b'TIT1http://www.example.org/TIT1.txt\x00', - ("TIT1", 'http://www.example.org/TIT1.txt'), '', - dict(frameid='TIT1', url='http://www.example.org/TIT1.txt')], - ['LINK', b'COMMhttp://www.example.org/COMM.txt\x00engfoo', - ("COMM", 'http://www.example.org/COMM.txt', 'engfoo'), '', - dict(frameid='COMM', url='http://www.example.org/COMM.txt', - data='engfoo')], - - # iTunes podcast frames - ['TGID', b'\x00i', u'i', '', dict(encoding=0)], - ['TDES', b'\x00ii', u'ii', '', dict(encoding=0)], - ['WFED', b'http://zzz', 'http://zzz', '', {}], - - # 2.2 tags - ['UFI', b'own\x00data', 'data', '', dict(data='data', owner='own')], - ['SLT', (b'\x00eng\x02\x01some lyrics\x00foo\x00\x00\x00\x00\x01bar' - b'\x00\x00\x00\x00\x10'), "foobar", '', - dict(encoding=0, lang='eng', type=1, format=2, desc='some lyrics')], - ['TT1', b'\x00ab\x00', 'ab', '', dict(encoding=0)], - ['TT2', b'\x00ab', 'ab', '', dict(encoding=0)], - ['TT3', b'\x00ab', 'ab', '', dict(encoding=0)], - ['TP1', b'\x00ab\x00', 'ab', '', dict(encoding=0)], - ['TP2', b'\x00ab', 'ab', '', dict(encoding=0)], - ['TP3', b'\x00ab', 'ab', '', dict(encoding=0)], - ['TP4', b'\x00ab', 'ab', '', dict(encoding=0)], - ['TCM', b'\x00ab/cd', 'ab/cd', '', dict(encoding=0)], - ['TXT', b'\x00lyr', 'lyr', '', dict(encoding=0)], - ['TLA', b'\x00ENU', 'ENU', '', dict(encoding=0)], - ['TCO', b'\x00gen', 'gen', '', dict(encoding=0)], - ['TAL', b'\x00alb', 'alb', '', dict(encoding=0)], - ['TPA', b'\x001/9', '1/9', 1, dict(encoding=0)], - ['TRK', b'\x002/8', '2/8', 2, dict(encoding=0)], - ['TRC', b'\x00isrc', 'isrc', '', dict(encoding=0)], - ['TYE', b'\x001900', '1900', 1900, dict(encoding=0)], - ['TDA', b'\x002512', '2512', '', dict(encoding=0)], - ['TIM', b'\x001225', '1225', '', dict(encoding=0)], - ['TRD', b'\x00Jul 17', 'Jul 17', '', dict(encoding=0)], - ['TMT', b'\x00DIG/A', 'DIG/A', '', dict(encoding=0)], - ['TFT', b'\x00MPG/3', 'MPG/3', '', dict(encoding=0)], - ['TBP', b'\x00133', '133', 133, dict(encoding=0)], - ['TCP', b'\x001', '1', 1, dict(encoding=0)], - ['TCP', b'\x000', '0', 0, dict(encoding=0)], - ['TCR', b'\x00Me', 'Me', '', dict(encoding=0)], - ['TPB', b'\x00Him', 'Him', '', dict(encoding=0)], - ['TEN', b'\x00Lamer', 'Lamer', '', dict(encoding=0)], - ['TSS', b'\x00ab', 'ab', '', dict(encoding=0)], - ['TOF', b'\x00ab:cd', 'ab:cd', '', dict(encoding=0)], - ['TLE', b'\x0012', '12', 12, dict(encoding=0)], - ['TSI', b'\x0012', '12', 12, dict(encoding=0)], - ['TDY', b'\x0012', '12', 12, dict(encoding=0)], - ['TKE', b'\x00A#m', 'A#m', '', dict(encoding=0)], - ['TOT', b'\x00org', 'org', '', dict(encoding=0)], - ['TOA', b'\x00org', 'org', '', dict(encoding=0)], - ['TOL', b'\x00org', 'org', '', dict(encoding=0)], - ['TOR', b'\x001877', '1877', 1877, dict(encoding=0)], - ['TXX', b'\x00desc\x00val', 'val', '', dict(encoding=0, desc='desc')], - - ['WAF', b'http://zzz', 'http://zzz', '', {}], - ['WAR', b'http://zzz', 'http://zzz', '', {}], - ['WAS', b'http://zzz', 'http://zzz', '', {}], - ['WCM', b'http://zzz', 'http://zzz', '', {}], - ['WCP', b'http://zzz', 'http://zzz', '', {}], - ['WPB', b'http://zzz', 'http://zzz', '', {}], - ['WXX', b'\x00desc\x00http', 'http', '', dict(encoding=0, desc='desc')], - - ['IPL', b'\x00a\x00A\x00b\x00B\x00', [['a','A'],['b','B']], '', - dict(encoding=0)], - ['MCI', b'\x01\x02\x03\x04', '\x01\x02\x03\x04', '', {}], - - ['ETC', b'\x01\x12\x00\x00\x7f\xff', [(18, 32767)], '', dict(format=1)], - - ['COM', b'\x00ENUT\x00Com', 'Com', '', - dict(desc='T', lang='ENU', encoding=0)], - ['PIC', b'\x00-->\x03cover\x00cover.jpg', 'cover.jpg', '', - dict(mime='-->', type=3, desc='cover', encoding=0)], - - ['POP', b'foo@bar.org\x00\xde\x00\x00\x00\x11', 222, 222, - dict(email="foo@bar.org", rating=222, count=17)], - ['CNT', b'\x00\x00\x00\x11', 17, 17, dict(count=17)], - ['GEO', b'\x00mime\x00name\x00desc\x00data', 'data', '', - dict(encoding=0, mime='mime', filename='name', desc='desc')], - ['ULT', b'\x00engsome lyrics\x00woo\nfun', 'woo\nfun', '', - dict(encoding=0, lang='eng', desc='some lyrics', text='woo\nfun')], - - ['BUF', b'\x00\x12\x00', 0x12*256, 0x12*256, dict(size=0x12*256)], - - ['CRA', b'a@b.org\x00\x00\x12\x00\x23', 'a@b.org', 'a@b.org', - dict(owner='a@b.org', preview_start=0x12, preview_length=0x23)], - ['CRA', b'a@b.org\x00\x00\x12\x00\x23!', 'a@b.org', 'a@b.org', - dict(owner='a@b.org', preview_start=0x12, preview_length=0x23, data='!')], - - ['REV', b'\x12\x12\x23\x23\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11', - (0x12*256+0x12, 0x23*256+0x23), '', - dict(left=0x12*256+0x12, right=0x23*256+0x23) ], - - ['STC', b"\x01\x10obar", '\x10obar', '', dict(format=1, data='\x10obar')], - - ['MLL', b'\x00\x01\x00\x00\x02\x00\x00\x03\x04\x08foobar', 'foobar', '', - dict(frames=1, bytes=2, milliseconds=3, bits_for_bytes=4, - bits_for_milliseconds=8, data='foobar')], - ['LNK', b'TT1http://www.example.org/TIT1.txt\x00', - ("TT1", b'http://www.example.org/TIT1.txt'), '', - dict(frameid='TT1', url='http://www.example.org/TIT1.txt')], - ['CRM', b'foo@example.org\x00test\x00woo', - 'woo', '', dict(owner='foo@example.org', desc='test', data='woo')], +def create_read_tag_tests(): + tests = [ + ['TALB', b'\x00a/b', 'a/b', '', dict(encoding=0)], + ['TBPM', b'\x00120', '120', 120, dict(encoding=0)], + ['TCMP', b'\x001', '1', 1, dict(encoding=0)], + ['TCMP', b'\x000', '0', 0, dict(encoding=0)], + ['TCOM', b'\x00a/b', 'a/b', '', dict(encoding=0)], + ['TCON', b'\x00(21)Disco', '(21)Disco', '', dict(encoding=0)], + ['TCOP', b'\x001900 c', '1900 c', '', dict(encoding=0)], + ['TDAT', b'\x00a/b', 'a/b', '', dict(encoding=0)], + ['TDEN', b'\x001987', '1987', '', dict(encoding=0, year=[1987])], + [ + 'TDOR', b'\x001987-12', '1987-12', '', + dict(encoding=0, year=[1987], month=[12]) + ], + ['TDRC', b'\x001987\x00', '1987', '', dict(encoding=0, year=[1987])], + [ + 'TDRL', b'\x001987\x001988', '1987,1988', '', + dict(encoding=0, year=[1987, 1988]) + ], + ['TDTG', b'\x001987', '1987', '', dict(encoding=0, year=[1987])], + ['TDLY', b'\x001205', '1205', 1205, dict(encoding=0)], + ['TENC', b'\x00a b/c d', 'a b/c d', '', dict(encoding=0)], + ['TEXT', b'\x00a b\x00c d', ['a b', 'c d'], '', dict(encoding=0)], + ['TFLT', b'\x00MPG/3', 'MPG/3', '', dict(encoding=0)], + ['TIME', b'\x001205', '1205', '', dict(encoding=0)], + [ + 'TIPL', b'\x02\x00a\x00\x00\x00b', [["a", "b"]], '', + dict(encoding=2) + ], + ['TIT1', b'\x00a/b', 'a/b', '', dict(encoding=0)], + # TIT2 checks misaligned terminator '\x00\x00' across crosses utf16 + # chars + [ + 'TIT2', b'\x01\xff\xfe\x38\x00\x00\x38', u'8\u3800', '', + dict(encoding=1) + ], + ['TIT3', b'\x00a/b', 'a/b', '', dict(encoding=0)], + ['TKEY', b'\x00A#m', 'A#m', '', dict(encoding=0)], + ['TLAN', b'\x006241', '6241', '', dict(encoding=0)], + ['TLEN', b'\x006241', '6241', 6241, dict(encoding=0)], + [ + 'TMCL', b'\x02\x00a\x00\x00\x00b', [["a", "b"]], '', + dict(encoding=2) + ], + ['TMED', b'\x00med', 'med', '', dict(encoding=0)], + ['TMOO', b'\x00moo', 'moo', '', dict(encoding=0)], + ['TOAL', b'\x00alb', 'alb', '', dict(encoding=0)], + ['TOFN', b'\x0012 : bar', '12 : bar', '', dict(encoding=0)], + ['TOLY', b'\x00lyr', 'lyr', '', dict(encoding=0)], + ['TOPE', b'\x00own/lic', 'own/lic', '', dict(encoding=0)], + ['TORY', b'\x001923', '1923', 1923, dict(encoding=0)], + ['TOWN', b'\x00own/lic', 'own/lic', '', dict(encoding=0)], + ['TPE1', b'\x00ab', ['ab'], '', dict(encoding=0)], + [ + 'TPE2', b'\x00ab\x00cd\x00ef', ['ab', 'cd', 'ef'], '', + dict(encoding=0) + ], + ['TPE3', b'\x00ab\x00cd', ['ab', 'cd'], '', dict(encoding=0)], + ['TPE4', b'\x00ab\x00', ['ab'], '', dict(encoding=0)], + ['TPOS', b'\x0008/32', '08/32', 8, dict(encoding=0)], + ['TPRO', b'\x00pro', 'pro', '', dict(encoding=0)], + ['TPUB', b'\x00pub', 'pub', '', dict(encoding=0)], + ['TRCK', b'\x004/9', '4/9', 4, dict(encoding=0)], + ['TRDA', b'\x00Sun Jun 12', 'Sun Jun 12', '', dict(encoding=0)], + ['TRSN', b'\x00ab/cd', 'ab/cd', '', dict(encoding=0)], + ['TRSO', b'\x00ab', 'ab', '', dict(encoding=0)], + ['TSIZ', b'\x0012345', '12345', 12345, dict(encoding=0)], + ['TSOA', b'\x00ab', 'ab', '', dict(encoding=0)], + ['TSOP', b'\x00ab', 'ab', '', dict(encoding=0)], + ['TSOT', b'\x00ab', 'ab', '', dict(encoding=0)], + ['TSO2', b'\x00ab', 'ab', '', dict(encoding=0)], + ['TSOC', b'\x00ab', 'ab', '', dict(encoding=0)], + ['TSRC', b'\x0012345', '12345', '', dict(encoding=0)], + ['TSSE', b'\x0012345', '12345', '', dict(encoding=0)], + ['TSST', b'\x0012345', '12345', '', dict(encoding=0)], + ['TYER', b'\x002004', '2004', 2004, dict(encoding=0)], + [ + 'TXXX', b'\x00usr\x00a/b\x00c', ['a/b', 'c'], '', + dict(encoding=0, desc='usr') + ], + ['WCOM', b'http://foo', 'http://foo', '', {}], + ['WCOP', b'http://bar', 'http://bar', '', {}], + ['WOAF', b'http://baz', 'http://baz', '', {}], + ['WOAR', b'http://bar', 'http://bar', '', {}], + ['WOAS', b'http://bar', 'http://bar', '', {}], + ['WORS', b'http://bar', 'http://bar', '', {}], + ['WPAY', b'http://bar', 'http://bar', '', {}], + ['WPUB', b'http://bar', 'http://bar', '', {}], + ['WXXX', b'\x00usr\x00http', 'http', '', dict(encoding=0, desc='usr')], + [ + 'IPLS', b'\x00a\x00A\x00b\x00B\x00', [['a', 'A'], ['b', 'B']], '', + dict(encoding=0) + ], + ['MCDI', b'\x01\x02\x03\x04', b'\x01\x02\x03\x04', '', {}], + [ + 'ETCO', b'\x01\x12\x00\x00\x7f\xff', [(18, 32767)], '', + dict(format=1) + ], + [ + 'COMM', b'\x00ENUT\x00Com', 'Com', '', + dict(desc='T', lang='ENU', encoding=0) + ], + [ + 'APIC', b'\x00-->\x00\x03cover\x00cover.jpg', b'cover.jpg', '', + dict(mime='-->', type=3, desc='cover', encoding=0) + ], + ['USER', b'\x00ENUCom', 'Com', '', dict(lang='ENU', encoding=0)], + [ + 'RVA2', b'testdata\x00\x01\xfb\x8c\x10\x12\x23', + 'Master volume: -2.2266 dB/0.1417', '', + dict(desc='testdata', channel=1, gain=-2.22656, peak=0.14169) + ], + [ + 'RVA2', b'testdata\x00\x01\xfb\x8c\x24\x01\x22\x30\x00\x00', + 'Master volume: -2.2266 dB/0.1417', '', + dict(desc='testdata', channel=1, gain=-2.22656, peak=0.14169) + ], + [ + 'RVA2', b'testdata2\x00\x01\x04\x01\x00', + 'Master volume: +2.0020 dB/0.0000', '', + dict(desc='testdata2', channel=1, gain=2.001953125, peak=0) + ], + ['PCNT', b'\x00\x00\x00\x11', 17, 17, dict(count=17)], + [ + 'POPM', b'foo@bar.org\x00\xde\x00\x00\x00\x11', 222, 222, + dict(email="foo@bar.org", rating=222, count=17) + ], + [ + 'POPM', b'foo@bar.org\x00\xde\x00', 222, 222, + dict(email="foo@bar.org", rating=222, count=0) + ], + # Issue #33 - POPM may have no playcount at all. + [ + 'POPM', b'foo@bar.org\x00\xde', 222, 222, + dict(email="foo@bar.org", rating=222) + ], + ['UFID', b'own\x00data', b'data', '', dict(data=b'data', owner='own')], + ['UFID', b'own\x00\xdd', b'\xdd', '', dict(data=b'\xdd', owner='own')], + [ + 'GEOB', b'\x00mime\x00name\x00desc\x00data', b'data', '', + dict(encoding=0, mime='mime', filename='name', desc='desc') + ], + [ + 'USLT', b'\x00engsome lyrics\x00woo\nfun', 'woo\nfun', '', + dict(encoding=0, lang='eng', desc='some lyrics', text='woo\nfun') + ], + [ + 'SYLT', (b'\x00eng\x02\x01some lyrics\x00foo\x00\x00\x00\x00\x01' + b'bar\x00\x00\x00\x00\x10'), "foobar", '', + dict(encoding=0, lang='eng', type=1, format=2, desc='some lyrics') + ], + ['POSS', b'\x01\x0f', 15, 15, dict(format=1, position=15)], + [ + 'OWNE', b'\x00USD10.01\x0020041010CDBaby', 'CDBaby', 'CDBaby', + dict(encoding=0, price="USD10.01", date='20041010', + seller='CDBaby') + ], + [ + 'PRIV', b'a@b.org\x00random data', b'random data', 'random data', + dict(owner='a@b.org', data=b'random data') + ], + [ + 'PRIV', b'a@b.org\x00\xdd', b'\xdd', '\xdd', + dict(owner='a@b.org', data=b'\xdd') + ], + ['SIGN', b'\x92huh?', b'huh?', 'huh?', dict(group=0x92, sig=b'huh?')], + [ + 'ENCR', b'a@b.org\x00\x92Data!', b'Data!', 'Data!', + dict(owner='a@b.org', method=0x92, data=b'Data!') + ], + [ + 'SEEK', b'\x00\x12\x00\x56', + 0x12 * 256 * 256 + 0x56, 0x12 * 256 * 256 + 0x56, + dict(offset=0x12 * 256 * 256 + 0x56) + ], + [ + 'SYTC', b"\x01\x10obar", b'\x10obar', '', + dict(format=1, data=b'\x10obar') + ], + [ + 'RBUF', b'\x00\x12\x00', 0x12 * 256, 0x12 * 256, + dict(size=0x12 * 256) + ], + [ + 'RBUF', b'\x00\x12\x00\x01', 0x12 * 256, 0x12 * 256, + dict(size=0x12 * 256, info=1) + ], + [ + 'RBUF', b'\x00\x12\x00\x01\x00\x00\x00\x23', + 0x12 * 256, 0x12 * 256, + dict(size=0x12 * 256, info=1, offset=0x23) + ], + [ + 'RVRB', b'\x12\x12\x23\x23\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11', + (0x12 * 256 + 0x12, 0x23 * 256 + 0x23), '', + dict(left=0x12 * 256 + 0x12, right=0x23 * 256 + 0x23) + ], + [ + 'AENC', b'a@b.org\x00\x00\x12\x00\x23', 'a@b.org', 'a@b.org', + dict(owner='a@b.org', preview_start=0x12, preview_length=0x23) + ], + [ + 'AENC', b'a@b.org\x00\x00\x12\x00\x23!', 'a@b.org', 'a@b.org', + dict(owner='a@b.org', preview_start=0x12, + preview_length=0x23, data=b'!') + ], + [ + 'GRID', b'a@b.org\x00\x99', 'a@b.org', 0x99, + dict(owner='a@b.org', group=0x99) + ], + [ + 'GRID', b'a@b.org\x00\x99data', 'a@b.org', 0x99, + dict(owner='a@b.org', group=0x99, data=b'data') + ], + [ + 'COMR', + (b'\x00USD10.00\x0020051010ql@sc.net\x00\x09Joe\x00A song\x00' + b'x-image/fake\x00some data'), + COMR(encoding=0, price="USD10.00", valid_until="20051010", + contact="ql@sc.net", format=9, seller="Joe", desc="A song", + mime='x-image/fake', logo=b'some data'), '', + dict(encoding=0, price="USD10.00", valid_until="20051010", + contact="ql@sc.net", format=9, seller="Joe", desc="A song", + mime='x-image/fake', logo=b'some data') + ], + [ + 'COMR', + b'\x00USD10.00\x0020051010ql@sc.net\x00\x09Joe\x00A song\x00', + COMR(encoding=0, price="USD10.00", valid_until="20051010", + contact="ql@sc.net", format=9, seller="Joe", desc="A song"), + '', + dict(encoding=0, price="USD10.00", valid_until="20051010", + contact="ql@sc.net", format=9, seller="Joe", desc="A song") + ], + [ + 'MLLT', b'\x00\x01\x00\x00\x02\x00\x00\x03\x04\x08foobar', + b'foobar', '', + dict(frames=1, bytes=2, milliseconds=3, bits_for_bytes=4, + bits_for_milliseconds=8, data=b'foobar') + ], + [ + 'EQU2', b'\x00Foobar\x00\x01\x01\x04\x00', [(128.5, 2.0)], '', + dict(method=0, desc="Foobar") + ], + [ + 'ASPI', + b'\x00\x00\x00\x00\x00\x00\x00\x10\x00\x03\x08\x01\x02\x03', + [1, 2, 3], '', dict(S=0, L=16, N=3, b=8) + ], + [ + 'ASPI', b'\x00\x00\x00\x00\x00\x00\x00\x10\x00\x03\x10' + b'\x00\x01\x00\x02\x00\x03', [1, 2, 3], '', + dict(S=0, L=16, N=3, b=16) + ], + [ + 'LINK', b'TIT1http://www.example.org/TIT1.txt\x00', + ("TIT1", 'http://www.example.org/TIT1.txt'), '', + dict(frameid='TIT1', url='http://www.example.org/TIT1.txt') + ], + [ + 'LINK', b'COMMhttp://www.example.org/COMM.txt\x00engfoo', + ("COMM", 'http://www.example.org/COMM.txt', b'engfoo'), '', + dict(frameid='COMM', url='http://www.example.org/COMM.txt', + data=b'engfoo') + ], + # iTunes podcast frames + ['TGID', b'\x00i', u'i', '', dict(encoding=0)], + ['TDES', b'\x00ii', u'ii', '', dict(encoding=0)], + ['WFED', b'http://zzz', 'http://zzz', '', {}], + + # 2.2 tags + ['UFI', b'own\x00data', b'data', '', dict(data=b'data', owner='own')], + [ + 'SLT', (b'\x00eng\x02\x01some lyrics\x00foo\x00\x00\x00\x00\x01bar' + b'\x00\x00\x00\x00\x10'), + "foobar", '', + dict(encoding=0, lang='eng', type=1, format=2, desc='some lyrics') + ], + ['TT1', b'\x00ab\x00', 'ab', '', dict(encoding=0)], + ['TT2', b'\x00ab', 'ab', '', dict(encoding=0)], + ['TT3', b'\x00ab', 'ab', '', dict(encoding=0)], + ['TP1', b'\x00ab\x00', 'ab', '', dict(encoding=0)], + ['TP2', b'\x00ab', 'ab', '', dict(encoding=0)], + ['TP3', b'\x00ab', 'ab', '', dict(encoding=0)], + ['TP4', b'\x00ab', 'ab', '', dict(encoding=0)], + ['TCM', b'\x00ab/cd', 'ab/cd', '', dict(encoding=0)], + ['TXT', b'\x00lyr', 'lyr', '', dict(encoding=0)], + ['TLA', b'\x00ENU', 'ENU', '', dict(encoding=0)], + ['TCO', b'\x00gen', 'gen', '', dict(encoding=0)], + ['TAL', b'\x00alb', 'alb', '', dict(encoding=0)], + ['TPA', b'\x001/9', '1/9', 1, dict(encoding=0)], + ['TRK', b'\x002/8', '2/8', 2, dict(encoding=0)], + ['TRC', b'\x00isrc', 'isrc', '', dict(encoding=0)], + ['TYE', b'\x001900', '1900', 1900, dict(encoding=0)], + ['TDA', b'\x002512', '2512', '', dict(encoding=0)], + ['TIM', b'\x001225', '1225', '', dict(encoding=0)], + ['TRD', b'\x00Jul 17', 'Jul 17', '', dict(encoding=0)], + ['TMT', b'\x00DIG/A', 'DIG/A', '', dict(encoding=0)], + ['TFT', b'\x00MPG/3', 'MPG/3', '', dict(encoding=0)], + ['TBP', b'\x00133', '133', 133, dict(encoding=0)], + ['TCP', b'\x001', '1', 1, dict(encoding=0)], + ['TCP', b'\x000', '0', 0, dict(encoding=0)], + ['TCR', b'\x00Me', 'Me', '', dict(encoding=0)], + ['TPB', b'\x00Him', 'Him', '', dict(encoding=0)], + ['TEN', b'\x00Lamer', 'Lamer', '', dict(encoding=0)], + ['TSS', b'\x00ab', 'ab', '', dict(encoding=0)], + ['TOF', b'\x00ab:cd', 'ab:cd', '', dict(encoding=0)], + ['TLE', b'\x0012', '12', 12, dict(encoding=0)], + ['TSI', b'\x0012', '12', 12, dict(encoding=0)], + ['TDY', b'\x0012', '12', 12, dict(encoding=0)], + ['TKE', b'\x00A#m', 'A#m', '', dict(encoding=0)], + ['TOT', b'\x00org', 'org', '', dict(encoding=0)], + ['TOA', b'\x00org', 'org', '', dict(encoding=0)], + ['TOL', b'\x00org', 'org', '', dict(encoding=0)], + ['TOR', b'\x001877', '1877', 1877, dict(encoding=0)], + ['TXX', b'\x00desc\x00val', 'val', '', dict(encoding=0, desc='desc')], + + ['WAF', b'http://zzz', 'http://zzz', '', {}], + ['WAR', b'http://zzz', 'http://zzz', '', {}], + ['WAS', b'http://zzz', 'http://zzz', '', {}], + ['WCM', b'http://zzz', 'http://zzz', '', {}], + ['WCP', b'http://zzz', 'http://zzz', '', {}], + ['WPB', b'http://zzz', 'http://zzz', '', {}], + [ + 'WXX', b'\x00desc\x00http', 'http', '', + dict(encoding=0, desc='desc') + ], + [ + 'IPL', b'\x00a\x00A\x00b\x00B\x00', [['a', 'A'], ['b', 'B']], '', + dict(encoding=0) + ], + ['MCI', b'\x01\x02\x03\x04', b'\x01\x02\x03\x04', '', {}], + [ + 'ETC', b'\x01\x12\x00\x00\x7f\xff', [(18, 32767)], '', + dict(format=1) + ], + [ + 'COM', b'\x00ENUT\x00Com', 'Com', '', + dict(desc='T', lang='ENU', encoding=0) + ], + [ + 'PIC', b'\x00-->\x03cover\x00cover.jpg', b'cover.jpg', '', + dict(mime='-->', type=3, desc='cover', encoding=0) + ], + [ + 'POP', b'foo@bar.org\x00\xde\x00\x00\x00\x11', 222, 222, + dict(email="foo@bar.org", rating=222, count=17) + ], + ['CNT', b'\x00\x00\x00\x11', 17, 17, dict(count=17)], + [ + 'GEO', b'\x00mime\x00name\x00desc\x00data', b'data', '', + dict(encoding=0, mime='mime', filename='name', desc='desc') + ], + [ + 'ULT', b'\x00engsome lyrics\x00woo\nfun', 'woo\nfun', '', + dict(encoding=0, lang='eng', desc='some lyrics', text='woo\nfun')], + [ + 'BUF', b'\x00\x12\x00', 0x12 * 256, 0x12 * 256, + dict(size=0x12 * 256) + ], + [ + 'CRA', b'a@b.org\x00\x00\x12\x00\x23', 'a@b.org', 'a@b.org', + dict(owner='a@b.org', preview_start=0x12, preview_length=0x23) + ], + [ + 'CRA', b'a@b.org\x00\x00\x12\x00\x23!', 'a@b.org', 'a@b.org', + dict(owner='a@b.org', preview_start=0x12, + preview_length=0x23, data=b'!') + ], + [ + 'REV', b'\x12\x12\x23\x23\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11', + (0x12 * 256 + 0x12, 0x23 * 256 + 0x23), '', + dict(left=0x12 * 256 + 0x12, right=0x23 * 256 + 0x23) + ], + [ + 'STC', b"\x01\x10obar", b'\x10obar', '', + dict(format=1, data=b'\x10obar') + ], + [ + 'MLL', b'\x00\x01\x00\x00\x02\x00\x00\x03\x04\x08foobar', + b'foobar', '', + dict(frames=1, bytes=2, milliseconds=3, bits_for_bytes=4, + bits_for_milliseconds=8, data=b'foobar') + ], + [ + 'LNK', b'TT1http://www.example.org/TIT1.txt\x00', + ("TT1", 'http://www.example.org/TIT1.txt'), '', + dict(frameid='TT1', url='http://www.example.org/TIT1.txt') + ], + [ + 'CRM', b'foo@example.org\x00test\x00woo', b'woo', '', + dict(owner='foo@example.org', desc='test', data=b'woo') + ], ] load_tests = {} @@ -756,7 +862,7 @@ from operator import pos id3 = __import__('mutagen.id3', globals(), locals(), [tag]) TAG = getattr(id3, tag) - tag = TAG.fromData(_23, 0, data) + tag = TAG._fromData(_23, 0, data) self.failUnless(tag.HashKey) self.failUnless(tag.pprint()) self.assertEquals(value, tag) @@ -770,7 +876,8 @@ for value, t in zip(value, iter(t)): if isinstance(value, float): self.failUnlessAlmostEqual(value, getattr(t, attr), 5) - else: self.assertEquals(value, getattr(t, attr)) + else: + self.assertEquals(value, getattr(t, attr)) if isinstance(intval, integer_types): self.assertEquals(intval, pos(t)) @@ -783,57 +890,57 @@ from mutagen.id3 import ID3TimeStamp id3 = __import__('mutagen.id3', globals(), locals(), [tag]) TAG = getattr(id3, tag) - tag = TAG.fromData(_23, 0, data) - tag2 = eval(repr(tag), {TAG.__name__:TAG, - 'ID3TimeStamp':ID3TimeStamp}) + tag = TAG._fromData(_23, 0, data) + tag2 = eval(repr(tag), {TAG.__name__: TAG, + 'ID3TimeStamp': ID3TimeStamp}) self.assertEquals(type(tag), type(tag2)) for spec in TAG._framespec: attr = spec.name self.assertEquals(getattr(tag, attr), getattr(tag2, attr)) + self.assertTrue(isinstance(tag.__str__(), str)) if PY2: - # test __str__, __unicode__ - self.assertTrue(isinstance(tag.__str__(), str)) if hasattr(tag, "__unicode__"): self.assertTrue(isinstance(tag.__unicode__(), unicode)) else: - self.assertTrue(isinstance(tag.__bytes__(), bytes)) - if hasattr(tag, "__str__"): - self.assertTrue(isinstance(tag.__str__(), str)) + if hasattr(tag, "__bytes__"): + self.assertTrue(isinstance(tag.__bytes__(), bytes)) repr_tests['test_repr_%s_%d' % (tag, i)] = test_tag_repr def test_tag_write(self, tag=tag, data=data): id3 = __import__('mutagen.id3', globals(), locals(), [tag]) TAG = getattr(id3, tag) - tag = TAG.fromData(_24, 0, data) + tag = TAG._fromData(_24, 0, data) towrite = tag._writeData() - tag2 = TAG.fromData(_24, 0, towrite) + tag2 = TAG._fromData(_24, 0, towrite) for spec in TAG._framespec: attr = spec.name self.assertEquals(getattr(tag, attr), getattr(tag2, attr)) write_tests['test_write_%s_%d' % (tag, i)] = test_tag_write testcase = type('TestReadTags', (TestCase,), load_tests) - add(testcase) + assert testcase.__name__ not in globals() + globals()[testcase.__name__] = testcase testcase = type('TestReadReprTags', (TestCase,), repr_tests) - add(testcase) + assert testcase.__name__ not in globals() + globals()[testcase.__name__] = testcase testcase = type('TestReadWriteTags', (TestCase,), write_tests) - add(testcase) + assert testcase.__name__ not in globals() + globals()[testcase.__name__] = testcase from mutagen.id3 import Frames, Frames_2_2 check = dict.fromkeys(list(Frames.keys()) + list(Frames_2_2.keys())) tested_tags = dict.fromkeys([row[0] for row in tests]) for tag in check: - def check(self, tag=tag): self.assert_(tag in tested_tags) + def check(self, tag=tag): + self.assert_(tag in tested_tags) tested_tags['test_' + tag + '_tested'] = check testcase = type('TestTestedTags', (TestCase,), tested_tags) - add(testcase) - -TestReadTags() -del TestReadTags - + assert testcase.__name__ not in globals() + globals()[testcase.__name__] = testcase +create_read_tag_tests() class UpdateTo24(TestCase): @@ -842,10 +949,18 @@ from mutagen.id3 import PIC id3 = ID3() id3.version = (2, 2) - id3.add(PIC(encoding=0, mime="PNG", desc="cover", type=3, data="")) + id3.add(PIC(encoding=0, mime="PNG", desc="cover", type=3, data=b"")) id3.update_to_v24() self.failUnlessEqual(id3["APIC:cover"].mime, "image/png") + def test_lnk(self): + from mutagen.id3 import LNK + id3 = ID3() + id3.version = (2, 2) + id3.add(LNK(frameid="PIC", url="http://foo.bar")) + id3.update_to_v24() + self.assertFalse(id3.getall("LINK")) + def test_tyer(self): from mutagen.id3 import TYER id3 = ID3() @@ -897,13 +1012,11 @@ id3.update_to_v24() self.assertFalse(id3.getall("TIME")) -add(UpdateTo24) - class Issue97_UpgradeUnknown23(TestCase): - SILENCE = os.path.join("tests", "data", "97-unknown-23-update.mp3") + SILENCE = os.path.join(DATA_DIR, "97-unknown-23-update.mp3") + def setUp(self): - from tempfile import mkstemp fd, self.filename = mkstemp(suffix='.mp3') os.close(fd) shutil.copy(self.SILENCE, self.filename) @@ -918,9 +1031,9 @@ translate=False) # TIT2 ends up in unknown_frames - self.failUnlessEqual(unknown.unknown_frames[0][:4], "TIT2") + self.failUnlessEqual(unknown.unknown_frames[0][:4], b"TIT2") - # frame should be different now + # frame should be different now orig_unknown = unknown.unknown_frames[0] unknown.update_to_v24() self.failIfEqual(unknown.unknown_frames[0], orig_unknown) @@ -939,13 +1052,13 @@ unknown = ID3(self.filename, known_frames={"TPE1": TPE1}) # Make sure the data doesn't get updated again unknown.update_to_v24() - unknown.unknown_frames = ["foobar"] + unknown.unknown_frames = [b"foobar"] unknown.update_to_v24() self.failUnless(unknown.unknown_frames) - def test_unkown_invalid(self): + def test_unknown_invalid(self): f = ID3(self.filename, translate=False) - f.unknown_frames = ["foobar", "\xff"*50] + f.unknown_frames = [b"foobar", b"\xff" * 50] # throw away invalid frames f.update_to_v24() self.failIf(f.unknown_frames) @@ -953,30 +1066,30 @@ def tearDown(self): os.unlink(self.filename) -add(Issue97_UpgradeUnknown23) - class BrokenDiscarded(TestCase): def test_empty(self): from mutagen.id3 import TPE1, ID3JunkFrameError - self.assertRaises(ID3JunkFrameError, TPE1.fromData, _24, 0x00, '') + self.assertRaises(ID3JunkFrameError, TPE1._fromData, _24, 0x00, b'') def test_wacky_truncated_RVA2(self): from mutagen.id3 import RVA2, ID3JunkFrameError - data = '\x01{\xf0\x10\xff\xff\x00' - self.assertRaises(ID3JunkFrameError, RVA2.fromData, _24, 0x00, data) + data = b'\x01{\xf0\x10\xff\xff\x00' + self.assertRaises(ID3JunkFrameError, RVA2._fromData, _24, 0x00, data) def test_bad_number_of_bits_RVA2(self): from mutagen.id3 import RVA2, ID3JunkFrameError - data = '\x00\x00\x01\xe6\xfc\x10{\xd7' - self.assertRaises(ID3JunkFrameError, RVA2.fromData, _24, 0x00, data) + data = b'\x00\x00\x01\xe6\xfc\x10{\xd7' + self.assertRaises(ID3JunkFrameError, RVA2._fromData, _24, 0x00, data) def test_drops_truncated_frames(self): from mutagen.id3 import Frames id3 = ID3() - tail = '\x00\x00\x00\x03\x00\x00' '\x01\x02\x03' - for head in 'RVA2 TXXX APIC'.split(): + id3._header = ID3Header() + id3._header.version = (2, 4, 0) + tail = b'\x00\x00\x00\x03\x00\x00' b'\x01\x02\x03' + for head in b'RVA2 TXXX APIC'.split(): data = head + tail self.assertEquals( 0, len(list(id3._ID3__read_frames(data, Frames)))) @@ -984,8 +1097,8 @@ def test_drops_nonalphanum_frames(self): from mutagen.id3 import Frames id3 = ID3() - tail = '\x00\x00\x00\x03\x00\x00' '\x01\x02\x03' - for head in ['\x06\xaf\xfe\x20', 'ABC\x00', 'A ']: + tail = b'\x00\x00\x00\x03\x00\x00' b'\x01\x02\x03' + for head in [b'\x06\xaf\xfe\x20', b'ABC\x00', b'A ']: data = head + tail self.assertEquals( 0, len(list(id3._ID3__read_frames(data, Frames)))) @@ -993,41 +1106,41 @@ def test_bad_unicodedecode(self): from mutagen.id3 import COMM, ID3JunkFrameError # 7 bytes of "UTF16" data. - data = '\x01\x00\x00\x00\xff\xfe\x00\xff\xfeh\x00' - self.assertRaises(ID3JunkFrameError, COMM.fromData, _24, 0x00, data) + data = b'\x01\x00\x00\x00\xff\xfe\x00\xff\xfeh\x00' + self.assertRaises(ID3JunkFrameError, COMM._fromData, _24, 0x00, data) -class BrokenButParsed(TestCase): - def test_missing_encoding(self): - from mutagen.id3 import TIT2 - tag = TIT2.fromData(_23, 0x00, 'a test') - self.assertEquals(0, tag.encoding) - self.assertEquals('a test', tag) - self.assertEquals(['a test'], tag) - self.assertEquals(['a test'], tag.text) +class BrokenButParsed(TestCase): def test_zerolength_framedata(self): from mutagen.id3 import Frames id3 = ID3() - tail = '\x00' * 6 - for head in 'WOAR TENC TCOP TOPE WXXX'.split(): + tail = b'\x00' * 6 + for head in b'WOAR TENC TCOP TOPE WXXX'.split(): data = head + tail self.assertEquals( 0, len(list(id3._ID3__read_frames(data, Frames)))) def test_lengthone_utf16(self): from mutagen.id3 import TPE1 - tpe1 = TPE1.fromData(_24, 0, '\x01\x00') + tpe1 = TPE1._fromData(_24, 0, b'\x01\x00') self.assertEquals(u'', tpe1) - tpe1 = TPE1.fromData(_24, 0, '\x01\x00\x00\x00\x00') + tpe1 = TPE1._fromData(_24, 0, b'\x01\x00\x00\x00\x00') self.assertEquals([u'', u''], tpe1) - def test_fake_zlib_pedantic(self): - from mutagen.id3 import TPE1, Frame, ID3BadCompressedData - id3 = ID3() - id3.PEDANTIC = True - self.assertRaises(ID3BadCompressedData, TPE1.fromData, id3, - Frame.FLAG24_COMPRESS, '\x03abcdefg') + def test_utf16_wrongnullterm(self): + # issue 169 + from mutagen.id3 import TPE1 + tpe1 = TPE1._fromData( + _24, 0, b'\x01\xff\xfeH\x00e\x00l\x00l\x00o\x00\x00') + self.assertEquals(tpe1, [u'Hello']) + + def test_fake_zlib(self): + from mutagen.id3 import TPE1, Frame + header = ID3Header() + header.version = (2, 4, 0) + self.assertRaises(ID3JunkFrameError, TPE1._fromData, header, + Frame.FLAG24_COMPRESS, b'\x03abcdefg') def test_zlib_bpi(self): from mutagen.id3 import TPE1 @@ -1036,54 +1149,59 @@ data = id3._ID3__save_frame(tpe1) datalen_size = data[4 + 4 + 2:4 + 4 + 2 + 4] self.failIf( - max(datalen_size) >= '\x80', "data is not syncsafe: %r" % data) - - def test_fake_zlib_nopedantic(self): - from mutagen.id3 import TPE1, Frame - id3 = ID3() - id3.PEDANTIC = False - tpe1 = TPE1.fromData(id3, Frame.FLAG24_COMPRESS, '\x03abcdefg') - self.assertEquals(u'abcdefg', tpe1) + max(datalen_size) >= b'\x80'[0], "data is not syncsafe: %r" % data) def test_ql_0_12_missing_uncompressed_size(self): from mutagen.id3 import TPE1 - tag = TPE1.fromData(_24, 0x08, 'x\x9cc\xfc\xff\xaf\x84!\x83!\x93' - '\xa1\x98A\x01J&2\xe83\x940\xa4\x02\xd9%\x0c\x00\x87\xc6\x07#') + tag = TPE1._fromData( + _24, 0x08, + b'x\x9cc\xfc\xff\xaf\x84!\x83!\x93' + b'\xa1\x98A\x01J&2\xe83\x940\xa4\x02\xd9%\x0c\x00\x87\xc6\x07#' + ) self.assertEquals(tag.encoding, 1) self.assertEquals(tag, ['this is a/test']) def test_zlib_latin1_missing_datalen(self): from mutagen.id3 import TPE1 - tag = TPE1.fromData(_24, 0x8, '\x00\x00\x00\x0f' - 'x\x9cc(\xc9\xc8,V\x00\xa2D\xfd\x92\xd4\xe2\x12\x00&\x7f\x05%') + tag = TPE1._fromData( + _24, 0x8, + b'\x00\x00\x00\x0f' + b'x\x9cc(\xc9\xc8,V\x00\xa2D\xfd\x92\xd4\xe2\x12\x00&\x7f\x05%' + ) self.assertEquals(tag.encoding, 0) self.assertEquals(tag, ['this is a/test']) def test_detect_23_ints_in_24_frames(self): from mutagen.id3 import Frames - head = 'TIT1\x00\x00\x01\x00\x00\x00\x00' - tail = 'TPE1\x00\x00\x00\x04\x00\x00Yay!' + head = b'TIT1\x00\x00\x01\x00\x00\x00\x00' + tail = b'TPE1\x00\x00\x00\x05\x00\x00\x00Yay!' + + id3 = ID3() + id3._header = ID3Header() + id3._header.version = (2, 4, 0) - tagsgood = list(_24._ID3__read_frames(head + 'a'*127 + tail, Frames)) - tagsbad = list(_24._ID3__read_frames(head + 'a'*255 + tail, Frames)) + tagsgood = list( + id3._ID3__read_frames(head + b'a' * 127 + tail, Frames)) + tagsbad = list(id3._ID3__read_frames(head + b'a' * 255 + tail, Frames)) self.assertEquals(2, len(tagsgood)) self.assertEquals(2, len(tagsbad)) - self.assertEquals('a'*127, tagsgood[0]) - self.assertEquals('a'*255, tagsbad[0]) + self.assertEquals('a' * 127, tagsgood[0]) + self.assertEquals('a' * 255, tagsbad[0]) self.assertEquals('Yay!', tagsgood[1]) self.assertEquals('Yay!', tagsbad[1]) - tagsgood = list(_24._ID3__read_frames(head + 'a'*127, Frames)) - tagsbad = list(_24._ID3__read_frames(head + 'a'*255, Frames)) + tagsgood = list(id3._ID3__read_frames(head + b'a' * 127, Frames)) + tagsbad = list(id3._ID3__read_frames(head + b'a' * 255, Frames)) self.assertEquals(1, len(tagsgood)) self.assertEquals(1, len(tagsbad)) - self.assertEquals('a'*127, tagsgood[0]) - self.assertEquals('a'*255, tagsbad[0]) + self.assertEquals('a' * 127, tagsgood[0]) + self.assertEquals('a' * 255, tagsbad[0]) class OddWrites(TestCase): - silence = join('tests', 'data', 'silence-44-s.mp3') - newsilence = join('tests', 'data', 'silence-written.mp3') + silence = join(DATA_DIR, 'silence-44-s.mp3') + newsilence = join(DATA_DIR, 'silence-written.mp3') + def setUp(self): shutil.copy(self.silence, self.newsilence) @@ -1099,19 +1217,23 @@ def test_1bfile(self): os.unlink(self.newsilence) f = open(self.newsilence, "wb") - f.write("!") + f.write(b"!") f.close() ID3(self.silence).save(self.newsilence) self.assert_(os.path.getsize(self.newsilence) > 1) - self.assertEquals(open(self.newsilence, "rb").read()[-1], "!") + self.assertEquals(open(self.newsilence, "rb").read()[-1], b"!"[0]) def tearDown(self): - try: os.unlink(self.newsilence) - except OSError: pass + try: + os.unlink(self.newsilence) + except OSError: + pass + class WriteRoundtrip(TestCase): - silence = join('tests', 'data', 'silence-44-s.mp3') - newsilence = join('tests', 'data', 'silence-written.mp3') + silence = join(DATA_DIR, 'silence-44-s.mp3') + newsilence = join(DATA_DIR, 'silence-written.mp3') + def setUp(self): shutil.copy(self.silence, self.newsilence) @@ -1177,14 +1299,14 @@ id3 = ID3(self.newsilence) os.unlink(self.newsilence) id3.save(self.newsilence) - self.assertEquals('ID3', open(self.newsilence).read(3)) + self.assertEquals(b'ID3', open(self.newsilence, 'rb').read(3)) self.test_same() def test_emptyfile_silencetag(self): id3 = ID3(self.newsilence) open(self.newsilence, 'wb').truncate() id3.save(self.newsilence) - self.assertEquals('ID3', open(self.newsilence).read(3)) + self.assertEquals(b'ID3', open(self.newsilence, 'rb').read(3)) self.test_same() def test_empty_plustag_minustag_empty(self): @@ -1193,7 +1315,7 @@ id3.save() id3.delete() self.failIf(id3) - self.assertEquals(open(self.newsilence).read(10), '') + self.assertEquals(open(self.newsilence, 'rb').read(10), b'') def test_empty_plustag_emptytag_empty(self): id3 = ID3(self.newsilence) @@ -1201,37 +1323,41 @@ id3.save() id3.clear() id3.save() - self.assertEquals(open(self.newsilence).read(10), '') + self.assertEquals(open(self.newsilence, 'rb').read(10), b'') def test_delete_invalid_zero(self): f = open(self.newsilence, 'wb') - f.write('ID3\x04\x00\x00\x00\x00\x00\x00abc') + f.write(b'ID3\x04\x00\x00\x00\x00\x00\x00abc') f.close() ID3(self.newsilence).delete() - self.assertEquals(open(self.newsilence).read(10), 'abc') + self.assertEquals(open(self.newsilence, 'rb').read(10), b'abc') def test_frame_order(self): from mutagen.id3 import TIT2, APIC, TALB, COMM f = ID3(self.newsilence) f["TIT2"] = TIT2(encoding=0, text="A title!") - f["APIC"] = APIC(encoding=0, mime="b", type=3, desc='', data="a") + f["APIC"] = APIC(encoding=0, mime="b", type=3, desc='', data=b"a") f["TALB"] = TALB(encoding=0, text="c") f["COMM"] = COMM(encoding=0, desc="x", text="y") f.save() data = open(self.newsilence, 'rb').read() - self.assert_(data.find("TIT2") < data.find("APIC")) - self.assert_(data.find("TIT2") < data.find("COMM")) - self.assert_(data.find("TALB") < data.find("APIC")) - self.assert_(data.find("TALB") < data.find("COMM")) - self.assert_(data.find("TIT2") < data.find("TALB")) + self.assert_(data.find(b"TIT2") < data.find(b"APIC")) + self.assert_(data.find(b"TIT2") < data.find(b"COMM")) + self.assert_(data.find(b"TALB") < data.find(b"APIC")) + self.assert_(data.find(b"TALB") < data.find(b"COMM")) + self.assert_(data.find(b"TIT2") < data.find(b"TALB")) def tearDown(self): - try: os.unlink(self.newsilence) - except EnvironmentError: pass + try: + os.unlink(self.newsilence) + except EnvironmentError: + pass + class WriteForEyeD3(TestCase): - silence = join('tests', 'data', 'silence-44-s.mp3') - newsilence = join('tests', 'data', 'silence-written.mp3') + silence = join(DATA_DIR, 'silence-44-s.mp3') + newsilence = join(DATA_DIR, 'silence-written.mp3') + def setUp(self): shutil.copy(self.silence, self.newsilence) # remove ID3v1 tag @@ -1277,7 +1403,7 @@ class BadTYER(TestCase): - filename = join('tests', 'data', 'bad-TYER-frame.mp3') + filename = join(DATA_DIR, 'bad-TYER-frame.mp3') def setUp(self): self.audio = ID3(self.filename) @@ -1291,17 +1417,20 @@ def tearDown(self): del(self.audio) + class BadPOPM(TestCase): - filename = join('tests', 'data', 'bad-POPM-frame.mp3') - newfilename = join('tests', 'data', 'bad-POPM-frame-written.mp3') + filename = join(DATA_DIR, 'bad-POPM-frame.mp3') + newfilename = join(DATA_DIR, 'bad-POPM-frame-written.mp3') def setUp(self): shutil.copy(self.filename, self.newfilename) def tearDown(self): - try: os.unlink(self.newfilename) - except EnvironmentError: pass + try: + os.unlink(self.newfilename) + except EnvironmentError: + pass def test_read_popm_long_counter(self): f = ID3(self.newfilename) @@ -1313,29 +1442,54 @@ def test_write_popm_long_counter(self): from mutagen.id3 import POPM f = ID3(self.newfilename) - f.add(POPM(email="foo@example.com", rating=125, count=2**32+1)) + f.add(POPM(email="foo@example.com", rating=125, count=2 ** 32 + 1)) f.save() f = ID3(self.newfilename) self.failUnless("POPM:foo@example.com" in f) self.failUnless("POPM:Windows Media Player 9 Series" in f) popm = f["POPM:foo@example.com"] self.assertEquals(popm.rating, 125) - self.assertEquals(popm.count, 2**32+1) + self.assertEquals(popm.count, 2 ** 32 + 1) class Issue69_BadV1Year(TestCase): def test_missing_year(self): from mutagen.id3 import ParseID3v1 - tag = ParseID3v1('ABCTAGhello world\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff') + + tag = ParseID3v1( + b'ABCTAGhello world\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\xff' + ) self.failUnlessEqual(tag["TIT2"], "hello world") def test_short_year(self): - from mutagen.id3 import ParseID3v1 - tag = ParseID3v1('XTAGhello world\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x001\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff') + from mutagen.id3 import ParseID3v1, _find_id3v1 + + data = ( + b'XTAGhello world\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x001\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\xff' + ) + tag = ParseID3v1(data) self.failUnlessEqual(tag["TIT2"], "hello world") self.failUnlessEqual(tag["TDRC"], "0001") + frames, offset = _find_id3v1(cBytesIO(data)) + self.assertEqual(offset, -125) + self.assertEqual(frames, tag) + def test_none(self): from mutagen.id3 import ParseID3v1, MakeID3v1 s = MakeID3v1(dict()) @@ -1383,7 +1537,7 @@ def test_genre_from_v24_1(self): tags = ID3() - tags.add(id3.TCON(encoding=1, text=["4","Rock"])) + tags.add(id3.TCON(encoding=1, text=["4", "Rock"])) tags.update_to_v23() self.failUnlessEqual(tags["TCON"].text, ["Disco", "Rock"]) @@ -1414,12 +1568,12 @@ self.failUnlessEqual(tags["IPLS"], [["a", "b"], ["c", "d"], ["e", "f"], ["g", "h"]]) + class WriteTo23(TestCase): - SILENCE = os.path.join("tests", "data", "silence-44-s.mp3") + SILENCE = os.path.join(DATA_DIR, "silence-44-s.mp3") def setUp(self): - from tempfile import mkstemp fd, self.filename = mkstemp(suffix='.mp3') os.close(fd) shutil.copy(self.SILENCE, self.filename) @@ -1493,20 +1647,134 @@ self.assertEqual(id3["TIPL"].encoding, 1) -add(ID3Loading) -add(ID3GetSetDel) -add(ID3Tags) -add(ID3v1Tags) -add(BrokenDiscarded) -add(BrokenButParsed) -add(WriteRoundtrip) -add(OddWrites) -add(BadTYER) -add(BadPOPM) -add(Issue69_BadV1Year) -add(UpdateTo23) -add(WriteTo23) - -try: import eyeD3 -except ImportError: pass -else: add(WriteForEyeD3) +class Read22FrameNamesin23(TestCase): + + def test_PIC_in_23(self): + fd, filename = mkstemp(suffix='.mp3') + os.close(fd) + + try: + with open(filename, "wb") as h: + # contains a bad upgraded frame, 2.3 structure with 2.2 name. + # PIC was upgraded to APIC, but mime was not + h.write(b"ID3\x03\x00\x00\x00\x00\x08\x00PIC\x00\x00\x00" + b"\x00\x0b\x00\x00\x00JPG\x00\x03foo\x00\x42" + b"\x00" * 100) + id3 = ID3(filename) + self.assertEqual(id3.version, (2, 3, 0)) + self.assertTrue(id3.getall("APIC")) + frame = id3.getall("APIC")[0] + self.assertEqual(frame.mime, "image/jpeg") + self.assertEqual(frame.data, b"\x42") + self.assertEqual(frame.type, 3) + self.assertEqual(frame.desc, "foo") + finally: + os.remove(filename) + + +class ID3V1_vs_APEv2(TestCase): + SILENCE = os.path.join(DATA_DIR, "silence-44-s.mp3") + + def setUp(self): + fd, self.filename = mkstemp(suffix='.mp3') + os.close(fd) + shutil.copy(self.SILENCE, self.filename) + + def test_save_id3_over_ape(self): + id3.delete(self.filename, delete_v2=False) + + ape_tag = APEv2() + ape_tag["oh"] = ["no"] + ape_tag.save(self.filename) + + ID3(self.filename).save() + self.assertEqual(APEv2(self.filename)["oh"], "no") + + def test_delete_id3_with_ape(self): + ID3(self.filename).save(v1=2) + + ape_tag = APEv2() + ape_tag["oh"] = ["no"] + ape_tag.save(self.filename) + + id3.delete(self.filename, delete_v2=False) + self.assertEqual(APEv2(self.filename)["oh"], "no") + + def test_ape_id3_lookalike(self): + # mp3 with apev2 tag that parses as id3v1 (at least with ParseID3v1) + + id3.delete(self.filename, delete_v2=False) + + ape_tag = APEv2() + ape_tag["oh"] = [ + "noooooooooo0000000000000000000000000000000000ooooooooooo"] + ape_tag.save(self.filename) + + ID3(self.filename).save() + self.assertTrue(APEv2(self.filename)) + + def tearDown(self): + os.remove(self.filename) + + +class TID3Misc(TestCase): + + def test_main(self): + self.assertEqual(id3.Encoding.UTF8, 3) + self.assertEqual(id3.ID3v1SaveOptions.UPDATE, 1) + self.assertEqual(id3.PictureType.COVER_FRONT, 3) + + def test_determine_bpi(self): + determine_bpi = id3._determine_bpi + # default to BitPaddedInt + self.assertTrue(determine_bpi("", {}) is BitPaddedInt) + + def get_frame_data(name, size, bpi=True): + data = name + if bpi: + data += BitPaddedInt.to_str(size) + else: + data += BitPaddedInt.to_str(size, bits=8) + data += b"\x00\x00" + b"\x01" * size + return data + + data = get_frame_data(b"TPE2", 1000, True) + self.assertTrue(determine_bpi(data, Frames) is BitPaddedInt) + self.assertTrue( + determine_bpi(data + b"\x00" * 1000, Frames) is BitPaddedInt) + + data = get_frame_data(b"TPE2", 1000, False) + self.assertTrue(determine_bpi(data, Frames) is int) + self.assertTrue(determine_bpi(data + b"\x00" * 1000, Frames) is int) + + # in this case it helps that we know the frame name + d = get_frame_data(b"TPE2", 1000) + get_frame_data(b"TPE2", 10) + \ + b"\x01" * 875 + self.assertTrue(determine_bpi(d, Frames) is BitPaddedInt) + + +class TID3Corrupt(TestCase): + + def setUp(self): + fd, self.filename = mkstemp(suffix='.mp3') + os.close(fd) + orig = os.path.join(DATA_DIR, "silence-44-s.mp3") + shutil.copy(orig, self.filename) + + def tearDown(self): + os.remove(self.filename) + + def test_header_too_small(self): + with open(self.filename, "r+b") as h: + h.truncate(5) + self.assertRaises(id3.error, ID3, self.filename) + + def test_tag_too_small(self): + with open(self.filename, "r+b") as h: + h.truncate(50) + self.assertRaises(id3.error, ID3, self.filename) + +try: + import eyeD3 +except ImportError: + del WriteForEyeD3 diff -Nru mutagen-1.23/tests/test__id3specs.py mutagen-1.30/tests/test__id3specs.py --- mutagen-1.23/tests/test__id3specs.py 2013-09-12 17:37:37.000000000 +0000 +++ mutagen-1.30/tests/test__id3specs.py 2015-05-09 10:28:13.000000000 +0000 @@ -1,13 +1,27 @@ +# -*- coding: utf-8 -*- + import sys -from tests import TestCase, add +from tests import TestCase from mutagen._compat import PY2, PY3, text_type -from mutagen.id3 import BitPaddedInt +from mutagen.id3 import BitPaddedInt, BitPaddedLong, unsynch +from mutagen.id3._specs import SpecError class SpecSanityChecks(TestCase): + def test_aspiindexspec(self): + from mutagen.id3 import ASPIIndexSpec + from mutagen.id3 import ASPI + + frame = ASPI(b=16, N=2) + s = ASPIIndexSpec('name') + self.assertRaises(SpecError, s.read, frame, b'') + self.assertEqual(s.read(frame, b'\x01\x00\x00\x01'), ([256, 1], b"")) + frame = ASPI(b=42) + self.assertRaises(SpecError, s.read, frame, b'') + def test_bytespec(self): from mutagen.id3 import ByteSpec s = ByteSpec('name') @@ -19,8 +33,8 @@ def test_encodingspec(self): from mutagen.id3 import EncodingSpec s = EncodingSpec('name') - self.assertEquals((0, b'abcdefg'), s.read(None, b'abcdefg')) self.assertEquals((3, b'abcdefg'), s.read(None, b'\x03abcdefg')) + self.assertRaises(SpecError, s.read, None, b'\x04abcdefg') self.assertEquals(b'\x00', s.write(None, 0)) self.assertRaises(TypeError, s.write, None, b'abc') self.assertRaises(TypeError, s.write, None, None) @@ -28,24 +42,26 @@ def test_stringspec(self): from mutagen.id3 import StringSpec s = StringSpec('name', 3) - self.assertEquals((b'abc', b'defg'), s.read(None, b'abcdefg')) - self.assertEquals(b'abc', s.write(None, b'abcdefg')) + self.assertEquals(('abc', b'defg'), s.read(None, b'abcdefg')) + self.assertEquals(b'abc', s.write(None, 'abcdefg')) self.assertEquals(b'\x00\x00\x00', s.write(None, None)) - self.assertEquals(b'\x00\x00\x00', s.write(None, b'\x00')) - self.assertEquals(b'a\x00\x00', s.write(None, b'a')) + self.assertEquals(b'\x00\x00\x00', s.write(None, '\x00')) + self.assertEquals(b'a\x00\x00', s.write(None, 'a')) + self.assertRaises(SpecError, s.read, None, b'\xff') def test_binarydataspec(self): from mutagen.id3 import BinaryDataSpec s = BinaryDataSpec('name') self.assertEquals((b'abcdefg', b''), s.read(None, b'abcdefg')) - self.assertEquals(b'', s.write(None, None)) - self.assertEquals(b'43', s.write(None, 43)) - self.assertEquals(b'abc', s.write(None, b'abc')) + self.assertEquals(b'', s.write(None, None)) + self.assertEquals(b'43', s.write(None, 43)) + self.assertEquals(b'abc', s.write(None, b'abc')) def test_encodedtextspec(self): from mutagen.id3 import EncodedTextSpec, Frame s = EncodedTextSpec('name') - f = Frame(); f.encoding = 0 + f = Frame() + f.encoding = 0 self.assertEquals((u'abcd', b'fg'), s.read(f, b'abcd\x00fg')) self.assertEquals(b'abcdefg\x00', s.write(f, u'abcdefg')) self.assertRaises(AttributeError, s.write, f, None) @@ -53,7 +69,8 @@ def test_timestampspec(self): from mutagen.id3 import TimeStampSpec, Frame, ID3TimeStamp s = TimeStampSpec('name') - f = Frame(); f.encoding = 0 + f = Frame() + f.encoding = 0 self.assertEquals((ID3TimeStamp('ab'), b'fg'), s.read(f, b'ab\x00fg')) self.assertEquals((ID3TimeStamp('1234'), b''), s.read(f, b'1234\x00')) self.assertEquals(b'1234\x00', s.write(f, ID3TimeStamp('1234'))) @@ -61,7 +78,7 @@ if PY3: self.assertRaises(TypeError, ID3TimeStamp, b"blah") self.assertEquals( - text_type(ID3TimeStamp(u"2000-01-01")), u"2000-01-01") + text_type(ID3TimeStamp(u"2000-01-01")), u"2000-01-01") self.assertEquals( bytes(ID3TimeStamp(u"2000-01-01")), b"2000-01-01") @@ -75,7 +92,29 @@ self.assertEquals(b'\x04\x00', s.write(None, 2.0)) self.assertEquals(b'\xfc\x00', s.write(None, -2.0)) -add(SpecSanityChecks) + def test_synchronizedtextspec(self): + from mutagen.id3 import SynchronizedTextSpec, Frame + s = SynchronizedTextSpec('name') + f = Frame() + + values = [(u"A", 100), (u"\xe4xy", 0), (u"", 42), (u"", 0)] + + # utf-16 + f.encoding = 1 + self.assertEqual(s.read(f, s.write(f, values)), (values, b"")) + self.assertEquals( + s.write(f, [(u"A", 100)]), b"\xff\xfeA\x00\x00\x00\x00\x00\x00d") + + # utf-16be + f.encoding = 2 + self.assertEqual(s.read(f, s.write(f, values)), (values, b"")) + self.assertEquals( + s.write(f, [(u"A", 100)]), b"\x00A\x00\x00\x00\x00\x00d") + + # utf-8 + f.encoding = 3 + self.assertEqual(s.read(f, s.write(f, values)), (values, b"")) + self.assertEquals(s.write(f, [(u"A", 100)]), b"A\x00\x00\x00\x00d") class SpecValidateChecks(TestCase): @@ -95,7 +134,29 @@ s = ByteSpec('byte') self.assertRaises(ValueError, s.validate, None, 1000) -add(SpecValidateChecks) + def test_stringspec(self): + from mutagen.id3 import StringSpec + s = StringSpec('byte', 3) + self.assertEqual(s.validate(None, None), None) + self.assertEqual(s.validate(None, "ABC"), "ABC") + self.assertEqual(s.validate(None, u"ABC"), u"ABC") + self.assertRaises(ValueError, s.validate, None, "abc2") + self.assertRaises(ValueError, s.validate, None, "ab") + + if PY3: + self.assertRaises(TypeError, s.validate, None, b"ABC") + self.assertRaises(ValueError, s.validate, None, u"\xf6\xe4\xfc") + + def test_binarydataspec(self): + from mutagen.id3 import BinaryDataSpec + s = BinaryDataSpec('name') + self.assertEqual(s.validate(None, None), None) + self.assertEqual(s.validate(None, b"abc"), b"abc") + if PY3: + self.assertRaises(TypeError, s.validate, None, "abc") + else: + self.assertEqual(s.validate(None, u"abc"), b"abc") + self.assertRaises(ValueError, s.validate, None, u"\xf6\xe4\xfc") class NoHashSpec(TestCase): @@ -104,11 +165,18 @@ from mutagen.id3 import Spec self.failUnlessRaises(TypeError, {}.__setitem__, Spec("foo"), None) -add(NoHashSpec) - class BitPaddedIntTest(TestCase): + def test_long(self): + if PY2: + data = BitPaddedInt.to_str(sys.maxint + 1, width=16) + val = BitPaddedInt(data) + self.assertEqual(val, sys.maxint + 1) + self.assertTrue(isinstance(val, BitPaddedLong)) + else: + self.assertTrue(BitPaddedInt is BitPaddedLong) + def test_zero(self): self.assertEquals(BitPaddedInt(b'\x00\x00\x00\x00'), 0) @@ -116,7 +184,8 @@ self.assertEquals(BitPaddedInt(b'\x00\x00\x00\x01'), 1) def test_1l(self): - self.assertEquals(BitPaddedInt(b'\x01\x00\x00\x00', bigendian=False), 1) + self.assertEquals( + BitPaddedInt(b'\x01\x00\x00\x00', bigendian=False), 1) def test_129(self): self.assertEquals(BitPaddedInt(b'\x00\x00\x01\x01'), 0x81) @@ -129,14 +198,14 @@ def test_32b(self): self.assertEquals(BitPaddedInt(b'\xFF\xFF\xFF\xFF', bits=8), - 0xFFFFFFFF) + 0xFFFFFFFF) def test_32bi(self): self.assertEquals(BitPaddedInt(0xFFFFFFFF, bits=8), 0xFFFFFFFF) def test_s32b(self): self.assertEquals(BitPaddedInt(b'\xFF\xFF\xFF\xFF', bits=8).as_str(), - b'\xFF\xFF\xFF\xFF') + b'\xFF\xFF\xFF\xFF') def test_s0(self): self.assertEquals(BitPaddedInt.to_str(0), b'\x00\x00\x00\x00') @@ -167,12 +236,12 @@ def test_str_int_init(self): from struct import pack self.assertEquals(BitPaddedInt(238).as_str(), - BitPaddedInt(pack('>L', 238)).as_str()) + BitPaddedInt(pack('>L', 238)).as_str()) def test_varwidth(self): self.assertEquals(len(BitPaddedInt.to_str(100)), 4) self.assertEquals(len(BitPaddedInt.to_str(100, width=-1)), 4) - self.assertEquals(len(BitPaddedInt.to_str(2**32, width=-1)), 5) + self.assertEquals(len(BitPaddedInt.to_str(2 ** 32, width=-1)), 5) def test_minwidth(self): self.assertEquals( @@ -203,25 +272,39 @@ self.failIf(BitPaddedInt.has_valid_padding(0x9f << 32, bits=6)) self.failUnless(BitPaddedInt.has_valid_padding(0x3f << 16, bits=6)) -add(BitPaddedIntTest) - class TestUnsynch(TestCase): - def test_unsync_encode(self): - from mutagen.id3 import unsynch as un - for d in (b'\xff\xff\xff\xff', b'\xff\xf0\x0f\x00', b'\xff\x00\x0f\xf0'): - self.assertEquals(d, un.decode(un.encode(d))) - self.assertNotEqual(d, un.encode(d)) - self.assertEquals(b'\xff\x44', un.encode(b'\xff\x44')) - self.assertEquals(b'\xff\x00\x00', un.encode(b'\xff\x00')) - - def test_unsync_decode(self): - from mutagen.id3 import unsynch as un - self.assertRaises(ValueError, un.decode, b'\xff\xff\xff\xff') - self.assertRaises(ValueError, un.decode, b'\xff\xf0\x0f\x00') - self.assertRaises(ValueError, un.decode, b'\xff\xe0') - self.assertRaises(ValueError, un.decode, b'\xff') - self.assertEquals(b'\xff\x44', un.decode(b'\xff\x44')) - -add(TestUnsynch) + def test_unsync_encode_decode(self): + pairs = [ + (b'', b''), + (b'\x00', b'\x00'), + (b'\x44', b'\x44'), + (b'\x44\xff', b'\x44\xff\x00'), + (b'\xe0', b'\xe0'), + (b'\xe0\xe0', b'\xe0\xe0'), + (b'\xe0\xff', b'\xe0\xff\x00'), + (b'\xff', b'\xff\x00'), + (b'\xff\x00', b'\xff\x00\x00'), + (b'\xff\x00\x00', b'\xff\x00\x00\x00'), + (b'\xff\x01', b'\xff\x01'), + (b'\xff\x44', b'\xff\x44'), + (b'\xff\xe0', b'\xff\x00\xe0'), + (b'\xff\xe0\xff', b'\xff\x00\xe0\xff\x00'), + (b'\xff\xf0\x0f\x00', b'\xff\x00\xf0\x0f\x00'), + (b'\xff\xff', b'\xff\x00\xff\x00'), + (b'\xff\xff\x01', b'\xff\x00\xff\x01'), + (b'\xff\xff\xff\xff', b'\xff\x00\xff\x00\xff\x00\xff\x00'), + ] + + for d, e in pairs: + self.assertEqual(unsynch.encode(d), e) + self.assertEqual(unsynch.decode(e), d) + self.assertEqual(unsynch.decode(unsynch.encode(e)), e) + self.assertEqual(unsynch.decode(e + e), d + d) + + def test_unsync_decode_invalid(self): + self.assertRaises(ValueError, unsynch.decode, b'\xff\xff\xff\xff') + self.assertRaises(ValueError, unsynch.decode, b'\xff\xf0\x0f\x00') + self.assertRaises(ValueError, unsynch.decode, b'\xff\xe0') + self.assertRaises(ValueError, unsynch.decode, b'\xff') diff -Nru mutagen-1.23/tests/test___init__.py mutagen-1.30/tests/test___init__.py --- mutagen-1.23/tests/test___init__.py 2014-05-02 17:16:01.000000000 +0000 +++ mutagen-1.30/tests/test___init__.py 2015-08-18 10:54:30.000000000 +0000 @@ -1,10 +1,12 @@ +# -*- coding: utf-8 -*- + import os from tempfile import mkstemp import shutil -from tests import TestCase, add -from mutagen._compat import cBytesIO, text_type -from mutagen import File, Metadata, FileType +from tests import TestCase, DATA_DIR +from mutagen._compat import cBytesIO, PY3 +from mutagen import File, Metadata, FileType, MutagenError from mutagen.oggvorbis import OggVorbis from mutagen.oggflac import OggFLAC from mutagen.oggspeex import OggSpeex @@ -21,13 +23,15 @@ from mutagen.optimfrog import OptimFROG from mutagen.asf import ASF from mutagen.aiff import AIFF -try: from os.path import devnull -except ImportError: devnull = "/dev/null" +from mutagen.aac import AAC +from os import devnull + class TMetadata(TestCase): class FakeMeta(Metadata): - def __init__(self): pass + def __init__(self): + pass def test_virtual_constructor(self): self.failUnlessRaises(NotImplementedError, Metadata, "filename") @@ -45,12 +49,21 @@ self.failUnlessRaises(NotImplementedError, self.FakeMeta().delete) self.failUnlessRaises( NotImplementedError, self.FakeMeta().delete, "filename") -add(TMetadata) + class TFileType(TestCase): def setUp(self): - self.vorbis = File(os.path.join("tests", "data", "empty.ogg")) + self.vorbis = File(os.path.join(DATA_DIR, "empty.ogg")) + + fd, filename = mkstemp(".mp3") + os.close(fd) + shutil.copy(os.path.join(DATA_DIR, "xing.mp3"), filename) + self.mp3_notags = File(filename) + self.mp3_filename = filename + + def tearDown(self): + os.remove(self.mp3_filename) def test_delitem_not_there(self): self.failUnlessRaises(KeyError, self.vorbis.__delitem__, "foobar") @@ -62,138 +75,153 @@ self.vorbis["foobar"] = "quux" del(self.vorbis["foobar"]) self.failIf("quux" in self.vorbis) -add(TFileType) + + def test_save_no_tags(self): + self.assertTrue(self.mp3_notags.tags is None) + self.mp3_notags.save() + self.assertTrue(self.mp3_notags.tags is None) + class TFile(TestCase): def test_bad(self): - try: self.failUnless(File(devnull) is None) + try: + self.failUnless(File(devnull) is None) except (OSError, IOError): print("WARNING: Unable to open %s." % devnull) self.failUnless(File(__file__) is None) def test_empty(self): - filename = os.path.join("tests", "data", "empty") + filename = os.path.join(DATA_DIR, "empty") open(filename, "wb").close() - try: self.failUnless(File(filename) is None) - finally: os.unlink(filename) + try: + self.failUnless(File(filename) is None) + finally: + os.unlink(filename) def test_not_file(self): self.failUnlessRaises(EnvironmentError, File, "/dev/doesnotexist") def test_no_options(self): for filename in ["empty.ogg", "empty.oggflac", "silence-44-s.mp3"]: - filename = os.path.join("tests", "data", "empty.ogg") + filename = os.path.join(DATA_DIR, "empty.ogg") self.failIf(File(filename, options=[])) def test_oggvorbis(self): self.failUnless(isinstance( - File(os.path.join("tests", "data", "empty.ogg")), OggVorbis)) + File(os.path.join(DATA_DIR, "empty.ogg")), OggVorbis)) def test_oggflac(self): self.failUnless(isinstance( - File(os.path.join("tests", "data", "empty.oggflac")), OggFLAC)) + File(os.path.join(DATA_DIR, "empty.oggflac")), OggFLAC)) def test_oggspeex(self): self.failUnless(isinstance( - File(os.path.join("tests", "data", "empty.spx")), OggSpeex)) + File(os.path.join(DATA_DIR, "empty.spx")), OggSpeex)) def test_oggtheora(self): self.failUnless(isinstance(File( - os.path.join("tests", "data", "sample.oggtheora")), OggTheora)) + os.path.join(DATA_DIR, "sample.oggtheora")), OggTheora)) def test_oggopus(self): self.failUnless(isinstance(File( - os.path.join("tests", "data", "example.opus")), OggOpus)) + os.path.join(DATA_DIR, "example.opus")), OggOpus)) def test_mp3(self): self.failUnless(isinstance( - File(os.path.join("tests", "data", "bad-xing.mp3")), MP3)) + File(os.path.join(DATA_DIR, "bad-xing.mp3")), MP3)) self.failUnless(isinstance( - File(os.path.join("tests", "data", "xing.mp3")), MP3)) + File(os.path.join(DATA_DIR, "xing.mp3")), MP3)) self.failUnless(isinstance( - File(os.path.join("tests", "data", "silence-44-s.mp3")), MP3)) + File(os.path.join(DATA_DIR, "silence-44-s.mp3")), MP3)) def test_easy_mp3(self): self.failUnless(isinstance( - File(os.path.join("tests", "data", "silence-44-s.mp3"), easy=True), + File(os.path.join(DATA_DIR, "silence-44-s.mp3"), easy=True), EasyMP3)) def test_flac(self): self.failUnless(isinstance( - File(os.path.join("tests", "data", "silence-44-s.flac")), FLAC)) + File(os.path.join(DATA_DIR, "silence-44-s.flac")), FLAC)) def test_musepack(self): self.failUnless(isinstance( - File(os.path.join("tests", "data", "click.mpc")), Musepack)) + File(os.path.join(DATA_DIR, "click.mpc")), Musepack)) self.failUnless(isinstance( - File(os.path.join("tests", "data", "sv4_header.mpc")), Musepack)) + File(os.path.join(DATA_DIR, "sv4_header.mpc")), Musepack)) self.failUnless(isinstance( - File(os.path.join("tests", "data", "sv5_header.mpc")), Musepack)) + File(os.path.join(DATA_DIR, "sv5_header.mpc")), Musepack)) self.failUnless(isinstance( - File(os.path.join("tests", "data", "sv8_header.mpc")), Musepack)) + File(os.path.join(DATA_DIR, "sv8_header.mpc")), Musepack)) def test_monkeysaudio(self): self.failUnless(isinstance( - File(os.path.join("tests", "data", "mac-399.ape")), MonkeysAudio)) + File(os.path.join(DATA_DIR, "mac-399.ape")), MonkeysAudio)) self.failUnless(isinstance( - File(os.path.join("tests", "data", "mac-396.ape")), MonkeysAudio)) + File(os.path.join(DATA_DIR, "mac-396.ape")), MonkeysAudio)) def test_apev2(self): self.failUnless(isinstance( - File(os.path.join("tests", "data", "oldtag.apev2")), APEv2File)) + File(os.path.join(DATA_DIR, "oldtag.apev2")), APEv2File)) def test_tta(self): self.failUnless(isinstance( - File(os.path.join("tests", "data", "empty.tta")), TrueAudio)) + File(os.path.join(DATA_DIR, "empty.tta")), TrueAudio)) def test_easy_tta(self): self.failUnless(isinstance( - File(os.path.join("tests", "data", "empty.tta"), easy=True), + File(os.path.join(DATA_DIR, "empty.tta"), easy=True), EasyTrueAudio)) def test_wavpack(self): self.failUnless(isinstance( - File(os.path.join("tests", "data", "silence-44-s.wv")), WavPack)) + File(os.path.join(DATA_DIR, "silence-44-s.wv")), WavPack)) def test_mp4(self): self.failUnless(isinstance( - File(os.path.join("tests", "data", "has-tags.m4a")), MP4)) + File(os.path.join(DATA_DIR, "has-tags.m4a")), MP4)) self.failUnless(isinstance( - File(os.path.join("tests", "data", "no-tags.m4a")), MP4)) + File(os.path.join(DATA_DIR, "no-tags.m4a")), MP4)) self.failUnless(isinstance( - File(os.path.join("tests", "data", "no-tags.3g2")), MP4)) + File(os.path.join(DATA_DIR, "no-tags.3g2")), MP4)) self.failUnless(isinstance( - File(os.path.join("tests", "data", "truncated-64bit.mp4")), MP4)) + File(os.path.join(DATA_DIR, "truncated-64bit.mp4")), MP4)) def test_optimfrog(self): self.failUnless(isinstance( - File(os.path.join("tests", "data", "empty.ofr")), OptimFROG)) + File(os.path.join(DATA_DIR, "empty.ofr")), OptimFROG)) self.failUnless(isinstance( - File(os.path.join("tests", "data", "empty.ofs")), OptimFROG)) + File(os.path.join(DATA_DIR, "empty.ofs")), OptimFROG)) def test_asf(self): self.failUnless(isinstance( - File(os.path.join("tests", "data", "silence-1.wma")), ASF)) + File(os.path.join(DATA_DIR, "silence-1.wma")), ASF)) self.failUnless(isinstance( - File(os.path.join("tests", "data", "silence-2.wma")), ASF)) + File(os.path.join(DATA_DIR, "silence-2.wma")), ASF)) self.failUnless(isinstance( - File(os.path.join("tests", "data", "silence-3.wma")), ASF)) + File(os.path.join(DATA_DIR, "silence-3.wma")), ASF)) def test_aiff(self): - data_path = os.path.join("tests", "data") self.failUnless(isinstance( - File(os.path.join(data_path, "with-id3.aif")), AIFF)) + File(os.path.join(DATA_DIR, "with-id3.aif")), AIFF)) + self.failUnless(isinstance( + File(os.path.join(DATA_DIR, "11k-1ch-2s-silence.aif")), AIFF)) self.failUnless(isinstance( - File(os.path.join(data_path, "11k-1ch-2s-silence.aif")), AIFF)) + File(os.path.join(DATA_DIR, "48k-2ch-s16-silence.aif")), AIFF)) self.failUnless(isinstance( - File(os.path.join(data_path, "48k-2ch-s16-silence.aif")), AIFF)) + File(os.path.join(DATA_DIR, "8k-1ch-1s-silence.aif")), AIFF)) self.failUnless(isinstance( - File(os.path.join(data_path, "8k-1ch-1s-silence.aif")), AIFF)) + File(os.path.join(DATA_DIR, "8k-1ch-3.5s-silence.aif")), AIFF)) self.failUnless(isinstance( - File(os.path.join(data_path, "8k-1ch-3.5s-silence.aif")), AIFF)) + File(os.path.join(DATA_DIR, "8k-4ch-1s-silence.aif")), AIFF)) + + def test_adts(self): + self.failUnless(isinstance( + File(os.path.join(DATA_DIR, "empty.aac")), AAC)) + + def test_adif(self): self.failUnless(isinstance( - File(os.path.join(data_path, "8k-4ch-1s-silence.aif")), AIFF)) + File(os.path.join(DATA_DIR, "adif.aac")), AAC)) def test_id3_indicates_mp3_not_tta(self): header = b"ID3 the rest of this is garbage" @@ -202,22 +230,35 @@ self.failUnless(TrueAudio.score(filename, fileobj, header) < MP3.score(filename, fileobj, header)) -add(TFile) + def test_prefer_theora_over_vorbis(self): + header = ( + b"OggS\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\xe1x\x06\x0f" + b"\x00\x00\x00\x00)S'\xf4\x01*\x80theora\x03\x02\x01\x006\x00\x1e" + b"\x00\x03V\x00\x01\xe0\x00\x00\x00\x00\x00\x18\x00\x00\x00\x01" + b"\x00\x00\x00\x00\x00\x00\x00&%\xa0\x00\xc0OggS\x00\x02\x00\x00" + b"\x00\x00\x00\x00\x00\x00d#\xa8\x1f\x00\x00\x00\x00]Y\xc0\xc0" + b"\x01\x1e\x01vorbis\x00\x00\x00\x00\x02\x80\xbb\x00\x00\x00\x00" + b"\x00\x00\x00\xee\x02\x00\x00\x00\x00\x00\xb8\x01") + fileobj = cBytesIO(header) + filename = "not-identifiable.ext" + self.failUnless(OggVorbis.score(filename, fileobj, header) < + OggTheora.score(filename, fileobj, header)) + class TFileUpperExt(TestCase): - FILES = [(os.path.join(b"tests", b"data", b"empty.ofr"), OptimFROG), - (os.path.join(b"tests", b"data", b"sv5_header.mpc"), Musepack), - (os.path.join(b"tests", b"data", b"silence-3.wma"), ASF), - (os.path.join(b"tests", b"data", b"truncated-64bit.mp4"), MP4), - (os.path.join(b"tests", b"data", b"silence-44-s.flac"), FLAC), - ] - + FILES = [ + (os.path.join(DATA_DIR, "empty.ofr"), OptimFROG), + (os.path.join(DATA_DIR, "sv5_header.mpc"), Musepack), + (os.path.join(DATA_DIR, "silence-3.wma"), ASF), + (os.path.join(DATA_DIR, "truncated-64bit.mp4"), MP4), + (os.path.join(DATA_DIR, "silence-44-s.flac"), FLAC), + ] + def setUp(self): checks = [] for (original, instance) in self.FILES: - ext = original.rsplit(b".", 1)[-1] - suffix = b'.' + ext.upper() - fd, filename = mkstemp(suffix=str(suffix)) + ext = os.path.splitext(original)[1] + fd, filename = mkstemp(suffix=ext.upper()) os.close(fd) shutil.copy(original, filename) checks.append((filename, instance)) @@ -237,20 +278,31 @@ for (path, instance) in self.checks: os.unlink(path) -add(TFileUpperExt) - class TModuleImportAll(TestCase): - def test_all(self): + def setUp(self): import mutagen files = os.listdir(mutagen.__path__[0]) - modules = [os.path.splitext(f)[0] for f in files] + modules = set(os.path.splitext(f)[0] for f in files) modules = [f for f in modules if not f.startswith("_")] + if PY3 and 'm4a' in modules: + modules.remove('m4a') + + self.modules = [] for module in modules: mod = getattr(__import__("mutagen." + module), module) + self.modules.append(mod) + + def tearDown(self): + del self.modules[:] + + def test_all(self): + for mod in self.modules: for attr in getattr(mod, "__all__", []): getattr(mod, attr) -add(TModuleImportAll) + def test_errors(self): + for mod in self.modules: + self.assertTrue(issubclass(mod.error, MutagenError), msg=mod.error) diff -Nru mutagen-1.23/tests/test_m4a.py mutagen-1.30/tests/test_m4a.py --- mutagen-1.23/tests/test_m4a.py 2013-09-09 10:19:25.000000000 +0000 +++ mutagen-1.30/tests/test_m4a.py 2015-05-09 12:22:16.000000000 +0000 @@ -1,17 +1,19 @@ +# -*- coding: utf-8 -*- + import os import shutil from cStringIO import StringIO from tempfile import mkstemp -from tests import TestCase, add +from tests import TestCase, DATA_DIR import warnings warnings.simplefilter("ignore", DeprecationWarning) -from mutagen.m4a import M4A, Atom, Atoms, M4ATags, M4AInfo, \ - delete, M4ACover, M4AMetadataError +from mutagen.m4a import (M4A, Atom, Atoms, M4ATags, M4AInfo, delete, M4ACover, + M4AMetadataError) + +from tests.test_mp4 import have_faad, call_faad -try: from os.path import devnull -except ImportError: devnull = "/dev/null" class TAtom(TestCase): @@ -29,7 +31,8 @@ def __len__(self): return 1L << 32 data = TooBig("test") - try: len(data) + try: + len(data) except OverflowError: # Py_ssize_t is still only 32 bits on this system. self.failUnlessRaises(OverflowError, Atom.render, "data", data) @@ -41,10 +44,10 @@ fileobj = StringIO("\x00\x00\x00\x00atom") Atom(fileobj) self.failUnlessEqual(fileobj.tell(), 8) -add(TAtom) + class TAtoms(TestCase): - filename = os.path.join("tests", "data", "has-tags.m4a") + filename = os.path.join(DATA_DIR, "has-tags.m4a") def setUp(self): self.atoms = Atoms(open(self.filename, "rb")) @@ -65,7 +68,7 @@ def test_repr(self): repr(self.atoms) -add(TAtoms) + class TM4AInfo(TestCase): @@ -75,7 +78,7 @@ def test_mdhd_version_1(self, soun="soun"): mdhd = Atom.render("mdhd", ("\x01\x00\x00\x00" + "\x00" * 16 + - "\x00\x00\x00\x02" + # 2 Hz + "\x00\x00\x00\x02" + # 2 Hz "\x00\x00\x00\x00\x00\x00\x00\x10")) hdlr = Atom.render("hdlr", soun) mdia = Atom.render("mdia", mdhd + hdlr) @@ -85,7 +88,7 @@ atoms = Atoms(fileobj) info = M4AInfo(atoms, fileobj) self.failUnlessEqual(info.length, 8) -add(TM4AInfo) + class TM4ATags(TestCase): @@ -95,7 +98,7 @@ data = Atom.render("moov", Atom.render("udta", meta)) fileobj = StringIO(data) return M4ATags(Atoms(fileobj), fileobj) - + def test_bad_freeform(self): mean = Atom.render("mean", "net.sacredchao.Mutagen") name = Atom.render("name", "empty test key") @@ -133,21 +136,25 @@ covr = Atom.render("covr", data) self.failUnlessRaises(M4AMetadataError, self.wrap_ilst, covr) -add(TM4ATags) class TM4A(TestCase): + def setUp(self): fd, self.filename = mkstemp(suffix='m4a') os.close(fd) shutil.copy(self.original, self.filename) self.audio = M4A(self.filename) + def tearDown(self): + os.unlink(self.filename) + + +class TM4AMixin(object): + def faad(self): - if not have_faad: return - value = os.system( - "faad %s -o %s > %s 2> %s" % ( - self.filename, devnull, devnull, devnull)) - self.failIf(value and value != NOTFOUND) + if not have_faad: + return + self.assertEqual(call_faad("-w", self.filename), 0) def test_bitrate(self): self.failUnlessEqual(self.audio.info.bitrate, 2914) @@ -177,11 +184,11 @@ def test_tracknumber_too_small(self): self.failUnlessRaises(ValueError, self.set_key, 'trkn', (-1, 0)) - self.failUnlessRaises(ValueError, self.set_key, 'trkn', (2**18, 1)) + self.failUnlessRaises(ValueError, self.set_key, 'trkn', (2 ** 18, 1)) def test_disk_too_small(self): self.failUnlessRaises(ValueError, self.set_key, 'disk', (-1, 0)) - self.failUnlessRaises(ValueError, self.set_key, 'disk', (2**18, 1)) + self.failUnlessRaises(ValueError, self.set_key, 'disk', (2 ** 18, 1)) def test_tracknumber_wrong_size(self): self.failUnlessRaises(ValueError, self.set_key, 'trkn', (1,)) @@ -237,11 +244,9 @@ def test_mime(self): self.failUnless("audio/mp4" in self.audio.mime) - def tearDown(self): - os.unlink(self.filename) -class TM4AHasTags(TM4A): - original = os.path.join("tests", "data", "has-tags.m4a") +class TM4AHasTags(TM4A, TM4AMixin): + original = os.path.join(DATA_DIR, "has-tags.m4a") def test_save_simple(self): self.audio.save() @@ -263,21 +268,11 @@ def test_not_my_file(self): self.failUnlessRaises( - IOError, M4A, os.path.join("tests", "data", "empty.ogg")) + IOError, M4A, os.path.join(DATA_DIR, "empty.ogg")) -add(TM4AHasTags) -class TM4ANoTags(TM4A): - original = os.path.join("tests", "data", "no-tags.m4a") +class TM4ANoTags(TM4A, TM4AMixin): + original = os.path.join(DATA_DIR, "no-tags.m4a") def test_no_tags(self): self.failUnless(self.audio.tags is None) - -add(TM4ANoTags) - -NOTFOUND = os.system("tools/notarealprogram 2> %s" % devnull) - -have_faad = True -if os.system("faad 2> %s > %s" % (devnull, devnull)) == NOTFOUND: - have_faad = False - print "WARNING: Skipping FAAD reference tests." diff -Nru mutagen-1.23/tests/test_monkeysaudio.py mutagen-1.30/tests/test_monkeysaudio.py --- mutagen-1.23/tests/test_monkeysaudio.py 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/tests/test_monkeysaudio.py 2015-05-09 13:10:58.000000000 +0000 @@ -1,17 +1,17 @@ +# -*- coding: utf-8 -*- + import os from mutagen.monkeysaudio import MonkeysAudio, MonkeysAudioHeaderError -from tests import TestCase, add +from tests import TestCase, DATA_DIR + class TMonkeysAudio(TestCase): def setUp(self): - self.mac399 = MonkeysAudio(os.path.join("tests", "data", - "mac-399.ape")) - self.mac396 = MonkeysAudio(os.path.join("tests", "data", - "mac-396.ape")) - self.mac390 = MonkeysAudio(os.path.join("tests", "data", - "mac-390-hdr.ape")) + self.mac399 = MonkeysAudio(os.path.join(DATA_DIR, "mac-399.ape")) + self.mac396 = MonkeysAudio(os.path.join(DATA_DIR, "mac-396.ape")) + self.mac390 = MonkeysAudio(os.path.join(DATA_DIR, "mac-390-hdr.ape")) def test_channels(self): self.failUnlessEqual(self.mac399.info.channels, 2) @@ -36,10 +36,10 @@ def test_not_my_file(self): self.failUnlessRaises( MonkeysAudioHeaderError, MonkeysAudio, - os.path.join("tests", "data", "empty.ogg")) + os.path.join(DATA_DIR, "empty.ogg")) self.failUnlessRaises( MonkeysAudioHeaderError, MonkeysAudio, - os.path.join("tests", "data", "click.mpc")) + os.path.join(DATA_DIR, "click.mpc")) def test_mime(self): self.failUnless("audio/x-ape" in self.mac399.mime) @@ -47,4 +47,3 @@ def test_pprint(self): self.failUnless(self.mac399.pprint()) self.failUnless(self.mac396.pprint()) -add(TMonkeysAudio) diff -Nru mutagen-1.23/tests/test_mp3.py mutagen-1.30/tests/test_mp3.py --- mutagen-1.23/tests/test_mp3.py 2014-01-09 14:48:43.000000000 +0000 +++ mutagen-1.30/tests/test_mp3.py 2015-08-18 10:54:14.000000000 +0000 @@ -1,21 +1,28 @@ +# -*- coding: utf-8 -*- + import os import shutil -from tests import TestCase -from mutagen._compat import cBytesIO -from tests import add -from mutagen.mp3 import MP3, error as MP3Error, delete, MPEGInfo, EasyMP3 +from tests import TestCase, DATA_DIR +from mutagen._compat import cBytesIO, text_type +from mutagen.mp3 import MP3, error as MP3Error, delete, MPEGInfo, EasyMP3, \ + BitrateMode +from mutagen._mp3util import XingHeader, XingHeaderError, VBRIHeader, \ + VBRIHeaderError, LAMEHeader, LAMEError from mutagen.id3 import ID3 from tempfile import mkstemp + class TMP3(TestCase): - silence = os.path.join('tests', 'data', 'silence-44-s.mp3') - silence_nov2 = os.path.join('tests', 'data', 'silence-44-s-v1.mp3') - silence_mpeg2 = os.path.join('tests', 'data', 'silence-44-s-mpeg2.mp3') - silence_mpeg25 = os.path.join('tests', 'data', 'silence-44-s-mpeg25.mp3') + silence = os.path.join(DATA_DIR, 'silence-44-s.mp3') + silence_nov2 = os.path.join(DATA_DIR, 'silence-44-s-v1.mp3') + silence_mpeg2 = os.path.join(DATA_DIR, 'silence-44-s-mpeg2.mp3') + silence_mpeg25 = os.path.join(DATA_DIR, 'silence-44-s-mpeg25.mp3') + lame = os.path.join(DATA_DIR, 'lame.mp3') + lame_peak = os.path.join(DATA_DIR, 'lame-peak.mp3') def setUp(self): - original = os.path.join("tests", "data", "silence-44-s.mp3") + original = os.path.join(DATA_DIR, "silence-44-s.mp3") fd, self.filename = mkstemp(suffix='.mp3') os.close(fd) shutil.copy(original, self.filename) @@ -23,6 +30,8 @@ self.mp3_2 = MP3(self.silence_nov2) self.mp3_3 = MP3(self.silence_mpeg2) self.mp3_4 = MP3(self.silence_mpeg25) + self.mp3_lame = MP3(self.lame) + self.mp3_lame_peak = MP3(self.lame_peak) def test_mode(self): from mutagen.mp3 import JOINTSTEREO @@ -31,6 +40,37 @@ self.failUnlessEqual(self.mp3_3.info.mode, JOINTSTEREO) self.failUnlessEqual(self.mp3_4.info.mode, JOINTSTEREO) + def test_replaygain(self): + self.assertEqual(self.mp3_3.info.track_gain, 51.0) + self.assertEqual(self.mp3_4.info.track_gain, 51.0) + self.assertEqual(self.mp3_lame.info.track_gain, 6.0) + self.assertAlmostEqual(self.mp3_lame_peak.info.track_gain, 6.8, 1) + self.assertAlmostEqual(self.mp3_lame_peak.info.track_peak, 0.21856, 4) + + self.assertTrue(self.mp3.info.track_gain is None) + self.assertTrue(self.mp3.info.track_peak is None) + self.assertTrue(self.mp3.info.album_gain is None) + + def test_channels(self): + self.assertEqual(self.mp3.info.channels, 2) + self.assertEqual(self.mp3_2.info.channels, 2) + self.assertEqual(self.mp3_3.info.channels, 2) + self.assertEqual(self.mp3_4.info.channels, 2) + + def test_encoder_info(self): + self.assertEqual(self.mp3.info.encoder_info, u"") + self.assertTrue(isinstance(self.mp3.info.encoder_info, text_type)) + self.assertEqual(self.mp3_2.info.encoder_info, u"") + self.assertEqual(self.mp3_3.info.encoder_info, u"LAME 3.98.1+") + self.assertEqual(self.mp3_4.info.encoder_info, u"LAME 3.98.1+") + self.assertTrue(isinstance(self.mp3_4.info.encoder_info, text_type)) + + def test_bitrate_mode(self): + self.failUnlessEqual(self.mp3.info.bitrate_mode, BitrateMode.UNKNOWN) + self.failUnlessEqual(self.mp3_2.info.bitrate_mode, BitrateMode.UNKNOWN) + self.failUnlessEqual(self.mp3_3.info.bitrate_mode, BitrateMode.VBR) + self.failUnlessEqual(self.mp3_4.info.bitrate_mode, BitrateMode.VBR) + def test_id3(self): self.failUnlessEqual(self.mp3.tags, ID3(self.silence)) self.failUnlessEqual(self.mp3_2.tags, ID3(self.silence_nov2)) @@ -38,26 +78,30 @@ def test_length(self): self.assertAlmostEquals(self.mp3.info.length, 3.77, 2) self.assertAlmostEquals(self.mp3_2.info.length, 3.77, 2) - self.assertAlmostEquals(self.mp3_3.info.length, 3.77, 2) - self.assertAlmostEquals(self.mp3_4.info.length, 3.84, 2) + self.assertAlmostEquals(self.mp3_3.info.length, 3.68475, 4) + self.assertAlmostEquals(self.mp3_4.info.length, 3.68475, 4) + def test_version(self): self.failUnlessEqual(self.mp3.info.version, 1) self.failUnlessEqual(self.mp3_2.info.version, 1) self.failUnlessEqual(self.mp3_3.info.version, 2) self.failUnlessEqual(self.mp3_4.info.version, 2.5) + def test_layer(self): self.failUnlessEqual(self.mp3.info.layer, 3) self.failUnlessEqual(self.mp3_2.info.layer, 3) self.failUnlessEqual(self.mp3_3.info.layer, 3) self.failUnlessEqual(self.mp3_4.info.layer, 3) + def test_bitrate(self): self.failUnlessEqual(self.mp3.info.bitrate, 32000) self.failUnlessEqual(self.mp3_2.info.bitrate, 32000) - self.failUnlessEqual(self.mp3_3.info.bitrate, 18191) - self.failUnlessEqual(self.mp3_4.info.bitrate, 9300) + self.failUnlessEqual(self.mp3_3.info.bitrate, 18602) + self.failUnlessEqual(self.mp3_4.info.bitrate, 9691) def test_notmp3(self): - self.failUnlessRaises(MP3Error, MP3, "README") + self.failUnlessRaises( + MP3Error, MP3, os.path.join(DATA_DIR, 'empty.ofr')) def test_sketchy(self): self.failIf(self.mp3.info.sketchy) @@ -66,27 +110,36 @@ self.failIf(self.mp3_4.info.sketchy) def test_sketchy_notmp3(self): - notmp3 = MP3(os.path.join("tests", "data", "silence-44-s.flac")) + notmp3 = MP3(os.path.join(DATA_DIR, "silence-44-s.flac")) self.failUnless(notmp3.info.sketchy) def test_pprint(self): self.failUnless(self.mp3.pprint()) + def test_info_pprint(self): + res = self.mp3.info.pprint() + self.assertTrue(res) + self.assertTrue(isinstance(res, text_type)) + self.assertTrue(res.startswith(u"MPEG 1 layer 3")) + def test_pprint_no_tags(self): self.mp3.tags = None self.failUnless(self.mp3.pprint()) def test_xing(self): - mp3 = MP3(os.path.join("tests", "data", "xing.mp3")) - self.failUnlessEqual(int(round(mp3.info.length)), 26122) - self.failUnlessEqual(mp3.info.bitrate, 306) + mp3 = MP3(os.path.join(DATA_DIR, "xing.mp3")) + self.assertAlmostEqual(mp3.info.length, 2.052, 3) + self.assertEqual(mp3.info.bitrate, 32000) def test_vbri(self): - mp3 = MP3(os.path.join("tests", "data", "vbri.mp3")) - self.failUnlessEqual(int(round(mp3.info.length)), 222) + mp3 = MP3(os.path.join(DATA_DIR, "vbri.mp3")) + self.assertAlmostEqual(mp3.info.length, 222.19755, 3) + self.assertEqual(mp3.info.bitrate, 233260) def test_empty_xing(self): - MP3(os.path.join("tests", "data", "bad-xing.mp3")) + mp3 = MP3(os.path.join(DATA_DIR, "bad-xing.mp3")) + self.assertEqual(mp3.info.length, 0) + self.assertEqual(mp3.info.bitrate, 48000) def test_delete(self): self.mp3.delete() @@ -103,25 +156,26 @@ self.failUnless(MP3(self.filename)["TIT1"] == "foobar") def test_load_non_id3(self): - filename = os.path.join("tests", "data", "apev2-lyricsv2.mp3") + filename = os.path.join(DATA_DIR, "apev2-lyricsv2.mp3") from mutagen.apev2 import APEv2 mp3 = MP3(filename, ID3=APEv2) self.failUnless("replaygain_track_peak" in mp3.tags) def test_add_tags(self): - mp3 = MP3(os.path.join("tests", "data", "xing.mp3")) + mp3 = MP3(os.path.join(DATA_DIR, "xing.mp3")) self.failIf(mp3.tags) mp3.add_tags() self.failUnless(isinstance(mp3.tags, ID3)) def test_add_tags_already_there(self): - mp3 = MP3(os.path.join("tests", "data", "silence-44-s.mp3")) + mp3 = MP3(os.path.join(DATA_DIR, "silence-44-s.mp3")) self.failUnless(mp3.tags) self.failUnlessRaises(Exception, mp3.add_tags) def test_save_no_tags(self): self.mp3.tags = None - self.failUnlessRaises(ValueError, self.mp3.save) + self.mp3.save() + self.assertTrue(self.mp3.tags is None) def test_mime(self): self.failUnless("audio/mp3" in self.mp3.mime) @@ -133,24 +187,23 @@ def tearDown(self): os.unlink(self.filename) -add(TMP3) class TMPEGInfo(TestCase): def test_not_real_file(self): - filename = os.path.join("tests", "data", "silence-44-s-v1.mp3") + filename = os.path.join(DATA_DIR, "silence-44-s-v1.mp3") fileobj = cBytesIO(open(filename, "rb").read(20)) MPEGInfo(fileobj) def test_empty(self): fileobj = cBytesIO(b"") self.failUnlessRaises(IOError, MPEGInfo, fileobj) -add(TMPEGInfo) + class TEasyMP3(TestCase): def setUp(self): - original = os.path.join("tests", "data", "silence-44-s.mp3") + original = os.path.join(DATA_DIR, "silence-44-s.mp3") fd, self.filename = mkstemp(suffix='.mp3') os.close(fd) shutil.copy(original, self.filename) @@ -175,11 +228,116 @@ def tearDown(self): os.unlink(self.filename) -add(TEasyMP3) + class Issue72_TooShortFile(TestCase): def test_load(self): - mp3 = MP3(os.path.join('tests', 'data', 'too-short.mp3')) + mp3 = MP3(os.path.join(DATA_DIR, 'too-short.mp3')) self.failUnlessEqual(mp3["TIT2"], "Track 10") self.failUnlessAlmostEqual(mp3.info.length, 0.03, 2) -add(Issue72_TooShortFile) + + +class TXingHeader(TestCase): + + def test_valid_info_header(self): + data = (b'Info\x00\x00\x00\x0f\x00\x00:>\x00\xed\xbd8\x00\x03\x05\x07' + b'\n\r\x0f\x12\x14\x17\x1a\x1c\x1e"$&)+.1359;=@CEGJLORTVZ\\^ac' + b'fikmqsux{}\x80\x82\x84\x87\x8a\x8c\x8e\x92\x94\x96\x99\x9c' + b'\x9e\xa1\xa3\xa5\xa9\xab\xad\xb0\xb3\xb5\xb8\xba\xbd\xc0\xc2' + b'\xc4\xc6\xca\xcc\xce\xd1\xd4\xd6\xd9\xdb\xdd\xe1\xe3\xe5\xe8' + b'\xeb\xed\xf0\xf2\xf5\xf8\xfa\xfc\x00\x00\x009') + + fileobj = cBytesIO(data) + xing = XingHeader(fileobj) + self.assertEqual(xing.bytes, 15580472) + self.assertEqual(xing.frames, 14910) + self.assertEqual(xing.vbr_scale, 57) + self.assertTrue(xing.toc) + self.assertEqual(len(xing.toc), 100) + self.assertEqual(sum(xing.toc), 12625) # only for coverage.. + self.assertEqual(xing.is_info, True) + + XingHeader(cBytesIO(data.replace(b'Info', b'Xing'))) + + def test_invalid(self): + self.assertRaises(XingHeaderError, XingHeader, cBytesIO(b"")) + self.assertRaises(XingHeaderError, XingHeader, cBytesIO(b"Xing")) + self.assertRaises(XingHeaderError, XingHeader, cBytesIO(b"aaaa")) + + def test_get_offset(self): + mp3 = MP3(os.path.join(DATA_DIR, "silence-44-s.mp3")) + self.assertEqual(XingHeader.get_offset(mp3.info), 36) + + +class TVBRIHeader(TestCase): + + def test_valid(self): + # parts of the trailing toc zeroed... + data = (b'VBRI\x00\x01\t1\x00d\x00\x0c\xb05\x00\x00\x049\x00\x87\x00' + b'\x01\x00\x02\x00\x08\n0\x19H\x18\xe0\x18x\x18\xe0\x18x\x19H' + b'\x18\xe0\x19H\x18\xe0\x18\xe0\x18x' + b'\x00' * 300) + + fileobj = cBytesIO(data) + vbri = VBRIHeader(fileobj) + self.assertEqual(vbri.bytes, 831541) + self.assertEqual(vbri.frames, 1081) + self.assertEqual(vbri.quality, 100) + self.assertEqual(vbri.version, 1) + self.assertEqual(vbri.toc_frames, 8) + self.assertTrue(vbri.toc) + self.assertEqual(len(vbri.toc), 135) + self.assertEqual(sum(vbri.toc), 72656) + + def test_invalid(self): + self.assertRaises(VBRIHeaderError, VBRIHeader, cBytesIO(b"")) + self.assertRaises(VBRIHeaderError, VBRIHeader, cBytesIO(b"VBRI")) + self.assertRaises(VBRIHeaderError, VBRIHeader, cBytesIO(b"Xing")) + + def test_get_offset(self): + mp3 = MP3(os.path.join(DATA_DIR, "silence-44-s.mp3")) + self.assertEqual(VBRIHeader.get_offset(mp3.info), 36) + + +class TLAMEHeader(TestCase): + + def test_version(self): + + def parse(data): + data = cBytesIO(data + b"\x00" * (20 - len(data))) + return LAMEHeader.parse_version(data) + + self.assertEqual(parse(b"LAME3.80"), (u"3.80", False)) + self.assertEqual(parse(b"LAME3.80 "), (u"3.80", False)) + self.assertEqual(parse(b"LAME3.88 (beta)"), (u"3.88 (beta)", False)) + self.assertEqual(parse(b"LAME3.90 (alpha)"), (u"3.90 (alpha)", False)) + self.assertEqual(parse(b"LAME3.90 "), (u"3.90.0+", True)) + self.assertEqual(parse(b"LAME3.96a"), (u"3.96 (alpha)", True)) + self.assertEqual(parse(b"LAME3.96b"), (u"3.96 (beta)", True)) + self.assertEqual(parse(b"LAME3.96x"), (u"3.96 (?)", True)) + self.assertEqual(parse(b"LAME3.98 "), (u"3.98.0", True)) + self.assertEqual(parse(b"LAME3.96r"), (u"3.96.1+", True)) + self.assertEqual(parse(b"L3.99r"), (u"3.99.1+", True)) + self.assertEqual(parse(b"LAME3100r"), (u"3.100.1+", True)) + self.assertEqual(parse(b"LAME3.90.\x03\xbe\x00"), (u"3.90.0+", True)) + + def test_invalid(self): + + def parse(data): + data = cBytesIO(data + b"\x00" * (20 - len(data))) + return LAMEHeader.parse_version(data) + + self.assertRaises(LAMEError, parse, b"") + self.assertRaises(LAMEError, parse, b"LAME") + self.assertRaises(LAMEError, parse, b"LAME3.999") + + def test_real(self): + with open(os.path.join(DATA_DIR, "lame.mp3"), "rb") as h: + h.seek(36, 0) + xing = XingHeader(h) + self.assertEqual(xing.lame_version, u"3.99.1+") + self.assertTrue(xing.lame_header) + self.assertEqual(xing.lame_header.track_gain_adjustment, 6.0) + + def test_length(self): + mp3 = MP3(os.path.join(DATA_DIR, "lame.mp3")) + self.assertAlmostEqual(mp3.info.length, 0.06160, 4) diff -Nru mutagen-1.23/tests/test_mp4.py mutagen-1.30/tests/test_mp4.py --- mutagen-1.23/tests/test_mp4.py 2013-10-05 17:15:42.000000000 +0000 +++ mutagen-1.30/tests/test_mp4.py 2015-08-20 11:20:50.000000000 +0000 @@ -1,15 +1,20 @@ +# -*- coding: utf-8 -*- + import os import shutil import struct +import subprocess -from mutagen._compat import cBytesIO +from mutagen._compat import cBytesIO, PY3, text_type, PY2 from tempfile import mkstemp -from tests import TestCase, add -from mutagen.mp4 import MP4, Atom, Atoms, MP4Tags, MP4Info, \ - delete, MP4Cover, MP4MetadataError, MP4FreeForm, error +from tests import TestCase, DATA_DIR +from mutagen.mp4 import (MP4, Atom, Atoms, MP4Tags, MP4Info, delete, MP4Cover, + MP4MetadataError, MP4FreeForm, error, AtomDataType, + MP4MetadataValueError, AtomError) +from mutagen.mp4._util import parse_full_atom +from mutagen.mp4._as_entry import AudioSampleEntry, ASEntryError from mutagen._util import cdata -try: from os.path import devnull -except ImportError: devnull = "/dev/null" + class TAtom(TestCase): @@ -26,18 +31,23 @@ def test_length_64bit_less_than_16(self): fileobj = cBytesIO(b"\x00\x00\x00\x01atom" b"\x00\x00\x00\x00\x00\x00\x00\x08" + b"\x00" * 8) - self.assertRaises(error, Atom, fileobj) + self.assertRaises(AtomError, Atom, fileobj) def test_length_less_than_8(self): fileobj = cBytesIO(b"\x00\x00\x00\x02atom") - self.assertRaises(MP4MetadataError, Atom, fileobj) + self.assertRaises(AtomError, Atom, fileobj) + + def test_truncated(self): + self.assertRaises(AtomError, Atom, cBytesIO(b"\x00")) + self.assertRaises(AtomError, Atom, cBytesIO(b"\x00\x00\x00\x01atom")) def test_render_too_big(self): class TooBig(bytes): def __len__(self): return 1 << 32 data = TooBig(b"test") - try: len(data) + try: + len(data) except OverflowError: # Py_ssize_t is still only 32 bits on this system. self.failUnlessRaises(OverflowError, Atom.render, b"data", data) @@ -47,7 +57,7 @@ def test_non_top_level_length_0_is_invalid(self): data = cBytesIO(struct.pack(">I4s", 0, b"whee")) - self.assertRaises(MP4MetadataError, Atom, data, level=1) + self.assertRaises(AtomError, Atom, data, level=1) def test_length_0(self): fileobj = cBytesIO(b"\x00\x00\x00\x00atom" + 40 * b"\x00") @@ -63,10 +73,24 @@ self.failUnlessEqual(atom.length, 20) self.failUnlessEqual(atom.children[-1].length, 12) -add(TAtom) + def test_read(self): + payload = 8 * b"\xff" + fileobj = cBytesIO(b"\x00\x00\x00\x10atom" + payload) + atom = Atom(fileobj) + ok, data = atom.read(fileobj) + self.assertTrue(ok) + self.assertEqual(data, payload) + + payload = 7 * b"\xff" + fileobj = cBytesIO(b"\x00\x00\x00\x10atom" + payload) + atom = Atom(fileobj) + ok, data = atom.read(fileobj) + self.assertFalse(ok) + self.assertEqual(data, payload) + class TAtoms(TestCase): - filename = os.path.join("tests", "data", "has-tags.m4a") + filename = os.path.join(DATA_DIR, "has-tags.m4a") def setUp(self): self.atoms = Atoms(open(self.filename, "rb")) @@ -96,7 +120,7 @@ def test_repr(self): repr(self.atoms) -add(TAtoms) + class TMP4Info(TestCase): @@ -106,7 +130,7 @@ def test_mdhd_version_1(self, soun=b"soun"): mdhd = Atom.render(b"mdhd", (b"\x01\x00\x00\x00" + b"\x00" * 16 + - b"\x00\x00\x00\x02" + # 2 Hz + b"\x00\x00\x00\x02" + # 2 Hz b"\x00\x00\x00\x00\x00\x00\x00\x10")) hdlr = Atom.render(b"hdlr", b"\x00" * 8 + soun) mdia = Atom.render(b"mdia", mdhd + hdlr) @@ -122,7 +146,7 @@ mdia = Atom.render(b"mdia", hdlr) trak1 = Atom.render(b"trak", mdia) mdhd = Atom.render(b"mdhd", (b"\x01\x00\x00\x00" + b"\x00" * 16 + - b"\x00\x00\x00\x02" + # 2 Hz + b"\x00\x00\x00\x02" + # 2 Hz b"\x00\x00\x00\x00\x00\x00\x00\x10")) hdlr = Atom.render(b"hdlr", b"\x00" * 8 + b"soun") mdia = Atom.render(b"mdia", mdhd + hdlr) @@ -132,7 +156,7 @@ atoms = Atoms(fileobj) info = MP4Info(atoms, fileobj) self.failUnlessEqual(info.length, 8) -add(TMP4Info) + class TMP4Tags(TestCase): @@ -143,25 +167,62 @@ fileobj = cBytesIO(data) return MP4Tags(Atoms(fileobj), fileobj) + def test_parse_multiple_atoms(self): + # while we don't write multiple values as multiple atoms + # still read them + # https://bitbucket.org/lazka/mutagen/issue/165 + data = Atom.render(b"data", b"\x00\x00\x00\x01" + b"\x00" * 4 + b"foo") + grp1 = Atom.render(b"\xa9grp", data) + data = Atom.render(b"data", b"\x00\x00\x00\x01" + b"\x00" * 4 + b"bar") + grp2 = Atom.render(b"\xa9grp", data) + tags = self.wrap_ilst(grp1 + grp2) + self.assertEqual(tags["\xa9grp"], [u"foo", u"bar"]) + + def test_purl(self): + # purl can have 0 or 1 flags (implicit or utf8) + data = Atom.render(b"data", b"\x00\x00\x00\x01" + b"\x00" * 4 + b"foo") + purl = Atom.render(b"purl", data) + tags = self.wrap_ilst(purl) + self.failUnlessEqual(tags["purl"], ["foo"]) + + data = Atom.render(b"data", b"\x00\x00\x00\x00" + b"\x00" * 4 + b"foo") + purl = Atom.render(b"purl", data) + tags = self.wrap_ilst(purl) + self.failUnlessEqual(tags["purl"], ["foo"]) + + # invalid flag + data = Atom.render(b"data", b"\x00\x00\x00\x03" + b"\x00" * 4 + b"foo") + purl = Atom.render(b"purl", data) + tags = self.wrap_ilst(purl) + self.assertFalse("purl" in tags) + + self.assertTrue("purl" in tags._failed_atoms) + + # invalid utf8 + data = Atom.render( + b"data", b"\x00\x00\x00\x01" + b"\x00" * 4 + b"\xff") + purl = Atom.render(b"purl", data) + tags = self.wrap_ilst(purl) + self.assertFalse("purl" in tags) + def test_genre(self): data = Atom.render(b"data", b"\x00" * 8 + b"\x00\x01") genre = Atom.render(b"gnre", data) tags = self.wrap_ilst(genre) - self.failIf(b"gnre" in tags) - self.failUnlessEqual(tags[b"\xa9gen"], ["Blues"]) + self.failIf("gnre" in tags) + self.failUnlessEqual(tags["\xa9gen"], ["Blues"]) def test_empty_cpil(self): cpil = Atom.render(b"cpil", Atom.render(b"data", b"\x00" * 8)) tags = self.wrap_ilst(cpil) - self.failUnless(b"cpil" in tags) - self.failIf(tags[b"cpil"]) + self.assertFalse("cpil" in tags) def test_genre_too_big(self): data = Atom.render(b"data", b"\x00" * 8 + b"\x01\x00") genre = Atom.render(b"gnre", data) tags = self.wrap_ilst(genre) - self.failIf(b"gnre" in tags) - self.failIf(b"\xa9gen" in tags) + self.failIf("gnre" in tags) + self.failIf("\xa9gen" in tags) def test_strips_unknown_types(self): data = Atom.render(b"data", b"\x00" * 8 + b"whee") @@ -176,80 +237,175 @@ self.failIf(tags) def test_bad_covr(self): - data = Atom.render(b"foob", b"\x00\x00\x00\x0E" + b"\x00" * 4 + b"whee") + data = Atom.render( + b"foob", b"\x00\x00\x00\x0E" + b"\x00" * 4 + b"whee") covr = Atom.render(b"covr", data) - self.failUnlessRaises(MP4MetadataError, self.wrap_ilst, covr) + tags = self.wrap_ilst(covr) + self.assertFalse(tags) def test_covr_blank_format(self): - data = Atom.render(b"data", b"\x00\x00\x00\x00" + b"\x00" * 4 + b"whee") + data = Atom.render( + b"data", b"\x00\x00\x00\x00" + b"\x00" * 4 + b"whee") covr = Atom.render(b"covr", data) tags = self.wrap_ilst(covr) - self.failUnlessEqual(MP4Cover.FORMAT_JPEG, tags[b"covr"][0].imageformat) + self.failUnlessEqual( + MP4Cover.FORMAT_JPEG, tags["covr"][0].imageformat) def test_render_bool(self): - self.failUnlessEqual(MP4Tags()._MP4Tags__render_bool(b'pgap', True), - b"\x00\x00\x00\x19pgap\x00\x00\x00\x11data" - b"\x00\x00\x00\x15\x00\x00\x00\x00\x01") - self.failUnlessEqual(MP4Tags()._MP4Tags__render_bool(b'pgap', False), - b"\x00\x00\x00\x19pgap\x00\x00\x00\x11data" - b"\x00\x00\x00\x15\x00\x00\x00\x00\x00") + self.failUnlessEqual( + MP4Tags()._MP4Tags__render_bool('pgap', True), + b"\x00\x00\x00\x19pgap\x00\x00\x00\x11data" + b"\x00\x00\x00\x15\x00\x00\x00\x00\x01" + ) + self.failUnlessEqual( + MP4Tags()._MP4Tags__render_bool('pgap', False), + b"\x00\x00\x00\x19pgap\x00\x00\x00\x11data" + b"\x00\x00\x00\x15\x00\x00\x00\x00\x00" + ) def test_render_text(self): self.failUnlessEqual( - MP4Tags()._MP4Tags__render_text(b'purl', ['http://foo/bar.xml'], 0), - b"\x00\x00\x00*purl\x00\x00\x00\"data\x00\x00\x00\x00\x00\x00" - b"\x00\x00http://foo/bar.xml") + MP4Tags()._MP4Tags__render_text( + 'purl', ['http://foo/bar.xml'], 0), + b"\x00\x00\x00*purl\x00\x00\x00\"data\x00\x00\x00\x00\x00\x00" + b"\x00\x00http://foo/bar.xml" + ) self.failUnlessEqual( - MP4Tags()._MP4Tags__render_text(b'aART', [u'\u0041lbum Artist']), - b"\x00\x00\x00$aART\x00\x00\x00\x1cdata\x00\x00\x00\x01\x00\x00" - b"\x00\x00\x41lbum Artist") + MP4Tags()._MP4Tags__render_text( + 'aART', [u'\u0041lbum Artist']), + b"\x00\x00\x00$aART\x00\x00\x00\x1cdata\x00\x00\x00\x01\x00\x00" + b"\x00\x00\x41lbum Artist" + ) self.failUnlessEqual( - MP4Tags()._MP4Tags__render_text(b'aART', [u'Album Artist', u'Whee']), - b"\x00\x00\x008aART\x00\x00\x00\x1cdata\x00\x00\x00\x01\x00\x00" - b"\x00\x00Album Artist\x00\x00\x00\x14data\x00\x00\x00\x01\x00" - b"\x00\x00\x00Whee") - + MP4Tags()._MP4Tags__render_text( + 'aART', [u'Album Artist', u'Whee']), + b"\x00\x00\x008aART\x00\x00\x00\x1cdata\x00\x00\x00\x01\x00\x00" + b"\x00\x00Album Artist\x00\x00\x00\x14data\x00\x00\x00\x01\x00" + b"\x00\x00\x00Whee" + ) + def test_render_data(self): self.failUnlessEqual( - MP4Tags()._MP4Tags__render_data(b'aART', 1, [b'whee']), - b"\x00\x00\x00\x1caART" - b"\x00\x00\x00\x14data\x00\x00\x00\x01\x00\x00\x00\x00whee") + MP4Tags()._MP4Tags__render_data('aART', 0, 1, [b'whee']), + b"\x00\x00\x00\x1caART" + b"\x00\x00\x00\x14data\x00\x00\x00\x01\x00\x00\x00\x00whee" + ) self.failUnlessEqual( - MP4Tags()._MP4Tags__render_data(b'aART', 2, [b'whee', b'wee']), - b"\x00\x00\x00/aART" - b"\x00\x00\x00\x14data\x00\x00\x00\x02\x00\x00\x00\x00whee" - b"\x00\x00\x00\x13data\x00\x00\x00\x02\x00\x00\x00\x00wee") + MP4Tags()._MP4Tags__render_data('aART', 0, 2, [b'whee', b'wee']), + b"\x00\x00\x00/aART" + b"\x00\x00\x00\x14data\x00\x00\x00\x02\x00\x00\x00\x00whee" + b"\x00\x00\x00\x13data\x00\x00\x00\x02\x00\x00\x00\x00wee" + ) def test_bad_text_data(self): data = Atom.render(b"datA", b"\x00\x00\x00\x01\x00\x00\x00\x00whee") data = Atom.render(b"aART", data) - self.failUnlessRaises(MP4MetadataError, self.wrap_ilst, data) + tags = self.wrap_ilst(data) + self.assertFalse(tags) + + def test_bad_cprt(self): + data = Atom.render(b"cprt", b"\x00\x00\x00#data\x00") + tags = self.wrap_ilst(data) + self.assertFalse(tags) + + def test_write_back_bad_atoms(self): + # write a broken atom and try to load it + data = Atom.render(b"datA", b"\x00\x00\x00\x01\x00\x00\x00\x00wheeee") + data = Atom.render(b"aART", data) + tags = self.wrap_ilst(data) + self.assertFalse(tags) + + # save it into an existing mp4 + original = os.path.join(DATA_DIR, "has-tags.m4a") + fd, filename = mkstemp(suffix='.mp4') + os.close(fd) + shutil.copy(original, filename) + delete(filename) + + # it should still end up in the file + tags.save(filename) + with open(filename, "rb") as h: + self.assertTrue(b"wheeee" in h.read()) + + # if we define our own aART throw away the broken one + tags["aART"] = ["new"] + tags.save(filename) + with open(filename, "rb") as h: + self.assertFalse(b"wheeee" in h.read()) + + # add the broken one back and delete all tags including the broken one + del tags["aART"] + tags.save(filename) + with open(filename, "rb") as h: + self.assertTrue(b"wheeee" in h.read()) + delete(filename) + with open(filename, "rb") as h: + self.assertFalse(b"wheeee" in h.read()) def test_render_freeform(self): + data = ( + b"\x00\x00\x00a----" + b"\x00\x00\x00\"mean\x00\x00\x00\x00net.sacredchao.Mutagen" + b"\x00\x00\x00\x10name\x00\x00\x00\x00test" + b"\x00\x00\x00\x14data\x00\x00\x00\x01\x00\x00\x00\x00whee" + b"\x00\x00\x00\x13data\x00\x00\x00\x01\x00\x00\x00\x00wee" + ) + + key = '----:net.sacredchao.Mutagen:test' self.failUnlessEqual( - MP4Tags()._MP4Tags__render_freeform( - b'----:net.sacredchao.Mutagen:test', [b'whee', b'wee']), - b"\x00\x00\x00a----" - b"\x00\x00\x00\"mean\x00\x00\x00\x00net.sacredchao.Mutagen" - b"\x00\x00\x00\x10name\x00\x00\x00\x00test" - b"\x00\x00\x00\x14data\x00\x00\x00\x01\x00\x00\x00\x00whee" - b"\x00\x00\x00\x13data\x00\x00\x00\x01\x00\x00\x00\x00wee") + MP4Tags()._MP4Tags__render_freeform(key, [b'whee', b'wee']), data) + + def test_parse_freeform(self): + double_data = ( + b"\x00\x00\x00a----" + b"\x00\x00\x00\"mean\x00\x00\x00\x00net.sacredchao.Mutagen" + b"\x00\x00\x00\x10name\x00\x00\x00\x00test" + b"\x00\x00\x00\x14data\x00\x00\x00\x01\x00\x00\x00\x00whee" + b"\x00\x00\x00\x13data\x00\x00\x00\x01\x00\x00\x00\x00wee" + ) + + key = '----:net.sacredchao.Mutagen:test' + double_atom = \ + MP4Tags()._MP4Tags__render_freeform(key, [b'whee', b'wee']) + + tags = self.wrap_ilst(double_data) + self.assertTrue(key in tags) + self.assertEqual(tags[key], [b'whee', b'wee']) + + tags2 = self.wrap_ilst(double_atom) + self.assertEqual(tags, tags2) + + def test_multi_freeform(self): + # merge multiple freeform tags with the same key + mean = Atom.render(b"mean", b"\x00" * 4 + b"net.sacredchao.Mutagen") + name = Atom.render(b"name", b"\x00" * 4 + b"foo") + + data = Atom.render(b"data", b"\x00\x00\x00\x01" + b"\x00" * 4 + b"bar") + result = Atom.render(b"----", mean + name + data) + data = Atom.render( + b"data", b"\x00\x00\x00\x01" + b"\x00" * 4 + b"quux") + result += Atom.render(b"----", mean + name + data) + tags = self.wrap_ilst(result) + values = tags["----:net.sacredchao.Mutagen:foo"] + self.assertEqual(values[0], b"bar") + self.assertEqual(values[1], b"quux") def test_bad_freeform(self): mean = Atom.render(b"mean", b"net.sacredchao.Mutagen") name = Atom.render(b"name", b"empty test key") bad_freeform = Atom.render(b"----", b"\x00" * 4 + mean + name) - self.failUnlessRaises(MP4MetadataError, self.wrap_ilst, bad_freeform) + tags = self.wrap_ilst(bad_freeform) + self.assertFalse(tags) def test_pprint_non_text_list(self): tags = MP4Tags() - tags[b"tmpo"] = [120, 121] - tags[b"trck"] = [(1, 2), (3, 4)] + tags["tmpo"] = [120, 121] + tags["trck"] = [(1, 2), (3, 4)] tags.pprint() def test_freeform_data(self): # http://code.google.com/p/mutagen/issues/detail?id=103 - key = b"----:com.apple.iTunes:Encoding Params" + key = "----:com.apple.iTunes:Encoding Params" value = (b"vers\x00\x00\x00\x01acbf\x00\x00\x00\x01brat\x00\x01\xf4" b"\x00cdcv\x00\x01\x05\x04") @@ -261,30 +417,35 @@ tags = self.wrap_ilst(Atom.render(b"----", data)) v = tags[key][0] self.failUnlessEqual(v, value) - self.failUnlessEqual(v.dataformat, MP4FreeForm.FORMAT_DATA) + self.failUnlessEqual(v.dataformat, AtomDataType.IMPLICIT) data = MP4Tags()._MP4Tags__render_freeform(key, v) v = self.wrap_ilst(data)[key][0] - self.failUnlessEqual(v.dataformat, MP4FreeForm.FORMAT_DATA) + self.failUnlessEqual(v.dataformat, AtomDataType.IMPLICIT) data = MP4Tags()._MP4Tags__render_freeform(key, value) v = self.wrap_ilst(data)[key][0] - self.failUnlessEqual(v.dataformat, MP4FreeForm.FORMAT_TEXT) + self.failUnlessEqual(v.dataformat, AtomDataType.UTF8) -add(TMP4Tags) class TMP4(TestCase): + def setUp(self): fd, self.filename = mkstemp(suffix='.m4a') os.close(fd) shutil.copy(self.original, self.filename) self.audio = MP4(self.filename) + def tearDown(self): + os.unlink(self.filename) + + +class TMP4Mixin(object): + def faad(self): - if not have_faad: return - value = os.system("faad %s -o %s > %s 2> %s" % ( - self.filename, devnull, devnull, devnull)) - self.failIf(value and value != NOTFOUND) + if not have_faad: + return + self.assertEqual(call_faad("-w", self.filename), 0) def test_score(self): fileobj = open(self.filename, "rb") @@ -307,25 +468,28 @@ def test_length(self): self.failUnlessAlmostEqual(3.7, self.audio.info.length, 1) + def test_kind(self): + self.assertEqual(self.audio.info.codec, u'mp4a.40.2') + def test_padding(self): - self.audio[b"\xa9nam"] = u"wheeee" * 10 + self.audio["\xa9nam"] = u"wheeee" * 10 self.audio.save() size1 = os.path.getsize(self.audio.filename) - self.audio[b"\xa9nam"] = u"wheeee" * 11 + self.audio["\xa9nam"] = u"wheeee" * 11 self.audio.save() size2 = os.path.getsize(self.audio.filename) self.failUnless(size1, size2) def test_padding_2(self): - self.audio[b"\xa9nam"] = u"wheeee" * 10 + self.audio["\xa9nam"] = u"wheeee" * 10 self.audio.save() # Reorder "free" and "ilst" atoms fileobj = open(self.audio.filename, "rb+") atoms = Atoms(fileobj) meta = atoms[b"moov", b"udta", b"meta"] meta_length1 = meta.length - ilst = meta[b"ilst",] - free = meta[b"free",] + ilst = meta[b"ilst", ] + free = meta[b"free", ] self.failUnlessEqual(ilst.offset + ilst.length, free.offset) fileobj.seek(ilst.offset) ilst_data = fileobj.read(ilst.length) @@ -337,20 +501,20 @@ fileobj = open(self.audio.filename, "rb+") atoms = Atoms(fileobj) meta = atoms[b"moov", b"udta", b"meta"] - ilst = meta[b"ilst",] - free = meta[b"free",] + ilst = meta[b"ilst", ] + free = meta[b"free", ] self.failUnlessEqual(free.offset + free.length, ilst.offset) fileobj.close() # Save the file - self.audio[b"\xa9nam"] = u"wheeee" * 11 + self.audio["\xa9nam"] = u"wheeee" * 11 self.audio.save() # Check the order of "free" and "ilst" atoms fileobj = open(self.audio.filename, "rb+") atoms = Atoms(fileobj) fileobj.close() meta = atoms[b"moov", b"udta", b"meta"] - ilst = meta[b"ilst",] - free = meta[b"free",] + ilst = meta[b"ilst", ] + free = meta[b"free", ] self.failUnlessEqual(meta.length, meta_length1) self.failUnlessEqual(ilst.offset + ilst.length, free.offset) @@ -364,112 +528,130 @@ self.faad() def test_unicode(self): - self.set_key(b'\xa9nam', [b'\xe3\x82\x8a\xe3\x81\x8b'], - result=[u'\u308a\u304b']) + try: + self.set_key('\xa9nam', [b'\xe3\x82\x8a\xe3\x81\x8b'], + result=[u'\u308a\u304b']) + except MP4MetadataValueError: + if not PY3: + raise + + def test_preserve_freeform(self): + self.set_key('----:net.sacredchao.Mutagen:test key', + [MP4FreeForm(b'woooo', 142, 42)]) + + def test_invalid_text(self): + self.assertRaises( + MP4MetadataValueError, self.set_key, '\xa9nam', [b'\xff']) def test_save_text(self): - self.set_key(b'\xa9nam', [u"Some test name"]) + self.set_key('\xa9nam', [u"Some test name"]) def test_save_texts(self): - self.set_key(b'\xa9nam', [u"Some test name", u"One more name"]) + self.set_key('\xa9nam', [u"Some test name", u"One more name"]) def test_freeform(self): - self.set_key(b'----:net.sacredchao.Mutagen:test key', [b"whee"]) + self.set_key('----:net.sacredchao.Mutagen:test key', [b"whee"]) def test_freeform_2(self): - self.set_key(b'----:net.sacredchao.Mutagen:test key', b"whee", [b"whee"]) + self.set_key( + '----:net.sacredchao.Mutagen:test key', b"whee", [b"whee"]) def test_freeforms(self): - self.set_key(b'----:net.sacredchao.Mutagen:test key', [b"whee", b"uhh"]) + self.set_key( + '----:net.sacredchao.Mutagen:test key', [b"whee", b"uhh"]) def test_freeform_bin(self): - self.set_key(b'----:net.sacredchao.Mutagen:test key', [ - MP4FreeForm(b'woooo', MP4FreeForm.FORMAT_TEXT), - MP4FreeForm(b'hoooo', MP4FreeForm.FORMAT_DATA), + self.set_key('----:net.sacredchao.Mutagen:test key', [ + MP4FreeForm(b'woooo', AtomDataType.UTF8), + MP4FreeForm(b'hoooo', AtomDataType.IMPLICIT), MP4FreeForm(b'boooo'), ]) def test_tracknumber(self): - self.set_key(b'trkn', [(1, 10)]) - self.set_key(b'trkn', [(1, 10), (5, 20)], faad=False) - self.set_key(b'trkn', []) + self.set_key('trkn', [(1, 10)]) + self.set_key('trkn', [(1, 10), (5, 20)], faad=False) + self.set_key('trkn', []) def test_disk(self): - self.set_key(b'disk', [(18, 0)]) - self.set_key(b'disk', [(1, 10), (5, 20)], faad=False) - self.set_key(b'disk', []) + self.set_key('disk', [(18, 0)]) + self.set_key('disk', [(1, 10), (5, 20)], faad=False) + self.set_key('disk', []) def test_tracknumber_too_small(self): - self.failUnlessRaises(ValueError, self.set_key, b'trkn', [(-1, 0)]) - self.failUnlessRaises(ValueError, self.set_key, b'trkn', [(2**18, 1)]) + self.failUnlessRaises(ValueError, self.set_key, 'trkn', [(-1, 0)]) + self.failUnlessRaises( + ValueError, self.set_key, 'trkn', [(2 ** 18, 1)]) def test_disk_too_small(self): - self.failUnlessRaises(ValueError, self.set_key, b'disk', [(-1, 0)]) - self.failUnlessRaises(ValueError, self.set_key, b'disk', [(2**18, 1)]) + self.failUnlessRaises(ValueError, self.set_key, 'disk', [(-1, 0)]) + self.failUnlessRaises( + ValueError, self.set_key, 'disk', [(2 ** 18, 1)]) def test_tracknumber_wrong_size(self): - self.failUnlessRaises(ValueError, self.set_key, b'trkn', (1,)) - self.failUnlessRaises(ValueError, self.set_key, b'trkn', (1, 2, 3,)) - self.failUnlessRaises(ValueError, self.set_key, b'trkn', [(1,)]) - self.failUnlessRaises(ValueError, self.set_key, b'trkn', [(1, 2, 3,)]) + self.failUnlessRaises(ValueError, self.set_key, 'trkn', (1,)) + self.failUnlessRaises(ValueError, self.set_key, 'trkn', (1, 2, 3,)) + self.failUnlessRaises(ValueError, self.set_key, 'trkn', [(1,)]) + self.failUnlessRaises(ValueError, self.set_key, 'trkn', [(1, 2, 3,)]) def test_disk_wrong_size(self): - self.failUnlessRaises(ValueError, self.set_key, b'disk', [(1,)]) - self.failUnlessRaises(ValueError, self.set_key, b'disk', [(1, 2, 3,)]) + self.failUnlessRaises(ValueError, self.set_key, 'disk', [(1,)]) + self.failUnlessRaises(ValueError, self.set_key, 'disk', [(1, 2, 3,)]) def test_tempo(self): - self.set_key(b'tmpo', [150]) - self.set_key(b'tmpo', []) + self.set_key('tmpo', [150]) + self.set_key('tmpo', []) def test_tempos(self): - self.set_key(b'tmpo', [160, 200], faad=False) + self.set_key('tmpo', [160, 200], faad=False) def test_tempo_invalid(self): for badvalue in [[10000000], [-1], 10, "foo"]: self.failUnlessRaises(ValueError, self.set_key, 'tmpo', badvalue) def test_compilation(self): - self.set_key(b'cpil', True) + self.set_key('cpil', True) def test_compilation_false(self): - self.set_key(b'cpil', False) + self.set_key('cpil', False) def test_gapless(self): - self.set_key(b'pgap', True) + self.set_key('pgap', True) def test_gapless_false(self): - self.set_key(b'pgap', False) + self.set_key('pgap', False) def test_podcast(self): - self.set_key(b'pcst', True) + self.set_key('pcst', True) def test_podcast_false(self): - self.set_key(b'pcst', False) + self.set_key('pcst', False) def test_cover(self): - self.set_key(b'covr', [b'woooo']) + self.set_key('covr', [b'woooo']) def test_cover_png(self): - self.set_key(b'covr', [ + self.set_key('covr', [ MP4Cover(b'woooo', MP4Cover.FORMAT_PNG), MP4Cover(b'hoooo', MP4Cover.FORMAT_JPEG), ]) def test_podcast_url(self): - self.set_key(b'purl', ['http://pdl.warnerbros.com/wbie/justiceleagueheroes/audio/JLH_EA.xml']) + self.set_key('purl', ['http://pdl.warnerbros.com/wbie/' + 'justiceleagueheroes/audio/JLH_EA.xml']) def test_episode_guid(self): - self.set_key(b'catg', ['falling-star-episode-1']) + self.set_key('catg', ['falling-star-episode-1']) def test_pprint(self): self.failUnless(self.audio.pprint()) + self.assertTrue(isinstance(self.audio.pprint(), text_type)) def test_pprint_binary(self): - self.audio[b"covr"] = "\x00\xa9\garbage" + self.audio["covr"] = [b"\x00\xa9\garbage"] self.failUnless(self.audio.pprint()) def test_pprint_pair(self): - self.audio[b"cpil"] = (1, 10) + self.audio["cpil"] = (1, 10) self.failUnless("cpil=(1, 10)" in self.audio.pprint()) def test_delete(self): @@ -485,7 +667,7 @@ self.faad() def test_reads_unknown_text(self): - self.set_key(b"foob", [u"A test"]) + self.set_key("foob", [u"A test"]) def __read_offsets(self, filename): fileobj = open(filename, 'rb') @@ -523,7 +705,7 @@ def test_update_offsets(self): aa = self.__read_offsets(self.original) - self.audio[b"\xa9nam"] = "wheeeeeeee" + self.audio["\xa9nam"] = "wheeeeeeee" self.audio.save() bb = self.__read_offsets(self.filename) for a, b in zip(aa, bb): @@ -532,11 +714,8 @@ def test_mime(self): self.failUnless("audio/mp4" in self.audio.mime) - def tearDown(self): - os.unlink(self.filename) - -class TMP4HasTags(TMP4): +class TMP4HasTagsMixin(TMP4Mixin): def test_save_simple(self): self.audio.save() self.faad() @@ -564,43 +743,45 @@ def test_not_my_file(self): # should raise something like "Not a MP4 file" self.failUnlessRaisesRegexp( - error, "MP4", MP4, os.path.join("tests", "data", "empty.ogg")) + error, "MP4", MP4, os.path.join(DATA_DIR, "empty.ogg")) -class TMP4Datatypes(TMP4HasTags): - original = os.path.join("tests", "data", "has-tags.m4a") +class TMP4Datatypes(TMP4, TMP4HasTagsMixin): + original = os.path.join(DATA_DIR, "has-tags.m4a") def test_has_freeform(self): - key = b"----:com.apple.iTunes:iTunNORM" + key = "----:com.apple.iTunes:iTunNORM" self.failUnless(key in self.audio.tags) ff = self.audio.tags[key] - self.failUnlessEqual(ff[0].dataformat, MP4FreeForm.FORMAT_TEXT) + self.failUnlessEqual(ff[0].dataformat, AtomDataType.UTF8) + self.failUnlessEqual(ff[0].version, 0) def test_has_covr(self): - self.failUnless(b'covr' in self.audio.tags) - covr = self.audio.tags[b'covr'] + self.failUnless('covr' in self.audio.tags) + covr = self.audio.tags['covr'] self.failUnlessEqual(len(covr), 2) self.failUnlessEqual(covr[0].imageformat, MP4Cover.FORMAT_PNG) self.failUnlessEqual(covr[1].imageformat, MP4Cover.FORMAT_JPEG) -add(TMP4Datatypes) + def test_pprint(self): + text = self.audio.tags.pprint().splitlines() + self.assertTrue(u"©ART=Test Artist" in text) -class TMP4CovrWithName(TMP4): +class TMP4CovrWithName(TMP4, TMP4Mixin): # http://bugs.musicbrainz.org/ticket/5894 - original = os.path.join("tests", "data", "covr-with-name.m4a") + original = os.path.join(DATA_DIR, "covr-with-name.m4a") def test_has_covr(self): - self.failUnless(b'covr' in self.audio.tags) - covr = self.audio.tags[b'covr'] + self.failUnless('covr' in self.audio.tags) + covr = self.audio.tags['covr'] self.failUnlessEqual(len(covr), 2) self.failUnlessEqual(covr[0].imageformat, MP4Cover.FORMAT_PNG) self.failUnlessEqual(covr[1].imageformat, MP4Cover.FORMAT_JPEG) -add(TMP4CovrWithName) -class TMP4HasTags64Bit(TMP4HasTags): - original = os.path.join("tests", "data", "truncated-64bit.mp4") +class TMP4HasTags64Bit(TMP4, TMP4HasTagsMixin): + original = os.path.join(DATA_DIR, "truncated-64bit.mp4") def test_has_covr(self): pass @@ -615,10 +796,9 @@ # This is only half a file, so FAAD segfaults. Can't test. :( pass -add(TMP4HasTags64Bit) -class TMP4NoTagsM4A(TMP4): - original = os.path.join("tests", "data", "no-tags.m4a") +class TMP4NoTagsM4A(TMP4, TMP4Mixin): + original = os.path.join(DATA_DIR, "no-tags.m4a") def test_no_tags(self): self.failUnless(self.audio.tags is None) @@ -627,10 +807,9 @@ self.audio.add_tags() self.failUnlessRaises(error, self.audio.add_tags) -add(TMP4NoTagsM4A) -class TMP4NoTags3G2(TMP4): - original = os.path.join("tests", "data", "no-tags.3g2") +class TMP4NoTags3G2(TMP4, TMP4Mixin): + original = os.path.join(DATA_DIR, "no-tags.3g2") def test_no_tags(self): self.failUnless(self.audio.tags is None) @@ -644,10 +823,9 @@ def test_length(self): self.failUnlessAlmostEqual(15, self.audio.info.length, 1) -add(TMP4NoTags3G2) class TMP4UpdateParents64Bit(TestCase): - original = os.path.join("tests", "data", "64bit.mp4") + original = os.path.join(DATA_DIR, "64bit.mp4") def setUp(self): fd, self.filename = mkstemp(suffix='.mp4') @@ -660,7 +838,7 @@ self.assertEqual(77, atoms.atoms[0].length) self.assertEqual(61, atoms.atoms[0].children[0].length) tags = MP4Tags(atoms, fileobj) - tags[b'pgap'] = True + tags['pgap'] = True tags.save(self.filename) with open(self.filename, "rb") as fileobj: @@ -672,11 +850,250 @@ def tearDown(self): os.unlink(self.filename) -add(TMP4UpdateParents64Bit) -NOTFOUND = os.system("tools/notarealprogram 2> %s" % devnull) +class TMP4ALAC(TestCase): + original = os.path.join(DATA_DIR, "alac.m4a") + + def setUp(self): + self.audio = MP4(self.original) + + def test_channels(self): + self.failUnlessEqual(self.audio.info.channels, 2) + + def test_sample_rate(self): + self.failUnlessEqual(self.audio.info.sample_rate, 44100) + + def test_bits_per_sample(self): + self.failUnlessEqual(self.audio.info.bits_per_sample, 16) + + def test_length(self): + self.failUnlessAlmostEqual(3.7, self.audio.info.length, 1) + + def test_bitrate(self): + self.assertEqual(self.audio.info.bitrate, 2764) + + def test_kind(self): + self.assertEqual(self.audio.info.codec, u'alac') + + +class TMP4Misc(TestCase): + + def test_parse_full_atom(self): + p = parse_full_atom(b"\x01\x02\x03\x04\xff") + self.assertEqual(p, (1, 131844, b'\xff')) + + self.assertRaises(ValueError, parse_full_atom, b"\x00\x00\x00") + + def test_sort_items(self): + items = [ + ("\xa9nam", ["foo"]), + ("gnre", ["fo"]), + ("----", ["123"]), + ("----", ["1234"]), + ] + + sorted_items = sorted(items, key=MP4Tags._key_sort) + self.assertEqual(sorted_items, items) + + +class TMP4Freeform(TestCase): + + def test_cmp(self): + self.assertReallyEqual( + MP4FreeForm(b'woooo', 142, 42), MP4FreeForm(b'woooo', 142, 42)) + self.assertReallyNotEqual( + MP4FreeForm(b'woooo', 142, 43), MP4FreeForm(b'woooo', 142, 42)) + self.assertReallyNotEqual( + MP4FreeForm(b'woooo', 143, 42), MP4FreeForm(b'woooo', 142, 42)) + self.assertReallyNotEqual( + MP4FreeForm(b'wooox', 142, 42), MP4FreeForm(b'woooo', 142, 42)) + + def test_cmp_bytes(self): + self.assertReallyEqual(MP4FreeForm(b'woooo'), b"woooo") + self.assertReallyNotEqual(MP4FreeForm(b'woooo'), b"foo") + if PY2: + self.assertReallyEqual(MP4FreeForm(b'woooo'), u"woooo") + self.assertReallyNotEqual(MP4FreeForm(b'woooo'), u"foo") + + +class TMP4Cover(TestCase): + + def test_cmp(self): + self.assertReallyEqual( + MP4Cover(b'woooo', 142), MP4Cover(b'woooo', 142)) + self.assertReallyNotEqual( + MP4Cover(b'woooo', 143), MP4Cover(b'woooo', 142)) + self.assertReallyNotEqual( + MP4Cover(b'woooo', 142), MP4Cover(b'wooox', 142)) + + def test_cmp_bytes(self): + self.assertReallyEqual(MP4Cover(b'woooo'), b"woooo") + self.assertReallyNotEqual(MP4Cover(b'woooo'), b"foo") + if PY2: + self.assertReallyEqual(MP4Cover(b'woooo'), u"woooo") + self.assertReallyNotEqual(MP4Cover(b'woooo'), u"foo") + + +class TMP4AudioSampleEntry(TestCase): + + def test_alac(self): + # an exampe where the channel count in the alac cookie is right + # but the SampleEntry is wrong + atom_data = ( + b'\x00\x00\x00Halac\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x02\x00\x10\x00\x00\x00\x00\x1f@\x00' + b'\x00\x00\x00\x00$alac\x00\x00\x00\x00\x00\x00\x10\x00\x00\x10' + b'(\n\x0e\x01\x00\xff\x00\x00P\x01\x00\x00\x00\x00\x00\x00\x1f@') + + fileobj = cBytesIO(atom_data) + atom = Atom(fileobj) + entry = AudioSampleEntry(atom, fileobj) + self.assertEqual(entry.bitrate, 0) + self.assertEqual(entry.channels, 1) + self.assertEqual(entry.codec, "alac") + self.assertEqual(entry.codec_description, "ALAC") + self.assertEqual(entry.sample_rate, 8000) + + def test_alac_2(self): + # an example where the samplerate is only correct in the cookie, + # also contains a bitrate + atom_data = ( + b'\x00\x00\x00Halac\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x02\x00\x18\x00\x00\x00\x00X\x88\x00' + b'\x00\x00\x00\x00$alac\x00\x00\x00\x00\x00\x00\x10\x00\x00\x18' + b'(\n\x0e\x02\x00\xff\x00\x00F/\x00%2\xd5\x00\x01X\x88') + + fileobj = cBytesIO(atom_data) + atom = Atom(fileobj) + entry = AudioSampleEntry(atom, fileobj) + self.assertEqual(entry.bitrate, 2437845) + self.assertEqual(entry.channels, 2) + self.assertEqual(entry.codec, "alac") + self.assertEqual(entry.codec_description, "ALAC") + self.assertEqual(entry.sample_rate, 88200) + + def test_pce(self): + atom_data = ( + b'\x00\x00\x00dmp4a\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x02\x00\x10\x00\x00\x00\x00\xbb\x80' + b'\x00\x00\x00\x00\x00@esds\x00\x00\x00\x00\x03\x80\x80\x80/\x00' + b'\x00\x00\x04\x80\x80\x80!@\x15\x00\x15\x00\x00\x03\xed\xaa\x00' + b'\x03k\x00\x05\x80\x80\x80\x0f+\x01\x88\x02\xc4\x04\x90,\x10\x8c' + b'\x80\x00\x00\xed@\x06\x80\x80\x80\x01\x02') + + fileobj = cBytesIO(atom_data) + atom = Atom(fileobj) + entry = AudioSampleEntry(atom, fileobj) + + self.assertEqual(entry.bitrate, 224000) + self.assertEqual(entry.channels, 8) + self.assertEqual(entry.codec_description, "AAC LC+SBR") + self.assertEqual(entry.codec, "mp4a.40.2") + self.assertEqual(entry.sample_rate, 48000) + self.assertEqual(entry.sample_size, 16) + + def test_sbr_ps_sig_1(self): + atom_data = ( + b"\x00\x00\x00\\mp4a\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x02\x00\x10\x00\x00\x00\x00\xbb\x80\x00" + b"\x00\x00\x00\x008esds\x00\x00\x00\x00\x03\x80\x80\x80'\x00\x00" + b"\x00\x04\x80\x80\x80\x19@\x15\x00\x03\x00\x00\x00\xe9j\x00\x00" + b"\xda\xc0\x05\x80\x80\x80\x07\x13\x08V\xe5\x9dH\x80\x06\x80\x80" + b"\x80\x01\x02") + + fileobj = cBytesIO(atom_data) + atom = Atom(fileobj) + entry = AudioSampleEntry(atom, fileobj) + + self.assertEqual(entry.bitrate, 56000) + self.assertEqual(entry.channels, 2) + self.assertEqual(entry.codec_description, "AAC LC+SBR+PS") + self.assertEqual(entry.codec, "mp4a.40.2") + self.assertEqual(entry.sample_rate, 48000) + self.assertEqual(entry.sample_size, 16) + + self.assertTrue(isinstance(entry.codec, text_type)) + self.assertTrue(isinstance(entry.codec_description, text_type)) + + def test_als(self): + atom_data = ( + b'\x00\x00\x00\x9dmp4a\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x02\x00\x00\x10\x00\x00\x00\x00\x07' + b'\xd0\x00\x00\x00\x00\x00yesds\x00\x00\x00\x00\x03k\x00\x00\x00' + b'\x04c@\x15\x10\xe7\xe6\x00W\xcbJ\x00W\xcbJ\x05T\xf8\x9e\x00\x0f' + b'\xa0\x00ALS\x00\x00\x00\x07\xd0\x00\x00\x0c\t\x01\xff$O\xff\x00' + b'g\xff\xfc\x80\x00\x00\x00,\x00\x00\x00\x00RIFF$$0\x00WAVEfmt ' + b'\x10\x00\x00\x00\x01\x00\x00\x02\xd0\x07\x00\x00\x00@\x1f\x00' + b'\x00\x04\x10\x00data\x00$0\x00\xf6\xceF+\x06\x01\x02') + + fileobj = cBytesIO(atom_data) + atom = Atom(fileobj) + entry = AudioSampleEntry(atom, fileobj) + + self.assertEqual(entry.bitrate, 5753674) + self.assertEqual(entry.channels, 512) + self.assertEqual(entry.codec_description, "ALS") + self.assertEqual(entry.codec, "mp4a.40.36") + self.assertEqual(entry.sample_rate, 2000) + self.assertEqual(entry.sample_size, 16) + + def test_ac3(self): + atom_data = ( + b'\x00\x00\x00/ac-3\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x02\x00\x10\x00\x00\x00\x00V"\x00\x00' + b'\x00\x00\x00\x0bdac3R\t\x00') + + fileobj = cBytesIO(atom_data) + atom = Atom(fileobj) + entry = AudioSampleEntry(atom, fileobj) + + self.assertEqual(entry.bitrate, 128000) + self.assertEqual(entry.channels, 1) + self.assertEqual(entry.codec_description, "AC-3") + self.assertEqual(entry.codec, "ac-3") + self.assertEqual(entry.sample_rate, 22050) + self.assertEqual(entry.sample_size, 16) + + self.assertTrue(isinstance(entry.codec, text_type)) + self.assertTrue(isinstance(entry.codec_description, text_type)) + + def test_samr(self): + # parsing not implemented, values are wrong but at least it loads. + # should be Mono 7.95kbps 8KHz + atom_data = ( + b'\x00\x00\x005samr\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x02\x00\x10\x00\x00\x00\x00\x1f@\x00' + b'\x00\x00\x00\x00\x11damrFFMP\x00\x81\xff\x00\x01') + + fileobj = cBytesIO(atom_data) + atom = Atom(fileobj) + entry = AudioSampleEntry(atom, fileobj) + + self.assertEqual(entry.bitrate, 0) + self.assertEqual(entry.channels, 2) + self.assertEqual(entry.codec_description, "SAMR") + self.assertEqual(entry.codec, "samr") + self.assertEqual(entry.sample_rate, 8000) + self.assertEqual(entry.sample_size, 16) + + self.assertTrue(isinstance(entry.codec, text_type)) + self.assertTrue(isinstance(entry.codec_description, text_type)) + + def test_error(self): + fileobj = cBytesIO(b"\x00" * 20) + atom = Atom(fileobj) + self.assertRaises(ASEntryError, AudioSampleEntry, atom, fileobj) + + +def call_faad(*args): + with open(os.devnull, 'wb') as null: + return subprocess.call( + ["faad"] + list(args), + stdout=null, stderr=subprocess.STDOUT) have_faad = True -if os.system("faad 2> %s > %s" % (devnull, devnull)) == NOTFOUND: +try: + call_faad() +except OSError: have_faad = False print("WARNING: Skipping FAAD reference tests.") diff -Nru mutagen-1.23/tests/test_musepack.py mutagen-1.30/tests/test_musepack.py --- mutagen-1.23/tests/test_musepack.py 2013-10-13 09:37:38.000000000 +0000 +++ mutagen-1.30/tests/test_musepack.py 2015-05-09 13:11:39.000000000 +0000 @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + import os import shutil from tempfile import mkstemp @@ -5,20 +7,21 @@ from mutagen.id3 import ID3, TIT2 from mutagen.musepack import Musepack, MusepackInfo, MusepackHeaderError from mutagen._compat import cBytesIO -from tests import TestCase, add +from tests import TestCase, DATA_DIR + class TMusepack(TestCase): def setUp(self): - self.sv8 = Musepack(os.path.join("tests", "data", "sv8_header.mpc")) - self.sv7 = Musepack(os.path.join("tests", "data", "click.mpc")) - self.sv5 = Musepack(os.path.join("tests", "data", "sv5_header.mpc")) - self.sv4 = Musepack(os.path.join("tests", "data", "sv4_header.mpc")) + self.sv8 = Musepack(os.path.join(DATA_DIR, "sv8_header.mpc")) + self.sv7 = Musepack(os.path.join(DATA_DIR, "click.mpc")) + self.sv5 = Musepack(os.path.join(DATA_DIR, "sv5_header.mpc")) + self.sv4 = Musepack(os.path.join(DATA_DIR, "sv4_header.mpc")) def test_bad_header(self): self.failUnlessRaises( MusepackHeaderError, - Musepack, os.path.join("tests", "data", "almostempty.mpc")) + Musepack, os.path.join(DATA_DIR, "almostempty.mpc")) def test_channels(self): self.failUnlessEqual(self.sv8.info.channels, 2) @@ -47,29 +50,37 @@ def test_gain(self): self.failUnlessAlmostEqual(self.sv8.info.title_gain, -4.668, 3) self.failUnlessAlmostEqual(self.sv8.info.title_peak, 0.5288, 3) - self.failUnlessEqual(self.sv8.info.title_gain, self.sv8.info.album_gain) - self.failUnlessEqual(self.sv8.info.title_peak, self.sv8.info.album_peak) + self.failUnlessEqual( + self.sv8.info.title_gain, self.sv8.info.album_gain) + self.failUnlessEqual( + self.sv8.info.title_peak, self.sv8.info.album_peak) self.failUnlessAlmostEqual(self.sv7.info.title_gain, 9.27, 6) self.failUnlessAlmostEqual(self.sv7.info.title_peak, 0.1149, 4) - self.failUnlessEqual(self.sv7.info.title_gain, self.sv7.info.album_gain) - self.failUnlessEqual(self.sv7.info.title_peak, self.sv7.info.album_peak) + self.failUnlessEqual( + self.sv7.info.title_gain, self.sv7.info.album_gain) + self.failUnlessEqual( + self.sv7.info.title_peak, self.sv7.info.album_peak) self.failUnlessRaises(AttributeError, getattr, self.sv5, 'title_gain') def test_not_my_file(self): self.failUnlessRaises( MusepackHeaderError, Musepack, - os.path.join("tests", "data", "empty.ogg")) + os.path.join(DATA_DIR, "empty.ogg")) self.failUnlessRaises( MusepackHeaderError, Musepack, - os.path.join("tests", "data", "emptyfile.mp3")) + os.path.join(DATA_DIR, "emptyfile.mp3")) def test_almost_my_file(self): self.failUnlessRaises( MusepackHeaderError, MusepackInfo, cBytesIO(b"MP+" + b"\x00" * 32)) self.failUnlessRaises( - MusepackHeaderError, MusepackInfo, cBytesIO(b"MP+" + b"\x00" * 100)) + MusepackHeaderError, + MusepackInfo, + cBytesIO(b"MP+" + b"\x00" * 100)) self.failUnlessRaises( - MusepackHeaderError, MusepackInfo, cBytesIO(b"MPCK" + b"\x00" * 100)) + MusepackHeaderError, + MusepackInfo, + cBytesIO(b"MPCK" + b"\x00" * 100)) def test_pprint(self): self.sv8.pprint() @@ -80,11 +91,19 @@ def test_mime(self): self.failUnless("audio/x-musepack" in self.sv7.mime) -add(TMusepack) + def test_zero_padded_sh_packet(self): + # https://bitbucket.org/lazka/mutagen/issue/198 + data = (b"MPCKSH\x10\x95 Q\xa2\x08\x81\xb8\xc9T\x00\x1e\x1b" + b"\x00RG\x0c\x01A\xcdY\x06?\x80Z\x06EI") + + fileobj = cBytesIO(data) + info = MusepackInfo(fileobj) + self.assertEqual(info.channels, 2) + self.assertEqual(info.samples, 3024084) class TMusepackWithID3(TestCase): - SAMPLE = os.path.join("tests", "data", "click.mpc") + SAMPLE = os.path.join(DATA_DIR, "click.mpc") def setUp(self): fd, self.NEW = mkstemp(suffix='mpc') @@ -108,5 +127,3 @@ self.failUnlessEqual(id3['TIT2'], 'id3 title') f = Musepack(self.NEW) self.failUnlessEqual(f['title'], 'apev2 title') - -add(TMusepackWithID3) diff -Nru mutagen-1.23/tests/test_oggflac.py mutagen-1.30/tests/test_oggflac.py --- mutagen-1.23/tests/test_oggflac.py 2013-09-13 09:40:58.000000000 +0000 +++ mutagen-1.30/tests/test_oggflac.py 2015-05-09 13:12:18.000000000 +0000 @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + import os import shutil @@ -6,21 +8,24 @@ from mutagen._compat import cBytesIO from mutagen.oggflac import OggFLAC, OggFLACStreamInfo, delete from mutagen.ogg import OggPage, error as OggError -from tests import add -from tests.test_ogg import TOggFileType -try: from os.path import devnull -except ImportError: devnull = "/dev/null" +from tests import TestCase, DATA_DIR +from tests.test_ogg import TOggFileTypeMixin +from tests.test_flac import have_flac, call_flac + -class TOggFLAC(TOggFileType): +class TOggFLAC(TestCase, TOggFileTypeMixin): Kind = OggFLAC def setUp(self): - original = os.path.join("tests", "data", "empty.oggflac") + original = os.path.join(DATA_DIR, "empty.oggflac") fd, self.filename = mkstemp(suffix='.ogg') os.close(fd) shutil.copy(original, self.filename) self.audio = OggFLAC(self.filename) + def tearDown(self): + os.unlink(self.filename) + def test_vendor(self): self.failUnless( self.audio.tags.vendor.startswith("reference libFLAC")) @@ -41,19 +46,19 @@ self.failUnlessRaises(IOError, OggFLACStreamInfo, cBytesIO(page)) def test_flac_reference_simple_save(self): - if not have_flac: return + if not have_flac: + return self.audio.save() self.scan_file() - value = os.system("flac --ogg -t %s 2> %s" % (self.filename, devnull)) - self.failIf(value and value != NOTFOUND) + self.assertEqual(call_flac("--ogg", "-t", self.filename), 0) def test_flac_reference_really_big(self): - if not have_flac: return + if not have_flac: + return self.test_really_big() self.audio.save() self.scan_file() - value = os.system("flac --ogg -t %s 2> %s" % (self.filename, devnull)) - self.failIf(value and value != NOTFOUND) + self.assertEqual(call_flac("--ogg", "-t", self.filename), 0) def test_module_delete(self): delete(self.filename) @@ -61,44 +66,35 @@ self.failIf(OggFLAC(self.filename).tags) def test_flac_reference_delete(self): - if not have_flac: return + if not have_flac: + return self.audio.delete() self.scan_file() - value = os.system("flac --ogg -t %s 2> %s" % (self.filename, devnull)) - self.failIf(value and value != NOTFOUND) - + self.assertEqual(call_flac("--ogg", "-t", self.filename), 0) + def test_flac_reference_medium_sized(self): - if not have_flac: return + if not have_flac: + return self.audio["foobar"] = "foobar" * 1000 self.audio.save() self.scan_file() - value = os.system("flac --ogg -t %s 2> %s" % (self.filename, devnull)) - self.failIf(value and value != NOTFOUND) + self.assertEqual(call_flac("--ogg", "-t", self.filename), 0) def test_flac_reference_delete_readd(self): - if not have_flac: return + if not have_flac: + return self.audio.delete() self.audio.tags.clear() self.audio["foobar"] = "foobar" * 1000 self.audio.save() self.scan_file() - value = os.system("flac --ogg -t %s 2> %s" % (self.filename, devnull)) - self.failIf(value and value != NOTFOUND) - + self.assertEqual(call_flac("--ogg", "-t", self.filename), 0) + def test_not_my_ogg(self): - fn = os.path.join('tests', 'data', 'empty.ogg') + fn = os.path.join(DATA_DIR, 'empty.ogg') self.failUnlessRaises(IOError, type(self.audio), fn) self.failUnlessRaises(IOError, self.audio.save, fn) self.failUnlessRaises(IOError, self.audio.delete, fn) def test_mime(self): self.failUnless("audio/x-oggflac" in self.audio.mime) - -add(TOggFLAC) - -NOTFOUND = os.system("tools/notarealprogram 2> %s" % devnull) - -have_flac = True -if os.system("flac 2> %s > %s" % (devnull, devnull)) == NOTFOUND: - have_flac = False - print("WARNING: Skipping Ogg FLAC reference tests.") diff -Nru mutagen-1.23/tests/test_oggopus.py mutagen-1.30/tests/test_oggopus.py --- mutagen-1.23/tests/test_oggopus.py 2013-09-10 16:16:11.000000000 +0000 +++ mutagen-1.30/tests/test_oggopus.py 2015-05-09 13:13:18.000000000 +0000 @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + import os import shutil from tempfile import mkstemp @@ -5,19 +7,23 @@ from mutagen._compat import BytesIO from mutagen.oggopus import OggOpus, OggOpusInfo, delete from mutagen.ogg import OggPage -from tests import add -from tests.test_ogg import TOggFileType +from tests import TestCase, DATA_DIR +from tests.test_ogg import TOggFileTypeMixin + -class TOggOpus(TOggFileType): +class TOggOpus(TestCase, TOggFileTypeMixin): Kind = OggOpus def setUp(self): - original = os.path.join("tests", "data", "example.opus") + original = os.path.join(DATA_DIR, "example.opus") fd, self.filename = mkstemp(suffix='.opus') os.close(fd) shutil.copy(original, self.filename) self.audio = self.Kind(self.filename) + def tearDown(self): + os.unlink(self.filename) + def test_length(self): self.failUnlessAlmostEqual(self.audio.info.length, 11.35, 2) @@ -43,12 +49,32 @@ page = OggPage(open(self.filename, "rb")) data = bytearray(page.packets[0]) - data[8] = ord(b"\x03") + data[8] = 0x03 page.packets[0] = bytes(data) OggOpusInfo(BytesIO(page.write())) - data[8] = ord(b"\x10") + data[8] = 0x10 page.packets[0] = bytes(data) self.failUnlessRaises(IOError, OggOpusInfo, BytesIO(page.write())) -add(TOggOpus) + def test_preserve_non_padding(self): + self.audio["FOO"] = ["BAR"] + self.audio.save() + + extra_data = b"\xde\xad\xbe\xef" + + with open(self.filename, "r+b") as fobj: + OggPage(fobj) # header + page = OggPage(fobj) + data = OggPage.to_packets([page])[0] + data = data.rstrip(b"\x00") + b"\x01" + extra_data + new_pages = OggPage.from_packets([data], page.sequence) + OggPage.replace(fobj, [page], new_pages) + + OggOpus(self.filename).save() + + with open(self.filename, "rb") as fobj: + OggPage(fobj) # header + page = OggPage(fobj) + data = OggPage.to_packets([page])[0] + self.assertTrue(data.endswith(b"\x01" + extra_data)) diff -Nru mutagen-1.23/tests/test_ogg.py mutagen-1.30/tests/test_ogg.py --- mutagen-1.23/tests/test_ogg.py 2013-09-10 15:56:56.000000000 +0000 +++ mutagen-1.30/tests/test_ogg.py 2015-05-09 13:13:03.000000000 +0000 @@ -1,19 +1,21 @@ +# -*- coding: utf-8 -*- + import os import random import shutil +import subprocess from mutagen._compat import BytesIO -from tests import TestCase, add +from tests import TestCase, DATA_DIR from mutagen.ogg import OggPage, error as OggError from mutagen._util import cdata from tempfile import mkstemp -try: from os.path import devnull -except ImportError: devnull = "/dev/null" + class TOggPage(TestCase): def setUp(self): - self.fileobj = open(os.path.join("tests", "data", "empty.ogg"), "rb") + self.fileobj = open(os.path.join(DATA_DIR, "empty.ogg"), "rb") self.page = OggPage(self.fileobj) pages = [OggPage(), OggPage(), OggPage()] @@ -74,7 +76,8 @@ def test_wiggle_room(self): packets = [b"1" * 511, b"2" * 511, b"3" * 511] - pages = OggPage.from_packets(packets, default_size=510, wiggle_room=100) + pages = OggPage.from_packets( + packets, default_size=510, wiggle_room=100) self.failUnlessEqual(len(pages), 3) self.failUnlessEqual(OggPage.to_packets(pages), packets) @@ -122,7 +125,7 @@ try: fd, filename = mkstemp(suffix=".ogg") os.close(fd) - shutil.copy(os.path.join("tests", "data", "multipagecomment.ogg"), + shutil.copy(os.path.join(DATA_DIR, "multipagecomment.ogg"), filename) fileobj = open(filename, "rb+") OggPage.renumber(fileobj, 1002429366, 20) @@ -131,8 +134,10 @@ OggPage.renumber(fileobj, 1002429366, 0) fileobj.close() finally: - try: os.unlink(filename) - except OSError: pass + try: + os.unlink(filename) + except OSError: + pass def test_renumber_muxed(self): pages = [OggPage() for i in range(10)] @@ -204,7 +209,8 @@ self.failUnlessEqual(OggPage.to_packets(pages), packets) def test_random_data_roundtrip(self): - try: random_file = open("/dev/urandom", "rb") + try: + random_file = open("/dev/urandom", "rb") except (IOError, OSError): print("WARNING: Random data round trip test disabled.") return @@ -255,7 +261,7 @@ packets = [b"1"] * 3000 pages = OggPage.from_packets(packets) map(OggPage.write, pages) - self.failUnless(len(pages) > 3000/255) + self.failUnless(len(pages) > 3000 // 255) def test_read_max_size(self): page = OggPage() @@ -290,7 +296,8 @@ def test_find_last(self): pages = [OggPage() for i in range(10)] - for i, page in enumerate(pages): page.sequence = i + for i, page in enumerate(pages): + page.sequence = i data = BytesIO(b"".join([page.write() for page in pages])) self.failUnlessEqual( OggPage.find_last(data, pages[0].serial), pages[-1]) @@ -298,14 +305,16 @@ def test_find_last_really_last(self): pages = [OggPage() for i in range(10)] pages[-1].last = True - for i, page in enumerate(pages): page.sequence = i + for i, page in enumerate(pages): + page.sequence = i data = BytesIO(b"".join([page.write() for page in pages])) self.failUnlessEqual( OggPage.find_last(data, pages[0].serial), pages[-1]) def test_find_last_muxed(self): pages = [OggPage() for i in range(10)] - for i, page in enumerate(pages): page.sequence = i + for i, page in enumerate(pages): + page.sequence = i pages[-2].last = True pages[-1].serial = pages[0].serial + 1 data = BytesIO(b"".join([page.write() for page in pages])) @@ -314,7 +323,8 @@ def test_find_last_no_serial(self): pages = [OggPage() for i in range(10)] - for i, page in enumerate(pages): page.sequence = i + for i, page in enumerate(pages): + page.sequence = i data = BytesIO(b"".join([page.write() for page in pages])) self.failUnless(OggPage.find_last(data, pages[0].serial + 1) is None) @@ -325,9 +335,9 @@ # Disabled because GStreamer will write Oggs with bad data, # which we need to make a best guess for. # - #def test_find_last_invalid_sync(self): - # data = BytesIO("if you think this is an OggS, you're crazy") - # self.failUnlessRaises(OggError, OggPage.find_last, data, 0) + # def test_find_last_invalid_sync(self): + # data = BytesIO("if you think this is an OggS, you're crazy") + # self.failUnlessRaises(OggError, OggPage.find_last, data, 0) def test_find_last_invalid_sync(self): data = BytesIO(b"if you think this is an OggS, you're crazy") @@ -342,8 +352,10 @@ import zlib old_crc = zlib.crc32 + def zlib_uint(*args): return (old_crc(*args) & 0xffffffff) + def zlib_int(*args): return cdata.int_be(cdata.to_uint_be(old_crc(*args) & 0xffffffff)) @@ -353,7 +365,7 @@ zlib.crc32 = zlib_uint uint_data = page.write() zlib.crc32 = zlib_int - int_data = page.write() + int_data = page.write() finally: zlib.crc32 = old_crc @@ -361,9 +373,10 @@ def tearDown(self): self.fileobj.close() -add(TOggPage) -class TOggFileType(TestCase): + +class TOggFileTypeMixin(object): + def scan_file(self): fileobj = open(self.filename, "rb") try: @@ -434,20 +447,20 @@ self.scan_file() def test_really_big(self): - self.audio["foo"] = "foo" * (2**16) - self.audio["bar"] = "bar" * (2**16) - self.audio["baz"] = "quux" * (2**16) + self.audio["foo"] = "foo" * (2 ** 16) + self.audio["bar"] = "bar" * (2 ** 16) + self.audio["baz"] = "quux" * (2 ** 16) self.audio.save() audio = self.Kind(self.filename) - self.failUnlessEqual(audio["foo"], ["foo" * 2**16]) - self.failUnlessEqual(audio["bar"], ["bar" * 2**16]) - self.failUnlessEqual(audio["baz"], ["quux" * 2**16]) + self.failUnlessEqual(audio["foo"], ["foo" * 2 ** 16]) + self.failUnlessEqual(audio["bar"], ["bar" * 2 ** 16]) + self.failUnlessEqual(audio["baz"], ["quux" * 2 ** 16]) self.scan_file() def test_delete_really_big(self): - self.audio["foo"] = "foo" * (2**16) - self.audio["bar"] = "bar" * (2**16) - self.audio["baz"] = "quux" * (2**16) + self.audio["foo"] = "foo" * (2 ** 16) + self.audio["bar"] = "bar" * (2 ** 16) + self.audio["baz"] = "quux" * (2 ** 16) self.audio.save() self.audio.delete() @@ -457,30 +470,27 @@ def test_invalid_open(self): self.failUnlessRaises(IOError, self.Kind, - os.path.join('tests', 'data', 'xing.mp3')) + os.path.join(DATA_DIR, 'xing.mp3')) def test_invalid_delete(self): self.failUnlessRaises(IOError, self.audio.delete, - os.path.join('tests', 'data', 'xing.mp3')) + os.path.join(DATA_DIR, 'xing.mp3')) def test_invalid_save(self): self.failUnlessRaises(IOError, self.audio.save, - os.path.join('tests', 'data', 'xing.mp3')) + os.path.join(DATA_DIR, 'xing.mp3')) def ogg_reference(self, filename): self.scan_file() if have_ogginfo: - value = os.system("ogginfo %s > %s 2> %s" % (filename, devnull, - devnull)) - self.failIf(value and value != NOTFOUND, - "ogginfo failed on %s" % filename) + self.assertEqual(call_ogginfo(filename), 0, + msg="ogginfo failed on %s" % filename) + if have_oggz_validate: if filename.endswith(".opus") and not have_oggz_validate_opus: return - value = os.system( - "oggz-validate %s > %s" % (filename, devnull)) - self.failIf(value and value != NOTFOUND, - "oggz-validate failed on %s" % filename) + self.assertEqual(call_oggz_validate(filename), 0, + msg="oggz-validate failed on %s" % filename) def test_ogg_reference_simple_save(self): self.audio.save() @@ -494,7 +504,7 @@ def test_ogg_reference_delete(self): self.audio.delete() self.ogg_reference(self.filename) - + def test_ogg_reference_medium_sized(self): self.audio["foobar"] = "foobar" * 1000 self.audio.save() @@ -509,29 +519,58 @@ def test_mime_secondary(self): self.failUnless('application/ogg' in self.audio.mime) - - def tearDown(self): - os.unlink(self.filename) -NOTFOUND = os.system("tools/notarealprogram 2> %s" % devnull) + +def call_ogginfo(*args): + with open(os.devnull, 'wb') as null: + return subprocess.call( + ["ogginfo"] + list(args), stdout=null, stderr=subprocess.STDOUT) + + +def call_oggz_validate(*args): + with open(os.devnull, 'wb') as null: + return subprocess.call( + ["oggz-validate"] + list(args), + stdout=null, stderr=subprocess.STDOUT) + + +def get_oggz_validate_version(): + """A version tuple or OSError if oggz-validate isn't available""" + + process = subprocess.Popen(["oggz-validate", "--version"], + stdout=subprocess.PIPE) + output, unused_err = process.communicate() + retcode = process.poll() + if retcode != 0: + return (0,) + lines = output.splitlines() + if not lines: + return (0,) + parts = lines[0].split() + if not parts: + return (0,) + try: + return tuple(map(int, parts[-1].split(b"."))) + except ValueError: + return (0,) + have_ogginfo = True -if os.system("ogginfo 2> %s > %s" % (devnull, devnull)) == NOTFOUND: +try: + call_ogginfo() +except OSError: have_ogginfo = False print("WARNING: Skipping ogginfo reference tests.") + + have_oggz_validate = True have_oggz_validate_opus = True -if os.system("oggz-validate 2> %s > %s" % (devnull, devnull)) == NOTFOUND: +try: + call_oggz_validate() +except OSError: have_oggz_validate = False print("WARNING: Skipping oggz-validate reference tests.") else: - f = os.popen("oggz-validate --version") - try: - version_string = f.read() - version_part = version_string.split()[-1] - version = tuple(map(int, version_part.split("."))) - if version <= (0, 9, 9): - have_oggz_validate_opus = False - print("WARNING: Skipping oggz-validate reference tests for opus") - finally: - f.close() + if get_oggz_validate_version() <= (0, 9, 9): + have_oggz_validate_opus = False + print("WARNING: Skipping oggz-validate reference tests for opus") diff -Nru mutagen-1.23/tests/test_oggspeex.py mutagen-1.30/tests/test_oggspeex.py --- mutagen-1.23/tests/test_oggspeex.py 2013-09-09 10:15:18.000000000 +0000 +++ mutagen-1.30/tests/test_oggspeex.py 2015-05-09 13:13:36.000000000 +0000 @@ -1,23 +1,29 @@ +# -*- coding: utf-8 -*- + import os import shutil from mutagen._compat import cBytesIO from mutagen.ogg import OggPage from mutagen.oggspeex import OggSpeex, OggSpeexInfo, delete -from tests import add -from tests.test_ogg import TOggFileType +from tests import TestCase, DATA_DIR +from tests.test_ogg import TOggFileTypeMixin from tempfile import mkstemp -class TOggSpeex(TOggFileType): + +class TOggSpeex(TestCase, TOggFileTypeMixin): Kind = OggSpeex - + def setUp(self): - original = os.path.join("tests", "data", "empty.spx") + original = os.path.join(DATA_DIR, "empty.spx") fd, self.filename = mkstemp(suffix='.ogg') os.close(fd) shutil.copy(original, self.filename) self.audio = self.Kind(self.filename) + def tearDown(self): + os.unlink(self.filename) + def test_module_delete(self): delete(self.filename) self.scan_file() @@ -43,14 +49,14 @@ self.failUnlessRaises(KeyError, self.audio.tags.__getitem__, "vendor") def test_not_my_ogg(self): - fn = os.path.join('tests', 'data', 'empty.oggflac') + fn = os.path.join(DATA_DIR, 'empty.oggflac') self.failUnlessRaises(IOError, type(self.audio), fn) self.failUnlessRaises(IOError, self.audio.save, fn) self.failUnlessRaises(IOError, self.audio.delete, fn) def test_multiplexed_in_headers(self): shutil.copy( - os.path.join("tests", "data", "multiplexed.spx"), self.filename) + os.path.join(DATA_DIR, "multiplexed.spx"), self.filename) audio = self.Kind(self.filename) audio.tags["foo"] = ["bar"] audio.save() @@ -59,5 +65,3 @@ def test_mime(self): self.failUnless("audio/x-speex" in self.audio.mime) - -add(TOggSpeex) diff -Nru mutagen-1.23/tests/test_oggtheora.py mutagen-1.30/tests/test_oggtheora.py --- mutagen-1.23/tests/test_oggtheora.py 2013-09-10 16:19:49.000000000 +0000 +++ mutagen-1.30/tests/test_oggtheora.py 2015-05-09 13:13:51.000000000 +0000 @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + import os import shutil @@ -6,22 +8,26 @@ from mutagen._compat import cBytesIO from mutagen.oggtheora import OggTheora, OggTheoraInfo, delete from mutagen.ogg import OggPage -from tests import add -from tests.test_ogg import TOggFileType +from tests import TestCase, DATA_DIR +from tests.test_ogg import TOggFileTypeMixin + -class TOggTheora(TOggFileType): +class TOggTheora(TestCase, TOggFileTypeMixin): Kind = OggTheora def setUp(self): - original = os.path.join("tests", "data", "sample.oggtheora") + original = os.path.join(DATA_DIR, "sample.oggtheora") fd, self.filename = mkstemp(suffix='.ogg') os.close(fd) shutil.copy(original, self.filename) self.audio = OggTheora(self.filename) self.audio2 = OggTheora( - os.path.join("tests", "data", "sample_length.oggtheora")) + os.path.join(DATA_DIR, "sample_length.oggtheora")) self.audio3 = OggTheora( - os.path.join("tests", "data", "sample_bitrate.oggtheora")) + os.path.join(DATA_DIR, "sample_bitrate.oggtheora")) + + def tearDown(self): + os.unlink(self.filename) def test_theora_bad_version(self): page = OggPage(open(self.filename, "rb")) @@ -43,7 +49,7 @@ self.failUnlessRaises(KeyError, self.audio.tags.__getitem__, "vendor") def test_not_my_ogg(self): - fn = os.path.join('tests', 'data', 'empty.ogg') + fn = os.path.join(DATA_DIR, 'empty.ogg') self.failUnlessRaises(IOError, type(self.audio), fn) self.failUnlessRaises(IOError, self.audio.save, fn) self.failUnlessRaises(IOError, self.audio.delete, fn) @@ -62,5 +68,3 @@ def test_mime(self): self.failUnless("video/x-theora" in self.audio.mime) - -add(TOggTheora) diff -Nru mutagen-1.23/tests/test_oggvorbis.py mutagen-1.30/tests/test_oggvorbis.py --- mutagen-1.23/tests/test_oggvorbis.py 2013-09-10 16:18:57.000000000 +0000 +++ mutagen-1.30/tests/test_oggvorbis.py 2015-05-09 13:14:14.000000000 +0000 @@ -1,23 +1,29 @@ +# -*- coding: utf-8 -*- + import os import shutil from mutagen._compat import cBytesIO from mutagen.ogg import OggPage from mutagen.oggvorbis import OggVorbis, OggVorbisInfo, delete -from tests import add -from tests.test_ogg import TOggFileType +from tests import TestCase, DATA_DIR +from tests.test_ogg import TOggFileTypeMixin from tempfile import mkstemp -class TOggVorbis(TOggFileType): + +class TOggVorbis(TestCase, TOggFileTypeMixin): Kind = OggVorbis - + def setUp(self): - original = os.path.join("tests", "data", "empty.ogg") + original = os.path.join(DATA_DIR, "empty.ogg") fd, self.filename = mkstemp(suffix='.ogg') os.close(fd) shutil.copy(original, self.filename) self.audio = self.Kind(self.filename) + def tearDown(self): + os.unlink(self.filename) + def test_module_delete(self): delete(self.filename) self.scan_file() @@ -81,21 +87,24 @@ def test_vorbiscomment(self): self.audio.save() self.scan_file() - if ogg is None: return + if ogg is None: + return self.failUnless(ogg.vorbis.VorbisFile(self.filename)) def test_vorbiscomment_big(self): self.test_really_big() self.audio.save() self.scan_file() - if ogg is None: return + if ogg is None: + return vfc = ogg.vorbis.VorbisFile(self.filename).comment() self.failUnlessEqual(self.audio["foo"], vfc["foo"]) def test_vorbiscomment_delete(self): self.audio.delete() self.scan_file() - if ogg is None: return + if ogg is None: + return vfc = ogg.vorbis.VorbisFile(self.filename).comment() self.failUnlessEqual(vfc.keys(), ["VENDOR"]) @@ -105,7 +114,8 @@ self.audio["foobar"] = "foobar" * 1000 self.audio.save() self.scan_file() - if ogg is None: return + if ogg is None: + return vfc = ogg.vorbis.VorbisFile(self.filename).comment() self.failUnlessEqual(self.audio["foobar"], vfc["foobar"]) self.failUnless("FOOBAR" in vfc.keys()) @@ -113,7 +123,7 @@ def test_huge_tag(self): vorbis = self.Kind( - os.path.join("tests", "data", "multipagecomment.ogg")) + os.path.join(DATA_DIR, "multipagecomment.ogg")) self.failUnless("big" in vorbis.tags) self.failUnless("bigger" in vorbis.tags) self.failUnlessEqual(vorbis.tags["big"], ["foobar" * 10000]) @@ -121,13 +131,13 @@ self.scan_file() def test_not_my_ogg(self): - fn = os.path.join('tests', 'data', 'empty.oggflac') + fn = os.path.join(DATA_DIR, 'empty.oggflac') self.failUnlessRaises(IOError, type(self.audio), fn) self.failUnlessRaises(IOError, self.audio.save, fn) self.failUnlessRaises(IOError, self.audio.delete, fn) def test_save_split_setup_packet(self): - fn = os.path.join("tests", "data", "multipage-setup.ogg") + fn = os.path.join(DATA_DIR, "multipage-setup.ogg") shutil.copy(fn, self.filename) audio = OggVorbis(self.filename) tags = audio.tags @@ -137,7 +147,8 @@ self.failUnlessEqual(self.audio.tags, tags) def test_save_split_setup_packet_reference(self): - if ogg is None: return + if ogg is None: + return self.test_save_split_setup_packet() vfc = ogg.vorbis.VorbisFile(self.filename).comment() for key in self.audio: @@ -145,8 +156,9 @@ self.ogg_reference(self.filename) def test_save_grown_split_setup_packet_reference(self): - if ogg is None: return - fn = os.path.join("tests", "data", "multipage-setup.ogg") + if ogg is None: + return + fn = os.path.join(DATA_DIR, "multipage-setup.ogg") shutil.copy(fn, self.filename) audio = OggVorbis(self.filename) audio["foobar"] = ["quux" * 50000] @@ -163,9 +175,8 @@ def test_mime(self): self.failUnless("audio/vorbis" in self.audio.mime) -try: import ogg.vorbis +try: + import ogg.vorbis except ImportError: print("WARNING: Skipping Ogg Vorbis reference tests.") ogg = None - -add(TOggVorbis) diff -Nru mutagen-1.23/tests/test_optimfrog.py mutagen-1.30/tests/test_optimfrog.py --- mutagen-1.23/tests/test_optimfrog.py 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/tests/test_optimfrog.py 2015-05-09 13:14:49.000000000 +0000 @@ -1,13 +1,16 @@ +# -*- coding: utf-8 -*- + import os from mutagen.optimfrog import OptimFROG, OptimFROGHeaderError -from tests import TestCase, add +from tests import TestCase, DATA_DIR + class TOptimFROG(TestCase): def setUp(self): - self.ofr = OptimFROG(os.path.join("tests", "data", "empty.ofr")) - self.ofs = OptimFROG(os.path.join("tests", "data", "empty.ofs")) + self.ofr = OptimFROG(os.path.join(DATA_DIR, "empty.ofr")) + self.ofs = OptimFROG(os.path.join(DATA_DIR, "empty.ofs")) def test_channels(self): self.failUnlessEqual(self.ofr.info.channels, 2) @@ -24,13 +27,11 @@ def test_not_my_file(self): self.failUnlessRaises( OptimFROGHeaderError, OptimFROG, - os.path.join("tests", "data", "empty.ogg")) + os.path.join(DATA_DIR, "empty.ogg")) self.failUnlessRaises( OptimFROGHeaderError, OptimFROG, - os.path.join("tests", "data", "click.mpc")) + os.path.join(DATA_DIR, "click.mpc")) def test_pprint(self): self.failUnless(self.ofr.pprint()) self.failUnless(self.ofs.pprint()) - -add(TOptimFROG) diff -Nru mutagen-1.23/tests/test_tools_mid3cp.py mutagen-1.30/tests/test_tools_mid3cp.py --- mutagen-1.23/tests/test_tools_mid3cp.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/tests/test_tools_mid3cp.py 2015-05-09 13:09:34.000000000 +0000 @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- + +# Copyright 2014 Ben Ockmore + +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +"""Tests for mid3cp tool. Since the tool is quite simple, most of the +functionality is covered by the mutagen package tests - these simply test +usage. +""" + +import os +from tempfile import mkstemp +import shutil +from mutagen.id3 import ID3, ParseID3v1 +from mutagen._toolsutil import fsnative as fsn + +from tests.test_tools import _TTools +from tests import DATA_DIR + + +class TMid3cp(_TTools): + + TOOL_NAME = u"mid3cp" + + def setUp(self): + super(TMid3cp, self).setUp() + original = os.path.join(DATA_DIR, fsn(u'silence-44-s.mp3')) + fd, self.filename = mkstemp(suffix=fsn(u'öäü.mp3')) + os.close(fd) + shutil.copy(original, self.filename) + + fd, self.blank_file = mkstemp(suffix=fsn(u'.mp3')) + os.close(fd) + + def tearDown(self): + super(TMid3cp, self).tearDown() + os.unlink(self.filename) + os.unlink(self.blank_file) + + def test_noop(self): + res, out, err = self.call2() + self.assertNotEqual(res, 0) + self.failUnless("Usage:" in err) + + def test_src_equal_dst(self): + res = self.call2(self.filename, self.filename)[0] + self.assertEqual(res, 0) + + def test_copy(self): + res = self.call(self.filename, self.blank_file)[0] + self.failIf(res) + + original_id3 = ID3(self.filename) + copied_id3 = ID3(self.blank_file) + + self.failUnlessEqual(original_id3, copied_id3) + + for key in original_id3: + # Go through every tag in the original file, and check that it's + # present and correct in the copy + self.failUnless(key in copied_id3) + self.failUnlessEqual(copied_id3[key], original_id3[key]) + + def test_include_id3v1(self): + self.call(fsn(u'--write-v1'), self.filename, self.blank_file) + + with open(self.blank_file, 'rb') as fileobj: + fileobj.seek(-128, 2) + frames = ParseID3v1(fileobj.read(128)) + + # If ID3v1 frames are present, assume they've been written correctly by + # mutagen, so no need to check them + self.failUnless(frames) + + def test_exclude_tag_unicode(self): + self.call(fsn(u'-x'), fsn(u''), self.filename, self.blank_file) + + def test_exclude_single_tag(self): + self.call(fsn(u'-x'), fsn(u'TLEN'), self.filename, self.blank_file) + + original_id3 = ID3(self.filename) + copied_id3 = ID3(self.blank_file) + + self.failUnless('TLEN' in original_id3) + self.failIf('TLEN' in copied_id3) + + def test_exclude_multiple_tag(self): + self.call(fsn(u'-x'), fsn(u'TLEN'), fsn(u'-x'), fsn(u'TCON'), + fsn(u'-x'), fsn(u'TALB'), self.filename, self.blank_file) + + original_id3 = ID3(self.filename) + copied_id3 = ID3(self.blank_file) + + self.failUnless('TLEN' in original_id3) + self.failUnless('TCON' in original_id3) + self.failUnless('TALB' in original_id3) + self.failIf('TLEN' in copied_id3) + self.failIf('TCON' in copied_id3) + self.failIf('TALB' in copied_id3) + + def test_no_src_header(self): + fd, blank_file2 = mkstemp(suffix=fsn(u'.mp3')) + os.close(fd) + try: + err = self.call2(self.blank_file, blank_file2)[2] + self.failUnless("No ID3 header found" in err) + finally: + os.unlink(blank_file2) + + def test_verbose(self): + err = self.call2(self.filename, fsn(u"--verbose"), self.blank_file)[2] + self.failUnless('mp3 contains:' in err) + self.failUnless('Successfully saved' in err) + + def test_quiet(self): + out = self.call(self.filename, self.blank_file)[1] + self.failIf(out) + + def test_exit_status(self): + status, out, err = self.call2(self.filename) + self.assertTrue(status) + + status, out, err = self.call2(self.filename, self.filename) + self.assertFalse(status) + + status, out, err = self.call2(self.blank_file, self.filename) + self.assertTrue(status) + + status, out, err = self.call2(fsn(u""), self.filename) + self.assertTrue(status) + + status, out, err = self.call2(self.filename, self.blank_file) + self.assertFalse(status) + + def test_v23_v24(self): + self.assertEqual(ID3(self.filename).version, (2, 3, 0)) + self.call(self.filename, self.blank_file) + self.assertEqual(ID3(self.blank_file).version, (2, 3, 0)) + + ID3(self.filename).save() + self.call(self.filename, self.blank_file) + self.assertEqual(ID3(self.blank_file).version, (2, 4, 0)) diff -Nru mutagen-1.23/tests/test_tools_mid3iconv.py mutagen-1.30/tests/test_tools_mid3iconv.py --- mutagen-1.23/tests/test_tools_mid3iconv.py 2013-09-11 14:15:45.000000000 +0000 +++ mutagen-1.30/tests/test_tools_mid3iconv.py 2015-05-09 12:39:31.000000000 +0000 @@ -1,11 +1,15 @@ +# -*- coding: utf-8 -*- + import os from tempfile import mkstemp import shutil from mutagen.id3 import ID3 +from mutagen._toolsutil import fsnative as fsn +from mutagen._compat import text_type -from tests import add from tests.test_tools import _TTools +from tests import DATA_DIR AMBIGUOUS = b"\xc3\xae\xc3\xa5\xc3\xb4\xc3\xb2 \xc3\xa0\xc3\xa9\xc3\xa7\xc3" \ @@ -16,12 +20,12 @@ class TMid3Iconv(_TTools): - TOOL_NAME = "mid3iconv" + TOOL_NAME = u"mid3iconv" def setUp(self): super(TMid3Iconv, self).setUp() - original = os.path.join('tests', 'data', 'silence-44-s.mp3') - fd, self.filename = mkstemp(suffix='.mp3') + original = os.path.join(DATA_DIR, fsn(u'silence-44-s.mp3')) + fd, self.filename = mkstemp(suffix=fsn(u'.mp3')) os.close(fd) shutil.copy(original, self.filename) @@ -35,12 +39,13 @@ self.failUnless("Usage:" in out) def test_debug(self): - res, out = self.call("-d", "-p", self.filename) + res, out = self.call(fsn(u"-d"), fsn(u"-p"), self.filename) self.failIf(res) + self.assertFalse("b'" in out) self.failUnless("TCON=Silence" in out) def test_quiet(self): - res, out = self.call("-q", self.filename) + res, out = self.call(fsn(u"-q"), self.filename) self.failIf(res) self.failIf(out) @@ -57,34 +62,34 @@ f = ID3(self.filename) f.add(TALB(text=[AMBIGUOUS.decode("latin-1")], encoding=0)) f.save() - res, out = self.call("-d", "-e", codec, self.filename) + res, out = self.call( + fsn(u"-d"), fsn(u"-e"), fsn(text_type(codec)), self.filename) f = ID3(self.filename) self.failUnlessEqual(f["TALB"].encoding, 1) - self.failUnlessEqual(f["TALB"].text[0] , AMBIGUOUS.decode(codec)) + self.failUnlessEqual(f["TALB"].text[0], AMBIGUOUS.decode(codec)) def test_comm(self): from mutagen.id3 import COMM for codec in CODECS: f = ID3(self.filename) - frame = COMM(desc="", lang="eng", encoding=0, + frame = COMM(desc="", lang="eng", encoding=0, text=[AMBIGUOUS.decode("latin-1")]) f.add(frame) f.save() - res, out = self.call("-d", "-e", codec, self.filename) + res, out = self.call( + fsn(u"-d"), fsn(u"-e"), fsn(text_type(codec)), self.filename) f = ID3(self.filename) new_frame = f[frame.HashKey] self.failUnlessEqual(new_frame.encoding, 1) - self.failUnlessEqual(new_frame.text[0] , AMBIGUOUS.decode(codec)) + self.failUnlessEqual(new_frame.text[0], AMBIGUOUS.decode(codec)) def test_remove_v1(self): from mutagen.id3 import ParseID3v1 - res, out = self.call("--remove-v1", self.filename) + res, out = self.call(fsn(u"--remove-v1"), self.filename) with open(self.filename, "rb") as h: h.seek(-128, 2) data = h.read() self.failUnlessEqual(len(data), 128) self.failIf(ParseID3v1(data)) - -add(TMid3Iconv) diff -Nru mutagen-1.23/tests/test_tools_mid3v2.py mutagen-1.30/tests/test_tools_mid3v2.py --- mutagen-1.23/tests/test_tools_mid3v2.py 2013-10-13 17:33:09.000000000 +0000 +++ mutagen-1.30/tests/test_tools_mid3v2.py 2015-05-09 13:10:15.000000000 +0000 @@ -1,23 +1,28 @@ +# -*- coding: utf-8 -*- + import os from tempfile import mkstemp import shutil +import locale import mutagen from mutagen.id3 import ID3 -from mutagen._compat import PY2 +from mutagen._compat import PY2, PY3 +from mutagen._toolsutil import fsnative as fsn, is_fsnative as isfsn -from tests import add from tests.test_tools import _TTools +from tests import DATA_DIR class TMid3v2(_TTools): - TOOL_NAME = "mid3v2" + TOOL_NAME = u"mid3v2" def setUp(self): super(TMid3v2, self).setUp() - original = os.path.join('tests', 'data', 'silence-44-s.mp3') - fd, self.filename = mkstemp(suffix='.mp3') + original = os.path.join(DATA_DIR, fsn(u'silence-44-s.mp3')) + fd, self.filename = mkstemp(suffix=fsn(u'öäü.mp3')) + assert isfsn(self.filename) os.close(fd) shutil.copy(original, self.filename) @@ -25,14 +30,20 @@ super(TMid3v2, self).tearDown() os.unlink(self.filename) + def test_no_tags(self): + f = ID3(self.filename) + f.delete() + res, out, err = self.call2(fsn(u"-l"), self.filename) + self.assertTrue("No ID3 header found" in out) + def test_list_genres(self): - for arg in ["-L", "--list-genres"]: + for arg in [fsn(u"-L"), fsn(u"--list-genres")]: res, out = self.call(arg) self.failUnlessEqual(res, 0) self.failUnless("Acid Punk" in out) def test_list_frames(self): - for arg in ["-f", "--list-frames"]: + for arg in [fsn(u"-f"), fsn(u"--list-frames")]: res, out = self.call(arg) self.failUnlessEqual(res, 0) self.failUnless("--APIC" in out) @@ -41,19 +52,20 @@ def test_list(self): f = ID3(self.filename) album = f["TALB"].text[0] - for arg in ["-l", "--list"]: + for arg in [fsn(u"-l"), fsn(u"--list")]: res, out = self.call(arg, self.filename) + self.assertFalse("b'" in out) self.failUnlessEqual(res, 0) - self.failUnless("TALB=" + album in out) + self.failUnless("TALB=" + fsn(album) in out) def test_list_raw(self): f = ID3(self.filename) - res, out = self.call("--list-raw", self.filename) + res, out = self.call(fsn(u"--list-raw"), self.filename) self.failUnlessEqual(res, 0) self.failUnless(repr(f["TALB"]) in out) def _test_text_frame(self, short, longer, frame): - new_value = "TEST" + new_value = fsn(u"TEST") for arg in [short, longer]: orig = ID3(self.filename) frame_class = mutagen.id3.Frames[frame] @@ -66,49 +78,24 @@ self.failUnlessEqual(ID3(self.filename)[frame].text, [new_value]) def test_artist(self): - self._test_text_frame("-a", "--artist", "TPE1") + self._test_text_frame(fsn(u"-a"), fsn(u"--artist"), "TPE1") def test_album(self): - self._test_text_frame("-A", "--album", "TALB") + self._test_text_frame(fsn(u"-A"), fsn(u"--album"), "TALB") def test_title(self): - self._test_text_frame("-t", "--song", "TIT2") + self._test_text_frame(fsn(u"-t"), fsn(u"--song"), "TIT2") def test_genre(self): - self._test_text_frame("-g", "--genre", "TCON") + self._test_text_frame(fsn(u"-g"), fsn(u"--genre"), "TCON") def test_convert(self): - res, out = self.call("--convert", self.filename) + res, out = self.call(fsn(u"--convert"), self.filename) self.failUnlessEqual((res, out), (0, "")) - def test_split_escape(self): - split_escape = self.get_var("split_escape") - - inout = [ - (("", ":"), [""]), - ((":", ":"), ["", ""]), - ((":", ":", 0), [":"]), - ((":b:c:", ":", 0), [":b:c:"]), - ((":b:c:", ":", 1), ["", "b:c:"]), - ((":b:c:", ":", 2), ["", "b", "c:"]), - ((":b:c:", ":", 3), ["", "b", "c", ""]), - (("a\\:b:c", ":"), ["a:b", "c"]), - (("a\\\\:b:c", ":"), ["a\\", "b", "c"]), - (("a\\\\\\:b:c\\:", ":"), ["a\\:b", "c:"]), - (("\\", ":"), [""]), - (("\\\\", ":"), ["\\"]), - (("\\\\a\\b", ":"), ["\\a\\b"]), - ] - - for inargs, out in inout: - self.assertEqual(split_escape(*inargs), out) - - def test_unescape(self): - unescape_string = self.get_var("unescape_string") - self.assertEqual(unescape_string("\\n"), "\n") - def test_artist_escape(self): - res, out = self.call("-e", "-a", "foo\\nbar", self.filename) + res, out = self.call( + fsn(u"-e"), fsn(u"-a"), fsn(u"foo\\nbar"), self.filename) self.failUnlessEqual(res, 0) self.failIf(out) f = ID3(self.filename) @@ -116,18 +103,19 @@ def test_txxx_escape(self): res, out = self.call( - "-e", "--TXXX", "EscapeTest\\:\\:albumartist:Ex\\:ample", + fsn(u"-e"), fsn(u"--TXXX"), + fsn(u"EscapeTest\\:\\:albumartist:Ex\\:ample"), self.filename) self.failUnlessEqual(res, 0) self.failIf(out) f = ID3(self.filename) frame = f.getall("TXXX")[0] - self.failUnlessEqual(frame.desc, "EscapeTest::albumartist") - self.failUnlessEqual(frame.text, ["Ex:ample"]) + self.failUnlessEqual(frame.desc, u"EscapeTest::albumartist") + self.failUnlessEqual(frame.text, [u"Ex:ample"]) def test_txxx(self): - res, out = self.call("--TXXX", "A\\:B:C", self.filename) + res, out = self.call(fsn(u"--TXXX"), fsn(u"A\\:B:C"), self.filename) self.failUnlessEqual((res, out), (0, "")) f = ID3(self.filename) @@ -136,7 +124,7 @@ self.failUnlessEqual(frame.text, ["B:C"]) def test_comm1(self): - res, out = self.call("--COMM", "A", self.filename) + res, out = self.call(fsn(u"--COMM"), fsn(u"A"), self.filename) self.failUnlessEqual((res, out), (0, "")) f = ID3(self.filename) @@ -145,7 +133,7 @@ self.failUnlessEqual(frame.text, ["A"]) def test_comm2(self): - res, out = self.call("--COMM", "Y:B", self.filename) + res, out = self.call(fsn(u"--COMM"), fsn(u"Y:B"), self.filename) self.failUnlessEqual((res, out), (0, "")) f = ID3(self.filename) @@ -154,7 +142,8 @@ self.failUnlessEqual(frame.text, ["B"]) def test_comm2_escape(self): - res, out = self.call("-e", "--COMM", "Y\\:B\\nG", self.filename) + res, out = self.call( + fsn(u"-e"), fsn(u"--COMM"), fsn(u"Y\\:B\\nG"), self.filename) self.failUnlessEqual((res, out), (0, "")) f = ID3(self.filename) @@ -163,7 +152,8 @@ self.failUnlessEqual(frame.text, ["Y:B\nG"]) def test_comm3(self): - res, out = self.call("--COMM", "Z:B:C:D:ger", self.filename) + res, out = self.call( + fsn(u"--COMM"), fsn(u"Z:B:C:D:ger"), self.filename) self.failUnlessEqual((res, out), (0, "")) f = ID3(self.filename) @@ -173,26 +163,72 @@ self.failUnlessEqual(frame.lang, "ger") def test_encoding_with_escape(self): - import locale + is_bytes = PY2 and os.name != "nt" + text = u'\xe4\xf6\xfc' - enc = locale.getpreferredencoding() - if PY2: - text = text.encode(enc) - res, out = self.call("-e", "-a", text, self.filename) + if is_bytes: + enc = locale.getpreferredencoding() + # don't fail in case getpreferredencoding doesn't give us a unicode + # encoding. + text = text.encode(enc, "replace") + res, out = self.call(fsn(u"-e"), fsn(u"-a"), text, self.filename) self.failUnlessEqual((res, out), (0, "")) + f = ID3(self.filename) + if is_bytes: + text = text.decode(enc) + self.assertEqual(f.getall("TPE1")[0], text) + + def test_invalid_encoding_escaped(self): + res, out, err = self.call2( + fsn(u"--TALB"), fsn(u'\\xff\\x81'), fsn(u'-e'), self.filename) + self.failIfEqual(res, 0) + self.failUnless("TALB" in err) def test_invalid_encoding(self): - res, out = self.call("--TALB", '\\xff', '-e', self.filename) + if os.name == "nt": + return + + value = b"\xff\xff\x81" + self.assertRaises(ValueError, value.decode, "utf-8") + self.assertRaises(ValueError, value.decode, "cp1252") + enc = locale.getpreferredencoding() + + # we need the decoding to fail for this test to work... + try: + value.decode(enc) + except ValueError: + pass + else: + return + + if not PY2: + value = value.decode(enc, "surrogateescape") + res, out, err = self.call2("--TALB", value, self.filename) self.failIfEqual(res, 0) - self.failUnless("TALB" in out) + self.failUnless("TALB" in err) def test_invalid_escape(self): - res, out = self.call("--TALB", '\\xaz', '-e', self.filename) + res, out, err = self.call2( + fsn(u"--TALB"), fsn(u'\\xaz'), fsn(u'-e'), self.filename) self.failIfEqual(res, 0) - self.failUnless("TALB" in out) + self.failUnless("TALB" in err) - res, out = self.call("--TALB", '\\', '-e', self.filename) + res, out, err = self.call2( + fsn(u"--TALB"), fsn(u'\\'), fsn(u'-e'), self.filename) self.failIfEqual(res, 0) - self.failUnless("TALB" in out) + self.failUnless("TALB" in err) -add(TMid3v2) + def test_value_from_fsnative(self): + vffs = self.get_var("value_from_fsnative") + self.assertEqual(vffs(fsn(u"öäü\\n"), True), u"öäü\n") + self.assertEqual(vffs(fsn(u"öäü\\n"), False), u"öäü\\n") + + if os.name != "nt" and PY3: + se = b"\xff".decode("utf-8", "surrogateescape") + self.assertRaises(ValueError, vffs, se, False) + + def test_frame_from_fsnative(self): + fffs = self.get_var("frame_from_fsnative") + self.assertTrue(isinstance(fffs(fsn(u"abc")), str)) + self.assertEqual(fffs(fsn(u"abc")), "abc") + self.assertRaises(ValueError, fffs, fsn(u"öäü")) diff -Nru mutagen-1.23/tests/test_tools_moggsplit.py mutagen-1.30/tests/test_tools_moggsplit.py --- mutagen-1.23/tests/test_tools_moggsplit.py 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/tests/test_tools_moggsplit.py 2015-05-09 12:29:33.000000000 +0000 @@ -1,25 +1,32 @@ +# -*- coding: utf-8 -*- + import os from tempfile import mkstemp import shutil -from tests import add +from mutagen._compat import text_type +from mutagen._toolsutil import fsnative as fsn + from tests.test_tools import _TTools +from tests import DATA_DIR class TMOggSPlit(_TTools): - TOOL_NAME = "moggsplit" + TOOL_NAME = u"moggsplit" def setUp(self): super(TMOggSPlit, self).setUp() - original = os.path.join('tests', 'data', 'multipagecomment.ogg') - fd, self.filename = mkstemp(suffix='.ogg') + original = os.path.join( + DATA_DIR, fsn(u'multipagecomment.ogg')) + fd, self.filename = mkstemp(suffix=fsn(u'.ogg')) os.close(fd) shutil.copy(original, self.filename) # append the second file first = open(self.filename, "ab") - to_append = os.path.join('tests', 'data', 'multipage-setup.ogg') + to_append = os.path.join( + DATA_DIR, fsn(u'multipage-setup.ogg')) second = open(to_append, "rb") first.write(second.read()) second.close() @@ -31,14 +38,13 @@ def test_basic(self): d = os.path.dirname(self.filename) - p = os.path.join(d, "%(stream)d.%(ext)s") - res, out = self.call("--pattern", p, self.filename) + p = os.path.join(d, fsn(u"%(stream)d.%(ext)s")) + res, out = self.call(fsn(u"--pattern"), p, self.filename) self.failIf(res) self.failIf(out) for stream in [1002429366, 1806412655]: - stream_path = os.path.join(d, str(stream) + ".ogg") + stream_path = os.path.join( + d, fsn(text_type(stream)) + fsn(u".ogg")) self.failUnless(os.path.exists(stream_path)) os.unlink(stream_path) - -add(TMOggSPlit) diff -Nru mutagen-1.23/tests/test_tools_mutagen_inspect.py mutagen-1.30/tests/test_tools_mutagen_inspect.py --- mutagen-1.23/tests/test_tools_mutagen_inspect.py 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/tests/test_tools_mutagen_inspect.py 2015-08-17 09:43:52.000000000 +0000 @@ -1,16 +1,19 @@ +# -*- coding: utf-8 -*- + import os import glob -from tests import add from tests.test_tools import _TTools +from mutagen._toolsutil import fsnative as fsn + class TMutagenInspect(_TTools): - TOOL_NAME = "mutagen-inspect" + TOOL_NAME = u"mutagen-inspect" def test_basic(self): - base = os.path.join('tests', 'data') + base = os.path.join(fsn(u'tests'), fsn(u'data')) self.paths = glob.glob(os.path.join(base, "empty*")) self.paths += glob.glob(os.path.join(base, "silence-*")) @@ -20,5 +23,3 @@ self.failUnless(out.strip()) self.failIf("Unknown file type" in out) self.failIf("Errno" in out) - -add(TMutagenInspect) diff -Nru mutagen-1.23/tests/test_tools_mutagen_pony.py mutagen-1.30/tests/test_tools_mutagen_pony.py --- mutagen-1.23/tests/test_tools_mutagen_pony.py 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/tests/test_tools_mutagen_pony.py 2015-04-29 19:04:39.000000000 +0000 @@ -1,17 +1,18 @@ +# -*- coding: utf-8 -*- + import os -from tests import add from tests.test_tools import _TTools +from mutagen._toolsutil import fsnative as fsn + class TMutagenPony(_TTools): - TOOL_NAME = "mutagen-pony" + TOOL_NAME = u"mutagen-pony" def test_basic(self): - base = os.path.join('tests', 'data') + base = os.path.join(fsn(u'tests'), fsn(u'data')) res, out = self.call(base) self.failIf(res) - self.failUnless("Report for tests/data" in out) - -add(TMutagenPony) + self.failUnless("Report for %s" % base in out) diff -Nru mutagen-1.23/tests/test_tools.py mutagen-1.30/tests/test_tools.py --- mutagen-1.23/tests/test_tools.py 2013-09-11 13:58:54.000000000 +0000 +++ mutagen-1.30/tests/test_tools.py 2015-05-09 12:38:04.000000000 +0000 @@ -1,15 +1,25 @@ +# -*- coding: utf-8 -*- + import os import sys import imp -from mutagen._compat import StringIO +import mutagen +from mutagen._compat import StringIO, text_type, PY2 +from mutagen._toolsutil import fsnative, is_fsnative from tests import TestCase def get_var(tool_name, entry="main"): - tool_path = os.path.join("tools", tool_name) - mod = imp.load_source(tool_name, tool_path) + tool_path = os.path.join( + mutagen.__path__[0], "..", "tools", fsnative(tool_name)) + dont_write_bytecode = sys.dont_write_bytecode + sys.dont_write_bytecode = True + try: + mod = imp.load_source(tool_name, tool_path) + finally: + sys.dont_write_bytecode = dont_write_bytecode return getattr(mod, entry) @@ -17,26 +27,40 @@ TOOL_NAME = None def setUp(self): + self.assertTrue(isinstance(self.TOOL_NAME, text_type)) self._main = get_var(self.TOOL_NAME) def get_var(self, name): return get_var(self.TOOL_NAME, name) - def call(self, *args): + def call2(self, *args): for arg in args: - assert isinstance(arg, str) + self.assertTrue(is_fsnative(arg)) old_stdout = sys.stdout + old_stderr = sys.stderr try: out = StringIO() + err = StringIO() sys.stdout = out + sys.stderr = err try: - ret = self._main([self.TOOL_NAME] + list(args)) + ret = self._main([fsnative(self.TOOL_NAME)] + list(args)) except SystemExit as e: ret = e.code ret = ret or 0 - return (ret, out.getvalue()) + out_val = out.getvalue() + err_val = err.getvalue() + if os.name == "nt" and PY2: + encoding = getattr(sys.stdout, "encoding", None) or "utf-8" + out_val = text_type(out_val, encoding) + err_val = text_type(err_val, encoding) + return (ret, out_val, err_val) finally: sys.stdout = old_stdout + sys.stderr = old_stderr + + def call(self, *args): + return self.call2(*args)[:2] def tearDown(self): del self._main diff -Nru mutagen-1.23/tests/test__toolsutil.py mutagen-1.30/tests/test__toolsutil.py --- mutagen-1.23/tests/test__toolsutil.py 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/tests/test__toolsutil.py 2015-04-29 19:12:25.000000000 +0000 @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- + +import os + +from mutagen._toolsutil import get_win32_unicode_argv, split_escape +from mutagen._compat import text_type + +from tests import TestCase + + +class Tget_win32_unicode_argv(TestCase): + + def test_main(self): + argv = get_win32_unicode_argv() + if os.name == "nt" and argv: + self.assertTrue(isinstance(argv[0], text_type)) + + +class Tsplit_escape(TestCase): + def test_split_escape(self): + inout = [ + (("", ":"), [""]), + ((":", ":"), ["", ""]), + ((":", ":", 0), [":"]), + ((":b:c:", ":", 0), [":b:c:"]), + ((":b:c:", ":", 1), ["", "b:c:"]), + ((":b:c:", ":", 2), ["", "b", "c:"]), + ((":b:c:", ":", 3), ["", "b", "c", ""]), + (("a\\:b:c", ":"), ["a:b", "c"]), + (("a\\\\:b:c", ":"), ["a\\", "b", "c"]), + (("a\\\\\\:b:c\\:", ":"), ["a\\:b", "c:"]), + (("\\", ":"), [""]), + (("\\\\", ":"), ["\\"]), + (("\\\\a\\b", ":"), ["\\a\\b"]), + ] + + for inargs, out in inout: + self.assertEqual(split_escape(*inargs), out) + + def test_types(self): + parts = split_escape(b"\xff:\xff", b":") + self.assertEqual(parts, [b"\xff", b"\xff"]) + self.assertTrue(isinstance(parts[0], bytes)) + + parts = split_escape(b"", b":") + self.assertEqual(parts, [b""]) + self.assertTrue(isinstance(parts[0], bytes)) + + parts = split_escape(u"a:b", u":") + self.assertEqual(parts, [u"a", u"b"]) + self.assertTrue(all(isinstance(p, text_type) for p in parts)) + + parts = split_escape(u"", u":") + self.assertEqual(parts, [u""]) + self.assertTrue(all(isinstance(p, text_type) for p in parts)) + + parts = split_escape(u":", u":") + self.assertEqual(parts, [u"", u""]) + self.assertTrue(all(isinstance(p, text_type) for p in parts)) diff -Nru mutagen-1.23/tests/test_trueaudio.py mutagen-1.30/tests/test_trueaudio.py --- mutagen-1.23/tests/test_trueaudio.py 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/tests/test_trueaudio.py 2015-05-09 13:15:18.000000000 +0000 @@ -1,14 +1,17 @@ +# -*- coding: utf-8 -*- + import os import shutil from mutagen.trueaudio import TrueAudio, delete from mutagen.id3 import TIT1 -from tests import TestCase, add +from tests import TestCase, DATA_DIR from tempfile import mkstemp + class TTrueAudio(TestCase): def setUp(self): - self.audio = TrueAudio(os.path.join("tests", "data", "empty.tta")) + self.audio = TrueAudio(os.path.join(DATA_DIR, "empty.tta")) def test_tags(self): self.failUnless(self.audio.tags is None) @@ -20,11 +23,11 @@ self.failUnlessEqual(44100, self.audio.info.sample_rate) def test_not_my_file(self): - filename = os.path.join("tests", "data", "empty.ogg") + filename = os.path.join(DATA_DIR, "empty.ogg") self.failUnlessRaises(IOError, TrueAudio, filename) def test_module_delete(self): - delete(os.path.join("tests", "data", "empty.tta")) + delete(os.path.join(DATA_DIR, "empty.tta")) def test_delete(self): self.audio.delete() @@ -49,5 +52,3 @@ def test_mime(self): self.failUnless("audio/x-tta" in self.audio.mime) - -add(TTrueAudio) diff -Nru mutagen-1.23/tests/test__util.py mutagen-1.30/tests/test__util.py --- mutagen-1.23/tests/test__util.py 2013-09-13 10:22:27.000000000 +0000 +++ mutagen-1.30/tests/test__util.py 2015-08-17 10:42:51.000000000 +0000 @@ -1,7 +1,19 @@ -from mutagen._util import DictMixin, cdata, utf8, insert_bytes, delete_bytes -from mutagen._compat import text_type, itervalues, iterkeys, iteritems, PY2 -from tests import TestCase, add +# -*- coding: utf-8 -*- + +from mutagen._util import DictMixin, cdata, insert_bytes, delete_bytes +from mutagen._util import decode_terminated, dict_match, enum +from mutagen._util import BitReader, BitReaderError +from mutagen._compat import text_type, itervalues, iterkeys, iteritems, PY2, \ + cBytesIO, xrange +from tests import TestCase import random +import mmap + +try: + import fcntl +except ImportError: + fcntl = None + class FDict(DictMixin): @@ -9,37 +21,15 @@ self.__d = {} self.keys = self.__d.keys - def __getitem__(self, *args): return self.__d.__getitem__(*args) - def __setitem__(self, *args): return self.__d.__setitem__(*args) - def __delitem__(self, *args): return self.__d.__delitem__(*args) - -class Tutf8(TestCase): - - def test_str(self): - value = utf8(b"1234") - self.failUnlessEqual(value, b"1234") - self.failUnless(isinstance(value, bytes)) - - def test_bad_str(self): - value = utf8(b"\xab\xde") - # Two '?' symbols. - self.failUnlessEqual(value, b"\xef\xbf\xbd\xef\xbf\xbd") - self.failUnless(isinstance(value, bytes)) - - def test_low_unicode(self): - value = utf8(u"1234") - self.failUnlessEqual(value, b"1234") - self.failUnless(isinstance(value, bytes)) - - def test_high_unicode(self): - value = utf8(u"\u1234") - self.failUnlessEqual(value, b'\xe1\x88\xb4') - self.failUnless(isinstance(value, bytes)) + def __getitem__(self, *args): + return self.__d.__getitem__(*args) - def test_invalid(self): - self.failUnlessRaises(TypeError, utf8, 1234) + def __setitem__(self, *args): + return self.__d.__setitem__(*args) + + def __delitem__(self, *args): + return self.__d.__delitem__(*args) -add(Tutf8) class TDictMixin(TestCase): @@ -133,42 +123,127 @@ self.failUnlessEqual(self.fdict, self.rdict) self.failUnlessEqual(self.rdict, self.fdict) -add(TDictMixin) class Tcdata(TestCase): - ZERO = b"\x00\x00\x00\x00" - LEONE = b"\x01\x00\x00\x00" - BEONE = b"\x00\x00\x00\x01" - NEGONE = b"\xff\xff\xff\xff" - - def test_int_le(self): - self.failUnlessEqual(cdata.int_le(self.ZERO), 0) - self.failUnlessEqual(cdata.int_le(self.LEONE), 1) - self.failUnlessEqual(cdata.int_le(self.BEONE), 16777216) - self.failUnlessEqual(cdata.int_le(self.NEGONE), -1) - - def test_uint_le(self): - self.failUnlessEqual(cdata.uint_le(self.ZERO), 0) - self.failUnlessEqual(cdata.uint_le(self.LEONE), 1) - self.failUnlessEqual(cdata.uint_le(self.BEONE), 16777216) - self.failUnlessEqual(cdata.uint_le(self.NEGONE), 2**32-1) - - def test_longlong_le(self): - self.failUnlessEqual(cdata.longlong_le(self.ZERO * 2), 0) - self.failUnlessEqual(cdata.longlong_le(self.LEONE + self.ZERO), 1) - self.failUnlessEqual(cdata.longlong_le(self.NEGONE * 2), -1) - - def test_ulonglong_le(self): - self.failUnlessEqual(cdata.ulonglong_le(self.ZERO * 2), 0) - self.failUnlessEqual(cdata.ulonglong_le(self.LEONE + self.ZERO), 1) - self.failUnlessEqual(cdata.ulonglong_le(self.NEGONE * 2), 2**64-1) + ZERO = staticmethod(lambda s: b"\x00" * s) + LEONE = staticmethod(lambda s: b"\x01" + b"\x00" * (s - 1)) + BEONE = staticmethod(lambda s: b"\x00" * (s - 1) + b"\x01") + NEGONE = staticmethod(lambda s: b"\xff" * s) + + def test_char(self): + self.failUnlessEqual(cdata.char(self.ZERO(1)), 0) + self.failUnlessEqual(cdata.char(self.LEONE(1)), 1) + self.failUnlessEqual(cdata.char(self.BEONE(1)), 1) + self.failUnlessEqual(cdata.char(self.NEGONE(1)), -1) + self.assertTrue(cdata.char is cdata.int8) + self.assertTrue(cdata.to_char is cdata.to_int8) + self.assertTrue(cdata.char_from is cdata.int8_from) + + def test_char_from_to(self): + self.assertEqual(cdata.to_char(-2), b"\xfe") + self.assertEqual(cdata.char_from(b"\xfe"), (-2, 1)) + self.assertEqual(cdata.char_from(b"\x00\xfe", 1), (-2, 2)) + self.assertRaises(cdata.error, cdata.char_from, b"\x00\xfe", 3) + + def test_uchar(self): + self.failUnlessEqual(cdata.uchar(self.ZERO(1)), 0) + self.failUnlessEqual(cdata.uchar(self.LEONE(1)), 1) + self.failUnlessEqual(cdata.uchar(self.BEONE(1)), 1) + self.failUnlessEqual(cdata.uchar(self.NEGONE(1)), 255) + self.assertTrue(cdata.uchar is cdata.uint8) + self.assertTrue(cdata.to_uchar is cdata.to_uint8) + self.assertTrue(cdata.uchar_from is cdata.uint8_from) + + def test_short(self): + self.failUnlessEqual(cdata.short_le(self.ZERO(2)), 0) + self.failUnlessEqual(cdata.short_le(self.LEONE(2)), 1) + self.failUnlessEqual(cdata.short_le(self.BEONE(2)), 256) + self.failUnlessEqual(cdata.short_le(self.NEGONE(2)), -1) + self.assertTrue(cdata.short_le is cdata.int16_le) + + self.failUnlessEqual(cdata.short_be(self.ZERO(2)), 0) + self.failUnlessEqual(cdata.short_be(self.LEONE(2)), 256) + self.failUnlessEqual(cdata.short_be(self.BEONE(2)), 1) + self.failUnlessEqual(cdata.short_be(self.NEGONE(2)), -1) + self.assertTrue(cdata.short_be is cdata.int16_be) + + def test_ushort(self): + self.failUnlessEqual(cdata.ushort_le(self.ZERO(2)), 0) + self.failUnlessEqual(cdata.ushort_le(self.LEONE(2)), 1) + self.failUnlessEqual(cdata.ushort_le(self.BEONE(2)), 2 ** 16 >> 8) + self.failUnlessEqual(cdata.ushort_le(self.NEGONE(2)), 65535) + self.assertTrue(cdata.ushort_le is cdata.uint16_le) + + self.failUnlessEqual(cdata.ushort_be(self.ZERO(2)), 0) + self.failUnlessEqual(cdata.ushort_be(self.LEONE(2)), 2 ** 16 >> 8) + self.failUnlessEqual(cdata.ushort_be(self.BEONE(2)), 1) + self.failUnlessEqual(cdata.ushort_be(self.NEGONE(2)), 65535) + self.assertTrue(cdata.ushort_be is cdata.uint16_be) + + def test_int(self): + self.failUnlessEqual(cdata.int_le(self.ZERO(4)), 0) + self.failUnlessEqual(cdata.int_le(self.LEONE(4)), 1) + self.failUnlessEqual(cdata.int_le(self.BEONE(4)), 2 ** 32 >> 8) + self.failUnlessEqual(cdata.int_le(self.NEGONE(4)), -1) + self.assertTrue(cdata.int_le is cdata.int32_le) + + self.failUnlessEqual(cdata.int_be(self.ZERO(4)), 0) + self.failUnlessEqual(cdata.int_be(self.LEONE(4)), 2 ** 32 >> 8) + self.failUnlessEqual(cdata.int_be(self.BEONE(4)), 1) + self.failUnlessEqual(cdata.int_be(self.NEGONE(4)), -1) + self.assertTrue(cdata.int_be is cdata.int32_be) + + def test_uint(self): + self.failUnlessEqual(cdata.uint_le(self.ZERO(4)), 0) + self.failUnlessEqual(cdata.uint_le(self.LEONE(4)), 1) + self.failUnlessEqual(cdata.uint_le(self.BEONE(4)), 2 ** 32 >> 8) + self.failUnlessEqual(cdata.uint_le(self.NEGONE(4)), 2 ** 32 - 1) + self.assertTrue(cdata.uint_le is cdata.uint32_le) + + self.failUnlessEqual(cdata.uint_be(self.ZERO(4)), 0) + self.failUnlessEqual(cdata.uint_be(self.LEONE(4)), 2 ** 32 >> 8) + self.failUnlessEqual(cdata.uint_be(self.BEONE(4)), 1) + self.failUnlessEqual(cdata.uint_be(self.NEGONE(4)), 2 ** 32 - 1) + self.assertTrue(cdata.uint_be is cdata.uint32_be) + + def test_longlong(self): + self.failUnlessEqual(cdata.longlong_le(self.ZERO(8)), 0) + self.failUnlessEqual(cdata.longlong_le(self.LEONE(8)), 1) + self.failUnlessEqual(cdata.longlong_le(self.BEONE(8)), 2 ** 64 >> 8) + self.failUnlessEqual(cdata.longlong_le(self.NEGONE(8)), -1) + self.assertTrue(cdata.longlong_le is cdata.int64_le) + + self.failUnlessEqual(cdata.longlong_be(self.ZERO(8)), 0) + self.failUnlessEqual(cdata.longlong_be(self.LEONE(8)), 2 ** 64 >> 8) + self.failUnlessEqual(cdata.longlong_be(self.BEONE(8)), 1) + self.failUnlessEqual(cdata.longlong_be(self.NEGONE(8)), -1) + self.assertTrue(cdata.longlong_be is cdata.int64_be) + + def test_ulonglong(self): + self.failUnlessEqual(cdata.ulonglong_le(self.ZERO(8)), 0) + self.failUnlessEqual(cdata.ulonglong_le(self.LEONE(8)), 1) + self.failUnlessEqual(cdata.longlong_le(self.BEONE(8)), 2 ** 64 >> 8) + self.failUnlessEqual(cdata.ulonglong_le(self.NEGONE(8)), 2 ** 64 - 1) + self.assertTrue(cdata.ulonglong_le is cdata.uint64_le) + + self.failUnlessEqual(cdata.ulonglong_be(self.ZERO(8)), 0) + self.failUnlessEqual(cdata.ulonglong_be(self.LEONE(8)), 2 ** 64 >> 8) + self.failUnlessEqual(cdata.longlong_be(self.BEONE(8)), 1) + self.failUnlessEqual(cdata.ulonglong_be(self.NEGONE(8)), 2 ** 64 - 1) + self.assertTrue(cdata.ulonglong_be is cdata.uint64_be) def test_invalid_lengths(self): + self.failUnlessRaises(cdata.error, cdata.char, b"") + self.failUnlessRaises(cdata.error, cdata.uchar, b"") self.failUnlessRaises(cdata.error, cdata.int_le, b"") self.failUnlessRaises(cdata.error, cdata.longlong_le, b"") self.failUnlessRaises(cdata.error, cdata.uint_le, b"") self.failUnlessRaises(cdata.error, cdata.ulonglong_le, b"") + self.failUnlessRaises(cdata.error, cdata.int_be, b"") + self.failUnlessRaises(cdata.error, cdata.longlong_be, b"") + self.failUnlessRaises(cdata.error, cdata.uint_be, b"") + self.failUnlessRaises(cdata.error, cdata.ulonglong_be, b"") def test_test(self): self.failUnless(cdata.test_bit((1), 0)) @@ -185,7 +260,6 @@ self.failIf(cdata.test_bit(v, 8)) self.failIf(cdata.test_bit(v, 13)) -add(Tcdata) class FileHandling(TestCase): def file(self, contents): @@ -287,26 +361,26 @@ def test_insert_6106_79_51760(self): # This appears to be due to ANSI C limitations in read/write on rb+ # files. The problematic behavior only showed up in our mmap fallback - # code for transfers of this or similar sizes. - data = u''.join(map(text_type, range(12574))) # 51760 bytes + # code for transfers of this or similar sizes. + data = u''.join(map(text_type, range(12574))) # 51760 bytes data = data.encode("ascii") o = self.file(data) insert_bytes(o, 6106, 79) - self.failUnless(data[:6106+79] + data[79:] == self.read(o)) + self.failUnless(data[:6106 + 79] + data[79:] == self.read(o)) def test_delete_6106_79_51760(self): # This appears to be due to ANSI C limitations in read/write on rb+ # files. The problematic behavior only showed up in our mmap fallback - # code for transfers of this or similar sizes. - data = u''.join(map(text_type, range(12574))) # 51760 bytes + # code for transfers of this or similar sizes. + data = u''.join(map(text_type, range(12574))) # 51760 bytes data = data.encode("ascii") - o = self.file(data[:6106+79] + data[79:]) + o = self.file(data[:6106 + 79] + data[79:]) delete_bytes(o, 6106, 79) self.failUnless(data == self.read(o)) # Generate a bunch of random insertions, apply them, delete them, # and make sure everything is still correct. - # + # # The num_runs and num_changes values are tuned to take about 10s # on my laptop, or about 30 seconds since we we have 3 variations # on insert/delete_bytes brokenness. If I ever get a faster @@ -326,14 +400,16 @@ # Generate the list of changes to apply changes = [] for i in range(num_changes): - change_size = random.randrange(min_change_size, max_change_size) + change_size = random.randrange( + min_change_size, max_change_size) change_offset = random.randrange(0, filesize) filesize += change_size changes.append((change_offset, change_size)) # Apply the changes, and make sure they all took. for offset, size in changes: - buffer_size = random.randrange(min_buffer_size, max_buffer_size) + buffer_size = random.randrange( + min_buffer_size, max_buffer_size) insert_bytes(fobj, size, offset, BUFFER_SIZE=buffer_size) fobj.seek(0) self.failIfEqual(fobj.read(len(data)), data) @@ -343,9 +419,210 @@ # Then, undo them. changes.reverse() for offset, size in changes: - buffer_size = random.randrange(min_buffer_size, max_buffer_size) + buffer_size = random.randrange( + min_buffer_size, max_buffer_size) delete_bytes(fobj, size, offset, BUFFER_SIZE=buffer_size) fobj.seek(0) self.failUnless(fobj.read() == data) -add(FileHandling) + +class FileHandlingMockedLock(FileHandling): + + def setUp(self): + def MockLockF(*args, **kwargs): + raise IOError + self._orig_lockf = fcntl.lockf + fcntl.lockf = MockLockF + + def tearDown(self): + fcntl.lockf = self._orig_lockf + +if not fcntl: + del FileHandlingMockedLock + + +class FileHandlingMockedMMapMove(FileHandling): + + def setUp(self): + class MockMMap(object): + def __init__(self, *args, **kwargs): + pass + + def move(self, dest, src, count): + raise ValueError + + def close(self): + pass + + self._orig_mmap = mmap.mmap + mmap.mmap = MockMMap + + def tearDown(self): + mmap.mmap = self._orig_mmap + + +class FileHandlingMockedMMap(FileHandling): + + def setUp(self): + def MockMMap2(*args, **kwargs): + raise EnvironmentError + + self._orig_mmap = mmap.mmap + mmap.mmap = MockMMap2 + + def tearDown(self): + mmap.mmap = self._orig_mmap + + +class Tdict_match(TestCase): + + def test_match(self): + self.assertEqual(dict_match({"*": 1}, "a"), 1) + self.assertEqual(dict_match({"*": 1}, "*"), 1) + self.assertEqual(dict_match({"*a": 1}, "ba"), 1) + self.assertEqual(dict_match({"?": 1}, "b"), 1) + self.assertEqual(dict_match({"[ab]": 1}, "b"), 1) + + def test_nomatch(self): + self.assertEqual(dict_match({"*a": 1}, "ab"), None) + self.assertEqual(dict_match({"??": 1}, "a"), None) + self.assertEqual(dict_match({"[ab]": 1}, "c"), None) + self.assertEqual(dict_match({"[ab]": 1}, "[ab]"), None) + + +class Tenum(TestCase): + + def test_enum(self): + @enum + class Foo(object): + FOO = 1 + BAR = 3 + + self.assertEqual(Foo.FOO, 1) + self.assertTrue(isinstance(Foo.FOO, Foo)) + self.assertEqual(repr(Foo.FOO), "") + self.assertEqual(repr(Foo(3)), "") + self.assertEqual(repr(Foo(42)), "42") + self.assertEqual(str(Foo(42)), "42") + self.assertEqual(int(Foo(42)), 42) + self.assertEqual(str(Foo(1)), "Foo.FOO") + self.assertEqual(int(Foo(1)), 1) + + self.assertTrue(isinstance(str(Foo.FOO), str)) + self.assertTrue(isinstance(repr(Foo.FOO), str)) + + +class Tdecode_terminated(TestCase): + + def test_all(self): + values = [u"", u"", u"\xe4", u"abc", u"", u""] + + for codec in ["utf8", "utf-8", "utf-16", "latin-1", "utf-16be"]: + # NULL without the BOM + term = u"\x00".encode(codec)[-2:] + data = b"".join(v.encode(codec) + term for v in values) + + for v in values: + dec, data = decode_terminated(data, codec) + self.assertEqual(dec, v) + self.assertEqual(data, b"") + + def test_invalid(self): + # invalid + self.assertRaises( + UnicodeDecodeError, decode_terminated, b"\xff", "utf-8") + # truncated + self.assertRaises( + UnicodeDecodeError, decode_terminated, b"\xff\xfe\x00", "utf-16") + # not null terminated + self.assertRaises(ValueError, decode_terminated, b"abc", "utf-8") + # invalid encoding + self.assertRaises(LookupError, decode_terminated, b"abc", "foobar") + + def test_lax(self): + # missing termination + self.assertEqual( + decode_terminated(b"abc", "utf-8", strict=False), (u"abc", b"")) + + # missing termination and truncated data + truncated = u"\xe4\xe4".encode("utf-8")[:-1] + self.assertRaises( + UnicodeDecodeError, decode_terminated, + truncated, "utf-8", strict=False) + + +class TBitReader(TestCase): + + def test_bits(self): + data = b"\x12\x34\x56\x78\x89\xAB\xCD\xEF" + ref = cdata.uint64_be(data) + + for i in xrange(64): + fo = cBytesIO(data) + r = BitReader(fo) + v = r.bits(i) << (64 - i) | r.bits(64 - i) + self.assertEqual(v, ref) + + def test_read_too_much(self): + r = BitReader(cBytesIO(b"")) + self.assertEqual(r.bits(0), 0) + self.assertRaises(BitReaderError, r.bits, 1) + + def test_skip(self): + r = BitReader(cBytesIO(b"\xEF")) + r.skip(4) + self.assertEqual(r.bits(4), 0xf) + + def test_skip_more(self): + r = BitReader(cBytesIO(b"\xAB\xCD")) + self.assertEqual(r.bits(4), 0xa) + r.skip(8) + self.assertEqual(r.bits(4), 0xd) + self.assertRaises(BitReaderError, r.bits, 1) + + def test_skip_too_much(self): + r = BitReader(cBytesIO(b"\xAB\xCD")) + # aligned skips don't fail, but the following read will + r.skip(32 + 8) + self.assertRaises(BitReaderError, r.bits, 1) + self.assertRaises(BitReaderError, r.skip, 1) + + def test_bytes(self): + r = BitReader(cBytesIO(b"\xAB\xCD\xEF")) + self.assertEqual(r.bytes(2), b"\xAB\xCD") + self.assertEqual(r.bytes(0), b"") + + def test_bytes_unaligned(self): + r = BitReader(cBytesIO(b"\xAB\xCD\xEF")) + r.skip(4) + self.assertEqual(r.bytes(2), b"\xBC\xDE") + + def test_get_position(self): + r = BitReader(cBytesIO(b"\xAB\xCD")) + self.assertEqual(r.get_position(), 0) + r.bits(3) + self.assertEqual(r.get_position(), 3) + r.skip(9) + self.assertEqual(r.get_position(), 3 + 9) + r.align() + self.assertEqual(r.get_position(), 16) + + def test_align(self): + r = BitReader(cBytesIO(b"\xAB\xCD\xEF")) + r.skip(3) + self.assertEqual(r.align(), 5) + self.assertEqual(r.get_position(), 8) + + def test_is_aligned(self): + r = BitReader(cBytesIO(b"\xAB\xCD\xEF")) + self.assertTrue(r.is_aligned()) + + r.skip(1) + self.assertFalse(r.is_aligned()) + r.skip(7) + self.assertTrue(r.is_aligned()) + + r.bits(7) + self.assertFalse(r.is_aligned()) + r.bits(1) + self.assertTrue(r.is_aligned()) diff -Nru mutagen-1.23/tests/test__vorbis.py mutagen-1.30/tests/test__vorbis.py --- mutagen-1.23/tests/test__vorbis.py 2013-09-10 16:08:15.000000000 +0000 +++ mutagen-1.30/tests/test__vorbis.py 2015-04-25 08:48:17.000000000 +0000 @@ -1,4 +1,6 @@ -from tests import add, TestCase +# -*- coding: utf-8 -*- + +from tests import TestCase from mutagen._vorbis import VComment, VCommentDict, istag from mutagen._compat import text_type, PY3 @@ -34,9 +36,7 @@ if PY3: def test_py3(self): - self.failUnlessRaises(ValueError, istag, b"abc") - -add(Tistag) + self.failUnlessRaises(TypeError, istag, b"abc") class TVComment(TestCase): @@ -77,7 +77,7 @@ self.failUnlessRaises(ValueError, self.c.write) def test_validate_nonunicode_value(self): - self.c.append(("uvalid", b"wt\xff")) + self.c.append((u"valid", b"wt\xff")) self.failUnlessRaises(ValueError, self.c.validate) self.failUnlessRaises(ValueError, self.c.write) @@ -93,6 +93,13 @@ self.failUnlessRaises(ValueError, self.c.validate) self.failUnlessRaises(ValueError, self.c.write) + def test_validate_utf8_value(self): + self.c.append((u"valid", b"\xc3\xbc\xc3\xb6\xc3\xa4")) + if PY3: + self.failUnlessRaises(ValueError, self.c.validate) + else: + self.c.validate() + def test_invalid_format_strict(self): data = (b'\x07\x00\x00\x00Mutagen\x01\x00\x00\x00\x03\x00\x00' b'\x00abc\x01') @@ -148,7 +155,6 @@ def test_roundtrip(self): self.assertReallyEqual(self.c, VComment(self.c.write())) -add(TVComment) class TVCommentDict(TestCase): @@ -186,6 +192,18 @@ self.c["woo"] = "bar" self.failUnlessEqual(self.c["woo"], ["bar"]) + def test_slice(self): + l = [("foo", "bar"), ("foo", "bar2")] + self.c[:] = l + self.assertEqual(self.c[:], l) + self.failUnlessEqual(self.c["foo"], ["bar", "bar2"]) + del self.c[:] + self.assertEqual(self.c[:], []) + + def test_iter(self): + self.assertEqual(next(iter(self.c)), ("artist", "mu")) + self.assertEqual(list(self.c)[0], ("artist", "mu")) + def test_del(self): del(self.c["title"]) self.failUnlessRaises(KeyError, self.c.__getitem__, "title") @@ -252,11 +270,11 @@ if PY3: def test_py3_bad_key(self): - self.failUnlessRaises(ValueError, self.c.get, b"a") + self.failUnlessRaises(TypeError, self.c.get, b"a") self.failUnlessRaises( - ValueError, self.c.__setitem__, b"a", "foo") + TypeError, self.c.__setitem__, b"a", "foo") self.failUnlessRaises( - ValueError, self.c.__delitem__, b"a") + TypeError, self.c.__delitem__, b"a") def test_duplicate_keys(self): self.c = VCommentDict() @@ -265,5 +283,3 @@ self.c.append((key, "value")) self.failUnlessEqual(len(self.c.keys()), 1) self.failUnlessEqual(len(self.c.as_dict()), 1) - -add(TVCommentDict) diff -Nru mutagen-1.23/tests/test_wavpack.py mutagen-1.30/tests/test_wavpack.py --- mutagen-1.23/tests/test_wavpack.py 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/tests/test_wavpack.py 2015-05-09 12:25:10.000000000 +0000 @@ -1,12 +1,18 @@ +# -*- coding: utf-8 -*- + import os from mutagen.wavpack import WavPack -from tests import TestCase, add +from tests import TestCase, DATA_DIR + class TWavPack(TestCase): def setUp(self): - self.audio = WavPack(os.path.join("tests", "data", "silence-44-s.wv")) + self.audio = WavPack(os.path.join(DATA_DIR, "silence-44-s.wv")) + + def test_version(self): + self.failUnlessEqual(self.audio.info.version, 0x403) def test_channels(self): self.failUnlessEqual(self.audio.info.channels, 2) @@ -19,7 +25,7 @@ def test_not_my_file(self): self.failUnlessRaises( - IOError, WavPack, os.path.join("tests", "data", "empty.ogg")) + IOError, WavPack, os.path.join(DATA_DIR, "empty.ogg")) def test_pprint(self): self.audio.pprint() @@ -27,4 +33,26 @@ def test_mime(self): self.failUnless("audio/x-wavpack" in self.audio.mime) -add(TWavPack) + +class TWavPackNoLength(TestCase): + + def setUp(self): + self.audio = WavPack(os.path.join(DATA_DIR, "no_length.wv")) + + def test_version(self): + self.failUnlessEqual(self.audio.info.version, 0x407) + + def test_channels(self): + self.failUnlessEqual(self.audio.info.channels, 2) + + def test_sample_rate(self): + self.failUnlessEqual(self.audio.info.sample_rate, 44100) + + def test_length(self): + self.failUnlessAlmostEqual(self.audio.info.length, 3.705, 3) + + def test_pprint(self): + self.audio.pprint() + + def test_mime(self): + self.failUnless("audio/x-wavpack" in self.audio.mime) diff -Nru mutagen-1.23/TODO mutagen-1.30/TODO --- mutagen-1.23/TODO 2013-09-08 22:01:19.000000000 +0000 +++ mutagen-1.30/TODO 1970-01-01 00:00:00.000000000 +0000 @@ -1,14 +0,0 @@ -mutagen.id3 needs: - * Detect tags in non-beginning locations, particularly if multiple - separate version tags exist at the beginning of the file - * Perhaps implement tag merging - "intelligently" merge information - from all known tag formats - * Some test cleanup - -mutagen.apev2 needs: - * General cleanup to fit the basic structure of the rest of Mutagen. - -Profile Hotspots: - * __determine_bpi takes 84% of TMP3,TEasyID3; BitPaddedInt takes 46%. - * OggPage.replace takes 43% of Ogg tests; OggPage.write takes 25%. - * insert_bytes/delete_bytes is still a hotspot but is much faster. diff -Nru mutagen-1.23/tools/mid3cp mutagen-1.30/tools/mid3cp --- mutagen-1.23/tools/mid3cp 1970-01-01 00:00:00.000000000 +0000 +++ mutagen-1.30/tools/mid3cp 2015-04-29 19:15:10.000000000 +0000 @@ -0,0 +1,123 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright 2014 Marcus Sundman + +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +"""A program replicating the functionality of id3lib's id3cp, using mutagen for +tag loading and saving. +""" + +import sys +import os.path +import mutagen +import mutagen.id3 +from mutagen._toolsutil import SignalHandler, get_win32_unicode_argv, print_, \ + OptionParser +from mutagen._compat import text_type + + +VERSION = (0, 1) +_sig = SignalHandler() + + +def printerr(*args, **kwargs): + kwargs.setdefault("file", sys.stderr) + print_(*args, **kwargs) + + +class ID3OptionParser(OptionParser): + def __init__(self): + mutagen_version = mutagen.version_string + my_version = ".".join(map(str, VERSION)) + version = "mid3cp %s\nUses Mutagen %s" % (my_version, mutagen_version) + self.disable_interspersed_args() + OptionParser.__init__( + self, version=version, + usage="%prog [option(s)] ", + description=("Copies ID3 tags from to . Mutagen-based " + "replacement for id3lib's id3cp.")) + + +def copy(src, dst, write_v1=True, excluded_tags=None, verbose=False): + """Returns 0 on success""" + + if excluded_tags is None: + excluded_tags = [] + + try: + id3 = mutagen.id3.ID3(src, translate=False) + except mutagen.id3.ID3NoHeaderError: + print_(u"No ID3 header found in ", src, file=sys.stderr) + return 1 + except StandardError as err: + print_(str(err), file=sys.stderr) + return 1 + else: + if verbose: + print_(u"File", src, u"contains:", file=sys.stderr) + print_(id3.pprint(), file=sys.stderr) + + for tag in excluded_tags: + id3.delall(tag) + + # if the source is 2.3 save it as 2.3 + if id3.version < (2, 4, 0): + id3.update_to_v23() + v2_version = 3 + else: + id3.update_to_v24() + v2_version = 4 + + try: + id3.save(dst, v1=(2 if write_v1 else 0), v2_version=v2_version) + except StandardError as err: + print_(u"Error saving", dst, u":\n%s" % text_type(err), + file=sys.stderr) + return 1 + else: + if verbose: + print_(u"Successfully saved", dst, file=sys.stderr) + return 0 + + +def main(argv): + parser = ID3OptionParser() + parser.add_option("-v", "--verbose", action="store_true", dest="verbose", + help="print out saved tags", default=False) + parser.add_option("--write-v1", action="store_true", dest="write_v1", + default=False, help="write id3v1 tags") + parser.add_option("-x", "--exclude-tag", metavar="TAG", action="append", + dest="x", help="exclude the specified tag", default=[]) + (options, args) = parser.parse_args(argv[1:]) + + if len(args) != 2: + parser.print_help(file=sys.stderr) + return 1 + + (src, dst) = args + + if not os.path.isfile(src): + print_(u"File not found:", src, file=sys.stderr) + parser.print_help(file=sys.stderr) + return 1 + + if not os.path.isfile(dst): + printerr(u"File not found:", dst, file=sys.stderr) + parser.print_help(file=sys.stderr) + return 1 + + # Strip tags - "-x FOO" adds whitespace at the beginning of the tag name + excluded_tags = [x.strip() for x in options.x] + + with _sig.block(): + return copy(src, dst, options.write_v1, excluded_tags, options.verbose) + + +if __name__ == "__main__": + get_win32_unicode_argv() + _sig.init() + sys.exit(main(sys.argv)) diff -Nru mutagen-1.23/tools/mid3iconv mutagen-1.30/tools/mid3iconv --- mutagen-1.23/tools/mid3iconv 2013-09-11 14:15:24.000000000 +0000 +++ mutagen-1.30/tools/mid3iconv 2015-04-29 19:15:30.000000000 +0000 @@ -9,21 +9,34 @@ import sys import locale -from optparse import OptionParser - import mutagen import mutagen.id3 +from mutagen._compat import PY3, text_type +from mutagen._toolsutil import SignalHandler, get_win32_unicode_argv, print_, \ + fsnative as fsn, OptionParser VERSION = (0, 3) +_sig = SignalHandler() -def getpreferredencoding(*args): - return locale.getpreferredencoding(*args) or "utf-8" +def getpreferredencoding(): + return locale.getpreferredencoding() or "utf-8" def isascii(string): - return not string or ord(max(string)) < 128 + """Checks whether a unicode string is non-empty and contains only ASCII + characters. + """ + if not string: + return False + + try: + string.encode('ascii') + except UnicodeEncodeError: + return False + + return True class ID3OptionParser(OptionParser): @@ -55,58 +68,58 @@ return uni.encode('iso-8859-1').decode(encoding) for filename in filenames: - if verbose != "quiet": - print("Updating", filename) + with _sig.block(): + if verbose != "quiet": + print_(u"Updating", filename) - if has_id3v1(filename) and not noupdate and force_v1: - mutagen.id3.delete(filename, False, True) + if has_id3v1(filename) and not noupdate and force_v1: + mutagen.id3.delete(filename, False, True) - try: - id3 = mutagen.id3.ID3(filename) - except mutagen.id3.ID3NoHeaderError: - if verbose != "quiet": - print("No ID3 header found; skipping...") - continue - except Exception as err: - print >>sys.stderr, str(err) - continue - - for tag in filter(lambda t: t.startswith(("T", "COMM")), id3): - frame = id3[tag] - if isinstance(frame, mutagen.id3.TimeStampTextFrame): - # non-unicode fields - continue try: - text = frame.text - except AttributeError: + id3 = mutagen.id3.ID3(filename) + except mutagen.id3.ID3NoHeaderError: + if verbose != "quiet": + print_(u"No ID3 header found; skipping...") continue - try: - text = map(conv, frame.text) - except (UnicodeError, LookupError): + except Exception as err: + print_(text_type(err), file=sys.stderr) continue - else: - frame.text = text - if not text or min(map(isascii, text)): - frame.encoding = 3 - else: - frame.encoding = 1 - enc = getpreferredencoding() - if verbose == "debug": - print(id3.pprint().encode(enc, "replace")) - - if not noupdate: - if remove_v1: - id3.save(filename, v1=False) - else: - id3.save(filename) + for tag in filter(lambda t: t.startswith(("T", "COMM")), id3): + frame = id3[tag] + if isinstance(frame, mutagen.id3.TimeStampTextFrame): + # non-unicode fields + continue + try: + text = frame.text + except AttributeError: + continue + try: + text = [conv(x) for x in frame.text] + except (UnicodeError, LookupError): + continue + else: + frame.text = text + if not text or min(map(isascii, text)): + frame.encoding = 3 + else: + frame.encoding = 1 + + if verbose == "debug": + print_(id3.pprint()) + + if not noupdate: + if remove_v1: + id3.save(filename, v1=False) + else: + id3.save(filename) def has_id3v1(filename): try: f = open(filename, 'rb+') f.seek(-128, 2) - return f.read(3) == "TAG" + return f.read(3) == b"TAG" except IOError: return False @@ -134,11 +147,11 @@ "-d", "--debug", action="store_const", dest="verbose", const="debug", help="Output updated tags") - for i, arg in enumerate(sys.argv): + for i, arg in enumerate(argv): if arg == "-v1": - sys.argv[i] = "--force-v1" + argv[i] = fsn(u"--force-v1") elif arg == "-removev1": - sys.argv[i] = "--remove-v1" + argv[i] = fsn(u"--remove-v1") (options, args) = parser.parse_args(argv[1:]) @@ -148,4 +161,6 @@ parser.print_help() if __name__ == "__main__": - main(sys.argv) + argv = get_win32_unicode_argv() + _sig.init() + main(argv) diff -Nru mutagen-1.23/tools/mid3v2 mutagen-1.30/tools/mid3v2 --- mutagen-1.23/tools/mid3v2 2013-10-16 16:04:20.000000000 +0000 +++ mutagen-1.30/tools/mid3v2 2015-04-29 19:11:28.000000000 +0000 @@ -1,4 +1,5 @@ #!/usr/bin/env python +# -*- coding: utf-8 -*- # Pretend to be /usr/bin/id3v2 from id3lib, sort of. # Copyright 2005 Joe Wreschnig # @@ -6,26 +7,27 @@ # it under the terms of version 2 of the GNU General Public License as # published by the Free Software Foundation. +import os import sys import locale +import codecs -from optparse import OptionParser, SUPPRESS_HELP +from optparse import SUPPRESS_HELP import mutagen import mutagen.id3 -from mutagen._compat import PY2, text_type +from mutagen._toolsutil import split_escape, SignalHandler, OptionParser,\ + get_win32_unicode_argv, fsnative, is_fsnative, fsencoding, print_ +from mutagen._compat import PY2, PY3, text_type VERSION = (1, 3) +_sig = SignalHandler() global verbose verbose = True -def getpreferredencoding(*args): - return locale.getpreferredencoding(*args) or "utf-8" - - class ID3OptionParser(OptionParser): def __init__(self): mutagen_version = ".".join(map(str, mutagen.version)) @@ -49,46 +51,10 @@ """ -def split_escape(string, sep, maxsplit=None, escape_char=u"\\"): - """Like unicode.split but allows for the separator to be escaped""" - - assert len(sep) == 1 - assert len(escape_char) == 1 - - if maxsplit is None: - maxsplit = len(string) - - result = [] - current = u"" - escaped = False - for char in string: - if escaped: - if char != escape_char and char != sep: - current += escape_char - current += char - escaped = False - else: - if char == escape_char: - escaped = True - elif char == sep and len(result) < maxsplit: - result.append(current) - current = u"" - else: - current += char - result.append(current) - return result - - -def unescape_string(string): - assert isinstance(string, str) - - return string.decode("string_escape") - - def list_frames(option, opt, value, parser): items = mutagen.id3.Frames.items() for name, frame in sorted(items): - print(" --%s %s" % (name, frame.__doc__.split("\n")[0])) + print_(u" --%s %s" % (name, frame.__doc__.split("\n")[0])) raise SystemExit @@ -96,64 +62,126 @@ items = mutagen.id3.Frames_2_2.items() items.sort() for name, frame in items: - print(" --%s %s" % (name, frame.__doc__.split("\n")[0])) + print_(u" --%s %s" % (name, frame.__doc__.split("\n")[0])) raise SystemExit def list_genres(option, opt, value, parser): for i, genre in enumerate(mutagen.id3.TCON.GENRES): - print("%3d: %s" % (i, genre)) + print_(u"%3d: %s" % (i, genre)) raise SystemExit def delete_tags(filenames, v1, v2): for filename in filenames: - if verbose: - print("deleting ID3 tag info in %s" % filename) - mutagen.id3.delete(filename, v1, v2) + with _sig.block(): + if verbose: + print_(u"deleting ID3 tag info in", filename, file=sys.stderr) + mutagen.id3.delete(filename, v1, v2) def delete_frames(deletes, filenames): + + try: + deletes = frame_from_fsnative(deletes) + except ValueError as err: + print_(text_type(err), file=sys.stderr) + frames = deletes.split(",") + for filename in filenames: - if verbose: - print("deleting %s from %s" % (deletes, filename)) - try: - id3 = mutagen.id3.ID3(filename) - except mutagen.id3.ID3NoHeaderError: + with _sig.block(): if verbose: - print("No ID3 header found; skipping.") - except StandardError as err: - print(str(err)) + print_(u"deleting %s from" % deletes, filename, file=sys.stderr) + try: + id3 = mutagen.id3.ID3(filename) + except mutagen.id3.ID3NoHeaderError: + if verbose: + print_(u"No ID3 header found; skipping.", file=sys.stderr) + except Exception as err: + print_(text_type(err), file=sys.stderr) + raise SystemExit(1) + else: + for frame in frames: + id3.delall(frame) + id3.save() + + +def frame_from_fsnative(arg): + """Takes item from argv and returns ascii native str + or raises ValueError. + """ + + assert is_fsnative(arg) + + if os.name == "nt": + # unicode + if PY2: + return arg.encode("ascii") else: - for frame in frames: - id3.delall(frame) - id3.save() + return arg.encode("ascii").decode("ascii") + else: + if PY2: + # bytes + return arg.decode(fsencoding()).encode("ascii") + else: + # unicode + surrogate + return arg.encode("ascii", "surrogateescape").decode("ascii") -def write_files(edits, filenames, escape): - enc = getpreferredencoding() +def value_from_fsnative(arg, escape): + """Takes an item from argv and returns a text_type value without + surrogate escapes or raises ValueError. + """ + assert is_fsnative(arg) + + if os.name == "nt": + if not escape: + return arg + if PY2: + return arg.encode("utf-8").decode("string_escape").decode("utf-8") + else: + return codecs.escape_decode(arg.encode("utf-8"))[0].decode("utf-8") + else: + enc = fsencoding() + if PY2: + if escape: + arg = arg.decode("string_escape") + return arg.decode(enc) + else: + if not escape: + # make sure no surrogateescapes + arg.encode("utf-8") + return arg + return codecs.escape_decode( + arg.encode(enc, "surrogateescape"))[0].decode(enc) + + +def write_files(edits, filenames, escape): # unescape escape sequences and decode values encoded_edits = [] for frame, value in edits: if not value: continue + + try: + frame = frame_from_fsnative(frame) + except ValueError as err: + print_(text_type(err), file=sys.stderr) + + assert isinstance(frame, str) + + # strip "--" frame = frame[2:] - if escape: - try: - value = unescape_string(value) - except ValueError as err: - print("%s: %s" % (frame, str(err))) - raise SystemExit(1) + try: + value = value_from_fsnative(value, escape) + except ValueError as err: + print_(u"%s: %s" % (frame, text_type(err)), file=sys.stderr) + raise SystemExit(1) - if PY2: - try: - value = value.decode(enc) - except UnicodeDecodeError as err: - print("%s: %s" % (frame, str(err))) - raise SystemExit(1) + assert isinstance(value, text_type) encoded_edits.append((frame, value)) edits = encoded_edits @@ -177,85 +205,93 @@ string_split = lambda s, *args, **kwargs: s.split(*args, **kwargs) for filename in filenames: - if verbose: - print("Writing", filename) - try: - id3 = mutagen.id3.ID3(filename) - except mutagen.id3.ID3NoHeaderError: + with _sig.block(): if verbose: - print("No ID3 header found; creating a new tag") - id3 = mutagen.id3.ID3() - except StandardError as err: - print(str(err)) - continue - for (frame, vlist) in edits.items(): - if frame == "POPM": - for value in vlist: - values = string_split(value, ":") - if len(values) == 1: - email, rating, count = values[0], 0, 0 - elif len(values) == 2: - email, rating, count = values[0], values[1], 0 - else: - email, rating, count = values - - frame = mutagen.id3.POPM( - email=email, rating=int(rating), count=int(count)) - id3.add(frame) - - elif frame == "COMM": - for value in vlist: - values = string_split(value, ":") - if len(values) == 1: - value, desc, lang = values[0], "", "eng" - elif len(values) == 2: - desc, value, lang = values[0], values[1], "eng" - else: - value = ":".join(values[1:-1]) - desc, lang = values[0], values[-1] - frame = mutagen.id3.COMM( - encoding=3, text=value, lang=lang, desc=desc) + print_(u"Writing", filename, file=sys.stderr) + try: + id3 = mutagen.id3.ID3(filename) + except mutagen.id3.ID3NoHeaderError: + if verbose: + print_(u"No ID3 header found; creating a new tag", + file=sys.stderr) + id3 = mutagen.id3.ID3() + except Exception as err: + print_(str(err), file=sys.stderr) + continue + for (frame, vlist) in edits.items(): + if frame == "POPM": + for value in vlist: + values = string_split(value, ":") + if len(values) == 1: + email, rating, count = values[0], 0, 0 + elif len(values) == 2: + email, rating, count = values[0], values[1], 0 + else: + email, rating, count = values + + frame = mutagen.id3.POPM( + email=email, rating=int(rating), count=int(count)) + id3.add(frame) + + elif frame == "COMM": + for value in vlist: + values = string_split(value, ":") + if len(values) == 1: + value, desc, lang = values[0], "", "eng" + elif len(values) == 2: + desc, value, lang = values[0], values[1], "eng" + else: + value = ":".join(values[1:-1]) + desc, lang = values[0], values[-1] + frame = mutagen.id3.COMM( + encoding=3, text=value, lang=lang, desc=desc) + id3.add(frame) + elif frame == "TXXX": + for value in vlist: + values = string_split(value, ":", 1) + if len(values) == 1: + desc, value = "", values[0] + else: + desc, value = values[0], values[1] + frame = mutagen.id3.TXXX( + encoding=3, text=value, desc=desc) + id3.add(frame) + elif issubclass(mutagen.id3.Frames[frame], mutagen.id3.UrlFrame): + frame = mutagen.id3.Frames[frame](encoding=3, url=vlist) id3.add(frame) - elif frame == "TXXX": - for value in vlist: - values = string_split(value, ":", 1) - if len(values) == 1: - desc, value = "", values[0] - else: - desc, value = values[0], values[1] - frame = mutagen.id3.TXXX(encoding=3, text=value, desc=desc) + else: + frame = mutagen.id3.Frames[frame](encoding=3, text=vlist) id3.add(frame) - elif issubclass(mutagen.id3.Frames[frame], mutagen.id3.UrlFrame): - frame = mutagen.id3.Frames[frame](encoding=3, url=vlist) - id3.add(frame) - else: - frame = mutagen.id3.Frames[frame](encoding=3, text=vlist) - id3.add(frame) - id3.save(filename) + id3.save(filename) def list_tags(filenames): - enc = getpreferredencoding() for filename in filenames: - print("IDv2 tag info for %s:" % filename) + print_("IDv2 tag info for", filename) try: id3 = mutagen.id3.ID3(filename, translate=False) - except StandardError as err: - print(str(err)) + except mutagen.id3.ID3NoHeaderError: + print_(u"No ID3 header found; skipping.") + except Exception as err: + print_(text_type(err), file=sys.stderr) + raise SystemExit(1) else: - print(id3.pprint().encode(enc, "replace")) + print_(id3.pprint()) def list_tags_raw(filenames): for filename in filenames: - print("Raw IDv2 tag info for %s:" % filename) + print_("Raw IDv2 tag info for", filename) try: id3 = mutagen.id3.ID3(filename, translate=False) - except StandardError as err: - print(str(err)) + except mutagen.id3.ID3NoHeaderError: + print_(u"No ID3 header found; skipping.") + except Exception as err: + print_(text_type(err), file=sys.stderr) + raise SystemExit(1) else: for frame in id3.values(): - print(repr(frame)) + print_(text_type(repr(frame))) def main(argv): @@ -304,31 +340,31 @@ parser.add_option( "-a", "--artist", metavar='"ARTIST"', action="callback", help="Set the artist information", type="string", - callback=lambda *args: args[3].edits.append(("--TPE1", args[2]))) + callback=lambda *args: args[3].edits.append((fsnative(u"--TPE1"), args[2]))) parser.add_option( "-A", "--album", metavar='"ALBUM"', action="callback", help="Set the album title information", type="string", - callback=lambda *args: args[3].edits.append(("--TALB", args[2]))) + callback=lambda *args: args[3].edits.append((fsnative(u"--TALB"), args[2]))) parser.add_option( "-t", "--song", metavar='"SONG"', action="callback", help="Set the song title information", type="string", - callback=lambda *args: args[3].edits.append(("--TIT2", args[2]))) + callback=lambda *args: args[3].edits.append((fsnative(u"--TIT2"), args[2]))) parser.add_option( "-c", "--comment", metavar='"DESCRIPTION":"COMMENT":"LANGUAGE"', action="callback", help="Set the comment information", type="string", - callback=lambda *args: args[3].edits.append(("--COMM", args[2]))) + callback=lambda *args: args[3].edits.append((fsnative(u"--COMM"), args[2]))) parser.add_option( "-g", "--genre", metavar='"GENRE"', action="callback", help="Set the genre or genre number", type="string", - callback=lambda *args: args[3].edits.append(("--TCON", args[2]))) + callback=lambda *args: args[3].edits.append((fsnative(u"--TCON"), args[2]))) parser.add_option( "-y", "--year", "--date", metavar='YYYY[-MM-DD]', action="callback", help="Set the year/date", type="string", - callback=lambda *args: args[3].edits.append(("--TDRC", args[2]))) + callback=lambda *args: args[3].edits.append((fsnative(u"--TDRC"), args[2]))) parser.add_option( "-T", "--track", metavar='"num/num"', action="callback", help="Set the track number/(optional) total tracks", type="string", - callback=lambda *args: args[3].edits.append(("--TRCK", args[2]))) + callback=lambda *args: args[3].edits.append((fsnative(u"--TRCK"), args[2]))) for frame in mutagen.id3.Frames: if (issubclass(mutagen.id3.Frames[frame], mutagen.id3.TextFrame) @@ -364,4 +400,6 @@ if __name__ == "__main__": - main(sys.argv) + argv = get_win32_unicode_argv() + _sig.init() + main(argv) diff -Nru mutagen-1.23/tools/moggsplit mutagen-1.30/tools/moggsplit --- mutagen-1.23/tools/moggsplit 2013-09-11 14:19:30.000000000 +0000 +++ mutagen-1.30/tools/moggsplit 2015-04-29 19:11:17.000000000 +0000 @@ -9,9 +9,12 @@ import os import sys -from optparse import OptionParser - import mutagen.ogg +from mutagen._toolsutil import SignalHandler, get_win32_unicode_argv, \ + OptionParser + + +_sig = SignalHandler() def main(argv): @@ -38,30 +41,33 @@ format = {'ext': options.extension} for filename in args: - fileobjs = {} - format["base"] = os.path.splitext(os.path.basename(filename))[0] - fileobj = open(filename, "rb") - if options.m3u: - m3u = open(format["base"] + ".m3u", "w") - fileobjs["m3u"] = m3u - else: - m3u = None - while True: - try: - page = OggPage(fileobj) - except EOFError: - break + with _sig.block(): + fileobjs = {} + format["base"] = os.path.splitext(os.path.basename(filename))[0] + fileobj = open(filename, "rb") + if options.m3u: + m3u = open(format["base"] + ".m3u", "w") + fileobjs["m3u"] = m3u else: - format["stream"] = page.serial - if page.serial not in fileobjs: - new_filename = options.pattern % format - new_fileobj = open(new_filename, "wb") - fileobjs[page.serial] = new_fileobj - if m3u: - m3u.write(new_filename + "\r\n") - fileobjs[page.serial].write(page.write()) - for f in fileobjs.values(): - f.close() + m3u = None + while True: + try: + page = OggPage(fileobj) + except EOFError: + break + else: + format["stream"] = page.serial + if page.serial not in fileobjs: + new_filename = options.pattern % format + new_fileobj = open(new_filename, "wb") + fileobjs[page.serial] = new_fileobj + if m3u: + m3u.write(new_filename + "\r\n") + fileobjs[page.serial].write(page.write()) + for f in fileobjs.values(): + f.close() if __name__ == "__main__": - main(sys.argv) + argv = get_win32_unicode_argv() + _sig.init() + main(argv) diff -Nru mutagen-1.23/tools/mutagen-inspect mutagen-1.30/tools/mutagen-inspect --- mutagen-1.23/tools/mutagen-inspect 2013-09-11 14:20:25.000000000 +0000 +++ mutagen-1.30/tools/mutagen-inspect 2015-04-29 19:11:10.000000000 +0000 @@ -9,7 +9,9 @@ import sys import locale -from optparse import OptionParser +from mutagen._toolsutil import SignalHandler, get_win32_unicode_argv, print_, \ + OptionParser +from mutagen._compat import text_type def main(argv): @@ -24,19 +26,18 @@ if not args: raise SystemExit(parser.print_help() or 1) - enc = locale.getpreferredencoding() or "utf-8" for filename in args: - print("--", filename) + print_(u"--", filename) try: - print("- " + File(filename).pprint().encode(enc, 'replace')) + print_(u"-", File(filename).pprint()) except AttributeError: - print("- Unknown file type") - except KeyboardInterrupt: - raise + print_(u"- Unknown file type") except Exception as err: - print(str(err)) - print + print_(text_type(err)) + print_(u"") if __name__ == "__main__": - main(sys.argv) + argv = get_win32_unicode_argv() + SignalHandler().init() + main(argv) diff -Nru mutagen-1.23/tools/mutagen-pony mutagen-1.30/tools/mutagen-pony --- mutagen-1.23/tools/mutagen-pony 2013-09-11 14:21:43.000000000 +0000 +++ mutagen-1.30/tools/mutagen-pony 2015-04-29 19:10:54.000000000 +0000 @@ -9,6 +9,8 @@ import sys import traceback +from mutagen._toolsutil import SignalHandler, get_win32_unicode_argv, print_ + class Report(object): def __init__(self, pathname): @@ -75,14 +77,10 @@ def check_dir(path): - from mutagen.id3 import ID3 from mutagen.mp3 import MP3 - class ID3Custom(ID3): - PEDANTIC = False - rep = Report(path) - print("Scanning", path) + print_(u"Scanning", path) for path, dirs, files in os.walk(path): files.sort() for fn in files: @@ -90,9 +88,7 @@ continue ffn = os.path.join(path, fn) try: - mp3 = MP3(ffn, ID3=ID3Custom) - except KeyboardInterrupt: - raise + mp3 = MP3(ffn) except Exception: rep.error(ffn) else: @@ -101,16 +97,18 @@ else: rep.success(mp3.tags) - print(str(rep)) + print_(str(rep)) def main(argv): if len(argv) == 1: - print("Usage: %s directory ..." % argv[0]) + print_(u"Usage:", argv[0], u"directory ...") else: for path in argv[1:]: check_dir(path) if __name__ == "__main__": - main(sys.argv) + argv = get_win32_unicode_argv() + SignalHandler().init() + main(argv)