├── .github └── workflows │ └── build.yml ├── .gitignore ├── .readthedocs.yaml ├── LICENSE ├── README.rst ├── docs ├── Makefile ├── conf.py └── index.rst ├── mediafile.py ├── pyproject.toml ├── setup.cfg ├── test ├── __init__.py ├── _common.py ├── rsrc │ ├── backup.wav │ ├── bpm.mp3 │ ├── cbr.mp3 │ ├── coverart.ogg │ ├── date_with_slashes.ogg │ ├── discc.ogg │ ├── empty.aiff │ ├── empty.alac.m4a │ ├── empty.ape │ ├── empty.dsf │ ├── empty.flac │ ├── empty.m4a │ ├── empty.mp3 │ ├── empty.mpc │ ├── empty.ogg │ ├── empty.opus │ ├── empty.wav │ ├── empty.wma │ ├── empty.wv │ ├── emptylist.mp3 │ ├── full.aiff │ ├── full.alac.m4a │ ├── full.ape │ ├── full.dsf │ ├── full.flac │ ├── full.m4a │ ├── full.mp3 │ ├── full.mpc │ ├── full.ogg │ ├── full.opus │ ├── full.wav │ ├── full.wma │ ├── full.wv │ ├── image-2x3.jpg │ ├── image-2x3.png │ ├── image-2x3.tiff │ ├── image.ape │ ├── image.flac │ ├── image.m4a │ ├── image.mp3 │ ├── image.ogg │ ├── image.wma │ ├── image_unknown_type.mp3 │ ├── oldape.ape │ ├── partial.flac │ ├── partial.m4a │ ├── partial.mp3 │ ├── pure.wma │ ├── read_only_tag.m4a │ ├── soundcheck-nonascii.m4a │ ├── space_time.mp3 │ ├── t_time.m4a │ ├── unparseable.aiff │ ├── unparseable.alac.m4a │ ├── unparseable.ape │ ├── unparseable.dsf │ ├── unparseable.flac │ ├── unparseable.m4a │ ├── unparseable.mp3 │ ├── unparseable.mpc │ ├── unparseable.ogg │ ├── unparseable.opus │ ├── unparseable.wav │ ├── unparseable.wma │ ├── unparseable.wv │ └── year.ogg ├── test_mediafile.py └── test_mediafile_edge.py └── tox.ini /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | name: '${{ matrix.os }}: ${{ matrix.tox-env }}' 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | tox-env: [py37-test, py38-test, py39-test, 18 | py310-test, pypy-test] 19 | os: [ubuntu-latest, windows-latest] 20 | 21 | # Only test on a couple of versions on Windows. 22 | exclude: 23 | - os: windows-latest 24 | tox-env: py37-test 25 | - os: windows-latest 26 | tox-env: pypy-test 27 | 28 | # Python interpreter versions. :/ 29 | include: 30 | - tox-env: py37-test 31 | python: '3.7' 32 | - tox-env: py38-test 33 | python: '3.8' 34 | - tox-env: py39-test 35 | python: '3.9' 36 | - tox-env: py310-test 37 | python: '3.10' 38 | - tox-env: pypy-test 39 | python: pypy3.9 40 | 41 | steps: 42 | - uses: actions/checkout@v3 43 | - name: Set up Python ${{ matrix.python }} 44 | uses: actions/setup-python@v4 45 | with: 46 | python-version: ${{ matrix.python }} 47 | - name: Install Tox 48 | run: pip install tox 49 | - name: Tox 50 | run: tox -e ${{ matrix.tox-env }} 51 | 52 | style: 53 | name: Style 54 | runs-on: ubuntu-latest 55 | steps: 56 | - uses: actions/checkout@v2 57 | - uses: TrueBrain/actions-flake8@master 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # general hidden files/directories 2 | .DS_Store 3 | .idea 4 | 5 | # file patterns 6 | *~ 7 | 8 | # Built documentation. 9 | docs/_build 10 | 11 | # The rest is from https://www.gitignore.io/api/python 12 | 13 | ### Python ### 14 | # Byte-compiled / optimized / DLL files 15 | __pycache__/ 16 | *.py[cod] 17 | *$py.class 18 | 19 | # C extensions 20 | *.so 21 | 22 | # Distribution / packaging 23 | .Python 24 | env/ 25 | build/ 26 | develop-eggs/ 27 | dist/ 28 | downloads/ 29 | eggs/ 30 | .eggs/ 31 | lib/ 32 | lib64/ 33 | parts/ 34 | sdist/ 35 | var/ 36 | *.egg-info/ 37 | .installed.cfg 38 | *.egg 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .coverage 54 | .coverage.* 55 | .cache 56 | nosetests.xml 57 | coverage.xml 58 | *,cover 59 | .hypothesis/ 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # PyBuilder 66 | target/ 67 | 68 | # pyenv 69 | .python-version 70 | 71 | # dotenv 72 | .env 73 | 74 | # virtualenv 75 | venv/ 76 | ENV/ 77 | .venv/ 78 | 79 | # Spyder project settings 80 | .spyderproject 81 | 82 | # Rope project settings 83 | .ropeproject 84 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-20.04 5 | tools: 6 | python: "3.9" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | python: 12 | install: 13 | - method: pip 14 | path: . 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2015 Adrian Sampson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | MediaFile: read and write audio files' tags in Python 2 | ===================================================== 3 | 4 | .. image:: https://github.com/beetbox/mediafile/workflows/Build/badge.svg?branch=master 5 | :target: https://github.com/beetbox/mediafile/actions 6 | 7 | .. image:: http://img.shields.io/pypi/v/mediafile.svg 8 | :target: https://pypi.python.org/pypi/mediafile 9 | 10 | MediaFile is a simple interface to the metadata tags for many audio file 11 | formats. It wraps `Mutagen`_, a high-quality library for low-level tag 12 | manipulation, with a high-level, format-independent interface for a common set 13 | of tags. 14 | 15 | .. _Mutagen: https://github.com/quodlibet/mutagen 16 | 17 | Synopsis 18 | -------- 19 | 20 | MediaFile is available `on PyPI`_. Install it by typing ``pip install 21 | mediafile``. It works on Python 3.7 or later. Then: 22 | 23 | .. code:: python 24 | 25 | >>> from mediafile import MediaFile 26 | >>> f = MediaFile('Lucy.mp3') 27 | >>> f.title 28 | u'Lucy in the Sky with Diamonds' 29 | >>> f.artist = 'The Beatles' 30 | >>> f.save() 31 | 32 | .. _on PyPI: https://pypi.python.org/pypi/mediafile 33 | 34 | Documentation 35 | ------------- 36 | 37 | See the `full documentation`_. 38 | 39 | .. _full documentation: http://mediafile.readthedocs.io/ 40 | 41 | Authors 42 | ------- 43 | 44 | MediaFile is part of the `beets`_ project. It was originally written by 45 | `Adrian Sampson`_ and is now developed by the beets community. The license is 46 | MIT. 47 | 48 | .. _Adrian Sampson: https://github.com/sampsyo 49 | .. _beets: https://github.com/beetbox/beets 50 | .. _MIT: http://www.opensource.org/licenses/mit-license.php 51 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help 18 | help: 19 | @echo "Please use \`make ' where is one of" 20 | @echo " html to make standalone HTML files" 21 | @echo " dirhtml to make HTML files named index.html in directories" 22 | @echo " singlehtml to make a single large HTML file" 23 | @echo " pickle to make pickle files" 24 | @echo " json to make JSON files" 25 | @echo " htmlhelp to make HTML files and a HTML help project" 26 | @echo " qthelp to make HTML files and a qthelp project" 27 | @echo " applehelp to make an Apple Help Book" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " epub3 to make an epub3" 31 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 32 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 33 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 34 | @echo " text to make text files" 35 | @echo " man to make manual pages" 36 | @echo " texinfo to make Texinfo files" 37 | @echo " info to make Texinfo files and run them through makeinfo" 38 | @echo " gettext to make PO message catalogs" 39 | @echo " changes to make an overview of all changed/added/deprecated items" 40 | @echo " xml to make Docutils-native XML files" 41 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 42 | @echo " linkcheck to check all external links for integrity" 43 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 44 | @echo " coverage to run coverage check of the documentation (if enabled)" 45 | @echo " dummy to check syntax errors of document sources" 46 | 47 | .PHONY: clean 48 | clean: 49 | rm -rf $(BUILDDIR)/* 50 | 51 | .PHONY: html 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | .PHONY: dirhtml 58 | dirhtml: 59 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 60 | @echo 61 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 62 | 63 | .PHONY: singlehtml 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | .PHONY: pickle 70 | pickle: 71 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 72 | @echo 73 | @echo "Build finished; now you can process the pickle files." 74 | 75 | .PHONY: json 76 | json: 77 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 78 | @echo 79 | @echo "Build finished; now you can process the JSON files." 80 | 81 | .PHONY: htmlhelp 82 | htmlhelp: 83 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 84 | @echo 85 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 86 | ".hhp project file in $(BUILDDIR)/htmlhelp." 87 | 88 | .PHONY: qthelp 89 | qthelp: 90 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 91 | @echo 92 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 93 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 94 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/MediaFile.qhcp" 95 | @echo "To view the help file:" 96 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/MediaFile.qhc" 97 | 98 | .PHONY: applehelp 99 | applehelp: 100 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 101 | @echo 102 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 103 | @echo "N.B. You won't be able to view it unless you put it in" \ 104 | "~/Library/Documentation/Help or install it in your application" \ 105 | "bundle." 106 | 107 | .PHONY: devhelp 108 | devhelp: 109 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 110 | @echo 111 | @echo "Build finished." 112 | @echo "To view the help file:" 113 | @echo "# mkdir -p $$HOME/.local/share/devhelp/MediaFile" 114 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/MediaFile" 115 | @echo "# devhelp" 116 | 117 | .PHONY: epub 118 | epub: 119 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 120 | @echo 121 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 122 | 123 | .PHONY: epub3 124 | epub3: 125 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 126 | @echo 127 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." 128 | 129 | .PHONY: latex 130 | latex: 131 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 132 | @echo 133 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 134 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 135 | "(use \`make latexpdf' here to do that automatically)." 136 | 137 | .PHONY: latexpdf 138 | latexpdf: 139 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 140 | @echo "Running LaTeX files through pdflatex..." 141 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 142 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 143 | 144 | .PHONY: latexpdfja 145 | latexpdfja: 146 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 147 | @echo "Running LaTeX files through platex and dvipdfmx..." 148 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 149 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 150 | 151 | .PHONY: text 152 | text: 153 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 154 | @echo 155 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 156 | 157 | .PHONY: man 158 | man: 159 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 160 | @echo 161 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 162 | 163 | .PHONY: texinfo 164 | texinfo: 165 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 166 | @echo 167 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 168 | @echo "Run \`make' in that directory to run these through makeinfo" \ 169 | "(use \`make info' here to do that automatically)." 170 | 171 | .PHONY: info 172 | info: 173 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 174 | @echo "Running Texinfo files through makeinfo..." 175 | make -C $(BUILDDIR)/texinfo info 176 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 177 | 178 | .PHONY: gettext 179 | gettext: 180 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 181 | @echo 182 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 183 | 184 | .PHONY: changes 185 | changes: 186 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 187 | @echo 188 | @echo "The overview file is in $(BUILDDIR)/changes." 189 | 190 | .PHONY: linkcheck 191 | linkcheck: 192 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 193 | @echo 194 | @echo "Link check complete; look for any errors in the above output " \ 195 | "or in $(BUILDDIR)/linkcheck/output.txt." 196 | 197 | .PHONY: doctest 198 | doctest: 199 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 200 | @echo "Testing of doctests in the sources finished, look at the " \ 201 | "results in $(BUILDDIR)/doctest/output.txt." 202 | 203 | .PHONY: coverage 204 | coverage: 205 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 206 | @echo "Testing of coverage in the sources finished, look at the " \ 207 | "results in $(BUILDDIR)/coverage/python.txt." 208 | 209 | .PHONY: xml 210 | xml: 211 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 212 | @echo 213 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 214 | 215 | .PHONY: pseudoxml 216 | pseudoxml: 217 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 218 | @echo 219 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 220 | 221 | .PHONY: dummy 222 | dummy: 223 | $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy 224 | @echo 225 | @echo "Build finished. Dummy builder generates no files." 226 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Path to mediafile module for autodoc. 4 | import os 5 | import sys 6 | sys.path.insert(0, os.path.abspath('..')) 7 | 8 | extensions = [ 9 | 'sphinx.ext.autodoc', 10 | ] 11 | 12 | source_suffix = '.rst' 13 | master_doc = 'index' 14 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 15 | 16 | project = 'MediaFile' 17 | copyright = '2016, the beets project' 18 | author = 'the beets project' 19 | 20 | version = '0.13' 21 | release = '0.13.0' 22 | 23 | pygments_style = 'sphinx' 24 | htmlhelp_basename = 'mediafiledoc' 25 | 26 | # LaTeX output. 27 | latex_documents = [ 28 | (master_doc, 'MediaFile.tex', 'MediaFile Documentation', 29 | 'the beets project', 'manual'), 30 | ] 31 | 32 | # mapage output. 33 | man_pages = [ 34 | (master_doc, 'mediafile', 'MediaFile Documentation', 35 | [author], 1) 36 | ] 37 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | MediaFile: elegant audio file tagging 2 | ===================================== 3 | 4 | MediaFile is a simple interface to the metadata tags for many audio file 5 | formats. It wraps `Mutagen`_, a high-quality library for low-level tag 6 | manipulation, with a high-level, format-independent interface for a common set 7 | of tags. 8 | 9 | It currently supports MP3 files (ID3 tags), AAC files (as tagged by iTunes) as 10 | well as FLAC, Ogg, Monkey's Audio, WavPack, and Musepack. 11 | 12 | If a tag does not exist, MediaFile will return ``None`` instead of throwing 13 | an exception. 14 | 15 | .. _Mutagen: https://github.com/quodlibet/mutagen 16 | 17 | .. toctree:: 18 | :maxdepth: 2 19 | 20 | Supported Metadata Fields 21 | ------------------------- 22 | The metadata schema is generally based on MusicBrainz' schema with similar naming. MediaFile supports: 23 | 24 | * basic fields like ``title``, ``album``, ``artist`` and ``albumartist``, 25 | * sorting variants like ``albumartist_sort`` and ``composer_sort``, 26 | * plural/list variants like ``artists`` and ``albumartists``, 27 | * identifiers like ``asin``, ``isrc`` or ``mb_releasegroupid``, 28 | * dates like the release ``year``, ``month`` and ``day`` with convenience wrapper ``date``, 29 | * detailed metadata like ``language`` or ``media``, 30 | * ``lyrics``, ``copyright``, ``url`` 31 | * calculated metadata like ``bpm`` (beats per minute) and ``r128_track_gain`` (ReplayGain), 32 | * embedded images (e.g. album art), 33 | * file metadata like ``samplerate``, ``bitdepth``, ``channels``, ``bitrate``, ``bitrate_mode``, ``encoder_info``, ``encoder_settings`` and ``length``. 34 | 35 | Compatibility 36 | ------------- 37 | The ID3 and MPEG-4 test cases were created with iTunes and the FLAC and Ogg test 38 | cases were created (mostly) with 39 | `MediaRage`_. The Monkey's Audio tags were mainly fabricated using the open-source 40 | `Tag`_. Thus, MediaFile's tag support most closely aligns with those three applications. 41 | Some open questions remain about how to most compatibly tag files. In particular, some 42 | fields MediaFile supports don't seem standardized among FLAC/Ogg taggers: 43 | 44 | .. _MediaRage: http://www.chaoticsoftware.com/ProductPages/MediaRage.html 45 | .. _Tag: http://sbooth.org/Tag/ 46 | 47 | * ``grouping`` and ``lyrics``: couldn't find anyone who supports these in a 48 | cursory search; MediaFile uses the keys ``grouping`` and ``lyrics`` 49 | * ``tracktotal`` and ``disctotal``: we use the keys ``tracktotal``, ``totaltracks``, 50 | and ``trackc`` all to mean the same thing 51 | * ``year``: this field appears both as a part of the ``date`` field and on its own 52 | using some taggers; both are supported 53 | 54 | For fields that have multiple possible storage keys, MediaFile optimizes for 55 | interoperability: it accepts _any_ of the possible storage keys and writes _all_ 56 | of them. This may result in duplicated information in the tags, but it ensures 57 | that other players with slightly divergent opinions on tag names will all be 58 | able to interact with `beets`_. 59 | 60 | 61 | Images (album art) are stored in the standard ways for ID3 and MPEG-4. For all 62 | other formats, images are stored with the `METADATA_BLOCK_PICTURE standard `_ from Vorbis 63 | Comments. The older `COVERART`_ unofficial format is also read but is not written. 64 | 65 | .. _MBP: http://wiki.xiph.org/VorbisComment#METADATA_BLOCK_PICTURE 66 | .. _COVERART: http://wiki.xiph.org/VorbisComment#Unofficial_COVERART_field_.28deprecated.29 67 | 68 | .. currentmodule:: mediafile 69 | 70 | MediaFile Class 71 | --------------- 72 | 73 | .. autoclass:: MediaFile 74 | 75 | .. automethod:: __init__ 76 | .. automethod:: fields 77 | .. automethod:: readable_fields 78 | .. automethod:: save 79 | .. automethod:: update 80 | 81 | Exceptions 82 | ---------- 83 | 84 | .. autoclass:: UnreadableFileError 85 | .. autoclass:: FileTypeError 86 | .. autoclass:: MutagenError 87 | 88 | Internals 89 | --------- 90 | 91 | .. autoclass:: MediaField 92 | 93 | .. automethod:: __init__ 94 | 95 | .. autoclass:: StorageStyle 96 | :members: 97 | 98 | 99 | Examples 100 | -------- 101 | 102 | To add cover art to a MediaFile: 103 | 104 | .. code:: python 105 | 106 | from mediafile import MediaFile, Image, ImageType 107 | 108 | image_file = "cover.jpg" 109 | with open(image_file, 'rb') as f: 110 | cover = f.read() 111 | cover = Image(data=cover, desc=u'album cover', type=ImageType.front) 112 | f = MediaFile("file.mp3) 113 | f.images = [cover] 114 | f.save() 115 | 116 | 117 | To copy tags from one MediaFile to another: 118 | 119 | .. code:: python 120 | 121 | from mediafile import MediaFile 122 | 123 | f = MediaFile("file1.mp3") 124 | g = MediaFile("file2.mp3") 125 | 126 | for field in f.fields(): 127 | try: 128 | setattr(g, field, getattr(f, field)) 129 | except: 130 | pass 131 | 132 | g.save() 133 | 134 | 135 | Changelog 136 | --------- 137 | 138 | v0.13.0 139 | ''''''' 140 | 141 | - Add a mapping compatible with Plex and ffmpeg for the "original date" 142 | fields. 143 | - Remove an unnecessary dependency on `six`. 144 | - Replace `imghdr` with `filetype` to support Python 3.13. 145 | 146 | v0.12.0 147 | ''''''' 148 | 149 | - Add the multiple-valued properties ``artists_credit``, ``artists_sort``, 150 | ``albumartists_credit``, and ``albumartists_sort``. 151 | 152 | v0.11.0 153 | ''''''' 154 | 155 | - List-valued properties now return ``None`` instead of an empty list when the 156 | underlying tags are missing altogether. 157 | 158 | v0.10.1 159 | ''''''' 160 | 161 | - Fix a test failure that arose with Mutagen 1.46. 162 | - Require Python 3.7 or later. 163 | 164 | v0.10.0 165 | ''''''' 166 | 167 | - Add the multiple-valued properties ``albumtypes``, ``catalognums`` and 168 | ``languages``. 169 | - The ``catalognum`` property now refers to additional file tags named 170 | ``CATALOGID`` and ``DISCOGS_CATALOG`` (but only for reading, not writing). 171 | - The multi-valued ``albumartists`` property now refers to additional file 172 | tags named ``ALBUM_ARTIST`` and ``ALBUM ARTISTS``. (The latter 173 | is used only for reading.) 174 | - The ``ListMediaField`` class now doesn't concatenate multiple lists if 175 | found. The first available tag is used instead, like with other kinds of 176 | fields. 177 | 178 | v0.9.0 179 | '''''' 180 | 181 | - Add the properties ``bitrate_mode``, ``encoder_info`` and 182 | ``encoder_settings``. 183 | 184 | v0.8.1 185 | '''''' 186 | 187 | - Fix a regression in v0.8.0 that caused a crash on Python versions below 3.8. 188 | 189 | v0.8.0 190 | '''''' 191 | 192 | - MediaFile now requires Python 3.6 or later. 193 | - Added support for Wave (`.wav`) files. 194 | 195 | v0.7.0 196 | '''''' 197 | 198 | - Mutagen 1.45.0 or later is now required. 199 | - MediaFile can now use file-like objects (instead of just the filesystem, via 200 | filenames). 201 | 202 | v0.6.0 203 | '''''' 204 | 205 | - Enforce a minimum value for SoundCheck gain values. 206 | 207 | v0.5.0 208 | '''''' 209 | 210 | - Refactored the distribution to use `Flit`_. 211 | 212 | .. _Flit: https://flit.readthedocs.io/ 213 | 214 | v0.4.0 215 | '''''' 216 | 217 | - Added a ``barcode`` field. 218 | - Added new tag mappings for ``albumtype`` and ``albumstatus``. 219 | 220 | v0.3.0 221 | '''''' 222 | 223 | - Fixed tests for compatibility with Mutagen 1.43. 224 | - Fix the MPEG-4 tag mapping for the ``label`` field to use the right 225 | capitalization. 226 | 227 | v0.2.0 228 | '''''' 229 | 230 | - R128 gain tags are now stored in Q7.8 integer format, as per 231 | `the relevant standard`_. 232 | - Added an ``mb_workid`` field. 233 | - The Python source distribution now includes an ``__init__.py`` file that 234 | makes it easier to run the tests. 235 | 236 | .. _the relevant standard: https://tools.ietf.org/html/rfc7845.html#page-25 237 | 238 | v0.1.0 239 | '''''' 240 | 241 | This is the first independent release of MediaFile. 242 | It is now synchronised with the embedded version released with `beets`_ v1.4.8. 243 | 244 | .. _beets: https://beets.io 245 | -------------------------------------------------------------------------------- /mediafile.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # This file is part of MediaFile. 3 | # Copyright 2016, Adrian Sampson. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | 16 | """Handles low-level interfacing for files' tags. Wraps Mutagen to 17 | automatically detect file types and provide a unified interface for a 18 | useful subset of music files' tags. 19 | 20 | Usage: 21 | 22 | >>> f = MediaFile('Lucy.mp3') 23 | >>> f.title 24 | u'Lucy in the Sky with Diamonds' 25 | >>> f.artist = 'The Beatles' 26 | >>> f.save() 27 | 28 | A field will always return a reasonable value of the correct type, even 29 | if no tag is present. If no value is available, the value will be false 30 | (e.g., zero or the empty string). 31 | 32 | Internally ``MediaFile`` uses ``MediaField`` descriptors to access the 33 | data from the tags. In turn ``MediaField`` uses a number of 34 | ``StorageStyle`` strategies to handle format specific logic. 35 | """ 36 | import mutagen 37 | import mutagen.id3 38 | import mutagen.mp3 39 | import mutagen.mp4 40 | import mutagen.flac 41 | import mutagen.asf 42 | import mutagen._util 43 | 44 | import base64 45 | import binascii 46 | import codecs 47 | import datetime 48 | import enum 49 | import filetype 50 | import functools 51 | import logging 52 | import math 53 | import os 54 | import re 55 | import struct 56 | import traceback 57 | 58 | 59 | __version__ = '0.13.0' 60 | __all__ = ['UnreadableFileError', 'FileTypeError', 'MediaFile'] 61 | 62 | log = logging.getLogger(__name__) 63 | 64 | # Human-readable type names. 65 | TYPES = { 66 | 'mp3': 'MP3', 67 | 'aac': 'AAC', 68 | 'alac': 'ALAC', 69 | 'ogg': 'OGG', 70 | 'opus': 'Opus', 71 | 'flac': 'FLAC', 72 | 'ape': 'APE', 73 | 'wv': 'WavPack', 74 | 'mpc': 'Musepack', 75 | 'asf': 'Windows Media', 76 | 'aiff': 'AIFF', 77 | 'dsf': 'DSD Stream File', 78 | 'wav': 'WAVE', 79 | } 80 | 81 | 82 | # Exceptions. 83 | 84 | class UnreadableFileError(Exception): 85 | """Mutagen is not able to extract information from the file. 86 | """ 87 | def __init__(self, filename, msg): 88 | Exception.__init__(self, msg if msg else repr(filename)) 89 | 90 | 91 | class FileTypeError(UnreadableFileError): 92 | """Reading this type of file is not supported. 93 | 94 | If passed the `mutagen_type` argument this indicates that the 95 | mutagen type is not supported by `Mediafile`. 96 | """ 97 | def __init__(self, filename, mutagen_type=None): 98 | if mutagen_type is None: 99 | msg = u'{0!r}: not in a recognized format'.format(filename) 100 | else: 101 | msg = u'{0}: of mutagen type {1}'.format( 102 | repr(filename), mutagen_type 103 | ) 104 | Exception.__init__(self, msg) 105 | 106 | 107 | class MutagenError(UnreadableFileError): 108 | """Raised when Mutagen fails unexpectedly---probably due to a bug. 109 | """ 110 | def __init__(self, filename, mutagen_exc): 111 | msg = u'{0}: {1}'.format(repr(filename), mutagen_exc) 112 | Exception.__init__(self, msg) 113 | 114 | 115 | # Interacting with Mutagen. 116 | 117 | 118 | def mutagen_call(action, filename, func, *args, **kwargs): 119 | """Call a Mutagen function with appropriate error handling. 120 | 121 | `action` is a string describing what the function is trying to do, 122 | and `filename` is the relevant filename. The rest of the arguments 123 | describe the callable to invoke. 124 | 125 | We require at least Mutagen 1.33, where `IOError` is *never* used, 126 | neither for internal parsing errors *nor* for ordinary IO error 127 | conditions such as a bad filename. Mutagen-specific parsing errors and IO 128 | errors are reraised as `UnreadableFileError`. Other exceptions 129 | raised inside Mutagen---i.e., bugs---are reraised as `MutagenError`. 130 | """ 131 | try: 132 | return func(*args, **kwargs) 133 | except mutagen.MutagenError as exc: 134 | log.debug(u'%s failed: %s', action, str(exc)) 135 | raise UnreadableFileError(filename, str(exc)) 136 | except UnreadableFileError: 137 | # Reraise our errors without changes. 138 | # Used in case of decorating functions (e.g. by `loadfile`). 139 | raise 140 | except Exception as exc: 141 | # Isolate bugs in Mutagen. 142 | log.debug(u'%s', traceback.format_exc()) 143 | log.error(u'uncaught Mutagen exception in %s: %s', action, exc) 144 | raise MutagenError(filename, exc) 145 | 146 | 147 | def loadfile(method=True, writable=False, create=False): 148 | """A decorator that works like `mutagen._util.loadfile` but with 149 | additional error handling. 150 | 151 | Opens a file and passes a `mutagen._utils.FileThing` to the 152 | decorated function. Should be used as a decorator for functions 153 | using a `filething` parameter. 154 | """ 155 | def decorator(func): 156 | f = mutagen._util.loadfile(method, writable, create)(func) 157 | 158 | @functools.wraps(func) 159 | def wrapper(*args, **kwargs): 160 | return mutagen_call('loadfile', '', f, *args, **kwargs) 161 | return wrapper 162 | return decorator 163 | 164 | 165 | # Utility. 166 | 167 | def _update_filething(filething): 168 | """Reopen a `filething` if it's a local file. 169 | 170 | A filething that is *not* an actual file is left unchanged; a 171 | filething with a filename is reopened and a new object is returned. 172 | """ 173 | if filething.filename: 174 | return mutagen._util.FileThing( 175 | None, filething.filename, filething.name 176 | ) 177 | else: 178 | return filething 179 | 180 | 181 | def _safe_cast(out_type, val): 182 | """Try to covert val to out_type but never raise an exception. 183 | 184 | If the value does not exist, return None. Or, if the value 185 | can't be converted, then a sensible default value is returned. 186 | out_type should be bool, int, or unicode; otherwise, the value 187 | is just passed through. 188 | """ 189 | if val is None: 190 | return None 191 | 192 | if out_type is int: 193 | if isinstance(val, int) or isinstance(val, float): 194 | # Just a number. 195 | return int(val) 196 | else: 197 | # Process any other type as a string. 198 | if isinstance(val, bytes): 199 | val = val.decode('utf-8', 'ignore') 200 | elif not isinstance(val, str): 201 | val = str(val) 202 | # Get a number from the front of the string. 203 | match = re.match(r'[\+-]?[0-9]+', val.strip()) 204 | return int(match.group(0)) if match else 0 205 | 206 | elif out_type is bool: 207 | try: 208 | # Should work for strings, bools, ints: 209 | return bool(int(val)) 210 | except ValueError: 211 | return False 212 | 213 | elif out_type is str: 214 | if isinstance(val, bytes): 215 | return val.decode('utf-8', 'ignore') 216 | elif isinstance(val, str): 217 | return val 218 | else: 219 | return str(val) 220 | 221 | elif out_type is float: 222 | if isinstance(val, int) or isinstance(val, float): 223 | return float(val) 224 | else: 225 | if isinstance(val, bytes): 226 | val = val.decode('utf-8', 'ignore') 227 | else: 228 | val = str(val) 229 | match = re.match(r'[\+-]?([0-9]+\.?[0-9]*|[0-9]*\.[0-9]+)', 230 | val.strip()) 231 | if match: 232 | val = match.group(0) 233 | if val: 234 | return float(val) 235 | return 0.0 236 | 237 | else: 238 | return val 239 | 240 | 241 | # Image coding for ASF/WMA. 242 | 243 | def _unpack_asf_image(data): 244 | """Unpack image data from a WM/Picture tag. Return a tuple 245 | containing the MIME type, the raw image data, a type indicator, and 246 | the image's description. 247 | 248 | This function is treated as "untrusted" and could throw all manner 249 | of exceptions (out-of-bounds, etc.). We should clean this up 250 | sometime so that the failure modes are well-defined. 251 | """ 252 | type, size = struct.unpack_from(' 0: 307 | gain = math.log10(maxgain / 1000.0) * -10 308 | else: 309 | # Invalid gain value found. 310 | gain = 0.0 311 | 312 | # SoundCheck stores peak values as the actual value of the sample, 313 | # and again separately for the left and right channels. We need to 314 | # convert this to a percentage of full scale, which is 32768 for a 315 | # 16 bit sample. Once again, we play it safe by using the larger of 316 | # the two values. 317 | peak = max(soundcheck[6:8]) / 32768.0 318 | 319 | return round(gain, 2), round(peak, 6) 320 | 321 | 322 | def _sc_encode(gain, peak): 323 | """Encode ReplayGain gain/peak values as a Sound Check string. 324 | """ 325 | # SoundCheck stores the peak value as the actual value of the 326 | # sample, rather than the percentage of full scale that RG uses, so 327 | # we do a simple conversion assuming 16 bit samples. 328 | peak *= 32768.0 329 | 330 | # SoundCheck stores absolute RMS values in some unknown units rather 331 | # than the dB values RG uses. We can calculate these absolute values 332 | # from the gain ratio using a reference value of 1000 units. We also 333 | # enforce the maximum and minimum value here, which is equivalent to 334 | # about -18.2dB and 30.0dB. 335 | g1 = int(min(round((10 ** (gain / -10)) * 1000), 65534)) or 1 336 | # Same as above, except our reference level is 2500 units. 337 | g2 = int(min(round((10 ** (gain / -10)) * 2500), 65534)) or 1 338 | 339 | # The purpose of these values are unknown, but they also seem to be 340 | # unused so we just use zero. 341 | uk = 0 342 | values = (g1, g1, g2, g2, uk, uk, int(peak), int(peak), uk, uk) 343 | return (u' %08X' * 10) % values 344 | 345 | 346 | # Cover art and other images. 347 | 348 | def image_mime_type(data): 349 | """Return the MIME type of the image data (a bytestring). 350 | """ 351 | return filetype.guess_mime(data) 352 | 353 | 354 | def image_extension(data): 355 | ext = filetype.guess_extension(data) 356 | # imghdr returned "tiff", so we should keep returning it with filetype. 357 | return ext if ext != 'tif' else 'tiff' 358 | 359 | 360 | class ImageType(enum.Enum): 361 | """Indicates the kind of an `Image` stored in a file's tag. 362 | """ 363 | other = 0 364 | icon = 1 365 | other_icon = 2 366 | front = 3 367 | back = 4 368 | leaflet = 5 369 | media = 6 370 | lead_artist = 7 371 | artist = 8 372 | conductor = 9 373 | group = 10 374 | composer = 11 375 | lyricist = 12 376 | recording_location = 13 377 | recording_session = 14 378 | performance = 15 379 | screen_capture = 16 380 | fish = 17 381 | illustration = 18 382 | artist_logo = 19 383 | publisher_logo = 20 384 | 385 | 386 | class Image(object): 387 | """Structure representing image data and metadata that can be 388 | stored and retrieved from tags. 389 | 390 | The structure has four properties. 391 | * ``data`` The binary data of the image 392 | * ``desc`` An optional description of the image 393 | * ``type`` An instance of `ImageType` indicating the kind of image 394 | * ``mime_type`` Read-only property that contains the mime type of 395 | the binary data 396 | """ 397 | def __init__(self, data, desc=None, type=None): 398 | assert isinstance(data, bytes) 399 | if desc is not None: 400 | assert isinstance(desc, str) 401 | self.data = data 402 | self.desc = desc 403 | if isinstance(type, int): 404 | try: 405 | type = list(ImageType)[type] 406 | except IndexError: 407 | log.debug(u"ignoring unknown image type index %s", type) 408 | type = ImageType.other 409 | self.type = type 410 | 411 | @property 412 | def mime_type(self): 413 | if self.data: 414 | return image_mime_type(self.data) 415 | 416 | @property 417 | def type_index(self): 418 | if self.type is None: 419 | # This method is used when a tag format requires the type 420 | # index to be set, so we return "other" as the default value. 421 | return 0 422 | return self.type.value 423 | 424 | 425 | # StorageStyle classes describe strategies for accessing values in 426 | # Mutagen file objects. 427 | 428 | class StorageStyle(object): 429 | """A strategy for storing a value for a certain tag format (or set 430 | of tag formats). This basic StorageStyle describes simple 1:1 431 | mapping from raw values to keys in a Mutagen file object; subclasses 432 | describe more sophisticated translations or format-specific access 433 | strategies. 434 | 435 | MediaFile uses a StorageStyle via three methods: ``get()``, 436 | ``set()``, and ``delete()``. It passes a Mutagen file object to 437 | each. 438 | 439 | Internally, the StorageStyle implements ``get()`` and ``set()`` 440 | using two steps that may be overridden by subtypes. To get a value, 441 | the StorageStyle first calls ``fetch()`` to retrieve the value 442 | corresponding to a key and then ``deserialize()`` to convert the raw 443 | Mutagen value to a consumable Python value. Similarly, to set a 444 | field, we call ``serialize()`` to encode the value and then 445 | ``store()`` to assign the result into the Mutagen object. 446 | 447 | Each StorageStyle type has a class-level `formats` attribute that is 448 | a list of strings indicating the formats that the style applies to. 449 | MediaFile only uses StorageStyles that apply to the correct type for 450 | a given audio file. 451 | """ 452 | 453 | formats = ['FLAC', 'OggOpus', 'OggTheora', 'OggSpeex', 'OggVorbis', 454 | 'OggFlac', 'APEv2File', 'WavPack', 'Musepack', 'MonkeysAudio'] 455 | """List of mutagen classes the StorageStyle can handle. 456 | """ 457 | 458 | def __init__(self, key, as_type=str, suffix=None, 459 | float_places=2, read_only=False): 460 | """Create a basic storage strategy. Parameters: 461 | 462 | - `key`: The key on the Mutagen file object used to access the 463 | field's data. 464 | - `as_type`: The Python type that the value is stored as 465 | internally (`unicode`, `int`, `bool`, or `bytes`). 466 | - `suffix`: When `as_type` is a string type, append this before 467 | storing the value. 468 | - `float_places`: When the value is a floating-point number and 469 | encoded as a string, the number of digits to store after the 470 | decimal point. 471 | - `read_only`: When true, writing to this field is disabled. 472 | Primary use case is so wrongly named fields can be addressed 473 | in a graceful manner. This does not block the delete method. 474 | 475 | """ 476 | self.key = key 477 | self.as_type = as_type 478 | self.suffix = suffix 479 | self.float_places = float_places 480 | self.read_only = read_only 481 | 482 | # Convert suffix to correct string type. 483 | if self.suffix and self.as_type is str \ 484 | and not isinstance(self.suffix, str): 485 | self.suffix = self.suffix.decode('utf-8') 486 | 487 | # Getter. 488 | 489 | def get(self, mutagen_file): 490 | """Get the value for the field using this style. 491 | """ 492 | return self.deserialize(self.fetch(mutagen_file)) 493 | 494 | def fetch(self, mutagen_file): 495 | """Retrieve the raw value of for this tag from the Mutagen file 496 | object. 497 | """ 498 | try: 499 | return mutagen_file[self.key][0] 500 | except (KeyError, IndexError): 501 | return None 502 | 503 | def deserialize(self, mutagen_value): 504 | """Given a raw value stored on a Mutagen object, decode and 505 | return the represented value. 506 | """ 507 | if self.suffix and isinstance(mutagen_value, str) \ 508 | and mutagen_value.endswith(self.suffix): 509 | return mutagen_value[:-len(self.suffix)] 510 | else: 511 | return mutagen_value 512 | 513 | # Setter. 514 | 515 | def set(self, mutagen_file, value): 516 | """Assign the value for the field using this style. 517 | """ 518 | self.store(mutagen_file, self.serialize(value)) 519 | 520 | def store(self, mutagen_file, value): 521 | """Store a serialized value in the Mutagen file object. 522 | """ 523 | mutagen_file[self.key] = [value] 524 | 525 | def serialize(self, value): 526 | """Convert the external Python value to a type that is suitable for 527 | storing in a Mutagen file object. 528 | """ 529 | if isinstance(value, float) and self.as_type is str: 530 | value = u'{0:.{1}f}'.format(value, self.float_places) 531 | value = self.as_type(value) 532 | elif self.as_type is str: 533 | if isinstance(value, bool): 534 | # Store bools as 1/0 instead of True/False. 535 | value = str(int(bool(value))) 536 | elif isinstance(value, bytes): 537 | value = value.decode('utf-8', 'ignore') 538 | else: 539 | value = str(value) 540 | else: 541 | value = self.as_type(value) 542 | 543 | if self.suffix: 544 | value += self.suffix 545 | 546 | return value 547 | 548 | def delete(self, mutagen_file): 549 | """Remove the tag from the file. 550 | """ 551 | if self.key in mutagen_file: 552 | del mutagen_file[self.key] 553 | 554 | 555 | class ListStorageStyle(StorageStyle): 556 | """Abstract storage style that provides access to lists. 557 | 558 | The ListMediaField descriptor uses a ListStorageStyle via two 559 | methods: ``get_list()`` and ``set_list()``. It passes a Mutagen file 560 | object to each. 561 | 562 | Subclasses may overwrite ``fetch`` and ``store``. ``fetch`` must 563 | return a (possibly empty) list or `None` if the tag does not exist. 564 | ``store`` receives a serialized list of values as the second argument. 565 | 566 | The `serialize` and `deserialize` methods (from the base 567 | `StorageStyle`) are still called with individual values. This class 568 | handles packing and unpacking the values into lists. 569 | """ 570 | def get(self, mutagen_file): 571 | """Get the first value in the field's value list. 572 | """ 573 | values = self.get_list(mutagen_file) 574 | if values is None: 575 | return None 576 | 577 | try: 578 | return values[0] 579 | except IndexError: 580 | return None 581 | 582 | def get_list(self, mutagen_file): 583 | """Get a list of all values for the field using this style. 584 | """ 585 | raw_values = self.fetch(mutagen_file) 586 | if raw_values is None: 587 | return None 588 | 589 | return [self.deserialize(item) for item in raw_values] 590 | 591 | def fetch(self, mutagen_file): 592 | """Get the list of raw (serialized) values. 593 | """ 594 | try: 595 | return mutagen_file[self.key] 596 | except KeyError: 597 | return None 598 | 599 | def set(self, mutagen_file, value): 600 | """Set an individual value as the only value for the field using 601 | this style. 602 | """ 603 | if value is None: 604 | self.store(mutagen_file, None) 605 | else: 606 | self.set_list(mutagen_file, [value]) 607 | 608 | def set_list(self, mutagen_file, values): 609 | """Set all values for the field using this style. `values` 610 | should be an iterable. 611 | """ 612 | if values is None: 613 | self.delete(mutagen_file) 614 | else: 615 | self.store( 616 | mutagen_file, [self.serialize(value) for value in values] 617 | ) 618 | 619 | def store(self, mutagen_file, values): 620 | """Set the list of all raw (serialized) values for this field. 621 | """ 622 | mutagen_file[self.key] = values 623 | 624 | 625 | class SoundCheckStorageStyleMixin(object): 626 | """A mixin for storage styles that read and write iTunes SoundCheck 627 | analysis values. The object must have an `index` field that 628 | indicates which half of the gain/peak pair---0 or 1---the field 629 | represents. 630 | """ 631 | def get(self, mutagen_file): 632 | data = self.fetch(mutagen_file) 633 | if data is not None: 634 | return _sc_decode(data)[self.index] 635 | 636 | def set(self, mutagen_file, value): 637 | data = self.fetch(mutagen_file) 638 | if data is None: 639 | gain_peak = [0, 0] 640 | else: 641 | gain_peak = list(_sc_decode(data)) 642 | gain_peak[self.index] = value or 0 643 | data = self.serialize(_sc_encode(*gain_peak)) 644 | self.store(mutagen_file, data) 645 | 646 | 647 | class ASFStorageStyle(ListStorageStyle): 648 | """A general storage style for Windows Media/ASF files. 649 | """ 650 | formats = ['ASF'] 651 | 652 | def deserialize(self, data): 653 | if isinstance(data, mutagen.asf.ASFBaseAttribute): 654 | data = data.value 655 | return data 656 | 657 | 658 | class MP4StorageStyle(StorageStyle): 659 | """A general storage style for MPEG-4 tags. 660 | """ 661 | formats = ['MP4'] 662 | 663 | def serialize(self, value): 664 | value = super(MP4StorageStyle, self).serialize(value) 665 | if self.key.startswith('----:') and isinstance(value, str): 666 | value = value.encode('utf-8') 667 | return value 668 | 669 | 670 | class MP4TupleStorageStyle(MP4StorageStyle): 671 | """A style for storing values as part of a pair of numbers in an 672 | MPEG-4 file. 673 | """ 674 | def __init__(self, key, index=0, **kwargs): 675 | super(MP4TupleStorageStyle, self).__init__(key, **kwargs) 676 | self.index = index 677 | 678 | def deserialize(self, mutagen_value): 679 | items = mutagen_value or [] 680 | packing_length = 2 681 | return list(items) + [0] * (packing_length - len(items)) 682 | 683 | def get(self, mutagen_file): 684 | value = super(MP4TupleStorageStyle, self).get(mutagen_file)[self.index] 685 | if value == 0: 686 | # The values are always present and saved as integers. So we 687 | # assume that "0" indicates it is not set. 688 | return None 689 | else: 690 | return value 691 | 692 | def set(self, mutagen_file, value): 693 | if value is None: 694 | value = 0 695 | items = self.deserialize(self.fetch(mutagen_file)) 696 | items[self.index] = int(value) 697 | self.store(mutagen_file, items) 698 | 699 | def delete(self, mutagen_file): 700 | if self.index == 0: 701 | super(MP4TupleStorageStyle, self).delete(mutagen_file) 702 | else: 703 | self.set(mutagen_file, None) 704 | 705 | 706 | class MP4ListStorageStyle(ListStorageStyle, MP4StorageStyle): 707 | pass 708 | 709 | 710 | class MP4SoundCheckStorageStyle(SoundCheckStorageStyleMixin, MP4StorageStyle): 711 | def __init__(self, key, index=0, **kwargs): 712 | super(MP4SoundCheckStorageStyle, self).__init__(key, **kwargs) 713 | self.index = index 714 | 715 | 716 | class MP4BoolStorageStyle(MP4StorageStyle): 717 | """A style for booleans in MPEG-4 files. (MPEG-4 has an atom type 718 | specifically for representing booleans.) 719 | """ 720 | def get(self, mutagen_file): 721 | try: 722 | return mutagen_file[self.key] 723 | except KeyError: 724 | return None 725 | 726 | def get_list(self, mutagen_file): 727 | raise NotImplementedError(u'MP4 bool storage does not support lists') 728 | 729 | def set(self, mutagen_file, value): 730 | mutagen_file[self.key] = value 731 | 732 | def set_list(self, mutagen_file, values): 733 | raise NotImplementedError(u'MP4 bool storage does not support lists') 734 | 735 | 736 | class MP4ImageStorageStyle(MP4ListStorageStyle): 737 | """Store images as MPEG-4 image atoms. Values are `Image` objects. 738 | """ 739 | def __init__(self, **kwargs): 740 | super(MP4ImageStorageStyle, self).__init__(key='covr', **kwargs) 741 | 742 | def deserialize(self, data): 743 | return Image(data) 744 | 745 | def serialize(self, image): 746 | if image.mime_type == 'image/png': 747 | kind = mutagen.mp4.MP4Cover.FORMAT_PNG 748 | elif image.mime_type == 'image/jpeg': 749 | kind = mutagen.mp4.MP4Cover.FORMAT_JPEG 750 | else: 751 | raise ValueError(u'MP4 files only supports PNG and JPEG images') 752 | return mutagen.mp4.MP4Cover(image.data, kind) 753 | 754 | 755 | class MP3StorageStyle(StorageStyle): 756 | """Store data in ID3 frames. 757 | """ 758 | formats = ['MP3', 'AIFF', 'DSF', 'WAVE'] 759 | 760 | def __init__(self, key, id3_lang=None, **kwargs): 761 | """Create a new ID3 storage style. `id3_lang` is the value for 762 | the language field of newly created frames. 763 | """ 764 | self.id3_lang = id3_lang 765 | super(MP3StorageStyle, self).__init__(key, **kwargs) 766 | 767 | def fetch(self, mutagen_file): 768 | try: 769 | return mutagen_file[self.key].text[0] 770 | except (KeyError, IndexError): 771 | return None 772 | 773 | def store(self, mutagen_file, value): 774 | frame = mutagen.id3.Frames[self.key](encoding=3, text=[value]) 775 | mutagen_file.tags.setall(self.key, [frame]) 776 | 777 | 778 | class MP3PeopleStorageStyle(MP3StorageStyle): 779 | """Store list of people in ID3 frames. 780 | """ 781 | def __init__(self, key, involvement='', **kwargs): 782 | self.involvement = involvement 783 | super(MP3PeopleStorageStyle, self).__init__(key, **kwargs) 784 | 785 | def store(self, mutagen_file, value): 786 | frames = mutagen_file.tags.getall(self.key) 787 | 788 | # Try modifying in place. 789 | found = False 790 | for frame in frames: 791 | if frame.encoding == mutagen.id3.Encoding.UTF8: 792 | for pair in frame.people: 793 | if pair[0].lower() == self.involvement.lower(): 794 | pair[1] = value 795 | found = True 796 | 797 | # Try creating a new frame. 798 | if not found: 799 | frame = mutagen.id3.Frames[self.key]( 800 | encoding=mutagen.id3.Encoding.UTF8, 801 | people=[[self.involvement, value]] 802 | ) 803 | mutagen_file.tags.add(frame) 804 | 805 | def fetch(self, mutagen_file): 806 | for frame in mutagen_file.tags.getall(self.key): 807 | for pair in frame.people: 808 | if pair[0].lower() == self.involvement.lower(): 809 | try: 810 | return pair[1] 811 | except IndexError: 812 | return None 813 | 814 | 815 | class MP3ListStorageStyle(ListStorageStyle, MP3StorageStyle): 816 | """Store lists of data in multiple ID3 frames. 817 | """ 818 | def fetch(self, mutagen_file): 819 | try: 820 | return mutagen_file[self.key].text 821 | except KeyError: 822 | return [] 823 | 824 | def store(self, mutagen_file, values): 825 | frame = mutagen.id3.Frames[self.key](encoding=3, text=values) 826 | mutagen_file.tags.setall(self.key, [frame]) 827 | 828 | 829 | class MP3UFIDStorageStyle(MP3StorageStyle): 830 | """Store string data in a UFID ID3 frame with a particular owner. 831 | """ 832 | def __init__(self, owner, **kwargs): 833 | self.owner = owner 834 | super(MP3UFIDStorageStyle, self).__init__('UFID:' + owner, **kwargs) 835 | 836 | def fetch(self, mutagen_file): 837 | try: 838 | return mutagen_file[self.key].data 839 | except KeyError: 840 | return None 841 | 842 | def store(self, mutagen_file, value): 843 | # This field type stores text data as encoded data. 844 | assert isinstance(value, str) 845 | value = value.encode('utf-8') 846 | 847 | frames = mutagen_file.tags.getall(self.key) 848 | for frame in frames: 849 | # Replace existing frame data. 850 | if frame.owner == self.owner: 851 | frame.data = value 852 | else: 853 | # New frame. 854 | frame = mutagen.id3.UFID(owner=self.owner, data=value) 855 | mutagen_file.tags.setall(self.key, [frame]) 856 | 857 | 858 | class MP3DescStorageStyle(MP3StorageStyle): 859 | """Store data in a TXXX (or similar) ID3 frame. The frame is 860 | selected based its ``desc`` field. 861 | ``attr`` allows to specify name of data accessor property in the frame. 862 | Most of frames use `text`. 863 | ``multispec`` specifies if frame data is ``mutagen.id3.MultiSpec`` 864 | which means that the data is being packed in the list. 865 | """ 866 | def __init__(self, desc=u'', key='TXXX', attr='text', multispec=True, 867 | **kwargs): 868 | assert isinstance(desc, str) 869 | self.description = desc 870 | self.attr = attr 871 | self.multispec = multispec 872 | super(MP3DescStorageStyle, self).__init__(key=key, **kwargs) 873 | 874 | def store(self, mutagen_file, value): 875 | frames = mutagen_file.tags.getall(self.key) 876 | if self.multispec: 877 | value = [value] 878 | 879 | # Try modifying in place. 880 | found = False 881 | for frame in frames: 882 | if frame.desc.lower() == self.description.lower(): 883 | setattr(frame, self.attr, value) 884 | frame.encoding = mutagen.id3.Encoding.UTF8 885 | found = True 886 | 887 | # Try creating a new frame. 888 | if not found: 889 | frame = mutagen.id3.Frames[self.key]( 890 | desc=self.description, 891 | encoding=mutagen.id3.Encoding.UTF8, 892 | **{self.attr: value} 893 | ) 894 | if self.id3_lang: 895 | frame.lang = self.id3_lang 896 | mutagen_file.tags.add(frame) 897 | 898 | def fetch(self, mutagen_file): 899 | for frame in mutagen_file.tags.getall(self.key): 900 | if frame.desc.lower() == self.description.lower(): 901 | if not self.multispec: 902 | return getattr(frame, self.attr) 903 | try: 904 | return getattr(frame, self.attr)[0] 905 | except IndexError: 906 | return None 907 | 908 | def delete(self, mutagen_file): 909 | found_frame = None 910 | for frame in mutagen_file.tags.getall(self.key): 911 | if frame.desc.lower() == self.description.lower(): 912 | found_frame = frame 913 | break 914 | if found_frame is not None: 915 | del mutagen_file[frame.HashKey] 916 | 917 | 918 | class MP3ListDescStorageStyle(MP3DescStorageStyle, ListStorageStyle): 919 | def __init__(self, desc=u'', key='TXXX', split_v23=False, **kwargs): 920 | self.split_v23 = split_v23 921 | super(MP3ListDescStorageStyle, self).__init__( 922 | desc=desc, key=key, **kwargs 923 | ) 924 | 925 | def fetch(self, mutagen_file): 926 | for frame in mutagen_file.tags.getall(self.key): 927 | if frame.desc.lower() == self.description.lower(): 928 | if mutagen_file.tags.version == (2, 3, 0) and self.split_v23: 929 | return sum((el.split('/') for el in frame.text), []) 930 | else: 931 | return frame.text 932 | return [] 933 | 934 | def store(self, mutagen_file, values): 935 | self.delete(mutagen_file) 936 | frame = mutagen.id3.Frames[self.key]( 937 | desc=self.description, 938 | text=values, 939 | encoding=mutagen.id3.Encoding.UTF8, 940 | ) 941 | if self.id3_lang: 942 | frame.lang = self.id3_lang 943 | mutagen_file.tags.add(frame) 944 | 945 | 946 | class MP3SlashPackStorageStyle(MP3StorageStyle): 947 | """Store value as part of pair that is serialized as a slash- 948 | separated string. 949 | """ 950 | def __init__(self, key, pack_pos=0, **kwargs): 951 | super(MP3SlashPackStorageStyle, self).__init__(key, **kwargs) 952 | self.pack_pos = pack_pos 953 | 954 | def _fetch_unpacked(self, mutagen_file): 955 | data = self.fetch(mutagen_file) 956 | if data: 957 | items = str(data).split('/') 958 | else: 959 | items = [] 960 | packing_length = 2 961 | return list(items) + [None] * (packing_length - len(items)) 962 | 963 | def get(self, mutagen_file): 964 | return self._fetch_unpacked(mutagen_file)[self.pack_pos] 965 | 966 | def set(self, mutagen_file, value): 967 | items = self._fetch_unpacked(mutagen_file) 968 | items[self.pack_pos] = value 969 | if items[0] is None: 970 | items[0] = '' 971 | if items[1] is None: 972 | items.pop() # Do not store last value 973 | self.store(mutagen_file, '/'.join(map(str, items))) 974 | 975 | def delete(self, mutagen_file): 976 | if self.pack_pos == 0: 977 | super(MP3SlashPackStorageStyle, self).delete(mutagen_file) 978 | else: 979 | self.set(mutagen_file, None) 980 | 981 | 982 | class MP3ImageStorageStyle(ListStorageStyle, MP3StorageStyle): 983 | """Converts between APIC frames and ``Image`` instances. 984 | 985 | The `get_list` method inherited from ``ListStorageStyle`` returns a 986 | list of ``Image``s. Similarly, the `set_list` method accepts a 987 | list of ``Image``s as its ``values`` argument. 988 | """ 989 | def __init__(self): 990 | super(MP3ImageStorageStyle, self).__init__(key='APIC') 991 | self.as_type = bytes 992 | 993 | def deserialize(self, apic_frame): 994 | """Convert APIC frame into Image.""" 995 | return Image(data=apic_frame.data, desc=apic_frame.desc, 996 | type=apic_frame.type) 997 | 998 | def fetch(self, mutagen_file): 999 | return mutagen_file.tags.getall(self.key) 1000 | 1001 | def store(self, mutagen_file, frames): 1002 | mutagen_file.tags.setall(self.key, frames) 1003 | 1004 | def delete(self, mutagen_file): 1005 | mutagen_file.tags.delall(self.key) 1006 | 1007 | def serialize(self, image): 1008 | """Return an APIC frame populated with data from ``image``. 1009 | """ 1010 | assert isinstance(image, Image) 1011 | frame = mutagen.id3.Frames[self.key]() 1012 | frame.data = image.data 1013 | frame.mime = image.mime_type 1014 | frame.desc = image.desc or u'' 1015 | 1016 | # For compatibility with OS X/iTunes prefer latin-1 if possible. 1017 | # See issue #899 1018 | try: 1019 | frame.desc.encode("latin-1") 1020 | except UnicodeEncodeError: 1021 | frame.encoding = mutagen.id3.Encoding.UTF16 1022 | else: 1023 | frame.encoding = mutagen.id3.Encoding.LATIN1 1024 | 1025 | frame.type = image.type_index 1026 | return frame 1027 | 1028 | 1029 | class MP3SoundCheckStorageStyle(SoundCheckStorageStyleMixin, 1030 | MP3DescStorageStyle): 1031 | def __init__(self, index=0, **kwargs): 1032 | super(MP3SoundCheckStorageStyle, self).__init__(**kwargs) 1033 | self.index = index 1034 | 1035 | 1036 | class ASFImageStorageStyle(ListStorageStyle): 1037 | """Store images packed into Windows Media/ASF byte array attributes. 1038 | Values are `Image` objects. 1039 | """ 1040 | formats = ['ASF'] 1041 | 1042 | def __init__(self): 1043 | super(ASFImageStorageStyle, self).__init__(key='WM/Picture') 1044 | 1045 | def deserialize(self, asf_picture): 1046 | mime, data, type, desc = _unpack_asf_image(asf_picture.value) 1047 | return Image(data, desc=desc, type=type) 1048 | 1049 | def serialize(self, image): 1050 | pic = mutagen.asf.ASFByteArrayAttribute() 1051 | pic.value = _pack_asf_image(image.mime_type, image.data, 1052 | type=image.type_index, 1053 | description=image.desc or u'') 1054 | return pic 1055 | 1056 | 1057 | class VorbisImageStorageStyle(ListStorageStyle): 1058 | """Store images in Vorbis comments. Both legacy COVERART fields and 1059 | modern METADATA_BLOCK_PICTURE tags are supported. Data is 1060 | base64-encoded. Values are `Image` objects. 1061 | """ 1062 | formats = ['OggOpus', 'OggTheora', 'OggSpeex', 'OggVorbis', 1063 | 'OggFlac'] 1064 | 1065 | def __init__(self): 1066 | super(VorbisImageStorageStyle, self).__init__( 1067 | key='metadata_block_picture' 1068 | ) 1069 | self.as_type = bytes 1070 | 1071 | def fetch(self, mutagen_file): 1072 | images = [] 1073 | if 'metadata_block_picture' not in mutagen_file: 1074 | # Try legacy COVERART tags. 1075 | if 'coverart' in mutagen_file: 1076 | for data in mutagen_file['coverart']: 1077 | images.append(Image(base64.b64decode(data))) 1078 | return images 1079 | for data in mutagen_file["metadata_block_picture"]: 1080 | try: 1081 | pic = mutagen.flac.Picture(base64.b64decode(data)) 1082 | except (TypeError, AttributeError): 1083 | continue 1084 | images.append(Image(data=pic.data, desc=pic.desc, 1085 | type=pic.type)) 1086 | return images 1087 | 1088 | def store(self, mutagen_file, image_data): 1089 | # Strip all art, including legacy COVERART. 1090 | if 'coverart' in mutagen_file: 1091 | del mutagen_file['coverart'] 1092 | if 'coverartmime' in mutagen_file: 1093 | del mutagen_file['coverartmime'] 1094 | super(VorbisImageStorageStyle, self).store(mutagen_file, image_data) 1095 | 1096 | def serialize(self, image): 1097 | """Turn a Image into a base64 encoded FLAC picture block. 1098 | """ 1099 | pic = mutagen.flac.Picture() 1100 | pic.data = image.data 1101 | pic.type = image.type_index 1102 | pic.mime = image.mime_type 1103 | pic.desc = image.desc or u'' 1104 | 1105 | # Encoding with base64 returns bytes on both Python 2 and 3. 1106 | # Mutagen requires the data to be a Unicode string, so we decode 1107 | # it before passing it along. 1108 | return base64.b64encode(pic.write()).decode('ascii') 1109 | 1110 | 1111 | class FlacImageStorageStyle(ListStorageStyle): 1112 | """Converts between ``mutagen.flac.Picture`` and ``Image`` instances. 1113 | """ 1114 | formats = ['FLAC'] 1115 | 1116 | def __init__(self): 1117 | super(FlacImageStorageStyle, self).__init__(key='') 1118 | 1119 | def fetch(self, mutagen_file): 1120 | return mutagen_file.pictures 1121 | 1122 | def deserialize(self, flac_picture): 1123 | return Image(data=flac_picture.data, desc=flac_picture.desc, 1124 | type=flac_picture.type) 1125 | 1126 | def store(self, mutagen_file, pictures): 1127 | """``pictures`` is a list of mutagen.flac.Picture instances. 1128 | """ 1129 | mutagen_file.clear_pictures() 1130 | for pic in pictures: 1131 | mutagen_file.add_picture(pic) 1132 | 1133 | def serialize(self, image): 1134 | """Turn a Image into a mutagen.flac.Picture. 1135 | """ 1136 | pic = mutagen.flac.Picture() 1137 | pic.data = image.data 1138 | pic.type = image.type_index 1139 | pic.mime = image.mime_type 1140 | pic.desc = image.desc or u'' 1141 | return pic 1142 | 1143 | def delete(self, mutagen_file): 1144 | """Remove all images from the file. 1145 | """ 1146 | mutagen_file.clear_pictures() 1147 | 1148 | 1149 | class APEv2ImageStorageStyle(ListStorageStyle): 1150 | """Store images in APEv2 tags. Values are `Image` objects. 1151 | """ 1152 | formats = ['APEv2File', 'WavPack', 'Musepack', 'MonkeysAudio', 'OptimFROG'] 1153 | 1154 | TAG_NAMES = { 1155 | ImageType.other: 'Cover Art (other)', 1156 | ImageType.icon: 'Cover Art (icon)', 1157 | ImageType.other_icon: 'Cover Art (other icon)', 1158 | ImageType.front: 'Cover Art (front)', 1159 | ImageType.back: 'Cover Art (back)', 1160 | ImageType.leaflet: 'Cover Art (leaflet)', 1161 | ImageType.media: 'Cover Art (media)', 1162 | ImageType.lead_artist: 'Cover Art (lead)', 1163 | ImageType.artist: 'Cover Art (artist)', 1164 | ImageType.conductor: 'Cover Art (conductor)', 1165 | ImageType.group: 'Cover Art (band)', 1166 | ImageType.composer: 'Cover Art (composer)', 1167 | ImageType.lyricist: 'Cover Art (lyricist)', 1168 | ImageType.recording_location: 'Cover Art (studio)', 1169 | ImageType.recording_session: 'Cover Art (recording)', 1170 | ImageType.performance: 'Cover Art (performance)', 1171 | ImageType.screen_capture: 'Cover Art (movie scene)', 1172 | ImageType.fish: 'Cover Art (colored fish)', 1173 | ImageType.illustration: 'Cover Art (illustration)', 1174 | ImageType.artist_logo: 'Cover Art (band logo)', 1175 | ImageType.publisher_logo: 'Cover Art (publisher logo)', 1176 | } 1177 | 1178 | def __init__(self): 1179 | super(APEv2ImageStorageStyle, self).__init__(key='') 1180 | 1181 | def fetch(self, mutagen_file): 1182 | images = [] 1183 | for cover_type, cover_tag in self.TAG_NAMES.items(): 1184 | try: 1185 | frame = mutagen_file[cover_tag] 1186 | text_delimiter_index = frame.value.find(b'\x00') 1187 | if text_delimiter_index > 0: 1188 | comment = frame.value[0:text_delimiter_index] 1189 | comment = comment.decode('utf-8', 'replace') 1190 | else: 1191 | comment = None 1192 | image_data = frame.value[text_delimiter_index + 1:] 1193 | images.append(Image(data=image_data, type=cover_type, 1194 | desc=comment)) 1195 | except KeyError: 1196 | pass 1197 | 1198 | return images 1199 | 1200 | def set_list(self, mutagen_file, values): 1201 | self.delete(mutagen_file) 1202 | 1203 | for image in values: 1204 | image_type = image.type or ImageType.other 1205 | comment = image.desc or '' 1206 | image_data = comment.encode('utf-8') + b'\x00' + image.data 1207 | cover_tag = self.TAG_NAMES[image_type] 1208 | mutagen_file[cover_tag] = image_data 1209 | 1210 | def delete(self, mutagen_file): 1211 | """Remove all images from the file. 1212 | """ 1213 | for cover_tag in self.TAG_NAMES.values(): 1214 | try: 1215 | del mutagen_file[cover_tag] 1216 | except KeyError: 1217 | pass 1218 | 1219 | 1220 | # MediaField is a descriptor that represents a single logical field. It 1221 | # aggregates several StorageStyles describing how to access the data for 1222 | # each file type. 1223 | 1224 | class MediaField(object): 1225 | """A descriptor providing access to a particular (abstract) metadata 1226 | field. 1227 | """ 1228 | def __init__(self, *styles, **kwargs): 1229 | """Creates a new MediaField. 1230 | 1231 | :param styles: `StorageStyle` instances that describe the strategy 1232 | for reading and writing the field in particular 1233 | formats. There must be at least one style for 1234 | each possible file format. 1235 | 1236 | :param out_type: the type of the value that should be returned when 1237 | getting this property. 1238 | 1239 | """ 1240 | self.out_type = kwargs.get('out_type', str) 1241 | self._styles = styles 1242 | 1243 | def styles(self, mutagen_file): 1244 | """Yields the list of storage styles of this field that can 1245 | handle the MediaFile's format. 1246 | """ 1247 | for style in self._styles: 1248 | if mutagen_file.__class__.__name__ in style.formats: 1249 | yield style 1250 | 1251 | def __get__(self, mediafile, owner=None): 1252 | out = None 1253 | for style in self.styles(mediafile.mgfile): 1254 | out = style.get(mediafile.mgfile) 1255 | if out: 1256 | break 1257 | return _safe_cast(self.out_type, out) 1258 | 1259 | def __set__(self, mediafile, value): 1260 | if value is None: 1261 | value = self._none_value() 1262 | for style in self.styles(mediafile.mgfile): 1263 | if not style.read_only: 1264 | style.set(mediafile.mgfile, value) 1265 | 1266 | def __delete__(self, mediafile): 1267 | for style in self.styles(mediafile.mgfile): 1268 | style.delete(mediafile.mgfile) 1269 | 1270 | def _none_value(self): 1271 | """Get an appropriate "null" value for this field's type. This 1272 | is used internally when setting the field to None. 1273 | """ 1274 | if self.out_type is int: 1275 | return 0 1276 | elif self.out_type is float: 1277 | return 0.0 1278 | elif self.out_type is bool: 1279 | return False 1280 | elif self.out_type is str: 1281 | return u'' 1282 | 1283 | 1284 | class ListMediaField(MediaField): 1285 | """Property descriptor that retrieves a list of multiple values from 1286 | a tag. 1287 | 1288 | Uses ``get_list`` and set_list`` methods of its ``StorageStyle`` 1289 | strategies to do the actual work. 1290 | """ 1291 | def __get__(self, mediafile, _=None): 1292 | for style in self.styles(mediafile.mgfile): 1293 | values = style.get_list(mediafile.mgfile) 1294 | if values: 1295 | return [_safe_cast(self.out_type, value) for value in values] 1296 | return None 1297 | 1298 | def __set__(self, mediafile, values): 1299 | for style in self.styles(mediafile.mgfile): 1300 | if not style.read_only: 1301 | style.set_list(mediafile.mgfile, values) 1302 | 1303 | def single_field(self): 1304 | """Returns a ``MediaField`` descriptor that gets and sets the 1305 | first item. 1306 | """ 1307 | options = {'out_type': self.out_type} 1308 | return MediaField(*self._styles, **options) 1309 | 1310 | 1311 | class DateField(MediaField): 1312 | """Descriptor that handles serializing and deserializing dates 1313 | 1314 | The getter parses value from tags into a ``datetime.date`` instance 1315 | and setter serializes such an instance into a string. 1316 | 1317 | For granular access to year, month, and day, use the ``*_field`` 1318 | methods to create corresponding `DateItemField`s. 1319 | """ 1320 | def __init__(self, *date_styles, **kwargs): 1321 | """``date_styles`` is a list of ``StorageStyle``s to store and 1322 | retrieve the whole date from. The ``year`` option is an 1323 | additional list of fallback styles for the year. The year is 1324 | always set on this style, but is only retrieved if the main 1325 | storage styles do not return a value. 1326 | """ 1327 | super(DateField, self).__init__(*date_styles) 1328 | year_style = kwargs.get('year', None) 1329 | if year_style: 1330 | self._year_field = MediaField(*year_style) 1331 | 1332 | def __get__(self, mediafile, owner=None): 1333 | year, month, day = self._get_date_tuple(mediafile) 1334 | if not year: 1335 | return None 1336 | try: 1337 | return datetime.date( 1338 | year, 1339 | month or 1, 1340 | day or 1 1341 | ) 1342 | except ValueError: # Out of range values. 1343 | return None 1344 | 1345 | def __set__(self, mediafile, date): 1346 | if date is None: 1347 | self._set_date_tuple(mediafile, None, None, None) 1348 | else: 1349 | self._set_date_tuple(mediafile, date.year, date.month, date.day) 1350 | 1351 | def __delete__(self, mediafile): 1352 | super(DateField, self).__delete__(mediafile) 1353 | if hasattr(self, '_year_field'): 1354 | self._year_field.__delete__(mediafile) 1355 | 1356 | def _get_date_tuple(self, mediafile): 1357 | """Get a 3-item sequence representing the date consisting of a 1358 | year, month, and day number. Each number is either an integer or 1359 | None. 1360 | """ 1361 | # Get the underlying data and split on hyphens and slashes. 1362 | datestring = super(DateField, self).__get__(mediafile, None) 1363 | if isinstance(datestring, str): 1364 | datestring = re.sub(r'[Tt ].*$', '', str(datestring)) 1365 | items = re.split('[-/]', str(datestring)) 1366 | else: 1367 | items = [] 1368 | 1369 | # Ensure that we have exactly 3 components, possibly by 1370 | # truncating or padding. 1371 | items = items[:3] 1372 | if len(items) < 3: 1373 | items += [None] * (3 - len(items)) 1374 | 1375 | # Use year field if year is missing. 1376 | if not items[0] and hasattr(self, '_year_field'): 1377 | items[0] = self._year_field.__get__(mediafile) 1378 | 1379 | # Convert each component to an integer if possible. 1380 | items_ = [] 1381 | for item in items: 1382 | try: 1383 | items_.append(int(item)) 1384 | except (TypeError, ValueError): 1385 | items_.append(None) 1386 | return items_ 1387 | 1388 | def _set_date_tuple(self, mediafile, year, month=None, day=None): 1389 | """Set the value of the field given a year, month, and day 1390 | number. Each number can be an integer or None to indicate an 1391 | unset component. 1392 | """ 1393 | if year is None: 1394 | self.__delete__(mediafile) 1395 | return 1396 | 1397 | date = [u'{0:04d}'.format(int(year))] 1398 | if month: 1399 | date.append(u'{0:02d}'.format(int(month))) 1400 | if month and day: 1401 | date.append(u'{0:02d}'.format(int(day))) 1402 | date = map(str, date) 1403 | super(DateField, self).__set__(mediafile, u'-'.join(date)) 1404 | 1405 | if hasattr(self, '_year_field'): 1406 | self._year_field.__set__(mediafile, year) 1407 | 1408 | def year_field(self): 1409 | return DateItemField(self, 0) 1410 | 1411 | def month_field(self): 1412 | return DateItemField(self, 1) 1413 | 1414 | def day_field(self): 1415 | return DateItemField(self, 2) 1416 | 1417 | 1418 | class DateItemField(MediaField): 1419 | """Descriptor that gets and sets constituent parts of a `DateField`: 1420 | the month, day, or year. 1421 | """ 1422 | def __init__(self, date_field, item_pos): 1423 | self.date_field = date_field 1424 | self.item_pos = item_pos 1425 | 1426 | def __get__(self, mediafile, _): 1427 | return self.date_field._get_date_tuple(mediafile)[self.item_pos] 1428 | 1429 | def __set__(self, mediafile, value): 1430 | items = self.date_field._get_date_tuple(mediafile) 1431 | items[self.item_pos] = value 1432 | self.date_field._set_date_tuple(mediafile, *items) 1433 | 1434 | def __delete__(self, mediafile): 1435 | self.__set__(mediafile, None) 1436 | 1437 | 1438 | class CoverArtField(MediaField): 1439 | """A descriptor that provides access to the *raw image data* for the 1440 | cover image on a file. This is used for backwards compatibility: the 1441 | full `ImageListField` provides richer `Image` objects. 1442 | 1443 | When there are multiple images we try to pick the most likely to be a front 1444 | cover. 1445 | """ 1446 | def __init__(self): 1447 | pass 1448 | 1449 | def __get__(self, mediafile, _): 1450 | candidates = mediafile.images 1451 | if candidates: 1452 | return self.guess_cover_image(candidates).data 1453 | else: 1454 | return None 1455 | 1456 | @staticmethod 1457 | def guess_cover_image(candidates): 1458 | if len(candidates) == 1: 1459 | return candidates[0] 1460 | try: 1461 | return next(c for c in candidates if c.type == ImageType.front) 1462 | except StopIteration: 1463 | return candidates[0] 1464 | 1465 | def __set__(self, mediafile, data): 1466 | if data: 1467 | mediafile.images = [Image(data=data)] 1468 | else: 1469 | mediafile.images = [] 1470 | 1471 | def __delete__(self, mediafile): 1472 | delattr(mediafile, 'images') 1473 | 1474 | 1475 | class QNumberField(MediaField): 1476 | """Access integer-represented Q number fields. 1477 | 1478 | Access a fixed-point fraction as a float. The stored value is shifted by 1479 | `fraction_bits` binary digits to the left and then rounded, yielding a 1480 | simple integer. 1481 | """ 1482 | def __init__(self, fraction_bits, *args, **kwargs): 1483 | super(QNumberField, self).__init__(out_type=int, *args, **kwargs) 1484 | self.__fraction_bits = fraction_bits 1485 | 1486 | def __get__(self, mediafile, owner=None): 1487 | q_num = super(QNumberField, self).__get__(mediafile, owner) 1488 | if q_num is None: 1489 | return None 1490 | return q_num / pow(2, self.__fraction_bits) 1491 | 1492 | def __set__(self, mediafile, value): 1493 | q_num = round(value * pow(2, self.__fraction_bits)) 1494 | q_num = int(q_num) # needed for py2.7 1495 | super(QNumberField, self).__set__(mediafile, q_num) 1496 | 1497 | 1498 | class ImageListField(ListMediaField): 1499 | """Descriptor to access the list of images embedded in tags. 1500 | 1501 | The getter returns a list of `Image` instances obtained from 1502 | the tags. The setter accepts a list of `Image` instances to be 1503 | written to the tags. 1504 | """ 1505 | def __init__(self): 1506 | # The storage styles used here must implement the 1507 | # `ListStorageStyle` interface and get and set lists of 1508 | # `Image`s. 1509 | super(ImageListField, self).__init__( 1510 | MP3ImageStorageStyle(), 1511 | MP4ImageStorageStyle(), 1512 | ASFImageStorageStyle(), 1513 | VorbisImageStorageStyle(), 1514 | FlacImageStorageStyle(), 1515 | APEv2ImageStorageStyle(), 1516 | out_type=Image, 1517 | ) 1518 | 1519 | 1520 | # MediaFile is a collection of fields. 1521 | 1522 | class MediaFile(object): 1523 | """Represents a multimedia file on disk and provides access to its 1524 | metadata. 1525 | """ 1526 | @loadfile() 1527 | def __init__(self, filething, id3v23=False): 1528 | """Constructs a new `MediaFile` reflecting the provided file. 1529 | 1530 | `filething` can be a path to a file (i.e., a string) or a 1531 | file-like object. 1532 | 1533 | May throw `UnreadableFileError`. 1534 | 1535 | By default, MP3 files are saved with ID3v2.4 tags. You can use 1536 | the older ID3v2.3 standard by specifying the `id3v23` option. 1537 | """ 1538 | self.filething = filething 1539 | 1540 | self.mgfile = mutagen_call( 1541 | 'open', self.filename, mutagen.File, filething 1542 | ) 1543 | 1544 | if self.mgfile is None: 1545 | # Mutagen couldn't guess the type 1546 | raise FileTypeError(self.filename) 1547 | elif type(self.mgfile).__name__ in ['M4A', 'MP4']: 1548 | info = self.mgfile.info 1549 | if info.codec and info.codec.startswith('alac'): 1550 | self.type = 'alac' 1551 | else: 1552 | self.type = 'aac' 1553 | elif type(self.mgfile).__name__ in ['ID3', 'MP3']: 1554 | self.type = 'mp3' 1555 | elif type(self.mgfile).__name__ == 'FLAC': 1556 | self.type = 'flac' 1557 | elif type(self.mgfile).__name__ == 'OggOpus': 1558 | self.type = 'opus' 1559 | elif type(self.mgfile).__name__ == 'OggVorbis': 1560 | self.type = 'ogg' 1561 | elif type(self.mgfile).__name__ == 'MonkeysAudio': 1562 | self.type = 'ape' 1563 | elif type(self.mgfile).__name__ == 'WavPack': 1564 | self.type = 'wv' 1565 | elif type(self.mgfile).__name__ == 'Musepack': 1566 | self.type = 'mpc' 1567 | elif type(self.mgfile).__name__ == 'ASF': 1568 | self.type = 'asf' 1569 | elif type(self.mgfile).__name__ == 'AIFF': 1570 | self.type = 'aiff' 1571 | elif type(self.mgfile).__name__ == 'DSF': 1572 | self.type = 'dsf' 1573 | elif type(self.mgfile).__name__ == 'WAVE': 1574 | self.type = 'wav' 1575 | else: 1576 | raise FileTypeError(self.filename, type(self.mgfile).__name__) 1577 | 1578 | # Add a set of tags if it's missing. 1579 | if self.mgfile.tags is None: 1580 | self.mgfile.add_tags() 1581 | 1582 | # Set the ID3v2.3 flag only for MP3s. 1583 | self.id3v23 = id3v23 and self.type == 'mp3' 1584 | 1585 | @property 1586 | def filename(self): 1587 | """The name of the file. 1588 | 1589 | This is the path if this object was opened from the filesystem, 1590 | or the name of the file-like object. 1591 | """ 1592 | return self.filething.name 1593 | 1594 | @filename.setter 1595 | def filename(self, val): 1596 | """Silently skips setting filename. 1597 | Workaround for `mutagen._util._openfile` setting instance's filename. 1598 | """ 1599 | pass 1600 | 1601 | @property 1602 | def path(self): 1603 | """The path to the file. 1604 | 1605 | This is `None` if the data comes from a file-like object instead 1606 | of a filesystem path. 1607 | """ 1608 | return self.filething.filename 1609 | 1610 | @property 1611 | def filesize(self): 1612 | """The size (in bytes) of the underlying file. 1613 | """ 1614 | if self.filething.filename: 1615 | return os.path.getsize(self.filething.filename) 1616 | if hasattr(self.filething.fileobj, '__len__'): 1617 | return len(self.filething.fileobj) 1618 | else: 1619 | tell = self.filething.fileobj.tell() 1620 | filesize = self.filething.fileobj.seek(0, 2) 1621 | self.filething.fileobj.seek(tell) 1622 | return filesize 1623 | 1624 | def save(self, **kwargs): 1625 | """Write the object's tags back to the file. 1626 | 1627 | May throw `UnreadableFileError`. Accepts keyword arguments to be 1628 | passed to Mutagen's `save` function. 1629 | """ 1630 | # Possibly save the tags to ID3v2.3. 1631 | if self.id3v23: 1632 | id3 = self.mgfile 1633 | if hasattr(id3, 'tags'): 1634 | # In case this is an MP3 object, not an ID3 object. 1635 | id3 = id3.tags 1636 | id3.update_to_v23() 1637 | kwargs['v2_version'] = 3 1638 | 1639 | mutagen_call('save', self.filename, self.mgfile.save, 1640 | _update_filething(self.filething), **kwargs) 1641 | 1642 | def delete(self): 1643 | """Remove the current metadata tag from the file. May 1644 | throw `UnreadableFileError`. 1645 | """ 1646 | mutagen_call('delete', self.filename, self.mgfile.delete, 1647 | _update_filething(self.filething)) 1648 | 1649 | # Convenient access to the set of available fields. 1650 | 1651 | @classmethod 1652 | def fields(cls): 1653 | """Get the names of all writable properties that reflect 1654 | metadata tags (i.e., those that are instances of 1655 | :class:`MediaField`). 1656 | """ 1657 | for property, descriptor in cls.__dict__.items(): 1658 | if isinstance(descriptor, MediaField): 1659 | if isinstance(property, bytes): 1660 | # On Python 2, class field names are bytes. This method 1661 | # produces text strings. 1662 | yield property.decode('utf8', 'ignore') 1663 | else: 1664 | yield property 1665 | 1666 | @classmethod 1667 | def _field_sort_name(cls, name): 1668 | """Get a sort key for a field name that determines the order 1669 | fields should be written in. 1670 | 1671 | Fields names are kept unchanged, unless they are instances of 1672 | :class:`DateItemField`, in which case `year`, `month`, and `day` 1673 | are replaced by `date0`, `date1`, and `date2`, respectively, to 1674 | make them appear in that order. 1675 | """ 1676 | if isinstance(cls.__dict__[name], DateItemField): 1677 | name = re.sub('year', 'date0', name) 1678 | name = re.sub('month', 'date1', name) 1679 | name = re.sub('day', 'date2', name) 1680 | return name 1681 | 1682 | @classmethod 1683 | def sorted_fields(cls): 1684 | """Get the names of all writable metadata fields, sorted in the 1685 | order that they should be written. 1686 | 1687 | This is a lexicographic order, except for instances of 1688 | :class:`DateItemField`, which are sorted in year-month-day 1689 | order. 1690 | """ 1691 | for property in sorted(cls.fields(), key=cls._field_sort_name): 1692 | yield property 1693 | 1694 | @classmethod 1695 | def readable_fields(cls): 1696 | """Get all metadata fields: the writable ones from 1697 | :meth:`fields` and also other audio properties. 1698 | """ 1699 | for property in cls.fields(): 1700 | yield property 1701 | for property in ('length', 'samplerate', 'bitdepth', 'bitrate', 1702 | 'bitrate_mode', 'channels', 'encoder_info', 1703 | 'encoder_settings', 'format'): 1704 | yield property 1705 | 1706 | @classmethod 1707 | def add_field(cls, name, descriptor): 1708 | """Add a field to store custom tags. 1709 | 1710 | :param name: the name of the property the field is accessed 1711 | through. It must not already exist on this class. 1712 | 1713 | :param descriptor: an instance of :class:`MediaField`. 1714 | """ 1715 | if not isinstance(descriptor, MediaField): 1716 | raise ValueError( 1717 | u'{0} must be an instance of MediaField'.format(descriptor)) 1718 | if name in cls.__dict__: 1719 | raise ValueError( 1720 | u'property "{0}" already exists on MediaFile'.format(name)) 1721 | setattr(cls, name, descriptor) 1722 | 1723 | def update(self, dict): 1724 | """Set all field values from a dictionary. 1725 | 1726 | For any key in `dict` that is also a field to store tags the 1727 | method retrieves the corresponding value from `dict` and updates 1728 | the `MediaFile`. If a key has the value `None`, the 1729 | corresponding property is deleted from the `MediaFile`. 1730 | """ 1731 | for field in self.sorted_fields(): 1732 | if field in dict: 1733 | if dict[field] is None: 1734 | delattr(self, field) 1735 | else: 1736 | setattr(self, field, dict[field]) 1737 | 1738 | def as_dict(self): 1739 | """Get a dictionary with all writable properties that reflect 1740 | metadata tags (i.e., those that are instances of 1741 | :class:`MediaField`). 1742 | """ 1743 | return dict((x, getattr(self, x)) for x in self.fields()) 1744 | 1745 | # Field definitions. 1746 | 1747 | title = MediaField( 1748 | MP3StorageStyle('TIT2'), 1749 | MP4StorageStyle('\xa9nam'), 1750 | StorageStyle('TITLE'), 1751 | ASFStorageStyle('Title'), 1752 | ) 1753 | artist = MediaField( 1754 | MP3StorageStyle('TPE1'), 1755 | MP4StorageStyle('\xa9ART'), 1756 | StorageStyle('ARTIST'), 1757 | ASFStorageStyle('Author'), 1758 | ) 1759 | artists = ListMediaField( 1760 | MP3ListDescStorageStyle(desc=u'ARTISTS'), 1761 | MP4ListStorageStyle('----:com.apple.iTunes:ARTISTS'), 1762 | ListStorageStyle('ARTISTS'), 1763 | ASFStorageStyle('WM/ARTISTS'), 1764 | ) 1765 | album = MediaField( 1766 | MP3StorageStyle('TALB'), 1767 | MP4StorageStyle('\xa9alb'), 1768 | StorageStyle('ALBUM'), 1769 | ASFStorageStyle('WM/AlbumTitle'), 1770 | ) 1771 | genres = ListMediaField( 1772 | MP3ListStorageStyle('TCON'), 1773 | MP4ListStorageStyle('\xa9gen'), 1774 | ListStorageStyle('GENRE'), 1775 | ASFStorageStyle('WM/Genre'), 1776 | ) 1777 | genre = genres.single_field() 1778 | 1779 | lyricist = MediaField( 1780 | MP3StorageStyle('TEXT'), 1781 | MP4StorageStyle('----:com.apple.iTunes:LYRICIST'), 1782 | StorageStyle('LYRICIST'), 1783 | ASFStorageStyle('WM/Writer'), 1784 | ) 1785 | composer = MediaField( 1786 | MP3StorageStyle('TCOM'), 1787 | MP4StorageStyle('\xa9wrt'), 1788 | StorageStyle('COMPOSER'), 1789 | ASFStorageStyle('WM/Composer'), 1790 | ) 1791 | composer_sort = MediaField( 1792 | MP3StorageStyle('TSOC'), 1793 | MP4StorageStyle('soco'), 1794 | StorageStyle('COMPOSERSORT'), 1795 | ASFStorageStyle('WM/Composersortorder'), 1796 | ) 1797 | arranger = MediaField( 1798 | MP3PeopleStorageStyle('TIPL', involvement='arranger'), 1799 | MP4StorageStyle('----:com.apple.iTunes:Arranger'), 1800 | StorageStyle('ARRANGER'), 1801 | ASFStorageStyle('beets/Arranger'), 1802 | ) 1803 | 1804 | grouping = MediaField( 1805 | MP3StorageStyle('TIT1'), 1806 | MP4StorageStyle('\xa9grp'), 1807 | StorageStyle('GROUPING'), 1808 | ASFStorageStyle('WM/ContentGroupDescription'), 1809 | ) 1810 | track = MediaField( 1811 | MP3SlashPackStorageStyle('TRCK', pack_pos=0), 1812 | MP4TupleStorageStyle('trkn', index=0), 1813 | StorageStyle('TRACK'), 1814 | StorageStyle('TRACKNUMBER'), 1815 | ASFStorageStyle('WM/TrackNumber'), 1816 | out_type=int, 1817 | ) 1818 | tracktotal = MediaField( 1819 | MP3SlashPackStorageStyle('TRCK', pack_pos=1), 1820 | MP4TupleStorageStyle('trkn', index=1), 1821 | StorageStyle('TRACKTOTAL'), 1822 | StorageStyle('TRACKC'), 1823 | StorageStyle('TOTALTRACKS'), 1824 | ASFStorageStyle('TotalTracks'), 1825 | out_type=int, 1826 | ) 1827 | disc = MediaField( 1828 | MP3SlashPackStorageStyle('TPOS', pack_pos=0), 1829 | MP4TupleStorageStyle('disk', index=0), 1830 | StorageStyle('DISC'), 1831 | StorageStyle('DISCNUMBER'), 1832 | ASFStorageStyle('WM/PartOfSet'), 1833 | out_type=int, 1834 | ) 1835 | disctotal = MediaField( 1836 | MP3SlashPackStorageStyle('TPOS', pack_pos=1), 1837 | MP4TupleStorageStyle('disk', index=1), 1838 | StorageStyle('DISCTOTAL'), 1839 | StorageStyle('DISCC'), 1840 | StorageStyle('TOTALDISCS'), 1841 | ASFStorageStyle('TotalDiscs'), 1842 | out_type=int, 1843 | ) 1844 | 1845 | url = MediaField( 1846 | MP3DescStorageStyle(key='WXXX', attr='url', multispec=False), 1847 | MP4StorageStyle('\xa9url'), 1848 | StorageStyle('URL'), 1849 | ASFStorageStyle('WM/URL'), 1850 | ) 1851 | lyrics = MediaField( 1852 | MP3DescStorageStyle(key='USLT', multispec=False), 1853 | MP4StorageStyle('\xa9lyr'), 1854 | StorageStyle('LYRICS'), 1855 | ASFStorageStyle('WM/Lyrics'), 1856 | ) 1857 | comments = MediaField( 1858 | MP3DescStorageStyle(key='COMM'), 1859 | MP4StorageStyle('\xa9cmt'), 1860 | StorageStyle('DESCRIPTION'), 1861 | StorageStyle('COMMENT'), 1862 | ASFStorageStyle('WM/Comments'), 1863 | ASFStorageStyle('Description') 1864 | ) 1865 | copyright = MediaField( 1866 | MP3StorageStyle('TCOP'), 1867 | MP4StorageStyle('cprt'), 1868 | StorageStyle('COPYRIGHT'), 1869 | ASFStorageStyle('Copyright'), 1870 | ) 1871 | bpm = MediaField( 1872 | MP3StorageStyle('TBPM'), 1873 | MP4StorageStyle('tmpo', as_type=int), 1874 | StorageStyle('BPM'), 1875 | ASFStorageStyle('WM/BeatsPerMinute'), 1876 | out_type=int, 1877 | ) 1878 | comp = MediaField( 1879 | MP3StorageStyle('TCMP'), 1880 | MP4BoolStorageStyle('cpil'), 1881 | StorageStyle('COMPILATION'), 1882 | ASFStorageStyle('WM/IsCompilation', as_type=bool), 1883 | out_type=bool, 1884 | ) 1885 | albumartist = MediaField( 1886 | MP3StorageStyle('TPE2'), 1887 | MP4StorageStyle('aART'), 1888 | StorageStyle('ALBUM ARTIST'), 1889 | StorageStyle('ALBUM_ARTIST'), 1890 | StorageStyle('ALBUMARTIST'), 1891 | ASFStorageStyle('WM/AlbumArtist'), 1892 | ) 1893 | albumartists = ListMediaField( 1894 | MP3ListDescStorageStyle(desc=u'ALBUMARTISTS'), 1895 | MP3ListDescStorageStyle(desc=u'ALBUM_ARTISTS'), 1896 | MP3ListDescStorageStyle(desc=u'ALBUM ARTISTS', read_only=True), 1897 | MP4ListStorageStyle('----:com.apple.iTunes:ALBUMARTISTS'), 1898 | MP4ListStorageStyle('----:com.apple.iTunes:ALBUM_ARTISTS'), 1899 | MP4ListStorageStyle( 1900 | '----:com.apple.iTunes:ALBUM ARTISTS', read_only=True 1901 | ), 1902 | ListStorageStyle('ALBUMARTISTS'), 1903 | ListStorageStyle('ALBUM_ARTISTS'), 1904 | ListStorageStyle('ALBUM ARTISTS', read_only=True), 1905 | ASFStorageStyle('WM/AlbumArtists'), 1906 | ) 1907 | albumtypes = ListMediaField( 1908 | MP3ListDescStorageStyle('MusicBrainz Album Type', split_v23=True), 1909 | MP4ListStorageStyle('----:com.apple.iTunes:MusicBrainz Album Type'), 1910 | ListStorageStyle('RELEASETYPE'), 1911 | ListStorageStyle('MUSICBRAINZ_ALBUMTYPE'), 1912 | ASFStorageStyle('MusicBrainz/Album Type'), 1913 | ) 1914 | albumtype = albumtypes.single_field() 1915 | 1916 | label = MediaField( 1917 | MP3StorageStyle('TPUB'), 1918 | MP4StorageStyle('----:com.apple.iTunes:LABEL'), 1919 | MP4StorageStyle('----:com.apple.iTunes:publisher'), 1920 | MP4StorageStyle('----:com.apple.iTunes:Label', read_only=True), 1921 | StorageStyle('LABEL'), 1922 | StorageStyle('PUBLISHER'), # Traktor 1923 | ASFStorageStyle('WM/Publisher'), 1924 | ) 1925 | artist_sort = MediaField( 1926 | MP3StorageStyle('TSOP'), 1927 | MP4StorageStyle('soar'), 1928 | StorageStyle('ARTISTSORT'), 1929 | ASFStorageStyle('WM/ArtistSortOrder'), 1930 | ) 1931 | albumartist_sort = MediaField( 1932 | MP3DescStorageStyle(u'ALBUMARTISTSORT'), 1933 | MP4StorageStyle('soaa'), 1934 | StorageStyle('ALBUMARTISTSORT'), 1935 | ASFStorageStyle('WM/AlbumArtistSortOrder'), 1936 | ) 1937 | asin = MediaField( 1938 | MP3DescStorageStyle(u'ASIN'), 1939 | MP4StorageStyle('----:com.apple.iTunes:ASIN'), 1940 | StorageStyle('ASIN'), 1941 | ASFStorageStyle('MusicBrainz/ASIN'), 1942 | ) 1943 | catalognums = ListMediaField( 1944 | MP3ListDescStorageStyle('CATALOGNUMBER', split_v23=True), 1945 | MP3ListDescStorageStyle('CATALOGID', read_only=True), 1946 | MP3ListDescStorageStyle('DISCOGS_CATALOG', read_only=True), 1947 | MP4ListStorageStyle('----:com.apple.iTunes:CATALOGNUMBER'), 1948 | MP4ListStorageStyle( 1949 | '----:com.apple.iTunes:CATALOGID', read_only=True 1950 | ), 1951 | MP4ListStorageStyle( 1952 | '----:com.apple.iTunes:DISCOGS_CATALOG', read_only=True 1953 | ), 1954 | ListStorageStyle('CATALOGNUMBER'), 1955 | ListStorageStyle('CATALOGID', read_only=True), 1956 | ListStorageStyle('DISCOGS_CATALOG', read_only=True), 1957 | ASFStorageStyle('WM/CatalogNo'), 1958 | ASFStorageStyle('CATALOGID', read_only=True), 1959 | ASFStorageStyle('DISCOGS_CATALOG', read_only=True), 1960 | ) 1961 | catalognum = catalognums.single_field() 1962 | 1963 | barcode = MediaField( 1964 | MP3DescStorageStyle(u'BARCODE'), 1965 | MP4StorageStyle('----:com.apple.iTunes:BARCODE'), 1966 | StorageStyle('BARCODE'), 1967 | StorageStyle('UPC', read_only=True), 1968 | StorageStyle('EAN/UPN', read_only=True), 1969 | StorageStyle('EAN', read_only=True), 1970 | StorageStyle('UPN', read_only=True), 1971 | ASFStorageStyle('WM/Barcode'), 1972 | ) 1973 | isrc = MediaField( 1974 | MP3StorageStyle(u'TSRC'), 1975 | MP4StorageStyle('----:com.apple.iTunes:ISRC'), 1976 | StorageStyle('ISRC'), 1977 | ASFStorageStyle('WM/ISRC'), 1978 | ) 1979 | disctitle = MediaField( 1980 | MP3StorageStyle('TSST'), 1981 | MP4StorageStyle('----:com.apple.iTunes:DISCSUBTITLE'), 1982 | StorageStyle('DISCSUBTITLE'), 1983 | ASFStorageStyle('WM/SetSubTitle'), 1984 | ) 1985 | encoder = MediaField( 1986 | MP3StorageStyle('TENC'), 1987 | MP4StorageStyle('\xa9too'), 1988 | StorageStyle('ENCODEDBY'), 1989 | StorageStyle('ENCODER'), 1990 | ASFStorageStyle('WM/EncodedBy'), 1991 | ) 1992 | script = MediaField( 1993 | MP3DescStorageStyle(u'Script'), 1994 | MP4StorageStyle('----:com.apple.iTunes:SCRIPT'), 1995 | StorageStyle('SCRIPT'), 1996 | ASFStorageStyle('WM/Script'), 1997 | ) 1998 | languages = ListMediaField( 1999 | MP3ListStorageStyle('TLAN'), 2000 | MP4ListStorageStyle('----:com.apple.iTunes:LANGUAGE'), 2001 | ListStorageStyle('LANGUAGE'), 2002 | ASFStorageStyle('WM/Language'), 2003 | ) 2004 | language = languages.single_field() 2005 | 2006 | country = MediaField( 2007 | MP3DescStorageStyle(u'MusicBrainz Album Release Country'), 2008 | MP4StorageStyle('----:com.apple.iTunes:MusicBrainz ' 2009 | 'Album Release Country'), 2010 | StorageStyle('RELEASECOUNTRY'), 2011 | ASFStorageStyle('MusicBrainz/Album Release Country'), 2012 | ) 2013 | albumstatus = MediaField( 2014 | MP3DescStorageStyle(u'MusicBrainz Album Status'), 2015 | MP4StorageStyle('----:com.apple.iTunes:MusicBrainz Album Status'), 2016 | StorageStyle('RELEASESTATUS'), 2017 | StorageStyle('MUSICBRAINZ_ALBUMSTATUS'), 2018 | ASFStorageStyle('MusicBrainz/Album Status'), 2019 | ) 2020 | media = MediaField( 2021 | MP3StorageStyle('TMED'), 2022 | MP4StorageStyle('----:com.apple.iTunes:MEDIA'), 2023 | StorageStyle('MEDIA'), 2024 | ASFStorageStyle('WM/Media'), 2025 | ) 2026 | albumdisambig = MediaField( 2027 | # This tag mapping was invented for beets (not used by Picard, etc). 2028 | MP3DescStorageStyle(u'MusicBrainz Album Comment'), 2029 | MP4StorageStyle('----:com.apple.iTunes:MusicBrainz Album Comment'), 2030 | StorageStyle('MUSICBRAINZ_ALBUMCOMMENT'), 2031 | ASFStorageStyle('MusicBrainz/Album Comment'), 2032 | ) 2033 | 2034 | # Release date. 2035 | date = DateField( 2036 | MP3StorageStyle('TDRC'), 2037 | MP4StorageStyle('\xa9day'), 2038 | StorageStyle('DATE'), 2039 | ASFStorageStyle('WM/Year'), 2040 | year=(StorageStyle('YEAR'),)) 2041 | 2042 | year = date.year_field() 2043 | month = date.month_field() 2044 | day = date.day_field() 2045 | 2046 | # *Original* release date. 2047 | original_date = DateField( 2048 | MP3StorageStyle('TDOR'), 2049 | MP4StorageStyle('----:com.apple.iTunes:ORIGINAL YEAR'), 2050 | MP4StorageStyle('----:com.apple.iTunes:ORIGINALDATE'), 2051 | StorageStyle('ORIGINALDATE'), 2052 | ASFStorageStyle('WM/OriginalReleaseYear')) 2053 | 2054 | original_year = original_date.year_field() 2055 | original_month = original_date.month_field() 2056 | original_day = original_date.day_field() 2057 | 2058 | # Nonstandard metadata. 2059 | artist_credit = MediaField( 2060 | MP3DescStorageStyle(u'Artist Credit'), 2061 | MP4StorageStyle('----:com.apple.iTunes:Artist Credit'), 2062 | StorageStyle('ARTIST_CREDIT'), 2063 | ASFStorageStyle('beets/Artist Credit'), 2064 | ) 2065 | artists_credit = ListMediaField( 2066 | MP3ListDescStorageStyle(desc=u'ARTISTS_CREDIT'), 2067 | MP4ListStorageStyle('----:com.apple.iTunes:ARTISTS_CREDIT'), 2068 | ListStorageStyle('ARTISTS_CREDIT'), 2069 | ASFStorageStyle('beets/ArtistsCredit'), 2070 | ) 2071 | artists_sort = ListMediaField( 2072 | MP3ListDescStorageStyle(desc=u'ARTISTS_SORT'), 2073 | MP4ListStorageStyle('----:com.apple.iTunes:ARTISTS_SORT'), 2074 | ListStorageStyle('ARTISTS_SORT'), 2075 | ASFStorageStyle('beets/ArtistsSort'), 2076 | ) 2077 | albumartist_credit = MediaField( 2078 | MP3DescStorageStyle(u'Album Artist Credit'), 2079 | MP4StorageStyle('----:com.apple.iTunes:Album Artist Credit'), 2080 | StorageStyle('ALBUMARTIST_CREDIT'), 2081 | ASFStorageStyle('beets/Album Artist Credit'), 2082 | ) 2083 | albumartists_credit = ListMediaField( 2084 | MP3ListDescStorageStyle(desc=u'ALBUMARTISTS_CREDIT'), 2085 | MP4ListStorageStyle('----:com.apple.iTunes:ALBUMARTISTS_CREDIT'), 2086 | ListStorageStyle('ALBUMARTISTS_CREDIT'), 2087 | ASFStorageStyle('beets/AlbumArtistsCredit'), 2088 | ) 2089 | albumartists_sort = ListMediaField( 2090 | MP3ListDescStorageStyle(desc=u'ALBUMARTISTS_SORT'), 2091 | MP4ListStorageStyle('----:com.apple.iTunes:ALBUMARTISTS_SORT'), 2092 | ListStorageStyle('ALBUMARTISTS_SORT'), 2093 | ASFStorageStyle('beets/AlbumArtistsSort'), 2094 | ) 2095 | 2096 | # Legacy album art field 2097 | art = CoverArtField() 2098 | 2099 | # Image list 2100 | images = ImageListField() 2101 | 2102 | # MusicBrainz IDs. 2103 | mb_trackid = MediaField( 2104 | MP3UFIDStorageStyle(owner='http://musicbrainz.org'), 2105 | MP4StorageStyle('----:com.apple.iTunes:MusicBrainz Track Id'), 2106 | StorageStyle('MUSICBRAINZ_TRACKID'), 2107 | ASFStorageStyle('MusicBrainz/Track Id'), 2108 | ) 2109 | mb_releasetrackid = MediaField( 2110 | MP3DescStorageStyle(u'MusicBrainz Release Track Id'), 2111 | MP4StorageStyle('----:com.apple.iTunes:MusicBrainz Release Track Id'), 2112 | StorageStyle('MUSICBRAINZ_RELEASETRACKID'), 2113 | ASFStorageStyle('MusicBrainz/Release Track Id'), 2114 | ) 2115 | mb_workid = MediaField( 2116 | MP3DescStorageStyle(u'MusicBrainz Work Id'), 2117 | MP4StorageStyle('----:com.apple.iTunes:MusicBrainz Work Id'), 2118 | StorageStyle('MUSICBRAINZ_WORKID'), 2119 | ASFStorageStyle('MusicBrainz/Work Id'), 2120 | ) 2121 | mb_albumid = MediaField( 2122 | MP3DescStorageStyle(u'MusicBrainz Album Id'), 2123 | MP4StorageStyle('----:com.apple.iTunes:MusicBrainz Album Id'), 2124 | StorageStyle('MUSICBRAINZ_ALBUMID'), 2125 | ASFStorageStyle('MusicBrainz/Album Id'), 2126 | ) 2127 | mb_artistids = ListMediaField( 2128 | MP3ListDescStorageStyle(u'MusicBrainz Artist Id', split_v23=True), 2129 | MP4ListStorageStyle('----:com.apple.iTunes:MusicBrainz Artist Id'), 2130 | ListStorageStyle('MUSICBRAINZ_ARTISTID'), 2131 | ASFStorageStyle('MusicBrainz/Artist Id'), 2132 | ) 2133 | mb_artistid = mb_artistids.single_field() 2134 | 2135 | mb_albumartistids = ListMediaField( 2136 | MP3ListDescStorageStyle( 2137 | u'MusicBrainz Album Artist Id', 2138 | split_v23=True, 2139 | ), 2140 | MP4ListStorageStyle( 2141 | '----:com.apple.iTunes:MusicBrainz Album Artist Id', 2142 | ), 2143 | ListStorageStyle('MUSICBRAINZ_ALBUMARTISTID'), 2144 | ASFStorageStyle('MusicBrainz/Album Artist Id'), 2145 | ) 2146 | mb_albumartistid = mb_albumartistids.single_field() 2147 | 2148 | mb_releasegroupid = MediaField( 2149 | MP3DescStorageStyle(u'MusicBrainz Release Group Id'), 2150 | MP4StorageStyle('----:com.apple.iTunes:MusicBrainz Release Group Id'), 2151 | StorageStyle('MUSICBRAINZ_RELEASEGROUPID'), 2152 | ASFStorageStyle('MusicBrainz/Release Group Id'), 2153 | ) 2154 | 2155 | # Acoustid fields. 2156 | acoustid_fingerprint = MediaField( 2157 | MP3DescStorageStyle(u'Acoustid Fingerprint'), 2158 | MP4StorageStyle('----:com.apple.iTunes:Acoustid Fingerprint'), 2159 | StorageStyle('ACOUSTID_FINGERPRINT'), 2160 | ASFStorageStyle('Acoustid/Fingerprint'), 2161 | ) 2162 | acoustid_id = MediaField( 2163 | MP3DescStorageStyle(u'Acoustid Id'), 2164 | MP4StorageStyle('----:com.apple.iTunes:Acoustid Id'), 2165 | StorageStyle('ACOUSTID_ID'), 2166 | ASFStorageStyle('Acoustid/Id'), 2167 | ) 2168 | 2169 | # ReplayGain fields. 2170 | rg_track_gain = MediaField( 2171 | MP3DescStorageStyle( 2172 | u'REPLAYGAIN_TRACK_GAIN', 2173 | float_places=2, suffix=u' dB' 2174 | ), 2175 | MP3DescStorageStyle( 2176 | u'replaygain_track_gain', 2177 | float_places=2, suffix=u' dB' 2178 | ), 2179 | MP3SoundCheckStorageStyle( 2180 | key='COMM', 2181 | index=0, desc=u'iTunNORM', 2182 | id3_lang='eng' 2183 | ), 2184 | MP4StorageStyle( 2185 | '----:com.apple.iTunes:replaygain_track_gain', 2186 | float_places=2, suffix=' dB' 2187 | ), 2188 | MP4SoundCheckStorageStyle( 2189 | '----:com.apple.iTunes:iTunNORM', 2190 | index=0 2191 | ), 2192 | StorageStyle( 2193 | u'REPLAYGAIN_TRACK_GAIN', 2194 | float_places=2, suffix=u' dB' 2195 | ), 2196 | ASFStorageStyle( 2197 | u'replaygain_track_gain', 2198 | float_places=2, suffix=u' dB' 2199 | ), 2200 | out_type=float 2201 | ) 2202 | rg_album_gain = MediaField( 2203 | MP3DescStorageStyle( 2204 | u'REPLAYGAIN_ALBUM_GAIN', 2205 | float_places=2, suffix=u' dB' 2206 | ), 2207 | MP3DescStorageStyle( 2208 | u'replaygain_album_gain', 2209 | float_places=2, suffix=u' dB' 2210 | ), 2211 | MP4StorageStyle( 2212 | '----:com.apple.iTunes:replaygain_album_gain', 2213 | float_places=2, suffix=' dB' 2214 | ), 2215 | StorageStyle( 2216 | u'REPLAYGAIN_ALBUM_GAIN', 2217 | float_places=2, suffix=u' dB' 2218 | ), 2219 | ASFStorageStyle( 2220 | u'replaygain_album_gain', 2221 | float_places=2, suffix=u' dB' 2222 | ), 2223 | out_type=float 2224 | ) 2225 | rg_track_peak = MediaField( 2226 | MP3DescStorageStyle( 2227 | u'REPLAYGAIN_TRACK_PEAK', 2228 | float_places=6 2229 | ), 2230 | MP3DescStorageStyle( 2231 | u'replaygain_track_peak', 2232 | float_places=6 2233 | ), 2234 | MP3SoundCheckStorageStyle( 2235 | key=u'COMM', 2236 | index=1, desc=u'iTunNORM', 2237 | id3_lang='eng' 2238 | ), 2239 | MP4StorageStyle( 2240 | '----:com.apple.iTunes:replaygain_track_peak', 2241 | float_places=6 2242 | ), 2243 | MP4SoundCheckStorageStyle( 2244 | '----:com.apple.iTunes:iTunNORM', 2245 | index=1 2246 | ), 2247 | StorageStyle(u'REPLAYGAIN_TRACK_PEAK', float_places=6), 2248 | ASFStorageStyle(u'replaygain_track_peak', float_places=6), 2249 | out_type=float, 2250 | ) 2251 | rg_album_peak = MediaField( 2252 | MP3DescStorageStyle( 2253 | u'REPLAYGAIN_ALBUM_PEAK', 2254 | float_places=6 2255 | ), 2256 | MP3DescStorageStyle( 2257 | u'replaygain_album_peak', 2258 | float_places=6 2259 | ), 2260 | MP4StorageStyle( 2261 | '----:com.apple.iTunes:replaygain_album_peak', 2262 | float_places=6 2263 | ), 2264 | StorageStyle(u'REPLAYGAIN_ALBUM_PEAK', float_places=6), 2265 | ASFStorageStyle(u'replaygain_album_peak', float_places=6), 2266 | out_type=float, 2267 | ) 2268 | 2269 | # EBU R128 fields. 2270 | r128_track_gain = QNumberField( 2271 | 8, 2272 | MP3DescStorageStyle( 2273 | u'R128_TRACK_GAIN' 2274 | ), 2275 | MP4StorageStyle( 2276 | '----:com.apple.iTunes:R128_TRACK_GAIN' 2277 | ), 2278 | StorageStyle( 2279 | u'R128_TRACK_GAIN' 2280 | ), 2281 | ASFStorageStyle( 2282 | u'R128_TRACK_GAIN' 2283 | ), 2284 | ) 2285 | r128_album_gain = QNumberField( 2286 | 8, 2287 | MP3DescStorageStyle( 2288 | u'R128_ALBUM_GAIN' 2289 | ), 2290 | MP4StorageStyle( 2291 | '----:com.apple.iTunes:R128_ALBUM_GAIN' 2292 | ), 2293 | StorageStyle( 2294 | u'R128_ALBUM_GAIN' 2295 | ), 2296 | ASFStorageStyle( 2297 | u'R128_ALBUM_GAIN' 2298 | ), 2299 | ) 2300 | 2301 | initial_key = MediaField( 2302 | MP3StorageStyle('TKEY'), 2303 | MP4StorageStyle('----:com.apple.iTunes:initialkey'), 2304 | StorageStyle('INITIALKEY'), 2305 | ASFStorageStyle('INITIALKEY'), 2306 | ) 2307 | 2308 | @property 2309 | def length(self): 2310 | """The duration of the audio in seconds (a float).""" 2311 | return self.mgfile.info.length 2312 | 2313 | @property 2314 | def samplerate(self): 2315 | """The audio's sample rate (an int).""" 2316 | if hasattr(self.mgfile.info, 'sample_rate'): 2317 | return self.mgfile.info.sample_rate 2318 | elif self.type == 'opus': 2319 | # Opus is always 48kHz internally. 2320 | return 48000 2321 | return 0 2322 | 2323 | @property 2324 | def bitdepth(self): 2325 | """The number of bits per sample in the audio encoding (an int). 2326 | Only available for certain file formats (zero where 2327 | unavailable). 2328 | """ 2329 | if hasattr(self.mgfile.info, 'bits_per_sample'): 2330 | return self.mgfile.info.bits_per_sample 2331 | return 0 2332 | 2333 | @property 2334 | def channels(self): 2335 | """The number of channels in the audio (an int).""" 2336 | if hasattr(self.mgfile.info, 'channels'): 2337 | return self.mgfile.info.channels 2338 | return 0 2339 | 2340 | @property 2341 | def bitrate(self): 2342 | """The number of bits per seconds used in the audio coding (an 2343 | int). If this is provided explicitly by the compressed file 2344 | format, this is a precise reflection of the encoding. Otherwise, 2345 | it is estimated from the on-disk file size. In this case, some 2346 | imprecision is possible because the file header is incorporated 2347 | in the file size. 2348 | """ 2349 | if hasattr(self.mgfile.info, 'bitrate') and self.mgfile.info.bitrate: 2350 | # Many formats provide it explicitly. 2351 | return self.mgfile.info.bitrate 2352 | else: 2353 | # Otherwise, we calculate bitrate from the file size. (This 2354 | # is the case for all of the lossless formats.) 2355 | if not self.length: 2356 | # Avoid division by zero if length is not available. 2357 | return 0 2358 | return int(self.filesize * 8 / self.length) 2359 | 2360 | @property 2361 | def bitrate_mode(self): 2362 | """The mode of the bitrate used in the audio coding 2363 | (a string, eg. "CBR", "VBR" or "ABR"). 2364 | Only available for the MP3 file format (empty where unavailable). 2365 | """ 2366 | if hasattr(self.mgfile.info, 'bitrate_mode'): 2367 | return { 2368 | mutagen.mp3.BitrateMode.CBR: 'CBR', 2369 | mutagen.mp3.BitrateMode.VBR: 'VBR', 2370 | mutagen.mp3.BitrateMode.ABR: 'ABR', 2371 | }.get(self.mgfile.info.bitrate_mode, '') 2372 | else: 2373 | return '' 2374 | 2375 | @property 2376 | def encoder_info(self): 2377 | """The name and/or version of the encoder used 2378 | (a string, eg. "LAME 3.97.0"). 2379 | Only available for some formats (empty where unavailable). 2380 | """ 2381 | if hasattr(self.mgfile.info, 'encoder_info'): 2382 | return self.mgfile.info.encoder_info 2383 | else: 2384 | return '' 2385 | 2386 | @property 2387 | def encoder_settings(self): 2388 | """A guess of the settings used for the encoder (a string, eg. "-V2"). 2389 | Only available for the MP3 file format (empty where unavailable). 2390 | """ 2391 | if hasattr(self.mgfile.info, 'encoder_settings'): 2392 | return self.mgfile.info.encoder_settings 2393 | else: 2394 | return '' 2395 | 2396 | @property 2397 | def format(self): 2398 | """A string describing the file format/codec.""" 2399 | return TYPES[self.type] 2400 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [tool.flit.metadata] 6 | module = "mediafile" 7 | author = "Adrian Sampson" 8 | author-email = "adrian@radbox.org" 9 | home-page = "https://github.com/beetbox/mediafile" 10 | description-file = "README.rst" 11 | requires = [ 12 | "mutagen>=1.46", 13 | "filetype>=1.2.0", 14 | ] 15 | requires-python = ">=3.7" 16 | classifiers = [ 17 | 'Topic :: Multimedia :: Sound/Audio', 18 | 'License :: OSI Approved :: MIT License', 19 | 'Environment :: Web Environment', 20 | 'Programming Language :: Python :: 3.7', 21 | 'Programming Language :: Python :: 3.8', 22 | 'Programming Language :: Python :: 3.9', 23 | 'Programming Language :: Python :: 3.10', 24 | 'Programming Language :: Python :: Implementation :: PyPy', 25 | ] 26 | 27 | [tool.flit.metadata.requires-extra] 28 | test = [ 29 | "tox" 30 | ] 31 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | verbosity=1 3 | logging-clear-handlers=1 4 | eval-attr="!=slow" 5 | 6 | [flake8] 7 | min-version=3.7 8 | # Default pyflakes errors we ignore: 9 | # - E241: missing whitespace after ',' (used to align visually) 10 | # - E221: multiple spaces before operator (used to align visually) 11 | # - E731: do not assign a lambda expression, use a def 12 | # - C901: function/method complexity 13 | ignore=C901,E241,E221,E731 14 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /test/_common.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # This file is part of mediafile. 3 | # Copyright 2016, Adrian Sampson. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | 16 | """Some common functionality for mediafile tests.""" 17 | import os 18 | import tempfile 19 | import shutil 20 | import sys 21 | 22 | 23 | # Test resources path. 24 | RSRC = os.path.join(os.path.dirname(__file__), 'rsrc').encode('utf8') 25 | 26 | # OS feature test. 27 | HAVE_SYMLINK = sys.platform != 'win32' 28 | 29 | 30 | # Convenience methods for setting up a temporary sandbox directory for tests 31 | # that need to interact with the filesystem. 32 | class TempDirMixin(object): 33 | """Text mixin for creating and deleting a temporary directory. 34 | """ 35 | 36 | def create_temp_dir(self): 37 | """Create a temporary directory and assign it into `self.temp_dir`. 38 | Call `remove_temp_dir` later to delete it. 39 | """ 40 | path = tempfile.mkdtemp() 41 | if not isinstance(path, bytes): 42 | path = path.encode('utf8') 43 | self.temp_dir = path 44 | 45 | def remove_temp_dir(self): 46 | """Delete the temporary directory created by `create_temp_dir`. 47 | """ 48 | if os.path.isdir(self.temp_dir): 49 | shutil.rmtree(self.temp_dir) 50 | -------------------------------------------------------------------------------- /test/rsrc/backup.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/backup.wav -------------------------------------------------------------------------------- /test/rsrc/bpm.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/bpm.mp3 -------------------------------------------------------------------------------- /test/rsrc/cbr.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/cbr.mp3 -------------------------------------------------------------------------------- /test/rsrc/coverart.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/coverart.ogg -------------------------------------------------------------------------------- /test/rsrc/date_with_slashes.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/date_with_slashes.ogg -------------------------------------------------------------------------------- /test/rsrc/discc.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/discc.ogg -------------------------------------------------------------------------------- /test/rsrc/empty.aiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/empty.aiff -------------------------------------------------------------------------------- /test/rsrc/empty.alac.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/empty.alac.m4a -------------------------------------------------------------------------------- /test/rsrc/empty.ape: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/empty.ape -------------------------------------------------------------------------------- /test/rsrc/empty.dsf: -------------------------------------------------------------------------------- 1 | DSD \fmt 4+data  -------------------------------------------------------------------------------- /test/rsrc/empty.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/empty.flac -------------------------------------------------------------------------------- /test/rsrc/empty.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/empty.m4a -------------------------------------------------------------------------------- /test/rsrc/empty.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/empty.mp3 -------------------------------------------------------------------------------- /test/rsrc/empty.mpc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/empty.mpc -------------------------------------------------------------------------------- /test/rsrc/empty.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/empty.ogg -------------------------------------------------------------------------------- /test/rsrc/empty.opus: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/empty.opus -------------------------------------------------------------------------------- /test/rsrc/empty.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/empty.wav -------------------------------------------------------------------------------- /test/rsrc/empty.wma: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/empty.wma -------------------------------------------------------------------------------- /test/rsrc/empty.wv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/empty.wv -------------------------------------------------------------------------------- /test/rsrc/emptylist.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/emptylist.mp3 -------------------------------------------------------------------------------- /test/rsrc/full.aiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/full.aiff -------------------------------------------------------------------------------- /test/rsrc/full.alac.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/full.alac.m4a -------------------------------------------------------------------------------- /test/rsrc/full.ape: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/full.ape -------------------------------------------------------------------------------- /test/rsrc/full.dsf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/full.dsf -------------------------------------------------------------------------------- /test/rsrc/full.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/full.flac -------------------------------------------------------------------------------- /test/rsrc/full.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/full.m4a -------------------------------------------------------------------------------- /test/rsrc/full.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/full.mp3 -------------------------------------------------------------------------------- /test/rsrc/full.mpc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/full.mpc -------------------------------------------------------------------------------- /test/rsrc/full.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/full.ogg -------------------------------------------------------------------------------- /test/rsrc/full.opus: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/full.opus -------------------------------------------------------------------------------- /test/rsrc/full.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/full.wav -------------------------------------------------------------------------------- /test/rsrc/full.wma: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/full.wma -------------------------------------------------------------------------------- /test/rsrc/full.wv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/full.wv -------------------------------------------------------------------------------- /test/rsrc/image-2x3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/image-2x3.jpg -------------------------------------------------------------------------------- /test/rsrc/image-2x3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/image-2x3.png -------------------------------------------------------------------------------- /test/rsrc/image-2x3.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/image-2x3.tiff -------------------------------------------------------------------------------- /test/rsrc/image.ape: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/image.ape -------------------------------------------------------------------------------- /test/rsrc/image.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/image.flac -------------------------------------------------------------------------------- /test/rsrc/image.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/image.m4a -------------------------------------------------------------------------------- /test/rsrc/image.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/image.mp3 -------------------------------------------------------------------------------- /test/rsrc/image.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/image.ogg -------------------------------------------------------------------------------- /test/rsrc/image.wma: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/image.wma -------------------------------------------------------------------------------- /test/rsrc/image_unknown_type.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/image_unknown_type.mp3 -------------------------------------------------------------------------------- /test/rsrc/oldape.ape: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/oldape.ape -------------------------------------------------------------------------------- /test/rsrc/partial.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/partial.flac -------------------------------------------------------------------------------- /test/rsrc/partial.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/partial.m4a -------------------------------------------------------------------------------- /test/rsrc/partial.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/partial.mp3 -------------------------------------------------------------------------------- /test/rsrc/pure.wma: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/pure.wma -------------------------------------------------------------------------------- /test/rsrc/read_only_tag.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/read_only_tag.m4a -------------------------------------------------------------------------------- /test/rsrc/soundcheck-nonascii.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/soundcheck-nonascii.m4a -------------------------------------------------------------------------------- /test/rsrc/space_time.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/space_time.mp3 -------------------------------------------------------------------------------- /test/rsrc/t_time.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/t_time.m4a -------------------------------------------------------------------------------- /test/rsrc/unparseable.aiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/unparseable.aiff -------------------------------------------------------------------------------- /test/rsrc/unparseable.alac.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/unparseable.alac.m4a -------------------------------------------------------------------------------- /test/rsrc/unparseable.ape: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/unparseable.ape -------------------------------------------------------------------------------- /test/rsrc/unparseable.dsf: -------------------------------------------------------------------------------- 1 | DSD {\fmt 4+data ID3TDRC Oct 3, 1995 -------------------------------------------------------------------------------- /test/rsrc/unparseable.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/unparseable.flac -------------------------------------------------------------------------------- /test/rsrc/unparseable.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/unparseable.m4a -------------------------------------------------------------------------------- /test/rsrc/unparseable.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/unparseable.mp3 -------------------------------------------------------------------------------- /test/rsrc/unparseable.mpc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/unparseable.mpc -------------------------------------------------------------------------------- /test/rsrc/unparseable.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/unparseable.ogg -------------------------------------------------------------------------------- /test/rsrc/unparseable.opus: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/unparseable.opus -------------------------------------------------------------------------------- /test/rsrc/unparseable.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/unparseable.wav -------------------------------------------------------------------------------- /test/rsrc/unparseable.wma: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/unparseable.wma -------------------------------------------------------------------------------- /test/rsrc/unparseable.wv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/unparseable.wv -------------------------------------------------------------------------------- /test/rsrc/year.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beetbox/mediafile/1d523c08698f0a0af381661f976eac2a8ea2ff17/test/rsrc/year.ogg -------------------------------------------------------------------------------- /test/test_mediafile.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # This file is part of MediaFile. 3 | # Copyright 2016, Adrian Sampson. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | 16 | """Automatically-generated blanket testing for the MediaFile metadata 17 | layer. 18 | """ 19 | import os 20 | import shutil 21 | import datetime 22 | import time 23 | import unittest 24 | 25 | from test import _common 26 | from mediafile import MediaFile, Image, \ 27 | ImageType, CoverArtField, UnreadableFileError 28 | import mutagen 29 | 30 | 31 | class ArtTestMixin(object): 32 | """Test reads and writes of the ``art`` property. 33 | """ 34 | 35 | @property 36 | def png_data(self): 37 | if not self._png_data: 38 | image_file = os.path.join(_common.RSRC, b'image-2x3.png') 39 | with open(image_file, 'rb') as f: 40 | self._png_data = f.read() 41 | return self._png_data 42 | _png_data = None 43 | 44 | @property 45 | def jpg_data(self): 46 | if not self._jpg_data: 47 | image_file = os.path.join(_common.RSRC, b'image-2x3.jpg') 48 | with open(image_file, 'rb') as f: 49 | self._jpg_data = f.read() 50 | return self._jpg_data 51 | _jpg_data = None 52 | 53 | @property 54 | def tiff_data(self): 55 | if not self._jpg_data: 56 | image_file = os.path.join(_common.RSRC, b'image-2x3.tiff') 57 | with open(image_file, 'rb') as f: 58 | self._jpg_data = f.read() 59 | return self._jpg_data 60 | _jpg_data = None 61 | 62 | def test_set_png_art(self): 63 | mediafile = self._mediafile_fixture('empty') 64 | mediafile.art = self.png_data 65 | mediafile.save() 66 | 67 | mediafile = MediaFile(mediafile.filename) 68 | self.assertEqual(mediafile.art, self.png_data) 69 | 70 | def test_set_jpg_art(self): 71 | mediafile = self._mediafile_fixture('empty') 72 | mediafile.art = self.jpg_data 73 | mediafile.save() 74 | 75 | mediafile = MediaFile(mediafile.filename) 76 | self.assertEqual(mediafile.art, self.jpg_data) 77 | 78 | def test_delete_art(self): 79 | mediafile = self._mediafile_fixture('empty') 80 | mediafile.art = self.jpg_data 81 | mediafile.save() 82 | 83 | mediafile = MediaFile(mediafile.filename) 84 | self.assertIsNotNone(mediafile.art) 85 | 86 | del mediafile.art 87 | mediafile.save() 88 | 89 | mediafile = MediaFile(mediafile.filename) 90 | self.assertIsNone(mediafile.art) 91 | 92 | 93 | class ImageStructureTestMixin(ArtTestMixin): 94 | """Test reading and writing multiple image tags. 95 | 96 | The tests use the `image` media file fixture. The tags of these files 97 | include two images, on in the PNG format, the other in JPEG format. If 98 | the tag format supports it they also include additional metadata. 99 | """ 100 | 101 | def test_read_image_structures(self): 102 | mediafile = self._mediafile_fixture('image') 103 | 104 | self.assertEqual(len(mediafile.images), 2) 105 | 106 | image = next(i for i in mediafile.images 107 | if i.mime_type == 'image/png') 108 | self.assertEqual(image.data, self.png_data) 109 | self.assertExtendedImageAttributes(image, desc=u'album cover', 110 | type=ImageType.front) 111 | 112 | image = next(i for i in mediafile.images 113 | if i.mime_type == 'image/jpeg') 114 | self.assertEqual(image.data, self.jpg_data) 115 | self.assertExtendedImageAttributes(image, desc=u'the artist', 116 | type=ImageType.artist) 117 | 118 | def test_set_image_structure(self): 119 | mediafile = self._mediafile_fixture('empty') 120 | image = Image(data=self.png_data, desc=u'album cover', 121 | type=ImageType.front) 122 | mediafile.images = [image] 123 | mediafile.save() 124 | 125 | mediafile = MediaFile(mediafile.filename) 126 | self.assertEqual(len(mediafile.images), 1) 127 | 128 | image = mediafile.images[0] 129 | self.assertEqual(image.data, self.png_data) 130 | self.assertEqual(image.mime_type, 'image/png') 131 | self.assertExtendedImageAttributes(image, desc=u'album cover', 132 | type=ImageType.front) 133 | 134 | def test_add_image_structure(self): 135 | mediafile = self._mediafile_fixture('image') 136 | self.assertEqual(len(mediafile.images), 2) 137 | 138 | image = Image(data=self.png_data, desc=u'the composer', 139 | type=ImageType.composer) 140 | mediafile.images += [image] 141 | mediafile.save() 142 | 143 | mediafile = MediaFile(mediafile.filename) 144 | self.assertEqual(len(mediafile.images), 3) 145 | 146 | images = (i for i in mediafile.images if i.desc == u'the composer') 147 | image = next(images, None) 148 | self.assertExtendedImageAttributes( 149 | image, desc=u'the composer', type=ImageType.composer 150 | ) 151 | 152 | def test_delete_image_structures(self): 153 | mediafile = self._mediafile_fixture('image') 154 | self.assertEqual(len(mediafile.images), 2) 155 | 156 | del mediafile.images 157 | mediafile.save() 158 | 159 | mediafile = MediaFile(mediafile.filename) 160 | self.assertIsNone(mediafile.images) 161 | 162 | def test_guess_cover(self): 163 | mediafile = self._mediafile_fixture('image') 164 | self.assertEqual(len(mediafile.images), 2) 165 | cover = CoverArtField.guess_cover_image(mediafile.images) 166 | self.assertEqual(cover.desc, u'album cover') 167 | self.assertEqual(mediafile.art, cover.data) 168 | 169 | def assertExtendedImageAttributes(self, image, **kwargs): # noqa 170 | """Ignore extended image attributes in the base tests. 171 | """ 172 | pass 173 | 174 | 175 | class ExtendedImageStructureTestMixin(ImageStructureTestMixin): 176 | """Checks for additional attributes in the image structure. 177 | 178 | Like the base `ImageStructureTestMixin`, per-format test classes 179 | should include this mixin to add image-related tests. 180 | """ 181 | 182 | def assertExtendedImageAttributes(self, image, desc=None, type=None): # noqa 183 | self.assertEqual(image.desc, desc) 184 | self.assertEqual(image.type, type) 185 | 186 | def test_add_tiff_image(self): 187 | mediafile = self._mediafile_fixture('image') 188 | self.assertEqual(len(mediafile.images), 2) 189 | 190 | image = Image(data=self.tiff_data, desc=u'the composer', 191 | type=ImageType.composer) 192 | mediafile.images += [image] 193 | mediafile.save() 194 | 195 | mediafile = MediaFile(mediafile.filename) 196 | self.assertEqual(len(mediafile.images), 3) 197 | 198 | # WMA does not preserve the order, so we have to work around this 199 | image = list(filter(lambda i: i.mime_type == 'image/tiff', 200 | mediafile.images))[0] 201 | self.assertExtendedImageAttributes( 202 | image, desc=u'the composer', type=ImageType.composer) 203 | 204 | 205 | class LazySaveTestMixin(object): 206 | """Mediafile should only write changes when tags have changed 207 | """ 208 | 209 | @unittest.skip(u'not yet implemented') 210 | def test_unmodified(self): 211 | mediafile = self._mediafile_fixture('full') 212 | mtime = self._set_past_mtime(mediafile.filename) 213 | self.assertEqual(os.stat(mediafile.filename).st_mtime, mtime) 214 | 215 | mediafile.save() 216 | self.assertEqual(os.stat(mediafile.filename).st_mtime, mtime) 217 | 218 | @unittest.skip(u'not yet implemented') 219 | def test_same_tag_value(self): 220 | mediafile = self._mediafile_fixture('full') 221 | mtime = self._set_past_mtime(mediafile.filename) 222 | self.assertEqual(os.stat(mediafile.filename).st_mtime, mtime) 223 | 224 | mediafile.title = mediafile.title 225 | mediafile.save() 226 | self.assertEqual(os.stat(mediafile.filename).st_mtime, mtime) 227 | 228 | def test_update_same_tag_value(self): 229 | mediafile = self._mediafile_fixture('full') 230 | mtime = self._set_past_mtime(mediafile.filename) 231 | self.assertEqual(os.stat(mediafile.filename).st_mtime, mtime) 232 | 233 | mediafile.update({'title': mediafile.title}) 234 | mediafile.save() 235 | self.assertEqual(os.stat(mediafile.filename).st_mtime, mtime) 236 | 237 | @unittest.skip(u'not yet implemented') 238 | def test_tag_value_change(self): 239 | mediafile = self._mediafile_fixture('full') 240 | mtime = self._set_past_mtime(mediafile.filename) 241 | self.assertEqual(os.stat(mediafile.filename).st_mtime, mtime) 242 | 243 | mediafile.title = mediafile.title 244 | mediafile.album = u'another' 245 | mediafile.save() 246 | self.assertNotEqual(os.stat(mediafile.filename).st_mtime, mtime) 247 | 248 | def test_update_changed_tag_value(self): 249 | mediafile = self._mediafile_fixture('full') 250 | mtime = self._set_past_mtime(mediafile.filename) 251 | self.assertEqual(os.stat(mediafile.filename).st_mtime, mtime) 252 | 253 | mediafile.update({'title': mediafile.title, 'album': u'another'}) 254 | mediafile.save() 255 | self.assertNotEqual(os.stat(mediafile.filename).st_mtime, mtime) 256 | 257 | def _set_past_mtime(self, path): 258 | mtime = round(time.time() - 10000) 259 | os.utime(path, (mtime, mtime)) 260 | return mtime 261 | 262 | 263 | class GenreListTestMixin(object): 264 | """Tests access to the ``genres`` property as a list. 265 | """ 266 | 267 | def test_read_genre_list(self): 268 | mediafile = self._mediafile_fixture('full') 269 | self.assertCountEqual(mediafile.genres, ['the genre']) 270 | 271 | def test_write_genre_list(self): 272 | mediafile = self._mediafile_fixture('empty') 273 | mediafile.genres = [u'one', u'two'] 274 | mediafile.save() 275 | 276 | mediafile = MediaFile(mediafile.filename) 277 | self.assertCountEqual(mediafile.genres, [u'one', u'two']) 278 | 279 | def test_write_genre_list_get_first(self): 280 | mediafile = self._mediafile_fixture('empty') 281 | mediafile.genres = [u'one', u'two'] 282 | mediafile.save() 283 | 284 | mediafile = MediaFile(mediafile.filename) 285 | self.assertEqual(mediafile.genre, u'one') 286 | 287 | def test_append_genre_list(self): 288 | mediafile = self._mediafile_fixture('full') 289 | self.assertEqual(mediafile.genre, u'the genre') 290 | mediafile.genres += [u'another'] 291 | mediafile.save() 292 | 293 | mediafile = MediaFile(mediafile.filename) 294 | self.assertCountEqual(mediafile.genres, [u'the genre', u'another']) 295 | 296 | 297 | class ReadWriteTestBase(ArtTestMixin, GenreListTestMixin, 298 | _common.TempDirMixin): 299 | """Test writing and reading tags. Subclasses must set ``extension`` 300 | and ``audio_properties``. 301 | 302 | The basic tests for all audio formats encompass three files provided 303 | in our `rsrc` folder: `full.*`, `empty.*`, and `unparseable.*`. 304 | Respectively, they should contain a full slate of common fields 305 | listed in `full_initial_tags` below; no fields contents at all; and 306 | an unparseable release date field. 307 | 308 | To add support for a new file format to MediaFile, add these three 309 | files and then create a `ReadWriteTestBase` subclass by copying n' 310 | pasting one of the existing subclasses below. You will want to 311 | update the `format` field in that subclass, and you will probably 312 | need to fiddle with the `bitrate` and other format-specific fields. 313 | 314 | You can also add image tests (using an additional `image.*` fixture 315 | file) by including one of the image-related mixins. 316 | """ 317 | 318 | full_initial_tags = { 319 | 'title': u'full', 320 | 'artist': u'the artist', 321 | 'album': u'the album', 322 | 'genre': u'the genre', 323 | 'composer': u'the composer', 324 | 'grouping': u'the grouping', 325 | 'year': 2001, 326 | 'month': None, 327 | 'day': None, 328 | 'date': datetime.date(2001, 1, 1), 329 | 'track': 2, 330 | 'tracktotal': 3, 331 | 'disc': 4, 332 | 'disctotal': 5, 333 | 'lyrics': u'the lyrics', 334 | 'comments': u'the comments', 335 | 'bpm': 6, 336 | 'comp': True, 337 | 'mb_trackid': '8b882575-08a5-4452-a7a7-cbb8a1531f9e', 338 | 'mb_releasetrackid': 'c29f3a57-b439-46fd-a2e2-93776b1371e0', 339 | 'mb_albumid': '9e873859-8aa4-4790-b985-5a953e8ef628', 340 | 'mb_artistid': '7cf0ea9d-86b9-4dad-ba9e-2355a64899ea', 341 | 'art': None, 342 | 'label': u'the label', 343 | } 344 | 345 | tag_fields = [ 346 | 'title', 347 | 'artist', 348 | 'album', 349 | 'genre', 350 | 'lyricist', 351 | 'composer', 352 | 'composer_sort', 353 | 'arranger', 354 | 'grouping', 355 | 'year', 356 | 'month', 357 | 'day', 358 | 'date', 359 | 'track', 360 | 'tracktotal', 361 | 'disc', 362 | 'disctotal', 363 | 'lyrics', 364 | 'comments', 365 | 'copyright', 366 | 'bpm', 367 | 'comp', 368 | 'mb_trackid', 369 | 'mb_releasetrackid', 370 | 'mb_workid', 371 | 'mb_albumid', 372 | 'mb_artistid', 373 | 'art', 374 | 'label', 375 | 'rg_track_peak', 376 | 'rg_track_gain', 377 | 'rg_album_peak', 378 | 'rg_album_gain', 379 | 'r128_track_gain', 380 | 'r128_album_gain', 381 | 'albumartist', 382 | 'mb_albumartistid', 383 | 'artist_sort', 384 | 'albumartist_sort', 385 | 'acoustid_fingerprint', 386 | 'acoustid_id', 387 | 'mb_releasegroupid', 388 | 'asin', 389 | 'catalognum', 390 | 'barcode', 391 | 'isrc', 392 | 'disctitle', 393 | 'script', 394 | 'language', 395 | 'country', 396 | 'albumstatus', 397 | 'media', 398 | 'albumdisambig', 399 | 'artist_credit', 400 | 'albumartist_credit', 401 | 'original_year', 402 | 'original_month', 403 | 'original_day', 404 | 'original_date', 405 | 'initial_key', 406 | ] 407 | 408 | def setUp(self): 409 | self.create_temp_dir() 410 | 411 | def tearDown(self): 412 | self.remove_temp_dir() 413 | 414 | def test_read_nonexisting(self): 415 | mediafile = self._mediafile_fixture('full') 416 | os.remove(mediafile.filename) 417 | self.assertRaises(UnreadableFileError, MediaFile, mediafile.filename) 418 | 419 | def test_save_nonexisting(self): 420 | mediafile = self._mediafile_fixture('full') 421 | os.remove(mediafile.filename) 422 | try: 423 | mediafile.save() 424 | except UnreadableFileError: 425 | pass 426 | 427 | def test_delete_nonexisting(self): 428 | mediafile = self._mediafile_fixture('full') 429 | os.remove(mediafile.filename) 430 | try: 431 | mediafile.delete() 432 | except UnreadableFileError: 433 | pass 434 | 435 | def test_read_audio_properties(self): 436 | mediafile = self._mediafile_fixture('full') 437 | for key, value in self.audio_properties.items(): 438 | if isinstance(value, float): 439 | self.assertAlmostEqual(getattr(mediafile, key), value, 440 | delta=0.1) 441 | else: 442 | self.assertEqual(getattr(mediafile, key), value) 443 | 444 | def test_read_full(self): 445 | mediafile = self._mediafile_fixture('full') 446 | self.assertTags(mediafile, self.full_initial_tags) 447 | 448 | def test_read_empty(self): 449 | mediafile = self._mediafile_fixture('empty') 450 | for field in self.tag_fields: 451 | value = getattr(mediafile, field) 452 | if isinstance(value, list): 453 | assert not value 454 | else: 455 | self.assertIsNone(value) 456 | 457 | def test_write_empty(self): 458 | mediafile = self._mediafile_fixture('empty') 459 | tags = self._generate_tags() 460 | 461 | for key, value in tags.items(): 462 | setattr(mediafile, key, value) 463 | mediafile.save() 464 | 465 | mediafile = MediaFile(mediafile.filename) 466 | self.assertTags(mediafile, tags) 467 | 468 | def test_update_empty(self): 469 | mediafile = self._mediafile_fixture('empty') 470 | tags = self._generate_tags() 471 | 472 | mediafile.update(tags) 473 | mediafile.save() 474 | 475 | mediafile = MediaFile(mediafile.filename) 476 | self.assertTags(mediafile, tags) 477 | 478 | def test_overwrite_full(self): 479 | mediafile = self._mediafile_fixture('full') 480 | tags = self._generate_tags() 481 | 482 | for key, value in tags.items(): 483 | setattr(mediafile, key, value) 484 | mediafile.save() 485 | 486 | # Make sure the tags are already set when writing a second time 487 | for key, value in tags.items(): 488 | setattr(mediafile, key, value) 489 | mediafile.save() 490 | 491 | mediafile = MediaFile(mediafile.filename) 492 | self.assertTags(mediafile, tags) 493 | 494 | def test_update_full(self): 495 | mediafile = self._mediafile_fixture('full') 496 | tags = self._generate_tags() 497 | 498 | mediafile.update(tags) 499 | mediafile.save() 500 | # Make sure the tags are already set when writing a second time 501 | mediafile.update(tags) 502 | mediafile.save() 503 | 504 | mediafile = MediaFile(mediafile.filename) 505 | self.assertTags(mediafile, tags) 506 | 507 | def test_write_date_components(self): 508 | mediafile = self._mediafile_fixture('full') 509 | mediafile.year = 2001 510 | mediafile.month = 1 511 | mediafile.day = 2 512 | mediafile.original_year = 1999 513 | mediafile.original_month = 12 514 | mediafile.original_day = 30 515 | mediafile.save() 516 | 517 | mediafile = MediaFile(mediafile.filename) 518 | self.assertEqual(mediafile.year, 2001) 519 | self.assertEqual(mediafile.month, 1) 520 | self.assertEqual(mediafile.day, 2) 521 | self.assertEqual(mediafile.date, datetime.date(2001, 1, 2)) 522 | self.assertEqual(mediafile.original_year, 1999) 523 | self.assertEqual(mediafile.original_month, 12) 524 | self.assertEqual(mediafile.original_day, 30) 525 | self.assertEqual(mediafile.original_date, datetime.date(1999, 12, 30)) 526 | 527 | def test_write_incomplete_date_components(self): 528 | mediafile = self._mediafile_fixture('empty') 529 | mediafile.year = 2001 530 | mediafile.month = None 531 | mediafile.day = 2 532 | mediafile.save() 533 | 534 | mediafile = MediaFile(mediafile.filename) 535 | self.assertEqual(mediafile.year, 2001) 536 | self.assertIsNone(mediafile.month) 537 | self.assertIsNone(mediafile.day) 538 | self.assertEqual(mediafile.date, datetime.date(2001, 1, 1)) 539 | 540 | def test_write_dates(self): 541 | mediafile = self._mediafile_fixture('full') 542 | mediafile.date = datetime.date(2001, 1, 2) 543 | mediafile.original_date = datetime.date(1999, 12, 30) 544 | mediafile.save() 545 | 546 | mediafile = MediaFile(mediafile.filename) 547 | self.assertEqual(mediafile.year, 2001) 548 | self.assertEqual(mediafile.month, 1) 549 | self.assertEqual(mediafile.day, 2) 550 | self.assertEqual(mediafile.date, datetime.date(2001, 1, 2)) 551 | self.assertEqual(mediafile.original_year, 1999) 552 | self.assertEqual(mediafile.original_month, 12) 553 | self.assertEqual(mediafile.original_day, 30) 554 | self.assertEqual(mediafile.original_date, datetime.date(1999, 12, 30)) 555 | 556 | def test_r128_gain_stored_as_q8_number(self): 557 | def round_trip(x): 558 | q_num = round(x * pow(2, 8)) 559 | return q_num / pow(2, 8) 560 | 561 | mediafile = self._mediafile_fixture('full') 562 | 563 | track = -1.1 564 | self.assertNotEqual(track, round_trip(track)) 565 | mediafile.r128_track_gain = track 566 | self.assertEqual(mediafile.r128_track_gain, round_trip(track)) 567 | 568 | album = 4.2 569 | self.assertNotEqual(album, round_trip(album)) 570 | mediafile.r128_album_gain = album 571 | self.assertEqual(mediafile.r128_album_gain, round_trip(album)) 572 | 573 | def test_write_packed(self): 574 | mediafile = self._mediafile_fixture('empty') 575 | 576 | mediafile.tracktotal = 2 577 | mediafile.track = 1 578 | mediafile.save() 579 | 580 | mediafile = MediaFile(mediafile.filename) 581 | self.assertEqual(mediafile.track, 1) 582 | self.assertEqual(mediafile.tracktotal, 2) 583 | 584 | def test_write_counters_without_total(self): 585 | mediafile = self._mediafile_fixture('full') 586 | self.assertEqual(mediafile.track, 2) 587 | self.assertEqual(mediafile.tracktotal, 3) 588 | self.assertEqual(mediafile.disc, 4) 589 | self.assertEqual(mediafile.disctotal, 5) 590 | 591 | mediafile.track = 10 592 | delattr(mediafile, 'tracktotal') 593 | mediafile.disc = 10 594 | delattr(mediafile, 'disctotal') 595 | mediafile.save() 596 | 597 | mediafile = MediaFile(mediafile.filename) 598 | self.assertEqual(mediafile.track, 10) 599 | self.assertEqual(mediafile.tracktotal, None) 600 | self.assertEqual(mediafile.disc, 10) 601 | self.assertEqual(mediafile.disctotal, None) 602 | 603 | def test_unparseable_date(self): 604 | """The `unparseable.*` fixture should not crash but should return None 605 | for all parts of the release date. 606 | """ 607 | mediafile = self._mediafile_fixture('unparseable') 608 | 609 | self.assertIsNone(mediafile.date) 610 | self.assertIsNone(mediafile.year) 611 | self.assertIsNone(mediafile.month) 612 | self.assertIsNone(mediafile.day) 613 | 614 | def test_delete_tag(self): 615 | mediafile = self._mediafile_fixture('full') 616 | 617 | keys = self.full_initial_tags.keys() 618 | for key in set(keys) - set(['art', 'month', 'day']): 619 | self.assertIsNotNone(getattr(mediafile, key)) 620 | for key in keys: 621 | delattr(mediafile, key) 622 | mediafile.save() 623 | mediafile = MediaFile(mediafile.filename) 624 | 625 | for key in keys: 626 | value = getattr(mediafile, key) 627 | if isinstance(value, list): 628 | assert not value 629 | else: 630 | self.assertIsNone(value) 631 | 632 | def test_delete_packed_total(self): 633 | mediafile = self._mediafile_fixture('full') 634 | 635 | delattr(mediafile, 'tracktotal') 636 | delattr(mediafile, 'disctotal') 637 | 638 | mediafile.save() 639 | mediafile = MediaFile(mediafile.filename) 640 | self.assertEqual(mediafile.track, self.full_initial_tags['track']) 641 | self.assertEqual(mediafile.disc, self.full_initial_tags['disc']) 642 | 643 | def test_delete_partial_date(self): 644 | mediafile = self._mediafile_fixture('empty') 645 | 646 | mediafile.date = datetime.date(2001, 12, 3) 647 | mediafile.save() 648 | mediafile = MediaFile(mediafile.filename) 649 | self.assertIsNotNone(mediafile.date) 650 | self.assertIsNotNone(mediafile.year) 651 | self.assertIsNotNone(mediafile.month) 652 | self.assertIsNotNone(mediafile.day) 653 | 654 | delattr(mediafile, 'month') 655 | mediafile.save() 656 | mediafile = MediaFile(mediafile.filename) 657 | self.assertIsNotNone(mediafile.date) 658 | self.assertIsNotNone(mediafile.year) 659 | self.assertIsNone(mediafile.month) 660 | self.assertIsNone(mediafile.day) 661 | 662 | def test_delete_year(self): 663 | mediafile = self._mediafile_fixture('full') 664 | 665 | self.assertIsNotNone(mediafile.date) 666 | self.assertIsNotNone(mediafile.year) 667 | 668 | delattr(mediafile, 'year') 669 | mediafile.save() 670 | mediafile = MediaFile(mediafile.filename) 671 | self.assertIsNone(mediafile.date) 672 | self.assertIsNone(mediafile.year) 673 | 674 | def assertTags(self, mediafile, tags): # noqa 675 | errors = [] 676 | for key, value in tags.items(): 677 | try: 678 | value2 = getattr(mediafile, key) 679 | except AttributeError: 680 | errors.append(u'Tag %s does not exist' % key) 681 | else: 682 | if value2 != value: 683 | errors.append(u'Tag %s: %r != %r' % (key, value2, value)) 684 | if any(errors): 685 | errors = [u'Tags did not match'] + errors 686 | self.fail('\n '.join(errors)) 687 | 688 | def _mediafile_fixture(self, name): 689 | name = name + '.' + self.extension 690 | if not isinstance(name, bytes): 691 | name = name.encode('utf8') 692 | src = os.path.join(_common.RSRC, name) 693 | target = os.path.join(self.temp_dir, name) 694 | shutil.copy(src, target) 695 | return MediaFile(target) 696 | 697 | def _generate_tags(self, base=None): 698 | """Return dictionary of tags, mapping tag names to values. 699 | """ 700 | tags = {} 701 | 702 | for key in self.tag_fields: 703 | if key.startswith('rg_'): 704 | # ReplayGain is float 705 | tags[key] = 1.0 706 | elif key.startswith('r128_'): 707 | # R128 is int 708 | tags[key] = -1 709 | else: 710 | tags[key] = 'value\u2010%s' % key 711 | 712 | for key in ['disc', 'disctotal', 'track', 'tracktotal', 'bpm']: 713 | tags[key] = 1 714 | 715 | for key in [ 716 | 'artists', 'albumartists', 'artists_credit', 717 | 'albumartists_credit', 'artists_sort', 'albumartists_sort' 718 | ]: 719 | tags[key] = ['multival', 'test'] 720 | 721 | tags['art'] = self.jpg_data 722 | tags['comp'] = True 723 | 724 | tags['url'] = "https://example.com/" 725 | 726 | date = datetime.date(2001, 4, 3) 727 | tags['date'] = date 728 | tags['year'] = date.year 729 | tags['month'] = date.month 730 | tags['day'] = date.day 731 | 732 | original_date = datetime.date(1999, 5, 6) 733 | tags['original_date'] = original_date 734 | tags['original_year'] = original_date.year 735 | tags['original_month'] = original_date.month 736 | tags['original_day'] = original_date.day 737 | 738 | return tags 739 | 740 | 741 | class PartialTestMixin(object): 742 | tags_without_total = { 743 | 'track': 2, 744 | 'tracktotal': 0, 745 | 'disc': 4, 746 | 'disctotal': 0, 747 | } 748 | 749 | def test_read_track_without_total(self): 750 | mediafile = self._mediafile_fixture('partial') 751 | self.assertEqual(mediafile.track, 2) 752 | self.assertIsNone(mediafile.tracktotal) 753 | self.assertEqual(mediafile.disc, 4) 754 | self.assertIsNone(mediafile.disctotal) 755 | 756 | 757 | class MP3Test(ReadWriteTestBase, PartialTestMixin, 758 | ExtendedImageStructureTestMixin, 759 | unittest.TestCase): 760 | extension = 'mp3' 761 | audio_properties = { 762 | 'length': 1.0, 763 | 'bitrate': 80000, 764 | 'bitrate_mode': '', 765 | 'encoder_info': '', 766 | 'encoder_settings': '', 767 | 'format': 'MP3', 768 | 'samplerate': 44100, 769 | 'bitdepth': 0, 770 | 'channels': 1, 771 | } 772 | 773 | def test_unknown_apic_type(self): 774 | mediafile = self._mediafile_fixture('image_unknown_type') 775 | self.assertEqual(mediafile.images[0].type, ImageType.other) 776 | 777 | def test_bitrate_mode(self): 778 | mediafile = self._mediafile_fixture('cbr') 779 | self.assertEqual(mediafile.bitrate_mode, 'CBR') 780 | 781 | def test_encoder_info(self): 782 | mediafile = self._mediafile_fixture('cbr') 783 | self.assertEqual(mediafile.encoder_info, 'LAME 3.100.0+') 784 | 785 | def test_encoder_settings(self): 786 | mediafile = self._mediafile_fixture('cbr') 787 | self.assertEqual(mediafile.encoder_settings, '-b 80') 788 | 789 | 790 | class MP4Test(ReadWriteTestBase, PartialTestMixin, 791 | ImageStructureTestMixin, unittest.TestCase): 792 | extension = 'm4a' 793 | audio_properties = { 794 | 'length': 1.0, 795 | 'bitrate': 64000, 796 | 'bitrate_mode': '', 797 | 'encoder_info': '', 798 | 'encoder_settings': '', 799 | 'format': 'AAC', 800 | 'samplerate': 44100, 801 | 'bitdepth': 16, 802 | 'channels': 2, 803 | } 804 | 805 | def test_add_tiff_image_fails(self): 806 | mediafile = self._mediafile_fixture('empty') 807 | with self.assertRaises(ValueError): 808 | mediafile.images = [Image(data=self.tiff_data)] 809 | 810 | def test_guess_cover(self): 811 | # There is no metadata associated with images, we pick one at random 812 | pass 813 | 814 | 815 | class AlacTest(ReadWriteTestBase, unittest.TestCase): 816 | extension = 'alac.m4a' 817 | audio_properties = { 818 | 'length': 1.0, 819 | 'bitrate': 21830, 820 | 'bitrate_mode': '', 821 | 'encoder_info': '', 822 | 'encoder_settings': '', 823 | # 'format': 'ALAC', 824 | 'samplerate': 44100, 825 | 'bitdepth': 16, 826 | 'channels': 1, 827 | } 828 | 829 | 830 | class MusepackTest(ReadWriteTestBase, unittest.TestCase): 831 | extension = 'mpc' 832 | audio_properties = { 833 | 'length': 1.0, 834 | 'bitrate': 24023, 835 | 'bitrate_mode': '', 836 | 'encoder_info': '', 837 | 'encoder_settings': '', 838 | 'format': u'Musepack', 839 | 'samplerate': 44100, 840 | 'bitdepth': 0, 841 | 'channels': 2, 842 | } 843 | 844 | 845 | class WMATest(ReadWriteTestBase, ExtendedImageStructureTestMixin, 846 | unittest.TestCase): 847 | extension = 'wma' 848 | audio_properties = { 849 | 'length': 1.0, 850 | 'bitrate': 128000, 851 | 'bitrate_mode': '', 852 | 'encoder_info': '', 853 | 'encoder_settings': '', 854 | 'format': u'Windows Media', 855 | 'samplerate': 44100, 856 | 'bitdepth': 0, 857 | 'channels': 1, 858 | } 859 | 860 | def test_write_genre_list_get_first(self): 861 | # WMA does not preserve list order 862 | mediafile = self._mediafile_fixture('empty') 863 | mediafile.genres = [u'one', u'two'] 864 | mediafile.save() 865 | 866 | mediafile = MediaFile(mediafile.filename) 867 | self.assertIn(mediafile.genre, [u'one', u'two']) 868 | 869 | def test_read_pure_tags(self): 870 | mediafile = self._mediafile_fixture('pure') 871 | self.assertEqual(mediafile.comments, u'the comments') 872 | self.assertEqual(mediafile.title, u'the title') 873 | self.assertEqual(mediafile.artist, u'the artist') 874 | 875 | 876 | class OggTest(ReadWriteTestBase, ExtendedImageStructureTestMixin, 877 | unittest.TestCase): 878 | extension = 'ogg' 879 | audio_properties = { 880 | 'length': 1.0, 881 | 'bitrate': 48000, 882 | 'bitrate_mode': '', 883 | 'encoder_info': '', 884 | 'encoder_settings': '', 885 | 'format': u'OGG', 886 | 'samplerate': 44100, 887 | 'bitdepth': 0, 888 | 'channels': 1, 889 | } 890 | 891 | def test_read_date_from_year_tag(self): 892 | mediafile = self._mediafile_fixture('year') 893 | self.assertEqual(mediafile.year, 2000) 894 | self.assertEqual(mediafile.date, datetime.date(2000, 1, 1)) 895 | 896 | def test_write_date_to_year_tag(self): 897 | mediafile = self._mediafile_fixture('empty') 898 | mediafile.year = 2000 899 | mediafile.save() 900 | 901 | mediafile = MediaFile(mediafile.filename) 902 | self.assertEqual(mediafile.mgfile['YEAR'], [u'2000']) 903 | 904 | def test_legacy_coverart_tag(self): 905 | mediafile = self._mediafile_fixture('coverart') 906 | self.assertTrue('coverart' in mediafile.mgfile) 907 | self.assertEqual(mediafile.art, self.png_data) 908 | 909 | mediafile.art = self.png_data 910 | mediafile.save() 911 | 912 | mediafile = MediaFile(mediafile.filename) 913 | self.assertFalse('coverart' in mediafile.mgfile) 914 | 915 | def test_date_tag_with_slashes(self): 916 | mediafile = self._mediafile_fixture('date_with_slashes') 917 | self.assertEqual(mediafile.year, 2005) 918 | self.assertEqual(mediafile.month, 6) 919 | self.assertEqual(mediafile.day, 5) 920 | 921 | 922 | class FlacTest(ReadWriteTestBase, PartialTestMixin, 923 | ExtendedImageStructureTestMixin, 924 | unittest.TestCase): 925 | extension = 'flac' 926 | audio_properties = { 927 | 'length': 1.0, 928 | 'bitrate': 108688, 929 | 'bitrate_mode': '', 930 | 'encoder_info': '', 931 | 'encoder_settings': '', 932 | 'format': u'FLAC', 933 | 'samplerate': 44100, 934 | 'bitdepth': 16, 935 | 'channels': 1, 936 | } 937 | 938 | 939 | class ApeTest(ReadWriteTestBase, ExtendedImageStructureTestMixin, 940 | unittest.TestCase): 941 | extension = 'ape' 942 | audio_properties = { 943 | 'length': 1.0, 944 | 'bitrate': 112608, 945 | 'bitrate_mode': '', 946 | 'encoder_info': '', 947 | 'encoder_settings': '', 948 | 'format': u'APE', 949 | 'samplerate': 44100, 950 | 'bitdepth': 16, 951 | 'channels': 1, 952 | } 953 | 954 | 955 | class WavpackTest(ReadWriteTestBase, unittest.TestCase): 956 | extension = 'wv' 957 | audio_properties = { 958 | 'length': 1.0, 959 | 'bitrate': 109312, 960 | 'bitrate_mode': '', 961 | 'encoder_info': '', 962 | 'encoder_settings': '', 963 | 'format': u'WavPack', 964 | 'samplerate': 44100, 965 | 'bitdepth': 16 if mutagen.version >= (1, 45, 0) else 0, 966 | 'channels': 1, 967 | } 968 | 969 | 970 | class OpusTest(ReadWriteTestBase, unittest.TestCase): 971 | extension = 'opus' 972 | audio_properties = { 973 | 'length': 1.0, 974 | 'bitrate': 66792, 975 | 'bitrate_mode': '', 976 | 'encoder_info': '', 977 | 'encoder_settings': '', 978 | 'format': u'Opus', 979 | 'samplerate': 48000, 980 | 'bitdepth': 0, 981 | 'channels': 1, 982 | } 983 | 984 | 985 | class AIFFTest(ReadWriteTestBase, unittest.TestCase): 986 | extension = 'aiff' 987 | audio_properties = { 988 | 'length': 1.0, 989 | 'bitrate': 705600, 990 | 'bitrate_mode': '', 991 | 'encoder_info': '', 992 | 'encoder_settings': '', 993 | 'format': u'AIFF', 994 | 'samplerate': 44100, 995 | 'bitdepth': 16, 996 | 'channels': 1, 997 | } 998 | 999 | 1000 | class WAVETest(ReadWriteTestBase, unittest.TestCase): 1001 | extension = 'wav' 1002 | audio_properties = { 1003 | 'length': 1.0, 1004 | 'bitrate': 705600, 1005 | 'bitrate_mode': '', 1006 | 'encoder_info': '', 1007 | 'encoder_settings': '', 1008 | 'format': u'WAVE', 1009 | 'samplerate': 44100, 1010 | 'bitdepth': 16, 1011 | 'channels': 1, 1012 | } 1013 | 1014 | full_initial_tags = { 1015 | 'title': u'full', 1016 | 'artist': u'the artist', 1017 | 'album': u'the album', 1018 | 'genre': u'the genre', 1019 | 'track': 2, 1020 | 'tracktotal': 3, 1021 | } 1022 | 1023 | tag_fields = [ 1024 | 'title', 1025 | 'artist', 1026 | 'album', 1027 | 'genre', 1028 | 'track', 1029 | 'original_year', 1030 | 'original_month', 1031 | 'original_day', 1032 | 'original_date', 1033 | ] 1034 | 1035 | # Only a small subset of fields are supported by LIST/INFO 1036 | # metadata format in WAVE, so some fields have been removed 1037 | # from the inherited test cases below. Concerned fields are 1038 | # commented above each test case. 1039 | 1040 | # Missing fields: disc, disctotal 1041 | def test_write_counters_without_total(self): 1042 | mediafile = self._mediafile_fixture('full') 1043 | self.assertEqual(mediafile.track, 2) 1044 | self.assertEqual(mediafile.tracktotal, 3) 1045 | 1046 | # Missing fields: date, year 1047 | def test_delete_year(self): 1048 | mediafile = self._mediafile_fixture('full') 1049 | 1050 | self.assertIsNotNone(mediafile.original_year) 1051 | 1052 | delattr(mediafile, 'original_year') 1053 | mediafile.save() 1054 | mediafile = MediaFile(mediafile.filename) 1055 | self.assertIsNone(mediafile.original_year) 1056 | 1057 | # Missing fields: disctotal 1058 | def test_delete_packed_total(self): 1059 | mediafile = self._mediafile_fixture('full') 1060 | 1061 | delattr(mediafile, 'tracktotal') 1062 | 1063 | mediafile.save() 1064 | mediafile = MediaFile(mediafile.filename) 1065 | self.assertEqual(mediafile.track, self.full_initial_tags['track']) 1066 | 1067 | 1068 | # Check whether we have a Mutagen version with DSF support. We can 1069 | # remove this once we require a version that includes the feature. 1070 | try: 1071 | import mutagen.dsf # noqa 1072 | except ImportError: 1073 | HAVE_DSF = False 1074 | else: 1075 | HAVE_DSF = True 1076 | 1077 | 1078 | @unittest.skipIf(not HAVE_DSF, "Mutagen does not have DSF support") 1079 | class DSFTest(ReadWriteTestBase, unittest.TestCase): 1080 | extension = 'dsf' 1081 | audio_properties = { 1082 | 'length': 0.01, 1083 | 'bitrate': 11289600, 1084 | 'bitrate_mode': '', 1085 | 'encoder_info': '', 1086 | 'encoder_settings': '', 1087 | 'format': u'DSD Stream File', 1088 | 'samplerate': 5644800, 1089 | 'bitdepth': 1, 1090 | 'channels': 2, 1091 | } 1092 | 1093 | 1094 | class MediaFieldTest(unittest.TestCase): 1095 | 1096 | def test_properties_from_fields(self): 1097 | path = os.path.join(_common.RSRC, b'full.mp3') 1098 | mediafile = MediaFile(path) 1099 | for field in MediaFile.fields(): 1100 | self.assertTrue(hasattr(mediafile, field)) 1101 | 1102 | def test_properties_from_readable_fields(self): 1103 | path = os.path.join(_common.RSRC, b'full.mp3') 1104 | mediafile = MediaFile(path) 1105 | for field in MediaFile.readable_fields(): 1106 | self.assertTrue(hasattr(mediafile, field)) 1107 | 1108 | def test_known_fields(self): 1109 | fields = list(ReadWriteTestBase.tag_fields) 1110 | fields.extend( 1111 | ('encoder', 'images', 'genres', 'albumtype', 'artists', 1112 | 'albumartists', 'url', 'mb_artistids', 'mb_albumartistids', 1113 | 'albumtypes', 'catalognums', 'languages', 'artists_credit', 1114 | 'artists_sort', 'albumartists_credit', 'albumartists_sort') 1115 | ) 1116 | self.assertCountEqual(MediaFile.fields(), fields) 1117 | 1118 | def test_fields_in_readable_fields(self): 1119 | readable = MediaFile.readable_fields() 1120 | for field in MediaFile.fields(): 1121 | self.assertIn(field, readable) 1122 | 1123 | 1124 | def suite(): 1125 | return unittest.TestLoader().loadTestsFromName(__name__) 1126 | 1127 | 1128 | if __name__ == '__main__': 1129 | unittest.main(defaultTest='suite') 1130 | -------------------------------------------------------------------------------- /test/test_mediafile_edge.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # This file is part of MediaFile. 3 | # Copyright 2016, Adrian Sampson. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | 16 | """Specific, edge-case tests for the MediaFile metadata layer. 17 | """ 18 | import os 19 | import shutil 20 | import unittest 21 | import mutagen.id3 22 | 23 | from test import _common 24 | 25 | import mediafile 26 | 27 | 28 | _sc = mediafile._safe_cast 29 | 30 | 31 | class EdgeTest(unittest.TestCase): 32 | def test_emptylist(self): 33 | # Some files have an ID3 frame that has a list with no elements. 34 | # This is very hard to produce, so this is just the first 8192 35 | # bytes of a file found "in the wild". 36 | emptylist = mediafile.MediaFile( 37 | os.path.join(_common.RSRC, b'emptylist.mp3') 38 | ) 39 | genre = emptylist.genre 40 | self.assertEqual(genre, None) 41 | 42 | def test_release_time_with_space(self): 43 | # Ensures that release times delimited by spaces are ignored. 44 | # Amie Street produces such files. 45 | space_time = mediafile.MediaFile( 46 | os.path.join(_common.RSRC, b'space_time.mp3') 47 | ) 48 | self.assertEqual(space_time.year, 2009) 49 | self.assertEqual(space_time.month, 9) 50 | self.assertEqual(space_time.day, 4) 51 | 52 | def test_release_time_with_t(self): 53 | # Ensures that release times delimited by Ts are ignored. 54 | # The iTunes Store produces such files. 55 | t_time = mediafile.MediaFile( 56 | os.path.join(_common.RSRC, b't_time.m4a') 57 | ) 58 | self.assertEqual(t_time.year, 1987) 59 | self.assertEqual(t_time.month, 3) 60 | self.assertEqual(t_time.day, 31) 61 | 62 | def test_tempo_with_bpm(self): 63 | # Some files have a string like "128 BPM" in the tempo field 64 | # rather than just a number. 65 | f = mediafile.MediaFile(os.path.join(_common.RSRC, b'bpm.mp3')) 66 | self.assertEqual(f.bpm, 128) 67 | 68 | def test_discc_alternate_field(self): 69 | # Different taggers use different vorbis comments to reflect 70 | # the disc and disc count fields: ensure that the alternative 71 | # style works. 72 | f = mediafile.MediaFile(os.path.join(_common.RSRC, b'discc.ogg')) 73 | self.assertEqual(f.disc, 4) 74 | self.assertEqual(f.disctotal, 5) 75 | 76 | def test_old_ape_version_bitrate(self): 77 | media_file = os.path.join(_common.RSRC, b'oldape.ape') 78 | f = mediafile.MediaFile(media_file) 79 | self.assertEqual(f.bitrate, 0) 80 | 81 | def test_soundcheck_non_ascii(self): 82 | # Make sure we don't crash when the iTunes SoundCheck field contains 83 | # non-ASCII binary data. 84 | f = mediafile.MediaFile(os.path.join(_common.RSRC, 85 | b'soundcheck-nonascii.m4a')) 86 | self.assertEqual(f.rg_track_gain, 0.0) 87 | 88 | 89 | class InvalidValueToleranceTest(unittest.TestCase): 90 | 91 | def test_safe_cast_string_to_int(self): 92 | self.assertEqual(_sc(int, u'something'), 0) 93 | 94 | def test_safe_cast_string_to_int_with_no_numbers(self): 95 | self.assertEqual(_sc(int, u'-'), 0) 96 | 97 | def test_safe_cast_int_string_to_int(self): 98 | self.assertEqual(_sc(int, u'20'), 20) 99 | 100 | def test_safe_cast_string_to_bool(self): 101 | self.assertEqual(_sc(bool, u'whatever'), False) 102 | 103 | def test_safe_cast_intstring_to_bool(self): 104 | self.assertEqual(_sc(bool, u'5'), True) 105 | 106 | def test_safe_cast_string_to_float(self): 107 | self.assertAlmostEqual(_sc(float, u'1.234'), 1.234) 108 | 109 | def test_safe_cast_int_to_float(self): 110 | self.assertAlmostEqual(_sc(float, 2), 2.0) 111 | 112 | def test_safe_cast_string_with_cruft_to_float(self): 113 | self.assertAlmostEqual(_sc(float, u'1.234stuff'), 1.234) 114 | 115 | def test_safe_cast_negative_string_to_float(self): 116 | self.assertAlmostEqual(_sc(float, u'-1.234'), -1.234) 117 | 118 | def test_safe_cast_special_chars_to_unicode(self): 119 | us = _sc(str, 'caf\xc3\xa9') 120 | self.assertTrue(isinstance(us, str)) 121 | self.assertTrue(us.startswith(u'caf')) 122 | 123 | def test_safe_cast_float_with_no_numbers(self): 124 | v = _sc(float, u'+') 125 | self.assertEqual(v, 0.0) 126 | 127 | def test_safe_cast_float_with_dot_only(self): 128 | v = _sc(float, u'.') 129 | self.assertEqual(v, 0.0) 130 | 131 | def test_safe_cast_float_with_multiple_dots(self): 132 | v = _sc(float, u'1.0.0') 133 | self.assertEqual(v, 1.0) 134 | 135 | 136 | class SafetyTest(unittest.TestCase, _common.TempDirMixin): 137 | def setUp(self): 138 | self.create_temp_dir() 139 | 140 | def tearDown(self): 141 | self.remove_temp_dir() 142 | 143 | def _exccheck(self, fn, exc, data=''): 144 | fn = os.path.join(self.temp_dir, fn) 145 | with open(fn, 'w') as f: 146 | f.write(data) 147 | try: 148 | self.assertRaises(exc, mediafile.MediaFile, fn) 149 | finally: 150 | os.unlink(fn) # delete the temporary file 151 | 152 | def test_corrupt_mp3_raises_unreadablefileerror(self): 153 | # Make sure we catch Mutagen reading errors appropriately. 154 | self._exccheck(b'corrupt.mp3', mediafile.UnreadableFileError) 155 | 156 | def test_corrupt_mp4_raises_unreadablefileerror(self): 157 | self._exccheck(b'corrupt.m4a', mediafile.UnreadableFileError) 158 | 159 | def test_corrupt_flac_raises_unreadablefileerror(self): 160 | self._exccheck(b'corrupt.flac', mediafile.UnreadableFileError) 161 | 162 | def test_corrupt_ogg_raises_unreadablefileerror(self): 163 | self._exccheck(b'corrupt.ogg', mediafile.UnreadableFileError) 164 | 165 | def test_invalid_ogg_header_raises_unreadablefileerror(self): 166 | self._exccheck(b'corrupt.ogg', mediafile.UnreadableFileError, 167 | 'OggS\x01vorbis') 168 | 169 | def test_corrupt_monkeys_raises_unreadablefileerror(self): 170 | self._exccheck(b'corrupt.ape', mediafile.UnreadableFileError) 171 | 172 | def test_invalid_extension_raises_filetypeerror(self): 173 | self._exccheck(b'something.unknown', mediafile.FileTypeError) 174 | 175 | def test_magic_xml_raises_unreadablefileerror(self): 176 | self._exccheck(b'nothing.xml', mediafile.UnreadableFileError, 177 | "ftyp") 178 | 179 | @unittest.skipUnless(_common.HAVE_SYMLINK, u'platform lacks symlink') 180 | def test_broken_symlink(self): 181 | fn = os.path.join(_common.RSRC, b'brokenlink') 182 | os.symlink('does_not_exist', fn) 183 | try: 184 | self.assertRaises(mediafile.UnreadableFileError, 185 | mediafile.MediaFile, fn) 186 | finally: 187 | os.unlink(fn) 188 | 189 | 190 | class SideEffectsTest(unittest.TestCase): 191 | def setUp(self): 192 | self.empty = os.path.join(_common.RSRC, b'empty.mp3') 193 | 194 | def test_opening_tagless_file_leaves_untouched(self): 195 | old_mtime = os.stat(self.empty).st_mtime 196 | mediafile.MediaFile(self.empty) 197 | new_mtime = os.stat(self.empty).st_mtime 198 | self.assertEqual(old_mtime, new_mtime) 199 | 200 | 201 | class MP4EncodingTest(unittest.TestCase, _common.TempDirMixin): 202 | def setUp(self): 203 | self.create_temp_dir() 204 | src = os.path.join(_common.RSRC, b'full.m4a') 205 | self.path = os.path.join(self.temp_dir, b'test.m4a') 206 | shutil.copy(src, self.path) 207 | 208 | self.mf = mediafile.MediaFile(self.path) 209 | 210 | def tearDown(self): 211 | self.remove_temp_dir() 212 | 213 | def test_unicode_label_in_m4a(self): 214 | self.mf.label = u'foo\xe8bar' 215 | self.mf.save() 216 | new_mf = mediafile.MediaFile(self.path) 217 | self.assertEqual(new_mf.label, u'foo\xe8bar') 218 | 219 | 220 | class MP3EncodingTest(unittest.TestCase, _common.TempDirMixin): 221 | def setUp(self): 222 | self.create_temp_dir() 223 | src = os.path.join(_common.RSRC, b'full.mp3') 224 | self.path = os.path.join(self.temp_dir, b'test.mp3') 225 | shutil.copy(src, self.path) 226 | 227 | self.mf = mediafile.MediaFile(self.path) 228 | 229 | def test_comment_with_latin1_encoding(self): 230 | # Set up the test file with a Latin1-encoded COMM frame. The encoding 231 | # indices defined by MP3 are listed here: 232 | # http://id3.org/id3v2.4.0-structure 233 | self.mf.mgfile['COMM::eng'].encoding = 0 234 | 235 | # Try to store non-Latin1 text. 236 | self.mf.comments = u'\u2028' 237 | self.mf.save() 238 | 239 | 240 | class ZeroLengthMediaFile(mediafile.MediaFile): 241 | @property 242 | def length(self): 243 | return 0.0 244 | 245 | 246 | class MissingAudioDataTest(unittest.TestCase): 247 | def setUp(self): 248 | super(MissingAudioDataTest, self).setUp() 249 | path = os.path.join(_common.RSRC, b'full.mp3') 250 | self.mf = ZeroLengthMediaFile(path) 251 | 252 | def test_bitrate_with_zero_length(self): 253 | del self.mf.mgfile.info.bitrate # Not available directly. 254 | self.assertEqual(self.mf.bitrate, 0) 255 | 256 | 257 | class TypeTest(unittest.TestCase): 258 | def setUp(self): 259 | super(TypeTest, self).setUp() 260 | path = os.path.join(_common.RSRC, b'full.mp3') 261 | self.mf = mediafile.MediaFile(path) 262 | 263 | def test_year_integer_in_string(self): 264 | self.mf.year = u'2009' 265 | self.assertEqual(self.mf.year, 2009) 266 | 267 | def test_set_replaygain_gain_to_none(self): 268 | self.mf.rg_track_gain = None 269 | self.assertEqual(self.mf.rg_track_gain, 0.0) 270 | 271 | def test_set_replaygain_peak_to_none(self): 272 | self.mf.rg_track_peak = None 273 | self.assertEqual(self.mf.rg_track_peak, 0.0) 274 | 275 | def test_set_year_to_none(self): 276 | self.mf.year = None 277 | self.assertIsNone(self.mf.year) 278 | 279 | def test_set_track_to_none(self): 280 | self.mf.track = None 281 | self.assertEqual(self.mf.track, 0) 282 | 283 | def test_set_date_to_none(self): 284 | self.mf.date = None 285 | self.assertIsNone(self.mf.date) 286 | self.assertIsNone(self.mf.year) 287 | self.assertIsNone(self.mf.month) 288 | self.assertIsNone(self.mf.day) 289 | 290 | 291 | class SoundCheckTest(unittest.TestCase): 292 | def test_round_trip(self): 293 | data = mediafile._sc_encode(1.0, 1.0) 294 | gain, peak = mediafile._sc_decode(data) 295 | self.assertEqual(gain, 1.0) 296 | self.assertEqual(peak, 1.0) 297 | 298 | def test_decode_zero(self): 299 | data = b' 80000000 80000000 00000000 00000000 00000000 00000000 ' \ 300 | b'00000000 00000000 00000000 00000000' 301 | gain, peak = mediafile._sc_decode(data) 302 | self.assertEqual(gain, 0.0) 303 | self.assertEqual(peak, 0.0) 304 | 305 | def test_malformatted(self): 306 | gain, peak = mediafile._sc_decode(b'foo') 307 | self.assertEqual(gain, 0.0) 308 | self.assertEqual(peak, 0.0) 309 | 310 | def test_special_characters(self): 311 | gain, peak = mediafile._sc_decode(u'caf\xe9'.encode('utf-8')) 312 | self.assertEqual(gain, 0.0) 313 | self.assertEqual(peak, 0.0) 314 | 315 | def test_decode_handles_unicode(self): 316 | # Most of the time, we expect to decode the raw bytes. But some formats 317 | # might give us text strings, which we need to handle. 318 | gain, peak = mediafile._sc_decode(u'caf\xe9') 319 | self.assertEqual(gain, 0.0) 320 | self.assertEqual(peak, 0.0) 321 | 322 | def test_encode_excessive_gain(self): 323 | # The mimimum value of SoundCheck gain is 30.0dB. 324 | data = mediafile._sc_encode(60.0, 1.0) 325 | gain, _ = mediafile._sc_decode(data) 326 | self.assertEqual(gain, 30.0) 327 | 328 | 329 | class ID3v23Test(unittest.TestCase, _common.TempDirMixin): 330 | def _make_test(self, ext=b'mp3', id3v23=False): 331 | self.create_temp_dir() 332 | src = os.path.join(_common.RSRC, 333 | b'full.' + ext) 334 | self.path = os.path.join(self.temp_dir, 335 | b'test.' + ext) 336 | shutil.copy(src, self.path) 337 | return mediafile.MediaFile(self.path, id3v23=id3v23) 338 | 339 | def _delete_test(self): 340 | self.remove_temp_dir() 341 | 342 | def test_v24_year_tag(self): 343 | mf = self._make_test(id3v23=False) 344 | try: 345 | mf.year = 2013 346 | mf.save() 347 | frame = mf.mgfile['TDRC'] 348 | self.assertTrue('2013' in str(frame)) 349 | self.assertTrue('TYER' not in mf.mgfile) 350 | finally: 351 | self._delete_test() 352 | 353 | def test_v23_year_tag(self): 354 | mf = self._make_test(id3v23=True) 355 | try: 356 | mf.year = 2013 357 | mf.save() 358 | frame = mf.mgfile['TYER'] 359 | self.assertTrue('2013' in str(frame)) 360 | self.assertTrue('TDRC' not in mf.mgfile) 361 | finally: 362 | self._delete_test() 363 | 364 | def test_v23_on_non_mp3_is_noop(self): 365 | mf = self._make_test(b'm4a', id3v23=True) 366 | try: 367 | mf.year = 2013 368 | mf.save() 369 | finally: 370 | self._delete_test() 371 | 372 | def test_image_encoding(self): 373 | """For compatibility with OS X/iTunes. 374 | 375 | See https://github.com/beetbox/beets/issues/899#issuecomment-62437773 376 | """ 377 | 378 | for v23 in [True, False]: 379 | mf = self._make_test(id3v23=v23) 380 | try: 381 | mf.images = [ 382 | mediafile.Image(b'data', desc=u""), 383 | mediafile.Image(b'data', desc=u"foo"), 384 | mediafile.Image(b'data', desc=u"\u0185"), 385 | ] 386 | mf.save() 387 | apic_frames = mf.mgfile.tags.getall('APIC') 388 | encodings = dict([(f.desc, f.encoding) for f in apic_frames]) 389 | self.assertEqual(encodings, { 390 | u"": mutagen.id3.Encoding.LATIN1, 391 | u"foo": mutagen.id3.Encoding.LATIN1, 392 | u"\u0185": mutagen.id3.Encoding.UTF16, 393 | }) 394 | finally: 395 | self._delete_test() 396 | 397 | 398 | class ReadOnlyTagTest(unittest.TestCase, _common.TempDirMixin): 399 | def setUp(self): 400 | self.create_temp_dir() 401 | self.key = "READ_ONLY_TEST" 402 | self.field = mediafile.MediaField( 403 | mediafile.MP3StorageStyle(self.key, read_only=True), 404 | mediafile.MP4StorageStyle( 405 | "----:com.apple.iTunes:" + self.key, read_only=True), 406 | mediafile.StorageStyle(self.key, read_only=True), 407 | mediafile.ASFStorageStyle(self.key, read_only=True), 408 | ) 409 | 410 | if "read_only_test" not in mediafile.MediaFile.fields(): 411 | mediafile.MediaFile.add_field("read_only_test", self.field) 412 | 413 | def test_read(self): 414 | path = os.path.join(_common.RSRC, b'empty.flac') 415 | mf = mediafile.MediaFile(path) 416 | mf.mgfile.tags[self.key] = "don't" 417 | self.assertEqual("don't", mf.read_only_test) 418 | 419 | def test_write(self): 420 | src = os.path.join(_common.RSRC, b'empty.flac') 421 | path = os.path.join(self.temp_dir, b'test.flac') 422 | shutil.copy(src, path) 423 | mf = mediafile.MediaFile(path) 424 | mf.read_only_field = "something terrible" 425 | mf.save() 426 | self.assertNotIn(self.key, mf.mgfile.tags) 427 | 428 | def tearDown(self): 429 | self.remove_temp_dir() 430 | try: 431 | del mediafile.MediaFile.read_only_test 432 | except AttributeError: 433 | pass 434 | 435 | 436 | def suite(): 437 | return unittest.TestLoader().loadTestsFromName(__name__) 438 | 439 | 440 | if __name__ == '__main__': 441 | unittest.main(defaultTest='suite') 442 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py310-test, py310-flake8 8 | isolated_build = True 9 | 10 | [tox:.package] 11 | basepython = python3 12 | 13 | [_test] 14 | deps = 15 | pytest 16 | 17 | [_flake8] 18 | deps = 19 | flake8 20 | flake8-future-import 21 | pep8-naming 22 | files = mediafile.py test setup.py 23 | 24 | [testenv] 25 | deps = 26 | {test,cov}: {[_test]deps} 27 | py{36,37,38,39,310,311}-flake8: {[_flake8]deps} 28 | commands = 29 | py3{6,7,8,9,10,11}-test: python -bb -m pytest {posargs} 30 | py36-flake8: flake8 --min-version 3.6 {posargs} {[_flake8]files} 31 | py37-flake8: flake8 --min-version 3.7 {posargs} {[_flake8]files} 32 | py38-flake8: flake8 --min-version 3.8 {posargs} {[_flake8]files} 33 | py39-flake8: flake8 --min-version 3.9 {posargs} {[_flake8]files} 34 | py310-flake8: flake8 --min-version 3.10 {posargs} {[_flake8]files} 35 | py311-flake8: flake8 --min-version 3.11 {posargs} {[_flake8]files} 36 | --------------------------------------------------------------------------------