Codebase list eyed3 / d6d2882
import upstream version 0.9.6 Gaetano Guerriero authored 2 years ago Gaetano Guerriero committed 1 year, 2 months ago
171 changed file(s) with 21333 addition(s) and 18667 deletion(s). Raw diff Collapse all Expand all
1717 project_short_description: "Python audio data toolkit (ID3 and MP3)"
1818 project_slug: "eyed3"
1919 py26: "no"
20 py27: "yes"
20 py27: "no"
2121 py33: "no"
22 py34: "yes"
23 py35: "yes"
22 py34: "no"
23 py35: "no"
2424 py36: "yes"
2525 py37: "yes"
26 py38: "yes"
27 py39: "yes"
2628 py_module: "eyed3"
2729 pyapp_type: "normal"
2830 pypi_repo_name: "eyeD3"
2931 pypi_username: "nicfit"
30 pypy: "yes"
31 pypy3: "yes"
32 pypy: "no"
33 pypy3: "no"
3234 release_date: "today"
3335 requirements_yaml: "yes"
34 src_dir: "./src"
36 src_dir: "."
3537 use_bitbucket: "no"
3638 use_github: "yes"
3739 use_make: "yes"
0 name: Main eyeD3 workflow
1
2 on:
3 push:
4 branches:
5 - master
6 pull_request:
7 branches:
8 - master
9
10 jobs:
11 build:
12
13 runs-on: ubuntu-latest
14 strategy:
15 matrix:
16 os: [ Ubuntu, MacOS, Windows ]
17 python-version: [ "3.9", "3.8", "3.7", "3.6"]
18
19 steps:
20 - uses: actions/checkout@v2
21
22 # Python
23 - name: Set up Python ${{ matrix.python-version }}
24 uses: actions/setup-python@v2
25 with:
26 python-version: ${{ matrix.python-version }}
27
28 # Test data
29 - name: Test data cache
30 id: cache-test-data
31 uses: actions/cache@v2
32 with:
33 path: ~/eyeD3-test-data
34 key: ${{ runner.os }}-test-data
35
36 - name: Install test data
37 # If the directory exists it is not re-downloaded, but symlinks are
38 # restored, and that's needed after a cache is restored.
39 run: make TEST_DATA_DIR=~ test-data
40
41 - name: Install dependencies
42 run: |
43 python -m pip install --upgrade pip
44 pip install .[test] .[display-plugin]
45
46 # Run tests
47 - name: Tests
48 run: |
49 make lint
50 make test
51 make check-manifest
52
53 # XXX: What to do with coverage? Was using coverall, meh. Github pages?
54 # XXX: Removed pypy3 because of some coverage module not found error...
00 test*.id3
1 src/test/data
2 src/test/eyeD3-test-data*
1 tests/data
2 tests/eyeD3-test-data*
33 .idea/
44 .cookiecutter.md5
55 _misc/
+0
-28
.travis.yml less more
0 language: python
1
2 python:
3 - "3.6"
4 - "2.7"
5 - "3.5"
6 - "3.4"
7 - "3.7-dev"
8 - "pypy3"
9 - "pypy"
10
11 cache:
12 pip: true
13 directories:
14 - $HOME/eyeD3-test-data
15
16 install:
17 - pip install -U -r requirements.txt
18 - pip install -U -r requirements/test.txt
19 - pip install coveralls
20 - pip install -e .
21 - make TEST_DATA_DIR=$HOME test-data
22
23 # command to run tests
24 script: make coverage
25
26 after_success:
27 - coveralls
2626 * Hans Petter Jansson <hpj@copyleft.no>
2727 * Sebastian Patschorke <sludgefeast@users.noreply.github.com>
2828 * Bouke Versteegh <info@boukeversteegh.nl>
29 * gaetano-guerriero <x.guerriero@tin.it>
2930 * mafro <github@mafro.net>
30 * Gaetano Guerriero <x.guerriero@tin.it>
31 * Grun Seid <grunseid@gmail.com>
32 * pyup-bot <github-bot@pyup.io>
31 * Gabriel Diego Teixeira <gabrieldiegoteixeira@gmail.com>
3332 * Chris Newton <redshodan@gmail.com>
34 * deoren@users.noreply.github.com
35 * chamatht@gmail.com
36 * Mic92@users.noreply.github.com
37 * gabrieldiegoteixeira@gmail.com
38 * guillaume.web@gmail.com
33 * Thomas Klausner <tk@giga.or.at>
34 * Tim Gates <tim.gates@iress.com>
35 * chamatht <chamatht@gmail.com>
36 * grun <grunseid@gmail.com>
37 * guiweber <guillaume.web@gmail.com>
38 * zhu <zhumumu@gmail.com>
11 ===============
22
33 .. :changelog:
4
5 v0.9.6 (2020-12-28) : True Blue
6 ----------------------------------
7
8 New
9 ~~~
10 - Id3.Tag(version=) keyword argument.
11 - Expose TextFrame ctor kwargs to Apple frames. fixes #407
12 - Added --about CLI argument for extra version/program info.
13
14 Fix
15 ~~~
16 - Preserve linked file info in Tag.clear(). fixes #442
17 - Handle v1 .id3/.tag files.
18 - Improved `art` plugin behavior when missing dependencies.
19 - [art plugin] Improved error for missing dependencies.
20 - TYER conversion (and restored non v2.2 breakage, for now)
21 - ID3 v2.2, date getters return values again.
22 - Passed filtered files list or handleDirectory, and skip non-existant symlinks
23 - Fixed installation supported Python text. fixes #405
24 - Implement v1.0/v1.1 tag conversion rules.
25
26 Other
27 ~~~~~
28 - Poetry build system (#500)
29
30
31 v0.9.5 (2020-03-28) : I Knew Her, She Knew Me
32 ----------------------------------------------
33
34 Fix
35 ~~~
36 - `eyeD3 --genre ""` to clear genre frame restored.
37 - Genre id->name mapping for non-standard genres and custom maps.
38
39
40 v0.9.4 (2020-03-21) : The Devil Made Me Do It
41 -----------------------------------------------
42
43 New
44 ~~~
45 - Relative volume adjustments (RVA2 and RVAD) (#399)
46 - Tag properties copyright and encoded_by
47 - Support GRP1 (Apple) frames.
48
49 Changes
50 ~~~~~~~
51 - Genre serialization not ID3 v2.3 format by default, and other genre cleanup (#402)
52 fixes #382
53
54 Fix
55 ~~~
56 - Date correctness between ID3 versions (#396)
57 - PopularityFrame email encoding bug.
58 - Plugins more featured in docs
59
60
61 v0.9.3 (2020-03-01) : It Dawned On Me
62 --------------------------------------
63
64 Changes
65 ~~~~~~~
66 - Track/disc numbers can be set with integer strings.
67 - Disc number getter and setter hooks
68
69 v0.9.2 (2020-02-10) : Into The Future
70 --------------------------------------
71
72 Fix
73 ~~~
74 - Removed setting of PYTHONIOENCODING, it breaks MacOS.
75 Fixes #388
76
77
78 v0.9.1 (2020-02-09) : Dead and Gone
79 ------------------------------------
80
81 Fix
82 ~~~
83 - Docs and pep8.
84
85 Other
86 ~~~~~
87 - Experiment with setting utf-8 writer for stdout and stderr.
88
89
90 v0.9 (2020-01-01) : Favorite Thing
91 -----------------------------------
92
93 Major Changes
94 ~~~~~~~~~~~~~
95 - Dropped support for Python versions 2.7, 3.4, and 3.5.
96 - File scanning is no longer recursive by default; use `-r / --recursive`.
97 - Default log-level changed from WARNING to ERROR.
98
99 New
100 ~~~
101 - Mime-type detection uses filetype.py (libmagic no longer required)
102 - setFileScannerOpts function accepts `default_recursive` option.
103 - A new `jsontag` plugin for converting tags to JSON.
104 - A new `extract` plugin for extracting tags from media.
105 - A new `yamltag` plugin for converting tags to YAML.
106 - A new `mimetypes` plugin for listing file mime-types / measuring performance
107 - Original artist support (TOPE frame, --orig-artist)
108 - Added support for Python 3.8 and pypy3.
109
110 Changes
111 ~~~~~~~
112 - Log warning when ID3 v1.x text truncation occurs. Fixes #299.
113 - Accept (invalid) date strings for the form YYYYMMDD. Fixes #379
114 - Adjust replay gain correctly for lame >= 3.95.1 headers.
115 - Added -r/--recursive argument. eyeD3 is no longer recursive by default (#378)
116 - Regenerated grako parser.
117 - New ValueError for _setNum when unknown type/values are passed.
118 - Moved src/* to top-level repo directory.
119
120 Fix
121 ~~~
122 - PRIV data type checking, fixed examples, etc.
123 - Use tox for `make test`
124 - ID3 v2.3 to v2.4 date conversion.
125 - Match mp3 mime-types against all possible mime-types.
126 Specifically, application/x-font-gdos. Fixes #338
127 - Fix simple typo: titel -> title. <tim.gates@iress.com>
128 - Fixed: load the right config file in arguments. <zhumumu@gmail.com>
129 - Fix issue tracker link. Fixes #333.
130 - Fixed art plugin when `pylast` is not installed.
131 - Unbound variable for track num/total. Fixes #327.
132 - Fixed MP3 header search to not false match on BOMs.
133 - Honor APIC text encoding when description is "". #200.
134 - Fixed bug with improper types when re-rendering unique file ID. (#324)
135 <gabrieldiegoteixeira@gmail.com>
136 - UFID fixes, update (#325) <gabrieldiegoteixeira@gmail.com>
137
138 Other
139 ~~~~~
140 - Deprecation of eyed3.utils.guessMimeType
141 - Removed ipdb from dev requirements
142
143
144 v0.8.12 (2019-12-27)
145 ---------------------
146
147 Changes
148 ~~~~~~~
149 - Accept (invalid) date strings for the form YYYYMMDD. Fixes #379
150
151 Other
152 ~~~~~
153 - Test with py38
154
4155
5156 v0.8.11 (2019-11-09)
6157 ------------------------
91242 - Classic plugin: --write-image will work with --quiet. Fixes #188
92243 - Multiple fixes for display plugin %images% replacements. Fixes #176
93244 - Allow --remove-* options to work when there are no tags. Fixes #183
245
94246
95247 v0.8.5 (2018-03-27) : 30$ Bag
96248 -----------------------------
256408 New Features:
257409 * Repo and issue tracker moved to GitHub: https://github.com/nicfit/eyeD3
258410 Bug Fixes:
259 * [:bbissue:`78`] - 'NoneType' object has no attribute 'year'
260 * [:bbissue:`108`] - Multiple date related fixes.
261 * [:bbissue:`110`] - Allow superfluous --no-tagging-ttme-frame option for
262 backward compatibility.
263 * [:bbissue:`111`] - The --version option now prints a short, version-only,
264 message.
265 * [:bbissue:`116`] - Allow --year option for backward compatibility.
266 Converts to --release-year.
267 * [:bbissue:`117`] - Fixes for --user-text-frame with multiple colons and
268 similar fixes.
269 * [:bbissue:`125`] - ID3 v1.1 encoding fixes.
411 * 'NoneType' object has no attribute 'year'
412 * Multiple date related fixes.
413 * Allow superfluous --no-tagging-ttme-frame option for backward
414 compatibility.
415 * The --version option now prints a short, version-only, message.
416 * Allow --year option for backward compatibility.
417 Converts to --release-year.
418 * Fixes for --user-text-frame with multiple colons and similar fixes.
419 * ID3 v1.1 encoding fixes.
270420
271421 .. _release-0.7.10:
272422
273423 0.7.10 - 12.10.2016 (Hollow)
274424 ---------------------------------
275425 Bug Fixes:
276 * [:bbissue:`97`] - Missing import
277 * [:bbissue:`105`] - Fix the rendering of default constructed id3.TagHeader
426 * Missing import
427 * Fix the rendering of default constructed id3.TagHeader
278428 * Fixed Tag.frameiter
279429
280430
291441
292442 Bug Fixes:
293443 * Fixed missing 'math' import.
294 * [:bbissue:`81`] - Replaced invalid Unicode.
295 * [:bbissue:`91`] - Disabled ANSI codes on Windows
296 * [:bbissue:`92`] - More friendly logging (as a module)
444 * Replaced invalid Unicode.
445 * Disabled ANSI codes on Windows
446 * More friendly logging (as a module)
297447
298448
299449 0.7.8 - 05.25.2015 (Chartsengrafs)
316466 * Removed python-magic dependency, it not longer offers any value (AFAICT).
317467
318468 Bug Fixes:
319 * [:bbissue:`50`] Crashing on --remove-frame PRIV
320 * [:bbissue:`75`] Parse lameinfo even if crc16 is not correct
321 * [:bbissue:`77`] Typo in docs/installation.rst
322 * [:bbissue:`79`] Request to update the GPL License in source files
469 * ashing on --remove-frame PRIV
470 * rse lameinfo even if crc16 is not correct
471 * po in docs/installation.rst
472 * Request to update the GPL License in source files
323473 * Fixes to eyed3.id3.tag.TagTemplate when expanding empty dates.
324474 * eyed3.plugins.Plugin.handleDone return code is not actually used.
325475 * [classic plugin] -- Fixed ID3v1 --verbose bug.
329479 0.7.5 - 09.06.2014 (Nerve Endings)
330480 ---------------------------------------
331481 New Features:
332 * [:bbissue:`49`] Support for album artist info.
482 * Support for album artist info.
333483 By Cyril Roelandt <tipecaml@gmail.com>
334484 * [fixup plugin] -- Custom patterns for file/directory renaming.
335485 By Matt Black <https://bitbucket.org/mafrosis>
337487 * API and TXXX frame mappings for album type (e.g. various, album, demo,
338488 etc.) and artist origin (i.e. where the artist/band is from).
339489 * Lower cases ANSI codes and other console fixes.
340 * [:bbissue:`9`] Added the ability to set (remove) tag padding. See
490 * Added the ability to set (remove) tag padding. See
341491 `eyeD3 --max-padding` option. By Hans Meine.
342492 * Tag class contains read_only attribute than can be set to ``True`` to
343493 disable the ``save`` method.
347497
348498 Bug Fixes:
349499 * Build from pypi when ``paver`` is not available.
350 * [:bbissue:`46`] Disable ANSI color codes when TERM == "dumb"
351 * [:bbissue:`47`] Locking around libmagic.
352 * [:bbissue:`54`] Work around for zero-padded utf16 strings.
353 * [:bbissue:`65`] Safer tempfile usage.
354 * [:bbissue:`65`] Better default v1.x genre.
500 * Disable ANSI color codes when TERM == "dumb"
501 * Locking around libmagic.
502 * Work around for zero-padded utf16 strings.
503 * Safer tempfile usage.
504 * Better default v1.x genre.
355505
356506
357507 0.7.3 - 07.12.2013 (Harder They Fall)
367517 * Python 2.6 is now supported if ``argparse`` and ``ordereddict``
368518 dependencies are installed. Thanks to Bouke Versteegh for much of this.
369519 * More support and bug fixes for `ID3 chapters and table-of-contents`_.
370 * [:bbissue:`28`] [classic plugin] ``-d/-D`` options for setting tag
520 * [classic plugin] ``-d/-D`` options for setting tag
371521 disc number and disc set total.
372522 * Frames are always written in sorted order, so if a tag is rewritten
373523 with no values changed the file's checksum remains the same.
390540 * Fixes for Unicode paths.
391541 * License clarification in pkg-info.
392542 * The ``-b`` setup.py argument is now properly supported.
393 * [:bbissue:`10`] Magic module `hasattr` fix.
394 * [:bbissue:`12`] More robust handling of bogus play count values.
395 * [:bbissue:`13`] More robust handling of bogus date values.
396 * [:bbissue:`18`] Proper unicode handling of APIC descriptions.
397 * [:bbissue:`19`] Proper use of argparse.ArgumentTypeError
398 * [:bbissue:`26`] Allow TCMP frames when parsing.
399 * [:bbissue:`30`] Accept more invalid frame types (iTunes)
400 * [:bbissue:`31`] Documentation fixes.
401 * [:bbissue:`31`] Fix for bash completion script.
402 * [:bbissue:`32`] Fix for certain mp3 bit rate and play time computations.
543 * Magic module `hasattr` fix.
544 * More robust handling of bogus play count values.
545 * More robust handling of bogus date values.
546 * Proper unicode handling of APIC descriptions.
547 * Proper use of argparse.ArgumentTypeError
548 * Allow TCMP frames when parsing.
549 * Accept more invalid frame types (iTunes)
550 * Documentation fixes.
551 * Fix for bash completion script.
552 * Fix for certain mp3 bit rate and play time computations.
403553
404554 .. _ID3 chapters and table-of-contents: http://www.id3.org/id3v2-chapters-1.0
405555
406556 0.7.1 - 11.25.2012 (Feel It)
407557 ------------------------------
408558 New Features:
409 * [:bbissue:`5`] Support for `ID3 chapters and table-of-contents`_ frames
559 * Support for `ID3 chapters and table-of-contents`_ frames
410560 (i.e.CHAP and CTOC).
411561 * A new plugin for toggling the state of iTunes podcast
412562 files. In other words, PCST and WFED support. Additionally, the Apple
423573 Bug fixes:
424574 * Fixed a very old bug where certain values of 0 would be written to
425575 the tag as '' instead of '\x00'.
426 * [:bbissue:`6`] Don't crash on malformed (invalid) UFID frames.
576 * Don't crash on malformed (invalid) UFID frames.
427577 * Handle timestamps that are terminated with 'Z' to show the time is UTC.
428578 * Conversions between ID3 v2.3 and v2.4 date frames fixed.
429579 * [classic plugin] Use the system text encoding (locale) when converting
88 graft docs
99 prune docs/_build
1010
11 recursive-include src/test *.py
11 recursive-include test *.py
1212
1313 exclude .cookiecutter.yml
1414 exclude .gitchangelog.rc
1616 global-exclude .editorconfig
1717 global-exclude *.py[co]
1818
19 include requirements.txt
20 recursive-include requirements *.txt *.yml
19 recursive-include requirements *.txt
2120
22 recursive-include src/eyed3 *.py
23 include src/eyed3/plugins/DisplayPattern.ebnf
21 recursive-include eyed3 *.py
22 include eyed3/plugins/DisplayPattern.ebnf
2423 recursive-include examples *
2524
2625 exclude fabfile.py
2726 exclude mkenv.sh
2827 exclude pavement.py
2928 prune etc
29
+237
-185
Makefile less more
0 .PHONY: help build test clean dist install coverage pre-release release \
1 docs clean-docs lint tags coverage-view changelog \
2 clean-pyc clean-build clean-patch clean-local clean-test-data \
3 test-all test-data build-release freeze-release tag-release \
4 pypi-release web-release github-release cookiecutter requirements
5 SRC_DIRS = ./src/eyed3
6 TEST_DIR = ./src/test
7 NAME ?= Travis Shirk
8 EMAIL ?= travis@pobox.com
9 GITHUB_USER ?= nicfit
10 GITHUB_REPO ?= eyeD3
11 PYPI_REPO = pypitest
0 ## User settings
1 PYTEST_ARGS ?= ./tests
2 PYPI_REPO ?= pypi
3 BUMP ?= prerelease
4 TEST_DATA_DIR ?= $(shell pwd)/tests
5 CC_MERGE ?= yes
6 CC_OPTS ?= --no-input
7
8 ifdef TERM
9 BOLD_COLOR = $(shell tput bold)
10 HELP_COLOR = $(shell tput setaf 6)
11 HEADER_COLOR = $(BOLD_COLOR)$(shell tput setaf 2)
12 NO_COLOR = $(shell tput sgr0)
13 endif
14
15 ## Defaults
16
17 help: ## List all commands
18 @printf "\n$(BOLD_COLOR)***** eyeD3 Makefile help *****$(NO_COLOR)\n"
19 @# This code borrowed from https://github.com/jedie/poetry-publish/blob/master/Makefile
20 @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z0-9 -]+:.*?## / {printf "$(HELP_COLOR)%-20s$(NO_COLOR) %s\n", $$1, $$2}' $(MAKEFILE_LIST)
21 @echo ""
22 @printf "$(BOLD_COLOR)Options:$(NO_COLOR)\n"
23 @printf "$(HELP_COLOR)%-20s$(NO_COLOR) %s\n" PYTEST_ARGS "If defined PDB options are added when 'pytest' is invoked"
24 @printf "$(HELP_COLOR)%-20s$(NO_COLOR) %s\n" PYPI_REPO "The package index to publish, 'pypi' by default."
25 @printf "$(HELP_COLOR)%-20s$(NO_COLOR) %s\n" BROWSER "HTML viewer used by docs-view/coverage-view"
26 @printf "$(HELP_COLOR)%-20s$(NO_COLOR) %s\n" CC_MERGE "Set to no to disable cookiecutter merging."
27 @printf "$(HELP_COLOR)%-20s$(NO_COLOR) %s\n" CC_OPTS "OVerrided the default options (--no-input) with your own."
28 @echo ""
29
30
31 all: build test ## Build and test
32
33
34 ## Config
1235 PROJECT_NAME = $(shell python setup.py --name 2> /dev/null)
1336 VERSION = $(shell python setup.py --version 2> /dev/null)
14 RELEASE_NAME = $(shell python setup.py --release-name 2> /dev/null)
37 SRC_DIRS = ./eyed3
38 ABOUT_PY = eyed3/__regarding__.py
39 GITHUB_USER = nicfit
40 GITHUB_REPO = eyeD3
41 RELEASE_NAME = $(shell sed -n "s/^release_name = \"\(.*\)\"/\1/p" pyproject.toml)
42 RELEASE_TAG = v$(VERSION)
1543 CHANGELOG = HISTORY.rst
1644 CHANGELOG_HEADER = v${VERSION} ($(shell date --iso-8601))$(if ${RELEASE_NAME}, : ${RELEASE_NAME},)
1745 TEST_DATA = eyeD3-test-data
1846 TEST_DATA_FILE = ${TEST_DATA}.tgz
19 TEST_DATA_DIR ?= $(shell pwd)/src/test
20
21 help:
22 @echo "test - run tests quickly with the default Python"
23 @echo "docs - generate Sphinx HTML documentation, including API docs"
24 @echo "clean - remove all build, test, coverage and Python artifacts"
25 @echo "clean-build - remove build artifacts"
26 @echo "clean-pyc - remove Python file artifacts"
27 @echo "clean-test - remove test and coverage artifacts"
28 @echo "clean-docs - remove autogenerating doc artifacts"
29 @echo "clean-patch - remove patch artifacts (.rej, .orig)"
30 @echo "build - byte-compile python files and generate other build objects"
31 @echo "lint - check style with flake8"
32 @echo "test - run tests quickly with the default Python"
33 @echo "test-all - run tests on every Python version with tox"
34 @echo "coverage - check code coverage quickly with the default Python"
35 @echo "test-all - run tests on various Python versions with tox"
36 @echo "release - package and upload a release"
37 @echo " PYPI_REPO=[pypitest]|pypi"
38 @echo "pre-release - check repo and show version, generate changelog, etc."
39 @echo "dist - package"
40 @echo "install - install the package to the active Python's site-packages"
41 @echo "build - build package source files"
42 @echo ""
43 @echo "Options:"
44 @echo "TEST_PDB - If defined PDB options are added when 'pytest' is invoked"
45 @echo "BROWSER - HTML viewer used by docs-view/coverage-view"
46 @echo "CC_MERGE - Set to no to disable cookiecutter merging."
47 @echo "CC_OPTS - OVerrided the default options (--no-input) with your own."
48
49 build:
50 python setup.py build
51
52 clean: clean-local clean-build clean-pyc clean-test clean-patch clean-docs
53
54 clean-local:
55 -rm tags
56 -rm all.id3 example.id3
57
58 clean-build:
59 rm -fr build/
60 rm -fr dist/
47
48
49 ## Build
50 .PHONY: build
51 build: $(ABOUT_PY) setup.py ## Build the project
52
53 setup.py: pyproject.toml poetry.lock
54 dephell deps convert --from pyproject.toml --to setup.py
55
56 $(ABOUT_PY): pyproject.toml
57 regarding -o $@
58
59 # Note, this clean rule is NOT to be called as part of `clean`
60 clean-autogen:
61 -rm $(ABOUT_PY) setup.py
62
63
64 ## Clean
65 clean: clean-test clean-dist clean-local clean-docs # Clean the project
66 rm -rf ./build
67 rm -rf eye{d,D}3.egg-info
6168 rm -fr .eggs/
62 find . -name '*.egg-info' -exec rm -fr {} +
6369 find . -name '*.egg' -exec rm -f {} +
64
65 clean-pyc:
6670 find . -name '*.pyc' -exec rm -f {} +
6771 find . -name '*.pyo' -exec rm -f {} +
6872 find . -name '*~' -exec rm -f {} +
6973 find . -name '__pycache__' -exec rm -fr {} +
7074
75 clean-local:
76 -rm tags
77 -rm all.id3 example.id3
78 find . -name '*.rej' -exec rm -f '{}' \;
79 find . -name '*.orig' -exec rm -f '{}' \;
80 find . -type f -name '*~' | xargs -r rm
81
82
83 ## Test
84 .PHONY: test
85 test: ## Run tests with default python
86 pytest $(PYTEST_ARGS)
87
88 test-all: ## Run tests with all supported versions of Python
89 tox --parallel=all $(PYTEST_ARGS)
90
91 test-data:
92 # Move these to eyed3.nicfit.net
93 test -f ${TEST_DATA_DIR}/${TEST_DATA_FILE} || \
94 wget --quiet "http://eyed3.nicfit.net/releases/${TEST_DATA_FILE}" \
95 -O ${TEST_DATA_DIR}/${TEST_DATA_FILE}
96 tar xzf ${TEST_DATA_DIR}/${TEST_DATA_FILE} -C ${TEST_DATA_DIR}
97 cd tests && rm -f ./data && ln -s ${TEST_DATA_DIR}/${TEST_DATA} ./data
98
7199 clean-test:
72100 rm -fr .tox/
73101 rm -f .coverage
74102 find . -name '.pytest_cache' -type d -exec rm -rf {} +
75
76 clean-patch:
77 find . -name '*.rej' -exec rm -f '{}' \;
78 find . -name '*.orig' -exec rm -f '{}' \;
79
80 lint:
81 flake8 $(SRC_DIRS)
82
83 _PYTEST_OPTS=
84 ifdef TEST_PDB
85 _PDB_OPTS=--pdb -s
86 endif
87 test:
88 pytest $(_PYTEST_OPTS) $(_PDB_OPTS) ${TEST_DIR}
89
90 test-all:
91 tox
92
93 test-most:
94 tox -e py27,py36
95
96 test-data:
97 # Move these to eyed3.nicfit.net
98 test -f ${TEST_DATA_DIR}/${TEST_DATA_FILE} || \
99 wget --quiet "http://nicfit.net/files/${TEST_DATA_FILE}" \
100 -O ${TEST_DATA_DIR}/${TEST_DATA_FILE}
101 tar xzf ${TEST_DATA_DIR}/${TEST_DATA_FILE} -C ${TEST_DATA_DIR}
102 cd src/test && rm -f ./data && ln -s ${TEST_DATA_DIR}/${TEST_DATA} ./data
103 -rm .testmondata
104 -rm examples/*.id3
103105
104106 clean-test-data:
105 -rm src/test/data
106 -rm src/test/${TEST_DATA_FILE}
107 -rm tests/data
108 -rm tests/${TEST_DATA_FILE}
107109
108110 pkg-test-data:
109 tar czf ./build/${TEST_DATA_FILE} -C ./src/test ./eyeD3-test-data
111 test -d build || mkdir build
112 tar czf ./build/${TEST_DATA_FILE} -h --exclude-vcs -C ./tests \
113 ./eyeD3-test-data
114
115 publish-test-data: pkg-test-data
116 scp ./build/${TEST_DATA_FILE} eyed3.nicfit.net:./data1/eyeD3-releases/
110117
111118 coverage:
112 pytest --cov=./src/eyed3 \
113 --cov-report=html --cov-report term \
114 --cov-config=setup.cfg ${TEST_DIR}
115
116 coverage-view: coverage
117 ${BROWSER} build/tests/coverage/index.html;\
118
119 docs:
119 coverage run --source $(SRC_DIRS) -m pytest $(PYTEST_ARGS)
120 coverage report
121 coverage html
122
123 coverage-view:
124 @if [ ! -f build/tests/coverage/index.html ]; then \
125 ${MAKE} coverage; \
126 fi
127 @${BROWSER} build/tests/coverage/index.html
128
129
130 ## Documentation
131 .PHONY: docs
132 docs: ## Generate project documentation with Sphinx
120133 rm -f docs/eyed3.rst
121134 rm -f docs/modules.rst
122 sphinx-apidoc -H $(PROJECT_NAME) -V $(VERSION) -o docs/ ${SRC_DIRS}
135 sphinx-apidoc --force -H "$(shell echo $(PROJECT_NAME) | tr '[:upper:]' '[:lower:]') module" -V $(VERSION) -o docs/ ${SRC_DIRS}
123136 $(MAKE) -C docs clean
124137 etc/mycog.py
125138 $(MAKE) -C docs html
137150 $(MAKE) -C docs clean
138151 -rm README.html
139152
140 pre-release: lint test changelog requirements
153
154 lint: ## Check coding style
155 flake8 $(SRC_DIRS)
156
157
158 ## Distribute
159 .PHONY: dist
160 dist: clean sdist bdist docs-dist ## Create source and binary distribution files
161 @# The cd dist keeps the dist/ prefix out of the md5sum files
162 @cd dist && \
163 for f in $$(ls); do \
164 md5sum $${f} > $${f}.md5; \
165 done
166 @ls dist
167
168 sdist: build
169 poetry build --format sdist
170
171 bdist: build
172 poetry build --format wheel
173
174 clean-dist: ## Clean distribution artifacts (included in `clean`)
175 rm -rf dist
176
177 check-manifest:
178 check-manifest
179
180 _check-version-tag:
181 @if git tag -l | grep -E '^$(shell echo ${RELEASE_TAG} | sed 's|\.|.|g')$$' > /dev/null; then \
182 echo "Version tag '${RELEASE_TAG}' already exists!"; \
183 false; \
184 fi
185
186 authors:
187 @git authors --list | while read auth ; do \
188 email=`echo "$$auth" | awk 'match($$0, /.*<(.*)>/, m) {print m[1]}'`;\
189 echo "Checking $$email...";\
190 if echo "$$email" | grep -v 'users.noreply.github.com'\
191 | grep -v 'github-bot@pyup.io' \
192 > /dev/null ; then \
193 grep "$$email" AUTHORS.rst > /dev/null || echo " * $$auth" >> AUTHORS.rst;\
194 fi;\
195 done
196
197
198 ## Install
199 install: ## Install project and dependencies
200 poetry install --no-dev
201
202 install-dev: ## Install project, dependencies, and developer tools
203 poetry install -E test
204
205
206 ## Release
207 release: pre-release clean install-dev \
208 _freeze-release dist _tag-release \
209 upload-release
210
211 pre-release: clean-autogen build _check-version-tag \
212 check-manifest authors changelog test-all
141213 @# Keep docs off pre-release target list, else it is pruned during 'release' but
142214 @# after a clean.
143215 @$(MAKE) docs
144 @echo "VERSION: $(VERSION)"
145 $(eval RELEASE_TAG = v${VERSION})
146 @echo "RELEASE_TAG: $(RELEASE_TAG)"
147 @echo "RELEASE_NAME: $(RELEASE_NAME)"
148 check-manifest
149 @if git tag -l | grep -E '^$(shell echo $${RELEASE_TAG} | sed 's|\.|.|g')$$' > /dev/null; then \
150 echo "Version tag '${RELEASE_TAG}' already exists!"; \
151 false; \
152 fi
153 IFS=$$'\n';\
154 for auth in `git authors --list | sed 's/.* <\(.*\)>/\1/'`; do \
155 echo "Checking $$auth...";\
156 grep "$$auth" AUTHORS.rst || echo "* $$auth" >> AUTHORS.rst;\
157 done
158216 @test -n "${GITHUB_USER}" || (echo "GITHUB_USER not set, needed for github" && false)
159217 @test -n "${GITHUB_TOKEN}" || (echo "GITHUB_TOKEN not set, needed for github" && false)
160218 @github-release --version # Just a exe existence check
161219 @git status -s -b
162220
221 bump-release: requirements
222 @# TODO: is not a pre-release, clear release_name
223 poetry version $(BUMP)
224
225 .PHONY: requirements
163226 requirements:
164 nicfit requirements
165 # XXX: pip-compile disable to support pathlib evironmemt marker, as pip-tools
166 # XXX: loses it. Could come back in future
167 @#pip-compile -U requirements.txt -o ./requirements.txt
168
169 changelog:
170 last=`git tag -l --sort=version:refname | grep '^v[0-9]' | tail -n1`;\
171 if ! grep "${CHANGELOG_HEADER}" ${CHANGELOG} > /dev/null; then \
172 rm -f ${CHANGELOG}.new; \
173 if test -n "$$last"; then \
174 gitchangelog --author-format=email \
175 --omit-author="travis@pobox.com" $${last}..HEAD |\
176 sed "s|^%%version%% .*|${CHANGELOG_HEADER}|" |\
177 sed '/^.. :changelog:/ r/dev/stdin' ${CHANGELOG} \
178 > ${CHANGELOG}.new; \
179 else \
180 cat ${CHANGELOG} |\
181 sed "s/^%%version%% .*/${CHANGELOG_HEADER}/" \
182 > ${CHANGELOG}.new;\
183 fi; \
184 mv ${CHANGELOG}.new ${CHANGELOG}; \
185 fi
186
187 build-release: test-most dist
188
189 freeze-release:
190 @(git diff --quiet && git diff --quiet --staged) || \
191 (printf "\n!!! Working repo has uncommited/unstaged changes. !!!\n" && \
192 printf "\nCommit and try again.\n" && false)
193
194 tag-release:
195 git tag -a $(RELEASE_TAG) -m "Release $(RELEASE_TAG)"
196 git push --tags origin
197
198 release: pre-release freeze-release build-release tag-release upload-release
199
200 github-release:
227 poetry show --outdated
228 poetry update --lock
229 poetry export -f requirements.txt --without-hashes\
230 --output requirements/requirements.txt
231 poetry export -f requirements.txt --without-hashes\
232 --output requirements/test-requirements.txt -E test
233 poetry export -f requirements.txt --without-hashes\
234 --output requirements/dev-requirements.txt -E dev
235 poetry export -f requirements.txt --without-hashes\
236 --output requirements/extra-requirements.txt \
237 -E display-plugin -E art-plugin -E yaml-plugin
238 $(MAKE) build
239
240 upload-release: _pypi-release _github-release _web-release
241
242 _pypi-release:
243 poetry publish -r ${PYPI_REPO}
244
245 _github-release:
201246 name="${RELEASE_TAG}"; \
202247 if test -n "${RELEASE_NAME}"; then \
203248 name="${RELEASE_TAG} (${RELEASE_NAME})"; \
217262 --tag ${RELEASE_TAG} --name $${file} --file dist/$${file}; \
218263 done
219264
220 web-release:
265 _web-release:
221266 for f in `find dist -type f`; do \
222 scp -P444 $$f eyed3.nicfit.net:eyeD3-releases/`basename $$f`; \
267 scp $$f eyed3.nicfit.net:./data1/eyeD3-releases/`basename $$f`; \
223268 done
224269
225 upload-release: github-release pypi-release web-release
226
227 pypi-release:
228 for f in `find dist -type f -name ${PROJECT_NAME}-${VERSION}.tar.gz \
229 -o -name \*.egg -o -name \*.whl`; do \
230 if test -f $$f ; then \
231 twine upload -r ${PYPI_REPO} --skip-existing $$f ; \
232 fi \
233 done
234
235 sdist: build
236 python setup.py sdist --formats=gztar,zip
237 python setup.py bdist_egg
238 python setup.py bdist_wheel
239
240 dist: clean sdist docs-dist
241 @# The cd dist keeps the dist/ prefix out of the md5sum files
242 cd dist && \
243 for f in $$(ls); do \
244 md5sum $${f} > $${f}.md5; \
245 done
246 ls -l dist
247
248 install: clean
249 python setup.py install
250
251 tags:
252 ctags -R ${SRC_DIRS}
253
270 _freeze-release:
271 @(git diff --quiet && git diff --quiet --staged) || \
272 (printf "\n!!! Working repo has uncommitted/un-staged changes. !!!\n" && \
273 printf "\nCommit and try again.\n" && false)
274
275 _tag-release:
276 git tag -a $(RELEASE_TAG) -m "Release $(RELEASE_TAG)"
277 git push --tags origin
278
279 changelog:
280 @last=`git tag -l --sort=version:refname | grep '^v[0-9]' | tail -n1`;\
281 if ! grep "${CHANGELOG_HEADER}" ${CHANGELOG} > /dev/null; then \
282 rm -f ${CHANGELOG}.new; \
283 if test -n "$$last"; then \
284 gitchangelog --author-format=email \
285 --omit-author="travis@pobox.com" $${last}..HEAD |\
286 sed "s|^%%version%% .*|${CHANGELOG_HEADER}|" |\
287 sed '/^.. :changelog:/ r/dev/stdin' ${CHANGELOG} \
288 > ${CHANGELOG}.new; \
289 else \
290 cat ${CHANGELOG} |\
291 sed "s/^%%version%% .*/${CHANGELOG_HEADER}/" \
292 > ${CHANGELOG}.new;\
293 fi; \
294 mv ${CHANGELOG}.new ${CHANGELOG}; \
295 fi
296
297
298 ## MISC
254299 README.html: README.rst
255300 rst2html5.py README.rst >| README.html
256301 if test -n "${BROWSER}"; then \
257302 ${BROWSER} README.html;\
258303 fi
259304
260 CC_MERGE ?= yes
261 CC_OPTS ?= --no-input
262305 GIT_COMMIT_HOOK = .git/hooks/commit-msg
263306 cookiecutter:
264307 tmp_d=`mktemp -d`; cc_d=$$tmp_d/eyeD3; \
265 if test "${CC_MERGE}" == "no"; then \
308 if test "${CC_MERGE}" = "no"; then \
266309 nicfit cookiecutter ${CC_OPTS} "$${tmp_d}"; \
267310 git -C "$$cc_d" diff; \
268311 git -C "$$cc_d" status -s -b; \
272315 fi; \
273316 rm -rf $$tmp_d
274317
275
318 ## Runtime environment
319 venv:
320 source /usr/bin/virtualenvwrapper.sh && \
321 mkvirtualenv eyeD3 && \
322 pip install -U pip && \
323 poetry install --no-dev
324
325 clean-venv:
326 source /usr/bin/virtualenvwrapper.sh && \
327 rmvirtualenv eyeD3
6262 import eyed3
6363
6464 audiofile = eyed3.load("song.mp3")
65 audiofile.tag.artist = u"Integrity"
66 audiofile.tag.album = u"Humanity Is The Devil"
67 audiofile.tag.album_artist = u"Integrity"
68 audiofile.tag.title = u"Hollow"
69 audiofile.tag.track_num = 2
65 audiofile.tag.artist = "Token Entry"
66 audiofile.tag.album = "Free For All Comp LP"
67 audiofile.tag.album_artist = "Various Artists"
68 audiofile.tag.title = "The Edge"
69 audiofile.tag.track_num = 3
7070
7171 audiofile.tag.save()
7272
7676 Features
7777 --------
7878
79 * Python package for writing application and/or plugins.
80 * Command-line tool driver script that supports plugins.
81 viewer/editor interface.
82 * Easy editing/viewing of audio metadata from the command-line, using the
83 'classic' plugin.
79 * Python package (`import eyed3`) for writing applications and plugins.
80 * `eyeD3` : Command-line tool driver script that supports plugins.
81 * Easy ID3 editing/viewing of audio metadata from the command-line.
82 * Plugins for: Tag to string formatting (display), album fixing (fixup),
83 cover art downloading (art), collection stats (stats),
84 and json/yaml/jabber/nfo output formats, and more included.
8485 * Support for ID3 versions 1.x, 2.2 (read-only), 2.3, and 2.4.
8586 * Support for the MP3 audio format exposing details such as play time, bit
8687 rate, sampling frequency, etc.
8788 * Abstract design allowing future support for different audio formats and
8889 metadata containers.
8990
90
9191 Get Started
9292 -----------
9393
94 Python 2.7, >= 3.4 is required.
94 Python >= 3.6 is required.
9595
9696 For `installation instructions`_ or more complete `documentation`_ see
9797 http://eyeD3.nicfit.net/
100100
101101 .. _eyeD3: http://eyeD3.nicfit.net/
102102 .. _Travis Shirk: travis@pobox.com
103 .. _issue tracker: https://bitbucket.org/nicfit/eyed3/issues?status=new&status=open
103 .. _issue tracker: https://github.com/nicfit/eyeD3/issues
104104 .. _mailing list: https://groups.google.com/forum/?fromgroups#!forum/eyed3-users
105105 .. _installation instructions: http://eyeD3.nicfit.net/index.html#installation
106106 .. _documentation: http://eyeD3.nicfit.net/index.html#documentation
22 ==========================
33
44 The ``eyeD3`` command line interface is based on plugins. The main driver
5 knows how to traverse file systems and load audio files for hand-off to the
5 knows how to traverse file systems and load audio files for hand-off to the
66 plugin to do something interesting. With no plugin selected a simplified usage
77 is:
88
4545 .. toctree::
4646 :maxdepth: 1
4747
48 plugins/classic_plugin
49 plugins/display_plugin
50 plugins/fixup_plugin
51 plugins/itunes_plugin
52 plugins/genres_plugin
53 plugins/lameinfo_plugin
54 plugins/nfo_plugin
55 plugins/pymod_plugin
56 plugins/stats_plugin
57 plugins/xep118_plugin
48 plugins
5849
5950 .. _config-files:
6051
10697 :class:`eyed3.plugins.classic.ClassicPlugin`,
10798 :class:`eyed3.mp3.Mp3AudioInfo`, :class:`eyed3.id3.tag.Tag`
10899
100 Documenting Plugins
101 ^^^^^^^^^^^^^^^^^^^^
102 Plugin docs are generated. Start each plugin with the following template;
103 **but replace the square brackets with curly.*** ::
104
105 Example Plugin
106 ===============
107
108 .. [[[cog
109 .. cog.out(cog_pluginHelp("example-plugin"))
110 .. ]]]
111
112 .. [[[end]]]
113
114 The documentation build process will run `eyeD3 --plugin example-plugin` and
115 generate docs from the command line options and plugin metadata such as the
116 description. The plugin index in `cli.rst` should also me updated to include
117 the new plugin.
00 #!/usr/bin/env python
1 # -*- coding: utf-8 -*-
21 #
32 # eyeD3 documentation build configuration file, created by
43 # sphinx-quickstart on Tue Jul 9 22:26:36 2013.
2423
2524 # Get the project root dir, which is the parent dir of this
2625 cwd = os.getcwd()
27 project_root = os.path.join("./src", os.path.dirname(cwd))
26 project_root = os.path.join("./", os.path.dirname(cwd))
2827
2928 # Insert the project root dir as the first element in the PYTHONPATH.
3029 # This lets us ensure that the source package is imported, and that its
3130 # version is used.
3231 sys.path.insert(0, project_root)
3332
34 from eyed3.__about__ import (
35 __project_name__, __version__, __years__, __author__)
33 from eyed3.__regarding__ import (
34 project_name, version, years, author
35 )
3636
3737 # -- General configuration ---------------------------------------------
3838
4242 # Add any Sphinx extension module names here, as strings. They can be extensions
4343 # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
4444 extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.todo',
45 'sphinx.ext.coverage', 'sphinx.ext.ifconfig',
46 'sphinx.ext.viewcode', 'sphinx.ext.extlinks']
47
48 extensions.append("sphinxcontrib.bitbucket")
49 bitbucket_project_url = 'https://bitbucket.org/nicfit/eyed3'
50
51 extensions.append("sphinx_issues")
45 'sphinx.ext.coverage', 'sphinx.ext.ifconfig', 'sphinx.ext.viewcode',
46 'sphinx.ext.extlinks', "sphinx_issues"]
47
5248 issues_github_path = "nicfit/eyeD3"
5349
5450 # Add any paths that contain templates here, relative to this directory.
6460 master_doc = 'index'
6561
6662 # General information about the project.
67 project = __project_name__
68 copyright = '{years}, {author}'.format(years=__years__, author=__author__)
63 project = project_name
64 copyright = '{years}, {author}'.format(years=years, author=author)
6965
7066 # The version info for the project you're documenting, acts as replacement
7167 # for |version| and |release|, also used in various other places throughout
7268 # the built documents.
7369 #
74 version = __version__
75 release = __version__
70 release = version
7671
7772 # The language for content autogenerated by Sphinx. Refer to documentation
7873 # for a list of supported languages.
77 ----------------------
88
99 .. automodule:: eyed3.id3.apple
10 :members:
11 :undoc-members:
12 :show-inheritance:
10 :members:
11 :undoc-members:
12 :show-inheritance:
1313
1414 eyed3.id3.frames module
1515 -----------------------
1616
1717 .. automodule:: eyed3.id3.frames
18 :members:
19 :undoc-members:
20 :show-inheritance:
18 :members:
19 :undoc-members:
20 :show-inheritance:
2121
2222 eyed3.id3.headers module
2323 ------------------------
2424
2525 .. automodule:: eyed3.id3.headers
26 :members:
27 :undoc-members:
28 :show-inheritance:
26 :members:
27 :undoc-members:
28 :show-inheritance:
2929
3030 eyed3.id3.tag module
3131 --------------------
3232
3333 .. automodule:: eyed3.id3.tag
34 :members:
35 :undoc-members:
36 :show-inheritance:
37
34 :members:
35 :undoc-members:
36 :show-inheritance:
3837
3938 Module contents
4039 ---------------
4140
4241 .. automodule:: eyed3.id3
43 :members:
44 :undoc-members:
45 :show-inheritance:
42 :members:
43 :undoc-members:
44 :show-inheritance:
77 ------------------------
88
99 .. automodule:: eyed3.mp3.headers
10 :members:
11 :undoc-members:
12 :show-inheritance:
13
10 :members:
11 :undoc-members:
12 :show-inheritance:
1413
1514 Module contents
1615 ---------------
1716
1817 .. automodule:: eyed3.mp3
19 :members:
20 :undoc-members:
21 :show-inheritance:
18 :members:
19 :undoc-members:
20 :show-inheritance:
77 ------------------------
88
99 .. automodule:: eyed3.plugins.art
10 :members:
11 :undoc-members:
12 :show-inheritance:
10 :members:
11 :undoc-members:
12 :show-inheritance:
1313
1414 eyed3.plugins.classic module
1515 ----------------------------
1616
1717 .. automodule:: eyed3.plugins.classic
18 :members:
19 :undoc-members:
20 :show-inheritance:
18 :members:
19 :undoc-members:
20 :show-inheritance:
2121
2222 eyed3.plugins.display module
2323 ----------------------------
2424
2525 .. automodule:: eyed3.plugins.display
26 :members:
27 :undoc-members:
28 :show-inheritance:
26 :members:
27 :undoc-members:
28 :show-inheritance:
29
30 eyed3.plugins.extract module
31 ----------------------------
32
33 .. automodule:: eyed3.plugins.extract
34 :members:
35 :undoc-members:
36 :show-inheritance:
2937
3038 eyed3.plugins.fixup module
3139 --------------------------
3240
3341 .. automodule:: eyed3.plugins.fixup
34 :members:
35 :undoc-members:
36 :show-inheritance:
42 :members:
43 :undoc-members:
44 :show-inheritance:
3745
3846 eyed3.plugins.genres module
3947 ---------------------------
4048
4149 .. automodule:: eyed3.plugins.genres
42 :members:
43 :undoc-members:
44 :show-inheritance:
50 :members:
51 :undoc-members:
52 :show-inheritance:
4553
4654 eyed3.plugins.itunes module
4755 ---------------------------
4856
4957 .. automodule:: eyed3.plugins.itunes
50 :members:
51 :undoc-members:
52 :show-inheritance:
58 :members:
59 :undoc-members:
60 :show-inheritance:
61
62 eyed3.plugins.jsontag module
63 ----------------------------
64
65 .. automodule:: eyed3.plugins.jsontag
66 :members:
67 :undoc-members:
68 :show-inheritance:
5369
5470 eyed3.plugins.lameinfo module
5571 -----------------------------
5672
5773 .. automodule:: eyed3.plugins.lameinfo
58 :members:
59 :undoc-members:
60 :show-inheritance:
74 :members:
75 :undoc-members:
76 :show-inheritance:
77
78 eyed3.plugins.lastfm module
79 ---------------------------
80
81 .. automodule:: eyed3.plugins.lastfm
82 :members:
83 :undoc-members:
84 :show-inheritance:
85
86 eyed3.plugins.mimetype module
87 -----------------------------
88
89 .. automodule:: eyed3.plugins.mimetype
90 :members:
91 :undoc-members:
92 :show-inheritance:
6193
6294 eyed3.plugins.nfo module
6395 ------------------------
6496
6597 .. automodule:: eyed3.plugins.nfo
66 :members:
67 :undoc-members:
68 :show-inheritance:
98 :members:
99 :undoc-members:
100 :show-inheritance:
69101
70102 eyed3.plugins.pymod module
71103 --------------------------
72104
73105 .. automodule:: eyed3.plugins.pymod
74 :members:
75 :undoc-members:
76 :show-inheritance:
106 :members:
107 :undoc-members:
108 :show-inheritance:
77109
78110 eyed3.plugins.stats module
79111 --------------------------
80112
81113 .. automodule:: eyed3.plugins.stats
82 :members:
83 :undoc-members:
84 :show-inheritance:
114 :members:
115 :undoc-members:
116 :show-inheritance:
85117
86 eyed3.plugins.xep_118 module
118 eyed3.plugins.xep\_118 module
119 -----------------------------
120
121 .. automodule:: eyed3.plugins.xep_118
122 :members:
123 :undoc-members:
124 :show-inheritance:
125
126 eyed3.plugins.yamltag module
87127 ----------------------------
88128
89 .. automodule:: eyed3.plugins.xep_118
90 :members:
91 :undoc-members:
92 :show-inheritance:
93
129 .. automodule:: eyed3.plugins.yamltag
130 :members:
131 :undoc-members:
132 :show-inheritance:
94133
95134 Module contents
96135 ---------------
97136
98137 .. automodule:: eyed3.plugins
99 :members:
100 :undoc-members:
101 :show-inheritance:
138 :members:
139 :undoc-members:
140 :show-inheritance:
44 -----------
55
66 .. toctree::
7 :maxdepth: 4
78
8 eyed3.id3
9 eyed3.mp3
10 eyed3.plugins
11 eyed3.utils
9 eyed3.id3
10 eyed3.mp3
11 eyed3.plugins
12 eyed3.utils
1213
1314 Submodules
1415 ----------
15
16 eyed3.compat module
17 -------------------
18
19 .. automodule:: eyed3.compat
20 :members:
21 :undoc-members:
22 :show-inheritance:
2316
2417 eyed3.core module
2518 -----------------
2619
2720 .. automodule:: eyed3.core
28 :members:
29 :undoc-members:
30 :show-inheritance:
21 :members:
22 :undoc-members:
23 :show-inheritance:
3124
3225 eyed3.main module
3326 -----------------
3427
3528 .. automodule:: eyed3.main
36 :members:
37 :undoc-members:
38 :show-inheritance:
29 :members:
30 :undoc-members:
31 :show-inheritance:
3932
33 eyed3.mimetype module
34 ---------------------
35
36 .. automodule:: eyed3.mimetype
37 :members:
38 :undoc-members:
39 :show-inheritance:
4040
4141 Module contents
4242 ---------------
4343
4444 .. automodule:: eyed3
45 :members:
46 :undoc-members:
47 :show-inheritance:
45 :members:
46 :undoc-members:
47 :show-inheritance:
77 ----------------------
88
99 .. automodule:: eyed3.utils.art
10 :members:
11 :undoc-members:
12 :show-inheritance:
10 :members:
11 :undoc-members:
12 :show-inheritance:
1313
1414 eyed3.utils.binfuncs module
1515 ---------------------------
1616
1717 .. automodule:: eyed3.utils.binfuncs
18 :members:
19 :undoc-members:
20 :show-inheritance:
21
18 :members:
19 :undoc-members:
20 :show-inheritance:
2221
2322 eyed3.utils.console module
2423 --------------------------
2524
2625 .. automodule:: eyed3.utils.console
27 :members:
28 :undoc-members:
29 :show-inheritance:
26 :members:
27 :undoc-members:
28 :show-inheritance:
3029
3130 eyed3.utils.log module
3231 ----------------------
3332
3433 .. automodule:: eyed3.utils.log
35 :members:
36 :undoc-members:
37 :show-inheritance:
34 :members:
35 :undoc-members:
36 :show-inheritance:
3837
3938 eyed3.utils.prompt module
4039 -------------------------
4140
4241 .. automodule:: eyed3.utils.prompt
43 :members:
44 :undoc-members:
45 :show-inheritance:
46
42 :members:
43 :undoc-members:
44 :show-inheritance:
4745
4846 Module contents
4947 ---------------
5048
5149 .. automodule:: eyed3.utils
52 :members:
53 :undoc-members:
54 :show-inheritance:
50 :members:
51 :undoc-members:
52 :show-inheritance:
3131 :maxdepth: 2
3232
3333 cli
34 plugins
35 modules
3436 compliance
35 modules
3637 contributing
3738 authors
3839
5455 ==========
5556 - ID3 `v1.x Specification <http://id3lib.sourceforge.net/id3/id3v1.html>`_
5657 - ID3 v2.4 `Structure <http://www.id3.org/id3v2.4.0-structure>`_ and
57 `Frames <http://www.id3.org/id3v2.4.0-frames>`_
58 `Frames <http://www.id3.org/id3v2.4.0-frames>`_
5859 - ID3 `v2.3 Specification <http://www.id3.org/id3v2.3.0>`_
5960 - ID3 `v2.2 Specification <http://www.id3.org/id3v2-00>`_
6061 - ISO `8601 Date and Time <http://www.cl.cam.ac.uk/~mgk25/iso-time.html>`_
88 -------------------
99 *pip* is a tool for installing Python packages from `Python Package Index`_ and
1010 is a replacement for *easy_install*. It will install the package using the
11 first 'python' in your path so it is especially useful when used along with
11 first 'python' in your path so it is especially useful when used along with
1212 `virtualenv`_, otherwise root access may be required.
1313
1414 .. code-block:: sh
2828
2929 Dependencies
3030 ============
31 eyeD3 |version| has been tested with Python 2.7, >=3.3 (see the 0.7.x
32 series for Python 2.6 support).
31 eyeD3 |version| has been tested with Python 2.7, >=3.3.
32 See version 0.8.x for Python 2.7,>=3.3 and version 0.7.x for Python 2.6 support.
3333
3434 The primary interface for building and installing is `Setuptools`_. For
3535 example, ``python setup.py install``.
3636
3737 .. _setuptools: http://pypi.python.org/pypi/setuptools
38 .. _Paver: http://paver.github.com/paver/
3938
4039 Development Dependencies
4140 ------------------------
0 eyeD3
1 =====
0 eyed3 module
1 ============
22
33 .. toctree::
44 :maxdepth: 4
0 art(work) plugin
1 =================
2
3 .. {{{cog
4 .. cog.out(cog_pluginHelp("art"))
5 .. }}}
6
7 *Art for albums, artists, etc.*
8
9 Names
10 -----
11 art
12
13 Description
14 -----------
15
16
17 Options
18 -------
19 .. code-block:: text
20
21 -F, --update-files Write art files from tag images.
22 -T, --update-tags Write tag image from art files.
23 -D, --download Attempt to download album art if missing.
24 -v, --verbose Show detailed information for all art found.
25
26
27 .. {{{end}}}
3131 -A STRING, --album STRING
3232 Set the album name.
3333 -b STRING, --album-artist STRING
34 Set the album artist name. 'Various Artists', for
35 example. Another example is collaborations when the
36 track artist might be 'Eminem featuring Proof' the
37 album artist would be 'Eminem'.
34 Set the album artist name. 'Various Artists', for example. Another example is collaborations when the track artist might be 'Eminem
35 featuring Proof' the album artist would be 'Eminem'.
3836 -t STRING, --title STRING
3937 Set the track title.
4038 -n NUM, --track NUM Set the track number. Use 0 to clear.
4139 -N NUM, --track-total NUM
4240 Set total number of tracks. Use 0 to clear.
43 --track-offset N Increment/decrement the track number by [-]N. This
44 option is applied after --track=N is set.
41 --track-offset N Increment/decrement the track number by [-]N. This option is applied after --track=N is set.
42 --composer STRING Set the composer's name.
43 --orig-artist STRING Set the orignal artist's name. For example, a cover song can include the orignal author of the track.
4544 -d NUM, --disc-num NUM
4645 Set the disc number. Use 0 to clear.
4746 -D NUM, --disc-total NUM
4847 Set total number of discs in set. Use 0 to clear.
4948 -G GENRE, --genre GENRE
50 Set the genre. If the argument is a standard ID3 genre
51 name or number both will be set. Otherwise, any string
52 can be used. Run 'eyeD3 --plugin=genres' for a list of
53 standard ID3 genre names/ids.
54 --non-std-genres Disables certain ID3 genre standards, such as the
55 mapping of numeric value to genre names.
49 Set the genre. If the argument is a standard ID3 genre name or number both will be set. Otherwise, any string can be used. Run 'eyeD3
50 --plugin=genres' for a list of standard ID3 genre names/ids.
51 --non-std-genres Disables certain ID3 genre standards, such as the mapping of numeric value to genre names. For example, genre=1 is taken literally,
52 not mapped to 'Classic Rock'.
5653 -Y YEAR, --release-year YEAR
57 Set the year the track was released. Use the date
58 options for more precise values or dates other than
59 release.
54 Set the year the track was released. Use the date options for more precise values or dates other than release.
6055 -c STRING, --comment STRING
61 Set a comment. In ID3 tags this is the comment with an
62 empty description. See --add-comment to add multiple
63 comment frames.
64 --rename PATTERN Rename file (the extension is not affected) based on
65 data in the tag using substitution variables: $album,
66 $album_artist, $artist, $best_date,
67 $best_date:prefer_recording,
68 $best_date:prefer_recording:year,
69 $best_date:prefer_release,
70 $best_date:prefer_release:year, $best_date:year,
71 $disc:num, $disc:total, $file, $file:ext,
72 $original_release_date, $original_release_date:year,
73 $recording_date, $recording_date:year, $release_date,
74 $release_date:year, $title, $track:num, $track:total
56 Set a comment. In ID3 tags this is the comment with an empty description. See --add-comment to add multiple comment frames.
57 --artist-city STRING The artist's city of origin. Stored as a user text frame `eyeD3#artist_origin`
58 --artist-state STRING
59 The artist's state of origin. Stored as a user text frame `eyeD3#artist_origin`
60 --artist-country STRING
61 The artist's country of origin. Stored as a user text frame `eyeD3#artist_origin`
62 --rename PATTERN Rename file (the extension is not affected) based on data in the tag using substitution variables: $album, $album_artist, $artist,
63 $best_date, $best_date:prefer_recording, $best_date:prefer_recording:year, $best_date:prefer_release, $best_date:prefer_release:year,
64 $best_date:year, $disc:num, $disc:total, $file, $file:ext, $original_release_date, $original_release_date:year, $recording_date,
65 $recording_date:year, $release_date, $release_date:year, $title, $track:num, $track:total
7566
7667 ID3 options:
77 -1, --v1 Only read and write ID3 v1.x tags. By default, v1.x
78 tags are only read or written if there is not a v2 tag
79 in the file.
80 -2, --v2 Only read/write ID3 v2.x tags. This is the default
81 unless the file only contains a v1 tag.
82 --to-v1.1 Convert the file's tag to ID3 v1.1 (Or 1.0 if there is
83 no track number)
68 -1, --v1 Only read and write ID3 v1.x tags. By default, v1.x tags are only read or written if there is not a v2 tag in the file.
69 -2, --v2 Only read/write ID3 v2.x tags. This is the default unless the file only contains a v1 tag.
70 --to-v1.1 Convert the file's tag to ID3 v1.1 (Or 1.0 if there is no track number)
8471 --to-v2.3 Convert the file's tag to ID3 v2.3
8572 --to-v2.4 Convert the file's tag to ID3 v2.4
8673 --release-date DATE Set the date the track/album was released
9178 --encoding-date DATE Set the date the file was encoded
9279 --tagging-date DATE Set the date the file was tagged
9380 --publisher STRING Set the publisher/label name
94 --play-count <+>N Set the number of times played counter. If the
95 argument value begins with '+' the tag's play count is
96 incremented by N, otherwise the value is set to
97 exactly N.
81 --play-count <+>N Set the number of times played counter. If the argument value begins with '+' the tag's play count is incremented by N, otherwise the
82 value is set to exactly N.
9883 --bpm N Set the beats per minute value.
9984 --unique-file-id OWNER_ID:ID
100 Add a unique file ID frame. If the ID arg is empty the
101 frame is removed. An OWNER_ID is required. The ID may
102 be no more than 64 bytes.
85 Add a unique file ID frame. If the ID arg is empty the frame is removed. An OWNER_ID is required. The ID may be no more than 64 bytes.
10386 --add-comment COMMENT[:DESCRIPTION[:LANG]]
104 Add or replace a comment. There may be more than one
105 comment in a tag, as long as the DESCRIPTION and LANG
106 values are unique. The default DESCRIPTION is '' and
107 the default language code is 'eng'.
87 Add or replace a comment. There may be more than one comment in a tag, as long as the DESCRIPTION and LANG values are unique. The
88 default DESCRIPTION is '' and the default language code is 'eng'.
10889 --remove-comment DESCRIPTION[:LANG]
109 Remove comment matching DESCRIPTION and LANG. The
110 default language code is 'eng'.
90 Remove comment matching DESCRIPTION and LANG. The default language code is 'eng'.
11191 --remove-all-comments
11292 Remove all comments from the tag.
11393 --add-lyrics LYRICS_FILE[:DESCRIPTION[:LANG]]
114 Add or replace a lyrics. There may be more than one
115 set of lyrics in a tag, as long as the DESCRIPTION and
116 LANG values are unique. The default DESCRIPTION is ''
117 and the default language code is 'eng'.
94 Add or replace a lyrics. There may be more than one set of lyrics in a tag, as long as the DESCRIPTION and LANG values are unique. The
95 default DESCRIPTION is '' and the default language code is 'eng'.
11896 --remove-lyrics DESCRIPTION[:LANG]
119 Remove lyrics matching DESCRIPTION and LANG. The
120 default language code is 'eng'.
97 Remove lyrics matching DESCRIPTION and LANG. The default language code is 'eng'.
12198 --remove-all-lyrics Remove all lyrics from the tag.
12299 --text-frame FID:TEXT
123 Set the value of a text frame. To remove the frame,
124 specify an empty value. For example, --text-
125 frame='TDRC:'
100 Set the value of a text frame. To remove the frame, specify an empty value. For example, --text-frame='TDRC:'
126101 --user-text-frame DESC:TEXT
127 Set the value of a user text frame (i.e., TXXX). To
128 remove the frame, specify an empty value. e.g.,
129 --user-text-frame='SomeDesc:'
130 --url-frame FID:URL Set the value of a URL frame. To remove the frame,
131 specify an empty value. e.g., --url-frame='WCOM:'
102 Set the value of a user text frame (i.e., TXXX). To remove the frame, specify an empty value. e.g., --user-text-frame='SomeDesc:'
103 --url-frame FID:URL Set the value of a URL frame. To remove the frame, specify an empty value. e.g., --url-frame='WCOM:'
132104 --user-url-frame DESCRIPTION:URL
133 Set the value of a user URL frame (i.e., WXXX). To
134 remove the frame, specify an empty value. e.g.,
135 --user-url-frame='SomeDesc:'
105 Set the value of a user URL frame (i.e., WXXX). To remove the frame, specify an empty value. e.g., --user-url-frame='SomeDesc:'
136106 --add-image IMG_PATH:TYPE[:DESCRIPTION]
137 Add or replace an image. There may be more than one
138 image in a tag, as long as the DESCRIPTION values are
139 unique. The default DESCRIPTION is ''. If PATH begins
140 with 'http[s]://' then it is interpreted as a URL
141 instead of a file containing image data. The TYPE must
142 be one of the following: OTHER, ICON, OTHER_ICON,
143 FRONT_COVER, BACK_COVER, LEAFLET, MEDIA, LEAD_ARTIST,
144 ARTIST, CONDUCTOR, BAND, COMPOSER, LYRICIST,
145 RECORDING_LOCATION, DURING_RECORDING,
146 DURING_PERFORMANCE, VIDEO, BRIGHT_COLORED_FISH,
147 ILLUSTRATION, BAND_LOGO, PUBLISHER_LOGO.
107 Add or replace an image. There may be more than one image in a tag, as long as the DESCRIPTION values are unique. The default
108 DESCRIPTION is ''. If PATH begins with 'http[s]://' then it is interpreted as a URL instead of a file containing image data. The TYPE
109 must be one of the following: OTHER, ICON, OTHER_ICON, FRONT_COVER, BACK_COVER, LEAFLET, MEDIA, LEAD_ARTIST, ARTIST, CONDUCTOR, BAND,
110 COMPOSER, LYRICIST, RECORDING_LOCATION, DURING_RECORDING, DURING_PERFORMANCE, VIDEO, BRIGHT_COLORED_FISH, ILLUSTRATION, BAND_LOGO,
111 PUBLISHER_LOGO.
148112 --remove-image DESCRIPTION
149113 Remove image matching DESCRIPTION.
150114 --remove-all-images Remove all images from the tag
151 --write-images DIR Causes all attached images (APIC frames) to be written
152 to the specified directory.
115 --write-images DIR Causes all attached images (APIC frames) to be written to the specified directory.
153116 --add-object OBJ_PATH:MIME-TYPE[:DESCRIPTION[:FILENAME]]
154 Add or replace an object. There may be more than one
155 object in a tag, as long as the DESCRIPTION values are
156 unique. The default DESCRIPTION is ''.
117 Add or replace an object. There may be more than one object in a tag, as long as the DESCRIPTION values are unique. The default
118 DESCRIPTION is ''.
157119 --remove-object DESCRIPTION
158120 Remove object matching DESCRIPTION.
159 --write-objects DIR Causes all attached objects (GEOB frames) to be
160 written to the specified directory.
121 --write-objects DIR Causes all attached objects (GEOB frames) to be written to the specified directory.
161122 --remove-all-objects Remove all objects from the tag
162123 --add-popularity EMAIL:RATING[:PLAY_COUNT]
163 Adds a pupularity metric. There may be multiples
164 popularity values, but each must have a unique email
165 address component. The rating is a number between 0
166 (worst) and 255 (best). The play count is optional,
167 and defaults to 0, since there is already a dedicated
168 play count frame.
124 Adds a pupularity metric. There may be multiples popularity values, but each must have a unique email address component. The rating is
125 a number between 0 (worst) and 255 (best). The play count is optional, and defaults to 0, since there is already a dedicated play
126 count frame.
169127 --remove-popularity EMAIL
170 Removes the popularity frame with the specified email
171 key.
128 Removes the popularity frame with the specified email key.
172129 --remove-v1 Remove ID3 v1.x tag.
173130 --remove-v2 Remove ID3 v2.x tag.
174131 --remove-all Remove ID3 v1.x and v2.x tags.
175 --remove-frame FID Remove all frames with the given ID. This option may
176 be specified multiple times.
132 --remove-frame FID Remove all frames with the given ID. This option may be specified multiple times.
177133 --max-padding NUM_BYTES
178 Shrink file if tag padding (unused space) exceeds the
179 given number of bytes. (Useful e.g. after removal of
180 large cover art.) Default is 64 KiB, file will be
181 rewritten with default padding (1 KiB) or max padding,
182 whichever is smaller.
134 Shrink file if tag padding (unused space) exceeds the given number of bytes. (Useful e.g. after removal of large cover art.) Default
135 is 64 KiB, file will be rewritten with default padding (1 KiB) or max padding, whichever is smaller.
183136 --no-max-padding Disable --max-padding altogether.
184137 --encoding latin1|utf8|utf16|utf16-be
185 Set the encoding that is used for all text frames.
186 This option is only applied if the tag is updated as
187 the result of an edit option (e.g. --artist, --title,
188 etc.) or --force-update is specified.
138 Set the encoding that is used for all text frames. This option is only applied if the tag is updated as the result of an edit option
139 (e.g. --artist, --title, etc.) or --force-update is specified.
189140
190141 Misc options:
191142 --force-update Rewrite the tag despite there being no edit options.
207158
208159 $ rm -f example.id3
209160 $ touch example.id3
210 $ ls -o example.id3
211
212 -rw-r--r-- 1 travis 0 Feb 26 17:14 example.id3
161 $ ls -s example.id3
162
163 0 example.id3
213164
214165 .. {{{end}}}
215166
251202 # Convert the current v2.4 frame to v2.3
252203 $ eyeD3 --to-v2.3 example.id3 -Q
253204
254 /home/travis/devel/eyeD3/git/example.id3 [ 0.00 Bytes ]
255 -------------------------------------------------------------------------------
205 .../home/travis/devel/eyeD3/git/example.id3[ 0.00 Bytes ]
206 -------------------------
256207 ID3 v2.4: 0 frames
257208 Writing ID3 version v2.3
258 -------------------------------------------------------------------------------
209 -------------------------
259210
260211 # Convert back
261212 $ eyeD3 --to-v2.4 example.id3 -Q
262213
263 /home/travis/devel/eyeD3/git/example.id3 [ 266.00 Bytes ]
264 -------------------------------------------------------------------------------
214 .../home/travis/devel/eyeD3/git/example.id3[ 266.00 Bytes ]
215 -------------------------
265216 ID3 v2.3: 0 frames
266217 Writing ID3 version v2.4
267 -------------------------------------------------------------------------------
218 -------------------------
268219
269220 # Convert to v1, this will lose all the more advanced data members ID3 v2 offers
270221 $ eyeD3 --to-v1.1 example.id3 -Q
271222
272 /home/travis/devel/eyeD3/git/example.id3 [ 266.00 Bytes ]
273 -------------------------------------------------------------------------------
223 .../home/travis/devel/eyeD3/git/example.id3[ 266.00 Bytes ]
224 -------------------------
274225 ID3 v2.4: 0 frames
275226 Writing ID3 version v1.1
276 -------------------------------------------------------------------------------
277
278 .. {{{end}}}
279
280 The last conversion above converted to v1.1, or so the output says. The
227 -------------------------
228
229 .. {{{end}}}
230
231 The last conversion above converted to v1.1, or so the output says. The
281232 final listing shows that the tag is version 2.4. This is because tags can
282233 contain both versions at once and eyeD3 will always show/load v2 tags first.
283234 To select the version 1 tag use the ``-1`` option. Also note how the
290241
291242 $ eyeD3 -1 example.id3
292243
293 /home/travis/devel/eyeD3/git/example.id3 [ 394.00 Bytes ]
294 -------------------------------------------------------------------------------
244 .../home/travis/devel/eyeD3/git/example.id3[ 394.00 Bytes ]
245 -------------------------
295246 ID3 v1.0:
296247 title:
297248 artist:
298249 album:
299 album artist: None
300250 track: genre: Other (id 12)
301 -------------------------------------------------------------------------------
251 -------------------------
302252
303253 .. {{{end}}}
304254
312262 # Set an artist value in the ID3 v1 tag
313263 $ eyeD3 -1 example.id3 -a id3v1
314264
315 /home/travis/devel/eyeD3/git/example.id3 [ 394.00 Bytes ]
316 -------------------------------------------------------------------------------
265 .../home/travis/devel/eyeD3/git/example.id3[ 394.00 Bytes ]
266 -------------------------
317267 Setting artist: id3v1
318268 ID3 v1.0:
319269 title:
320270 artist: id3v1
321271 album:
322 album artist: None
323272 track: genre: Other (id 12)
324273 Writing ID3 version v1.0
325 -------------------------------------------------------------------------------
274 -------------------------
326275
327276 # The file now has a v1 and v2 tag, change the v2 artist
328277 $ eyeD3 -2 example.id3 -a id3v2
329278
330 /home/travis/devel/eyeD3/git/example.id3 [ 394.00 Bytes ]
331 -------------------------------------------------------------------------------
279 .../home/travis/devel/eyeD3/git/example.id3[ 394.00 Bytes ]
280 -------------------------
332281 Setting artist: id3v2
333282 ID3 v2.4:
334283 title:
335284 artist: id3v2
336285 album:
337 album artist: None
338286 track:
339287 Writing ID3 version v2.4
340 -------------------------------------------------------------------------------
288 -------------------------
341289
342290 # Take all the values from v2.4 tag (the default) and set them in the v1 tag.
343291 $ eyeD3 -2 --to-v1.1 example.id3
344292
345 /home/travis/devel/eyeD3/git/example.id3 [ 394.00 Bytes ]
346 -------------------------------------------------------------------------------
293 .../home/travis/devel/eyeD3/git/example.id3[ 394.00 Bytes ]
294 -------------------------
347295 ID3 v2.4:
348296 title:
349297 artist: id3v2
350298 album:
351 album artist: None
352299 track:
353300 Writing ID3 version v1.1
354 -------------------------------------------------------------------------------
301 -------------------------
355302
356303 # Take all the values from v1 tag and convert to ID3 v2.3
357304 $ eyeD3 -1 --to-v2.3 example.id3
358305
359 /home/travis/devel/eyeD3/git/example.id3 [ 394.00 Bytes ]
360 -------------------------------------------------------------------------------
306 .../home/travis/devel/eyeD3/git/example.id3[ 394.00 Bytes ]
307 -------------------------
361308 ID3 v1.0:
362309 title:
363310 artist: id3v2
364311 album:
365 album artist: None
366312 track: genre: Other (id 12)
367313 Writing ID3 version v2.3
368 -------------------------------------------------------------------------------
314 -------------------------
369315
370316 .. {{{end}}}
371317
378324
379325 $ eyeD3 --remove-all example.id3
380326
381 /home/travis/devel/eyeD3/git/example.id3 [ 394.00 Bytes ]
382 -------------------------------------------------------------------------------
327 .../home/travis/devel/eyeD3/git/example.id3[ 394.00 Bytes ]
328 -------------------------
383329 Removing ID3 v1.x and/or v2.x tag: SUCCESS
384330 No ID3 v1.x/v2.x tag found!
385331
390336
391337 Some of the command line options contain multiple pieces of information in
392338 a single value. Take for example the ``--add-image`` option::
393
339
394340 --add-image IMG_PATH:TYPE[:DESCRIPTION]
395341
396342 This option has 3 pieced of information where one (DESCRIPTION) is optional
398344 so:
399345
400346 .. code-block:: bash
401
347
402348 $ eyeD3 --add-image cover.png:FRONT_COVER
403349
404350 This will load the image data from ``cover.png`` and store it in the tag with
412358 $ eyeD3 --add-image http://example.com/cover.jpg:FRONT_COVER
413359 eyeD3: error: argument --add-image: invalid ImageArg value: 'http://example.com/cover.jpg:FRONT_COVER'
414360
415 The problem is the ``':'`` character in the the URL, it confuses the format description of the option value. To solve this escape all delimeter characters in
361 The problem is the ``':'`` character in the the URL, it confuses the format description of the option value. To solve this escape all delimeter characters in
416362 option values with ``'\\'`` (for linux and macOS), single ``'\'`` for Windows).
417363
418364 Linux/MacOS:
423369
424370 $ eyeD3 --add-image http\\://example.com/cover.jpg:FRONT_COVER example.id3
425371
426 /home/travis/devel/eyeD3/git/example.id3 [ 0.00 Bytes ]
427 -------------------------------------------------------------------------------
372 .../home/travis/devel/eyeD3/git/example.id3[ 0.00 Bytes ]
373 -------------------------
428374 Adding image http://example.com/cover.jpg
429375 ID3 v2.4:
430376 title:
431377 artist:
432378 album:
433 album artist: None
434379 track:
435380 FRONT_COVER Image: [Type: -->] [URL: b'http://example.com/cover.jpg']
436381 Description:
437382
438383 Writing ID3 version v2.4
439 -------------------------------------------------------------------------------
384 -------------------------
440385
441386 .. {{{end}}}
442387
446391
447392 .. code-block:: bash
448393
449 $ eyeD3 --add-image http\://example.com/cover.jpg:FRONT_COVER example.id3
450
451 C:\Users\user\Downloads\example.id3 [ 0.00 Bytes ]
452 -------------------------------------------------------------------------------
394 $ eyeD3 --add-image http\\://example.com/cover.jpg:FRONT_COVER example.id3
395
396 .../home/travis/devel/eyeD3/git/example.id3[ 311.00 Bytes ]
397 -------------------------
453398 Adding image http://example.com/cover.jpg
454399 ID3 v2.4:
455400 title:
456401 artist:
457402 album:
458 album artist: None
459403 track:
460404 FRONT_COVER Image: [Type: -->] [URL: b'http://example.com/cover.jpg']
461405 Description:
462406
463407 Writing ID3 version v2.4
464 -------------------------------------------------------------------------------
465
466 .. {{{end}}}
467
408 -------------------------
409
410 .. {{{end}}}
411
189189 Example
190190 -------
191191
192 Asuming an audio file with artist 'Madonna', titel 'Frozen' and album 'Ray of Light'
192 Asuming an audio file with artist 'Madonna', title 'Frozen' and album 'Ray of Light'
193193
194194 .. code-block:: text
195195
0 Extract Plugin
1 ===============
2
3 .. {{{cog
4 .. cog.out(cog_pluginHelp("extract"))
5 .. }}}
6
7 *Extract tags from audio files.*
8
9 Names
10 -----
11 extract
12
13 Description
14 -----------
15
16
17 Options
18 -------
19 .. code-block:: text
20
21 -o OUTPUT_FILE, --output-file OUTPUT_FILE
22 The the tag is written to this file in native format.
23 -H, --hex Output hexadecimal format.
24 --strip-padding Exclude tag padding, if any.
25
26
27 .. {{{end}}}
28
7171 -------
7272 .. code-block:: text
7373
74 --file-rename-pattern 12. Directories are renamed as follows: - Type
75 ``live``: ${best_date:prefer_recording} - ${album} - All other types:
76 ${best_date:prefer_release} - ${album} - A rename template can be supplied
77 in --dir-rename-pattern Album types: - ``lp``: A traditinal "album" of
78 songs from a single artist. No extra info is written to the tag since this
79 is the default. - ``ep``: A short collection of songs from a single
80 artist. The string 'ep' is written to the tag's ``eyeD3#album_type``
81 field. - ``various``: A collection of songs from different artists. The
82 string 'various' is written to the tag's ``eyeD3#album_type`` field. -
83 ``live``: A collection of live recordings from a single artist. The string
84 'live' is written to the tag's ``eyeD3#album_type`` field. -
85 ``compilation``: A collection of songs from various recordings by a single
86 artist. The string 'compilation' is written to the tag's
87 ``eyeD3#album_type`` field. Compilation dates, unlike other types, may
88 differ. - ``demo``: A demo recording by a single artist. The string 'demo'
89 is written to the tag's ``eyeD3#album_type`` field. - ``single``: A track
90 that should no be associated with an album (even if it has album
91 metadata). The string 'single' is written to the tag's
92 ``eyeD3#album_type`` field.
74 - Type ``live``: ${best_date:prefer_recording} - ${album} - All other types: ${best_date:prefer_release} - ${album} - A rename template can be supplied in
75 --dir-rename-pattern Album types: - ``lp``: A traditinal "album" of songs from a single artist. No extra info is written to the tag since this is the
76 default. - ``ep``: A short collection of songs from a single artist. The string 'ep' is written to the tag's ``eyeD3#album_type`` field. - ``various``: A
77 collection of songs from different artists. The string 'various' is written to the tag's ``eyeD3#album_type`` field. - ``live``: A collection of live
78 recordings from a single artist. The string 'live' is written to the tag's ``eyeD3#album_type`` field. - ``compilation``: A collection of songs from
79 various recordings by a single artist. The string 'compilation' is written to the tag's ``eyeD3#album_type`` field. Compilation dates, unlike other types,
80 may differ. - ``demo``: A demo recording by a single artist. The string 'demo' is written to the tag's ``eyeD3#album_type`` field. - ``single``: A track
81 that should no be associated with an album (even if it has album metadata). The string 'single' is written to the tag's ``eyeD3#album_type`` field.
9382
94 -t {lp,ep,compilation,live,various,demo,single}, --type {lp,ep,compilation,live,various,demo,single}
95 How to treat each directory. The default is 'lp',
96 although you may be prompted for an alternate choice
97 if the files look like another type.
98 --fix-case Fix casing on each string field by capitalizing each
99 word.
100 -n, --dry-run Only print the operations that would take place, but
101 do not execute them.
83 --type {lp,ep,compilation,live,various,demo,single}
84 How to treat each directory. The default is 'lp', although you may be prompted for an alternate choice if the files look like another
85 type.
86 --fix-case Fix casing on each string field by capitalizing each word.
87 -n, --dry-run Only print the operations that would take place, but do not execute them.
10288 --no-prompt Exit if prompted.
103 --dotted-dates Separate date with '.' instead of '-' when naming
104 directories.
89 --dotted-dates Separate date with '.' instead of '-' when naming directories.
10590 --file-rename-pattern FILE_RENAME_PATTERN
106 Rename file (the extension is not affected) based on
107 data in the tag using substitution variables: $album,
108 $album_artist, $artist, $best_date,
109 $best_date:prefer_recording,
110 $best_date:prefer_recording:year,
111 $best_date:prefer_release,
112 $best_date:prefer_release:year, $best_date:year,
113 $disc:num, $disc:total, $file, $file:ext,
114 $original_release_date, $original_release_date:year,
115 $recording_date, $recording_date:year, $release_date,
116 $release_date:year, $title, $track:num, $track:total
91 Rename file (the extension is not affected) based on data in the tag using substitution variables: $album, $album_artist, $artist,
92 $best_date, $best_date:prefer_recording, $best_date:prefer_recording:year, $best_date:prefer_release, $best_date:prefer_release:year,
93 $best_date:year, $disc:num, $disc:total, $file, $file:ext, $original_release_date, $original_release_date:year, $recording_date,
94 $recording_date:year, $release_date, $release_date:year, $title, $track:num, $track:total
11795 --dir-rename-pattern DIR_RENAME_PATTERN
118 Rename directory based on data in the tag using
119 substitution variables: $album, $album_artist,
120 $artist, $best_date, $best_date:prefer_recording,
121 $best_date:prefer_recording:year,
122 $best_date:prefer_release,
123 $best_date:prefer_release:year, $best_date:year,
124 $disc:num, $disc:total, $file, $file:ext,
125 $original_release_date, $original_release_date:year,
126 $recording_date, $recording_date:year, $release_date,
127 $release_date:year, $title, $track:num, $track:total
96 Rename directory based on data in the tag using substitution variables: $album, $album_artist, $artist, $best_date,
97 $best_date:prefer_recording, $best_date:prefer_recording:year, $best_date:prefer_release, $best_date:prefer_release:year,
98 $best_date:year, $disc:num, $disc:total, $file, $file:ext, $original_release_date, $original_release_date:year, $recording_date,
99 $recording_date:year, $release_date, $release_date:year, $title, $track:num, $track:total
128100
129101
130102 .. {{{end}}}
0 JSON Plugin
1 ===============
2
3 .. {{{cog
4 .. cog.out(cog_pluginHelp("json"))
5 .. }}}
6
7 *Outputs all tags as JSON.*
8
9 Names
10 -----
11 json
12
13 Description
14 -----------
15
16
17 Options
18 -------
19 .. code-block:: text
20
21 -c, --compact Output in compact form, wound new lines or indentation.
22 -s, --sort Output JSON in sorted by key.
23
24
25 .. {{{end}}}
3131
3232 .. code-block:: bash
3333
34 $ eyeD3 -P lameinfo src/test/data/notag-vbr.mp3
34 $ eyeD3 -P lameinfo tests/data/notag-vbr.mp3
3535
36
37 notag-vbr.mp3 [ 5.98 MB ]
38 -------------------------------------------------------------------------------
36 .../home/travis/devel/eyeD3/git/tests/data/notag-vbr.mp3[ 5.98 MB ]
37 -------------------------
3938 Encoder Version : LAME3.91
4039 LAME Tag Revision : 0
4140 VBR Method : Variable Bitrate method2 (mtrh)
0 Mime-types Plugin
1 ==================
2
3 .. {{{cog
4 .. cog.out(cog_pluginHelp("mimetypes"))
5 .. }}}
6
7 *eyeD3 plugin*
8
9 Names
10 -----
11 mimetypes
12
13 Description
14 -----------
15
16
17 Options
18 -------
19 .. code-block:: text
20
21 --status Print dot status.
22 --parse-files Parse each file.
23 --hide-notfound
24
25
26 .. {{{end}}}
1313 Description
1414 -----------
1515
16 If no module if provided (see -m/--module) a file named eyeD3mod.py in
17 the current working directory is imported. If any of the following methods
18 exist they still be invoked:
16 If no module if provided a file named eyeD3mod.py in the current working directory is
17 imported. If any of the following methods exist they still be invoked:
1918
2019 def audioFile(audio_file):
2120 '''Invoked for every audio file that is encountered. The ``audio_file``
3837 .. code-block:: text
3938
4039 -m MODULE, --module MODULE
41 The Python module module to invoke. The default is
42 ./eyeD3mod.py
40 The Python module module to invoke. The default is ./eyeD3mod.py
4341
4442
4543 .. {{{end}}}
1818 -------
1919 .. code-block:: text
2020
21 No extra options supported
21 --no-pretty-print Output without new lines or indentation.
22
2223
2324 .. {{{end}}}
0 YAML Plugin
1 ===============
2
3 .. {{{cog
4 .. cog.out(cog_pluginHelp("yaml"))
5 .. }}}
6
7 *Outputs all tags as YAML.*
8
9 Names
10 -----
11 yaml
12
13 Description
14 -----------
15
16
17 Options
18 -------
19 .. code-block:: text
20
21 No extra options supported
22
23 .. {{{end}}}
0 Plugins
1 --------
2 .. toctree::
3 :maxdepth: 1
4
5 plugins/art_plugin
6 plugins/classic_plugin
7 plugins/display_plugin
8 plugins/extract_plugin
9 plugins/fixup_plugin
10 plugins/itunes_plugin
11 plugins/json_plugin
12 plugins/genres_plugin
13 plugins/lameinfo_plugin
14 plugins/mimetypes_plugin
15 plugins/nfo_plugin
16 plugins/pymod_plugin
17 plugins/stats_plugin
18 plugins/xep118_plugin
19 plugins/yaml_plugin
11 import sys
22 import cogapp
33 from pathlib import Path
4
54 from paver.easy import sh
65
76 options = {
98 'endoutput': '{{{end}}}',
109 'endspec': '}}}',
1110 'includedir': str(Path.cwd())},
12 'dry_run': None,
13 'pavement_file': 'pavement.py',
14 'sphinx': {'builddir': '_build',
15 'builder': 'html',
16 'docroot': 'docs',
17 'template_args': {}}}
18
11 'dry_run': None,
12 'sphinx': {'builddir': '_build',
13 'builder': 'html',
14 'docroot': 'docs',
15 'template_args': {}}}
1916
2017 _default_include_marker = dict(
2118 py="# "
127124 else:
128125 substs["altnames"] = ""
129126 substs["summary"] = plugin.SUMMARY
130 substs["description"] = plugin.DESCRIPTION if plugin.DESCRIPTION else u""
127 substs["description"] = plugin.DESCRIPTION if plugin.DESCRIPTION else ""
131128
132129 arg_parser = argparse.ArgumentParser()
133130 _ = plugin(arg_parser) # noqa
134131
135 buffer = u""
132 buffer = ""
136133 found_opts = False
137134 for line in arg_parser.format_help().splitlines(True):
138135 if not found_opts:
139 if (line.lstrip().startswith('-') and
140 not line.lstrip().startswith("-h")):
136 if line.lstrip().startswith('-') and not line.lstrip().startswith("-h"):
141137 buffer += (" " * 2) + line
142138 found_opts = True
143139 else:
148144 if buffer.strip():
149145 substs["options"] = buffer
150146 else:
151 substs["options"] = u" No extra options supported"
147 substs["options"] = " No extra options supported"
152148
153149 return template.substitute(substs)
154150
155151
156152 setattr(__builtins__, "cog_pluginHelp", cog_pluginHelp)
153
157154
158155 class CliExample(Includer):
159156 def __call__(self, fn, section=None, lang="bash"):
163160 raw = Includer.__call__(self, fn, section=section)
164161 self.cog = cog
165162
166 self.cog.cogmodule.out(u"\n.. code-block:: %s\n\n" % lang)
163 self.cog.cogmodule.out("\n.. code-block:: %s\n\n" % lang)
167164 for line in raw.splitlines(True):
168165 if line.strip() == "":
169166 self.cog.cogmodule.out(line)
231228 else:
232229 # FIXME: This cannot happen since pattern is never None
233230 files = basedir.glob("**/*")
231
234232 for f in sorted(files):
235 sh("cog %s" % f, cog.processOneFile, str(f))
233 cog.processOneFile(str(f))
236234
237235
238236 def main():
239 sys.path.append("./src")
237 sys.path.append("./")
240238 try:
241239 _runcog(options)
242240 finally:
243 sys.path.remove("./src")
241 sys.path.remove("./")
244242
245243
246244 if __name__ == "__main__":
00 #!/usr/bin/env python
1 # -*- coding: utf-8 -*-
2 ################################################################################
3 # Copyright (C) 2012 Travis Shirk <travis@pobox.com>
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program. If not, see <http://www.gnu.org/licenses/>
17 ################################################################################
18 from __future__ import print_function
191 import sys
202 from eyed3.id3.tag import Tag
3
214
225 def printChapter(chapter):
236 # The element ID is the unique key for this chapter
55 # [[[section SETUP]]]
66 rm -f example.id3
77 touch example.id3
8 ls -o example.id3
8 ls -s example.id3
99 # [[[endsection]]]
1010
1111 # [[[section ART_TIT_SET]]]
5555 # [[[endsection]]]
5656
5757 # [[[section LAME_PLUGIN]]]
58 eyeD3 -P lameinfo src/test/data/notag-vbr.mp3
58 eyeD3 -P lameinfo tests/data/notag-vbr.mp3
5959 # [[[endsection]]]
6060
6161 # [[[section PLUGINS_LIST]]]
0 from __future__ import print_function
10 import eyed3
21 from eyed3.plugins import Plugin
32 from eyed3.utils import guessMimetype
43
5 eyed3.require((0, 7))
64
75 class EchoPlugin(eyed3.plugins.Plugin):
86 NAMES = ["echo"]
0 # -*- coding: utf-8 -*-
1 from __future__ import print_function
2 import eyed3
30 from eyed3.plugins import LoaderPlugin
41
5 eyed3.require((0, 7))
62
73 class Echo2Plugin(LoaderPlugin):
84 SUMMARY = u"Displays details about audio files"
00 #!/usr/bin/env python
1 # -*- coding: utf-8 -*-
2 ################################################################################
3 # Copyright (C) 2012 Travis Shirk <travis@pobox.com>
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program. If not, see <http://www.gnu.org/licenses/>
17 ################################################################################
181 from eyed3.id3 import Tag
192 from eyed3.id3 import ID3_V1_0, ID3_V1_1, ID3_V2_3, ID3_V2_4
203
236 log.setLevel(logging.DEBUG)
247
258 t = Tag()
26 t.artist = u"M.O.P."
27 t.title = u"How About Some Hardcore"
28 t.album = u"To The Death"
29 t.genre = u"Hip-Hop"
30 t.track_num = (3,5)
31 t.disc_num = (1,1)
9 t.artist = "M.O.P."
10 t.title = "How About Some Hardcore"
11 t.album = "To The Death"
12 t.genre = "Hip-Hop"
13 t.track_num = (3, 5)
14 t.disc_num = (1, 1)
3215
3316 t.original_release_date = "1994-04-07"
3417 t.release_date = "1994-04-07"
3619 t.recording_date = 1996
3720 t.tagging_date = "2012-2-5"
3821
39 t.comments.set(u"Gritty, yo!")
40 t.comments.set(u"Brownsville, Brooklyn", u"Origin")
22 t.comments.set("Gritty, yo!")
23 t.comments.set("Brownsville, Brooklyn", "Origin")
4124
42 t.user_text_frames.set(u"****", u"Rating")
25 t.user_text_frames.set("****", "Rating")
4326 t.artist_url = b"http://allmusic.com/artist/mop-p194909"
4427 t.user_url_frames.set(b"http://eyed3.nicfit.net/")
4528
4730 t.play_count = 125
4831 t.unique_file_ids.set(b"43e888e067ea107f964916af6259cbe7", "md5sum")
4932 t.cd_id = b"\x3c\x33\x4d\x41\x43\x59\x3c\x33"
50 t.privates.set("Secrets", "Billy Danzenie")
51 t.terms_of_use = u"Blunted"
52 t.lyrics.set(u"""
33 t.privates.set(b"Secrets", b"Billy Danzenie")
34 t.terms_of_use = "Blunted"
35 t.lyrics.set("""
5336 [ Billy Danzenie ]
5437 How about some hardcore?
5538 (Yeah, we like it raw!) (4x)
0 from .__regarding__ import * # noqa: F403
1
2 __project_name__ = project_name
3 __version__ = version
4 __version_info__ = version_info
5 __release__ = version_info.release
6 __release_name__ = release_name
7 __years__ = years
8
9 __project_slug__ = "eyed3"
10 __pypi_name__ = "eyeD3"
11 __author__ = author
12 __author_email__ = author_email
13 __url__ = homepage
14 __description__ = description
15 # FIXME: __long_description__ not being used anywhere.
16 __long_description__ = """
17 eyeD3 is a Python module and command line program for processing ID3 tags.
18 Information about mp3 files (i.e bit rate, sample frequency,
19 play time, etc.) is also provided. The formats supported are ID3
20 v1.0/v1.1 and v2.3/v2.4.
21 """
22 __license__ = "GNU GPL v3.0"
23 __github_url__ = "https://github.com/nicfit/eyeD3",
24 __version_txt__ = """%(__project_name__)s %(__version__)s © Copyright %(__years__)s %(__author__)s
25 This program comes with ABSOLUTELY NO WARRANTY! See LICENSE for details.
26 Run with --help/-h for usage information or read the docs at
27 %(__url__)s""" % (locals())
0 import sys
1 import codecs
2 import locale
3 from .__about__ import __version__ as version
4
5 _DEFAULT_ENCODING = "latin1"
6 # The local encoding, used when parsing command line options, console output,
7 # etc. The default is always ``latin1`` if it cannot be determined, it is NOT
8 # the value shown.
9 LOCAL_ENCODING = locale.getpreferredencoding(do_setlocale=True)
10 if not LOCAL_ENCODING or LOCAL_ENCODING == "ANSI_X3.4-1968": # pragma: no cover
11 LOCAL_ENCODING = _DEFAULT_ENCODING
12
13 # The local file system encoding, the default is ``latin1`` if it cannot be determined.
14 LOCAL_FS_ENCODING = sys.getfilesystemencoding()
15 if not LOCAL_FS_ENCODING: # pragma: no cover
16 LOCAL_FS_ENCODING = _DEFAULT_ENCODING
17
18
19 class Error(Exception):
20 """Base exception type for all eyed3 errors."""
21 def __init__(self, *args):
22 super().__init__(*args)
23 if args:
24 # The base class will do exactly this if len(args) == 1,
25 # but not when > 1. Note, the 2.7 base class will, 3 will not.
26 # Make it so.
27 self.message = args[0]
28
29
30 from .utils.log import log # noqa: E402
31 from .core import load # noqa: E402
32
33 del sys
34 del codecs
35 del locale
36
37 __all__ = ["log", "load", "version", "LOCAL_ENCODING", "LOCAL_FS_ENCODING",
38 "Error"]
0 """
1 ~~~~~~~~~~ DO NOT EDIT THIS FILE! Autogenerated by `regarding` ~~~~~~~~~~
2 https://github.com/nicfit/regarding
3 """
4 import dataclasses
5
6 __all__ = ["Version", "project_name", "version", "version_info", "release_name",
7 "author", "author_email", "years", "description", "homepage"]
8
9
10 @dataclasses.dataclass
11 class Version:
12 major: int
13 minor: int
14 maint: int
15 release: str
16 release_name: str
17
18
19 project_name = "eyeD3"
20 version = "0.9.6"
21 release_name = "True Blue"
22 author = "Travis Shirk"
23 author_email = "travis@pobox.com"
24 years = "2002-2020"
25 version_info = Version(
26 0, 9, 6,
27 "final", "True Blue"
28 )
29 description = "Python audio data toolkit (ID3 and MP3)"
30 homepage = ""
0 """Basic core types and utilities."""
1 import os
2 import time
3 import functools
4 import pathlib
5 import dataclasses
6
7 from . import LOCAL_FS_ENCODING
8 from .utils.log import getLogger
9 log = getLogger(__name__)
10
11 # Audio type selector for no audio.
12 AUDIO_NONE = 0
13 # Audio type selector for MPEG (mp3) audio.
14 AUDIO_MP3 = 1
15
16
17 AUDIO_TYPES = (AUDIO_NONE, AUDIO_MP3)
18
19 LP_TYPE = "lp"
20 EP_TYPE = "ep"
21 EP_MAX_SIZE_HINT = 6
22 COMP_TYPE = "compilation"
23 LIVE_TYPE = "live"
24 VARIOUS_TYPE = "various"
25 DEMO_TYPE = "demo"
26 SINGLE_TYPE = "single"
27 ALBUM_TYPE_IDS = [LP_TYPE, EP_TYPE, COMP_TYPE, LIVE_TYPE, VARIOUS_TYPE,
28 DEMO_TYPE, SINGLE_TYPE]
29
30 VARIOUS_ARTISTS = "Various Artists"
31
32 # A key that can be used in a TXXX frame to specify the type of collection
33 # (or album) a file belongs. See :class:`eyed3.core.ALBUM_TYPE_IDS`.
34 TXXX_ALBUM_TYPE = "eyeD3#album_type"
35
36 # A key that can be used in a TXXX frame to specify the origin of an
37 # artist/band. i.e. where they are from.
38 # The format is: city<tab>state<tab>country
39 TXXX_ARTIST_ORIGIN = "eyeD3#artist_origin"
40
41
42 @dataclasses.dataclass
43 class ArtistOrigin:
44 city: str
45 state: str
46 country: str
47
48 def __bool__(self):
49 return bool(self.city or self.state or self.country)
50
51 def id3Encode(self):
52 return "\t".join([(o if o else "") for o in dataclasses.astuple(self)])
53
54
55 class AudioInfo:
56 """A base container for common audio details."""
57
58 # The number of seconds of audio data (i.e., the playtime)
59 time_secs = 0.0
60 # The number of bytes of audio data.
61 size_bytes = 0
62
63
64 class Tag:
65 """An abstract interface for audio tag (meta) data (e.g. artist, title,
66 etc.)
67 """
68
69 read_only = False
70
71 def _setArtist(self, val):
72 raise NotImplementedError() # pragma: nocover
73
74 def _getArtist(self):
75 raise NotImplementedError() # pragma: nocover
76
77 def _getAlbumArtist(self):
78 raise NotImplementedError() # pragma: nocover
79
80 def _setAlbumArtist(self, val):
81 raise NotImplementedError() # pragma: nocover
82
83 def _setAlbum(self, val):
84 raise NotImplementedError() # pragma: nocover
85
86 def _getAlbum(self):
87 raise NotImplementedError() # pragma: nocover
88
89 def _setTitle(self, val):
90 raise NotImplementedError() # pragma: nocover
91
92 def _getTitle(self):
93 raise NotImplementedError() # pragma: nocover
94
95 def _setTrackNum(self, val):
96 raise NotImplementedError() # pragma: nocover
97
98 def _getTrackNum(self):
99 raise NotImplementedError() # pragma: nocover
100
101 @property
102 def artist(self):
103 return self._getArtist()
104
105 @artist.setter
106 def artist(self, v):
107 self._setArtist(v)
108
109 @property
110 def album_artist(self):
111 return self._getAlbumArtist()
112
113 @album_artist.setter
114 def album_artist(self, v):
115 self._setAlbumArtist(v)
116
117 @property
118 def album(self):
119 return self._getAlbum()
120
121 @album.setter
122 def album(self, v):
123 self._setAlbum(v)
124
125 @property
126 def title(self):
127 return self._getTitle()
128
129 @title.setter
130 def title(self, v):
131 self._setTitle(v)
132
133 @property
134 def track_num(self):
135 """Track number property.
136 Must return a 2-tuple of (track-number, total-number-of-tracks).
137 Either tuple value may be ``None``.
138 """
139 return self._getTrackNum()
140
141 @track_num.setter
142 def track_num(self, v):
143 self._setTrackNum(v)
144
145 def __init__(self, title=None, artist=None, album=None, album_artist=None, track_num=None):
146 self.title = title
147 self.artist = artist
148 self.album = album
149 self.album_artist = album_artist
150 self.track_num = track_num
151
152
153 class AudioFile:
154 """Abstract base class for audio file types (AudioInfo + Tag)"""
155
156 def _read(self):
157 """Subclasses MUST override this method and set ``self._info``,
158 ``self._tag`` and ``self.type``.
159 """
160 raise NotImplementedError()
161
162 def initTag(self, version=None):
163 raise NotImplementedError()
164
165 def rename(self, name, fsencoding=LOCAL_FS_ENCODING,
166 preserve_file_time=False):
167 """Rename the file to ``name``.
168 The encoding used for the file name is :attr:`eyed3.LOCAL_FS_ENCODING`
169 unless overridden by ``fsencoding``. Note, if the target file already
170 exists, or the full path contains non-existent directories the
171 operation will fail with :class:`IOError`.
172 File times are not modified when ``preserve_file_time`` is ``True``,
173 ``False`` is the default.
174 """
175 curr_path = pathlib.Path(self.path)
176 ext = curr_path.suffix
177
178 new_path = curr_path.parent / "{name}{ext}".format(**locals())
179 if new_path.exists():
180 raise IOError("File '%s' exists, will not overwrite" % new_path)
181 elif not new_path.parent.exists():
182 raise IOError("Target directory '%s' does not exists, will not "
183 "create" % new_path.parent)
184
185 os.rename(self.path, str(new_path))
186 if self.tag:
187 self.tag.file_info.name = str(new_path)
188 if preserve_file_time:
189 self.tag.file_info.touch((self.tag.file_info.atime,
190 self.tag.file_info.mtime))
191
192 self.path = str(new_path)
193
194 @property
195 def path(self):
196 """The absolute path of this file."""
197 return self._path
198
199 @path.setter
200 def path(self, path):
201 """Set the path"""
202 if isinstance(path, pathlib.Path):
203 path = str(path)
204 self._path = path
205
206 @property
207 def info(self):
208 """Returns a concrete implemenation of :class:`eyed3.core.AudioInfo`"""
209 return self._info
210
211 @property
212 def tag(self):
213 """Returns a concrete implemenation of :class:`eyed3.core.Tag`"""
214 return self._tag
215
216 @tag.setter
217 def tag(self, t):
218 self._tag = t
219
220 def __init__(self, path):
221 """Construct with a path and invoke ``_read``.
222 All other members are set to None."""
223 if isinstance(path, pathlib.Path):
224 path = str(path)
225 self.path = path
226
227 self.type = None
228 self._info = None
229 self._tag = None
230 self._read()
231
232
233 @functools.total_ordering
234 class Date:
235 """
236 A class for representing a date and time (optional). This class differs
237 from ``datetime.datetime`` in that the default values for month, day,
238 hour, minute, and second is ``None`` and not 'January 1, 00:00:00'.
239 This allows for an object that is simply 1987, and not January 1 12AM,
240 for example. But when more resolution is required those vales can be set
241 as well.
242 """
243
244 TIME_STAMP_FORMATS = ["%Y",
245 "%Y-%m",
246 "%Y-%m-%d",
247 "%Y-%m-%dT%H",
248 "%Y-%m-%dT%H:%M",
249 "%Y-%m-%dT%H:%M:%S",
250 # The following end with 'Z' signally time is UTC
251 "%Y-%m-%dT%HZ",
252 "%Y-%m-%dT%H:%MZ",
253 "%Y-%m-%dT%H:%M:%SZ",
254 # The following are wrong per the specs, but ...
255 "%Y-%m-%d %H:%M:%S",
256 "%Y-00-00",
257 "%Y%m%d",
258 ]
259 """Valid time stamp formats per ISO 8601 and used by `strptime`."""
260
261 def __init__(self, year, month=None, day=None,
262 hour=None, minute=None, second=None):
263 # Validate with datetime
264 from datetime import datetime
265 _ = datetime(year, month if month is not None else 1,
266 day if day is not None else 1,
267 hour if hour is not None else 0,
268 minute if minute is not None else 0,
269 second if second is not None else 0)
270
271 self._year = year
272 self._month = month
273 self._day = day
274 self._hour = hour
275 self._minute = minute
276 self._second = second
277
278 # Python's date classes do a lot more date validation than does not
279 # need to be duplicated here. Validate it
280 _ = Date._validateFormat(str(self)) # noqa
281
282 @property
283 def year(self):
284 return self._year
285
286 @property
287 def month(self):
288 return self._month
289
290 @property
291 def day(self):
292 return self._day
293
294 @property
295 def hour(self):
296 return self._hour
297
298 @property
299 def minute(self):
300 return self._minute
301
302 @property
303 def second(self):
304 return self._second
305
306 def __eq__(self, rhs):
307 if not rhs:
308 return False
309
310 return (self.year == rhs.year and
311 self.month == rhs.month and
312 self.day == rhs.day and
313 self.hour == rhs.hour and
314 self.minute == rhs.minute and
315 self.second == rhs.second)
316
317 def __ne__(self, rhs):
318 return not(self == rhs)
319
320 def __lt__(self, rhs):
321 if not rhs:
322 return False
323
324 for left, right in ((self.year, rhs.year),
325 (self.month, rhs.month),
326 (self.day, rhs.day),
327 (self.hour, rhs.hour),
328 (self.minute, rhs.minute),
329 (self.second, rhs.second)):
330
331 left = left if left is not None else -1
332 right = right if right is not None else -1
333
334 if left < right:
335 return True
336 elif left > right:
337 return False
338
339 return False
340
341 def __hash__(self):
342 return hash(str(self))
343
344 @staticmethod
345 def _validateFormat(s):
346 pdate, fmt = None, None
347 for fmt in Date.TIME_STAMP_FORMATS:
348 try:
349 pdate = time.strptime(s, fmt)
350 break
351 except ValueError:
352 # date string did not match format.
353 continue
354
355 if pdate is None:
356 raise ValueError(f"Invalid date string: {s}")
357
358 assert pdate
359 return pdate, fmt
360
361 @staticmethod
362 def parse(s):
363 """Parses date strings that conform to ISO-8601."""
364 if not isinstance(s, str):
365 s = s.decode("ascii")
366 s = s.strip('\x00')
367
368 pdate, fmt = Date._validateFormat(s)
369
370 # Here is the difference with Python date/datetime objects, some
371 # of the members can be None
372 kwargs = {}
373 if "%m" in fmt:
374 kwargs["month"] = pdate.tm_mon
375 if "%d" in fmt:
376 kwargs["day"] = pdate.tm_mday
377 if "%H" in fmt:
378 kwargs["hour"] = pdate.tm_hour
379 if "%M" in fmt:
380 kwargs["minute"] = pdate.tm_min
381 if "%S" in fmt:
382 kwargs["second"] = pdate.tm_sec
383
384 return Date(pdate.tm_year, **kwargs)
385
386 def __str__(self):
387 """Returns date strings that conform to ISO-8601.
388 The returned string will be no larger than 17 characters."""
389 s = "%d" % self.year
390 if self.month:
391 s += "-%s" % str(self.month).rjust(2, '0')
392 if self.day:
393 s += "-%s" % str(self.day).rjust(2, '0')
394 if self.hour is not None:
395 s += "T%s" % str(self.hour).rjust(2, '0')
396 if self.minute is not None:
397 s += ":%s" % str(self.minute).rjust(2, '0')
398 if self.second is not None:
399 s += ":%s" % str(self.second).rjust(2, '0')
400 return s
401
402
403 def parseError(ex):
404 """A function that is invoked when non-fatal parse, format, etc. errors
405 occur. In most cases the invalid values will be ignored or possibly fixed.
406 This function simply logs the error."""
407 log.warning(ex)
408
409
410 def load(path, tag_version=None) -> AudioFile:
411 """Loads the file identified by ``path`` and returns a concrete type of
412 :class:`eyed3.core.AudioFile`. If ``path`` is not a file an ``IOError`` is
413 raised. ``None`` is returned when the file type (i.e. mime-type) is not
414 recognized.
415 The following AudioFile types are supported:
416
417 * :class:`eyed3.mp3.Mp3AudioFile` - For mp3 audio files.
418 * :class:`eyed3.id3.TagFile` - For raw ID3 data files.
419
420 If ``tag_version`` is not None (the default) only a specific version of
421 metadata is loaded. This value must be a version constant specific to the
422 eventual format of the metadata.
423 """
424 from . import mp3, id3
425 from .mimetype import guessMimetype
426
427 if not isinstance(path, pathlib.Path):
428 path = pathlib.Path(path)
429 log.debug(f"Loading file: {path}")
430
431 if path.exists():
432 if not path.is_file():
433 raise IOError(f"not a file: {path}")
434 else:
435 raise IOError(f"file not found: {path}")
436
437 mtype = guessMimetype(path)
438 log.debug(f"File mime-type: {mtype}")
439
440 if mtype in mp3.MIME_TYPES:
441 return mp3.Mp3AudioFile(path, tag_version)
442 elif mtype == id3.ID3_MIME_TYPE:
443 return id3.TagFile(path, tag_version)
444 else:
445 return None
0 import re
1 import functools
2
3 from .. import core
4 from .. import Error
5 from ..utils.log import getLogger
6
7 log = getLogger(__name__)
8
9 # Version 1, 1.0 or 1.1
10 ID3_V1 = (1, None, None)
11 # Version 1.0, specifically
12 ID3_V1_0 = (1, 0, 0)
13 # Version 1.1, specifically
14 ID3_V1_1 = (1, 1, 0)
15 # Version 2, 2.2, 2.3 or 2.4
16 ID3_V2 = (2, None, None)
17 # Version 2.2, specifically
18 ID3_V2_2 = (2, 2, 0)
19 # Version 2.3, specifically
20 ID3_V2_3 = (2, 3, 0)
21 # Version 2.4, specifically
22 ID3_V2_4 = (2, 4, 0)
23 # The default version for eyeD3 tags and save operations.
24 ID3_DEFAULT_VERSION = ID3_V2_4
25 # Useful for operations where any version will suffice.
26 ID3_ANY_VERSION = (ID3_V1[0] | ID3_V2[0], None, None)
27
28 # Byte code for latin1
29 LATIN1_ENCODING = b"\x00"
30 # Byte code for UTF-16
31 UTF_16_ENCODING = b"\x01"
32 # Byte code for UTF-16 (big endian)
33 UTF_16BE_ENCODING = b"\x02"
34 # Byte code for UTF-8 (Not supported in ID3 versions < 2.4)
35 UTF_8_ENCODING = b"\x03"
36
37 # Default language code for frames that contain a language portion.
38 DEFAULT_LANG = b"eng"
39
40 ID3_MIME_TYPE = "application/x-id3"
41 ID3_MIME_TYPE_EXTENSIONS = (".id3", ".tag")
42
43
44 def isValidVersion(v, fully_qualified=False):
45 """Check the tuple ``v`` against the list of valid ID3 version constants.
46 If ``fully_qualified`` is ``True`` it is enforced that there are 3
47 components to the version in ``v``. Returns ``True`` when valid and
48 ``False`` otherwise."""
49 valid = v in [ID3_V1, ID3_V1_0, ID3_V1_1,
50 ID3_V2, ID3_V2_2, ID3_V2_3, ID3_V2_4,
51 ID3_ANY_VERSION]
52 if not valid:
53 return False
54
55 if fully_qualified:
56 return None not in (v[0], v[1], v[2])
57 else:
58 return True
59
60
61 def normalizeVersion(v):
62 """If version tuple ``v`` is of the non-specific type (v1 or v2, any, etc.)
63 a fully qualified version is returned."""
64 if v == ID3_V1:
65 v = ID3_V1_1
66 elif v == ID3_V2:
67 assert ID3_DEFAULT_VERSION[0] & ID3_V2[0]
68 v = ID3_DEFAULT_VERSION
69 elif v == ID3_ANY_VERSION:
70 v = ID3_DEFAULT_VERSION
71
72 # Now, correct bogus version as seen in the wild
73 if v[:2] == (2, 2) and v[2] != 0:
74 v = (2, 2, 0)
75
76 return v
77
78
79 # Convert an ID3 version constant to a display string
80 def versionToString(v):
81 """Conversion version tuple ``v`` to a string description."""
82 if v == ID3_ANY_VERSION:
83 return "v1.x/v2.x"
84 elif v[0] == 1:
85 if v == ID3_V1_0:
86 return "v1.0"
87 elif v == ID3_V1_1:
88 return "v1.1"
89 elif v == ID3_V1:
90 return "v1.x"
91 elif v[0] == 2:
92 if v == ID3_V2_2:
93 return "v2.2"
94 elif v == ID3_V2_3:
95 return "v2.3"
96 elif v == ID3_V2_4:
97 return "v2.4"
98 elif v == ID3_V2:
99 return "v2.x"
100 raise ValueError("Invalid ID3 version constant: %s" % str(v))
101
102
103 class GenreException(Error):
104 """Excpetion type for exceptions related to genres."""
105
106
107 @functools.total_ordering
108 class Genre:
109 """A genre in terms of a ``name`` and and ``id``. Only when ``name`` is
110 a "standard" genre (as defined by ID3 v1) will ``id`` be a value other
111 than ``None``."""
112
113 def __init__(self, name=None, id: int=None, genre_map=None):
114 """Constructor takes an optional name and ID. If `id` is
115 provided the `name`, regardless of value, is set to the string the
116 id maps to. Likewise, if `name` is passed and is a standard genre the
117 id is set to the correct value. Any invalid id values cause a
118 `ValueError` to be raised. Genre names that are not in the standard
119 list are still accepted but the `id` value is set to `None`."""
120 self._id, self._name = None, None
121 self._genre_map = genre_map or genres
122 if not name and id is None:
123 return
124
125 # An ID always takes precedence
126 if id is not None:
127 try:
128 self.id = id
129 # valid id will set name
130 if name and name != self.name:
131 log.warning(f"Genre ID takes precedence and remapped '{name}' to '{self.name}'")
132 except ValueError:
133 log.warning(f"Invalid numeric genre ID: {id}")
134 if not name:
135 # Gave an invalid ID and no name to fallback on
136 raise
137 self.name = name
138 self.id = None
139 else:
140 # All we have is a name
141 self.name = name
142
143 assert self.id or self.name
144
145 @property
146 def id(self):
147 """The Genre's id property.
148 When setting the value is strictly enforced and if the value is not
149 a valid genre code a ``ValueError`` is raised. Otherwise the id is
150 set **and** the ``name`` property is updated to the code's string
151 name.
152 """
153 return self._id
154
155 @id.setter
156 def id(self, val):
157 if val is None:
158 self._id = None
159 return
160
161 val = int(val)
162 if val not in self._genre_map.keys() or not self._genre_map[val]:
163 raise ValueError(f"Unknown genre ID: {val}")
164
165 name = self._genre_map[val]
166 self._id = val
167 self._name = name
168
169 @property
170 def name(self):
171 """The Genre's name property.
172 When setting the value the name is looked up in the standard genre
173 map and if found the ``id`` ppropery is set to the numeric valud **and**
174 the name is normalized to the sting found in the map. Non standard
175 genres are set (with a warning log) and the ``id`` is set to ``None``.
176 It is valid to set the value to ``None``.
177 """
178 return self._name
179
180 @name.setter
181 def name(self, val):
182 if val is None:
183 self._name = None
184 return
185
186 if val.lower() in list(self._genre_map.keys()):
187 self._id = self._genre_map[val]
188 # normalize the name
189 self._name = self._genre_map[self._id]
190 else:
191 log.warning(f"Non standard genre name: {val}")
192 self._id = None
193 self._name = val
194
195 @staticmethod
196 def parse(g_str, id3_std=True):
197 """Parses genre information from `genre_str`.
198 The following formats are supported:
199 01, 2, 23, 125 - ID3 v1.x style.
200 (01), (2), (129)Hardcore, (9)Metal, Indie - ID3v2 style with and without
201 refinement.
202 Raises GenreException when an invalid string is passed.
203 """
204
205 g_str = g_str.strip()
206 if not g_str:
207 return None
208
209 def strip0Padding(s):
210 if len(s) > 1:
211 return s.lstrip("0")
212 else:
213 return s
214
215 if id3_std:
216 # ID3 v1 style.
217 # Match 03, 34, 129.
218 if re.compile(r"[0-9][0-9]*$").match(g_str):
219 return Genre(id=int(strip0Padding(g_str)))
220
221 # ID3 v2 style.
222 # Match (03), (0)Blues, (15) Rap
223 v23_match = re.compile(r"\(([0-9][0-9]*)\)(.*)$").match(g_str)
224 if v23_match:
225 (gid, name) = v23_match.groups()
226
227 gid = int(strip0Padding(gid))
228 if gid and name:
229 gid = gid
230 name = name.strip()
231 else:
232 gid = gid
233 name = None
234
235 return Genre(id=gid, name=name)
236
237 return Genre(id=None, name=g_str)
238
239 def __str__(self):
240 s = ""
241 if self.id is not None:
242 s += f"({self.id:d})"
243 if self.name:
244 s += self.name
245 return s
246
247 def __eq__(self, rhs):
248 if not rhs:
249 return False
250 elif type(rhs) is str:
251 return self.name == rhs
252 else:
253 return self.id == rhs.id and self.name == rhs.name
254
255 def __lt__(self, rhs):
256 if not rhs:
257 return False
258 elif type(rhs) is str:
259 return self.name == rhs
260 else:
261 return self.name < rhs.name
262
263
264 class GenreMap(dict):
265 """Classic genres defined around ID3 v1 but suitable anywhere. This class
266 is used primarily as a way to map numeric genre values to a string name.
267 Genre strings on the other hand are not required to exist in this list.
268 """
269 GENRE_MIN = 0
270 GENRE_MAX = None
271 ID3_GENRE_MIN = 0
272 ID3_GENRE_MAX = 79
273 WINAMP_GENRE_MIN = 80
274 WINAMP_GENRE_MAX = 191
275 GENRE_ID3V1_MAX = 255
276
277 def __init__(self, *args):
278 """The optional ``*args`` are passed directly to the ``dict``
279 constructor."""
280 global ID3_GENRES
281 super().__init__(*args)
282
283 # ID3 genres as defined by the v1.1 spec with WinAmp extensions.
284 for i, g in enumerate(ID3_GENRES):
285 self[i] = g
286 self[g.lower() if g else None] = i
287
288 GenreMap.GENRE_MAX = len(ID3_GENRES) - 1
289 # Pad up to 255
290 for i in range(GenreMap.GENRE_MAX + 1, 255 + 1):
291 self[i] = None
292 self[None] = 255
293
294 def get(self, key):
295 if type(key) is int:
296 name, gid = self[key], key
297 else:
298 gid = self[key]
299 name = self[gid]
300 return Genre(name, id=gid, genre_map=self)
301
302 def __getitem__(self, key):
303 if key and type(key) is not int:
304 key = key.lower()
305 return super().__getitem__(key)
306
307 @property
308 def ids(self):
309 return list(sorted([k for k in self.keys() if type(k) is int and self[k]]))
310
311 def iter(self):
312 for gid in self.ids:
313 g = self[gid]
314 if g:
315 yield Genre(g, id=gid)
316
317
318 class TagFile(core.AudioFile):
319 """
320 A shim class for dealing with files that contain only ID3 data, no audio.
321 """
322 def __init__(self, path, version=ID3_ANY_VERSION):
323 self._tag_version = version
324 core.AudioFile.__init__(self, path)
325 assert(self.type == core.AUDIO_NONE)
326
327 def _read(self):
328
329 with open(self.path, 'rb') as file_obj:
330 tag = Tag()
331 tag_found = tag.parse(file_obj, self._tag_version)
332 self._tag = tag if tag_found else None
333
334 self.type = core.AUDIO_NONE
335
336 def initTag(self, version=ID3_DEFAULT_VERSION):
337 """Add a id3.Tag to the file (removing any existing tag if one exists).
338 """
339 self.tag = Tag()
340 self.tag.version = version
341 self.tag.file_info = FileInfo(self.path)
342
343
344 # ID3 genres, as defined in ID3 v1. The position in the list is the genre's numeric byte value.
345 ID3_GENRES = [
346 'Blues',
347 'Classic Rock',
348 'Country',
349 'Dance',
350 'Disco',
351 'Funk',
352 'Grunge',
353 'Hip-Hop',
354 'Jazz',
355 'Metal',
356 'New Age',
357 'Oldies',
358 'Other',
359 'Pop',
360 'R&B',
361 'Rap',
362 'Reggae',
363 'Rock',
364 'Techno',
365 'Industrial',
366 'Alternative',
367 'Ska',
368 'Death Metal',
369 'Pranks',
370 'Soundtrack',
371 'Euro-Techno',
372 'Ambient',
373 'Trip-Hop',
374 'Vocal',
375 'Jazz+Funk',
376 'Fusion',
377 'Trance',
378 'Classical',
379 'Instrumental',
380 'Acid',
381 'House',
382 'Game',
383 'Sound Clip',
384 'Gospel',
385 'Noise',
386 'AlternRock',
387 'Bass',
388 'Soul',
389 'Punk',
390 'Space',
391 'Meditative',
392 'Instrumental Pop',
393 'Instrumental Rock',
394 'Ethnic',
395 'Gothic',
396 'Darkwave',
397 'Techno-Industrial',
398 'Electronic',
399 'Pop-Folk',
400 'Eurodance',
401 'Dream',
402 'Southern Rock',
403 'Comedy',
404 'Cult',
405 'Gangsta Rap',
406 'Top 40',
407 'Christian Rap',
408 'Pop / Funk',
409 'Jungle',
410 'Native American',
411 'Cabaret',
412 'New Wave',
413 'Psychedelic',
414 'Rave',
415 'Showtunes',
416 'Trailer',
417 'Lo-Fi',
418 'Tribal',
419 'Acid Punk',
420 'Acid Jazz',
421 'Polka',
422 'Retro',
423 'Musical',
424 'Rock & Roll',
425 'Hard Rock',
426 'Folk',
427 'Folk-Rock',
428 'National Folk',
429 'Swing',
430 'Fast Fusion',
431 'Bebob',
432 'Latin',
433 'Revival',
434 'Celtic',
435 'Bluegrass',
436 'Avantgarde',
437 'Gothic Rock',
438 'Progressive Rock',
439 'Psychedelic Rock',
440 'Symphonic Rock',
441 'Slow Rock',
442 'Big Band',
443 'Chorus',
444 'Easy Listening',
445 'Acoustic',
446 'Humour',
447 'Speech',
448 'Chanson',
449 'Opera',
450 'Chamber Music',
451 'Sonata',
452 'Symphony',
453 'Booty Bass',
454 'Primus',
455 'Porn Groove',
456 'Satire',
457 'Slow Jam',
458 'Club',
459 'Tango',
460 'Samba',
461 'Folklore',
462 'Ballad',
463 'Power Ballad',
464 'Rhythmic Soul',
465 'Freestyle',
466 'Duet',
467 'Punk Rock',
468 'Drum Solo',
469 'A Cappella',
470 'Euro-House',
471 'Dance Hall',
472 'Goa',
473 'Drum & Bass',
474 'Club-House',
475 'Hardcore',
476 'Terror',
477 'Indie',
478 'BritPop',
479 'Negerpunk',
480 'Polsk Punk',
481 'Beat',
482 'Christian Gangsta Rap',
483 'Heavy Metal',
484 'Black Metal',
485 'Crossover',
486 'Contemporary Christian',
487 'Christian Rock',
488 'Merengue',
489 'Salsa',
490 'Thrash Metal',
491 'Anime',
492 'JPop',
493 'Synthpop',
494 # https://de.wikipedia.org/wiki/Liste_der_ID3v1-Genres
495 'Abstract',
496 'Art Rock',
497 'Baroque',
498 'Bhangra',
499 'Big Beat',
500 'Breakbeat',
501 'Chillout',
502 'Downtempo',
503 'Dub',
504 'EBM',
505 'Eclectic',
506 'Electro',
507 'Electroclash',
508 'Emo',
509 'Experimental',
510 'Garage',
511 'Global',
512 'IDM',
513 'Illbient',
514 'Industro-Goth',
515 'Jam Band',
516 'Krautrock',
517 'Leftfield',
518 'Lounge',
519 'Math Rock',
520 'New Romantic',
521 'Nu-Breakz',
522 'Post-Punk',
523 'Post-Rock',
524 'Psytrance',
525 'Shoegaze',
526 'Space Rock',
527 'Trop Rock',
528 'World Music',
529 'Neoclassical',
530 'Audiobook',
531 'Audio Theatre',
532 'Neue Deutsche Welle',
533 'Podcast',
534 'Indie Rock',
535 'G-Funk',
536 'Dubstep',
537 'Garage Rock',
538 'Psybient',
539 ]
540
541 # A map of standard genre names and IDs per the ID3 v1 genre definition.
542 genres = GenreMap()
543
544 from . import frames # noqa
545 from .tag import Tag, TagException, TagTemplate, FileInfo # noqa
0 """
1 Here lies Apple frames, all of which are non-standard. All of these would have
2 been standard user text frames by anyone not being a bastard, on purpose.
3 """
4 from .frames import Frame, TextFrame
5
6 PCST_FID = b"PCST"
7 WFED_FID = b"WFED"
8 TKWD_FID = b"TKWD"
9 TDES_FID = b"TDES"
10 TGID_FID = b"TGID"
11 GRP1_FID = b"GRP1"
12
13
14 class PCST(Frame):
15 """Indicates a podcast. The 4 bytes of data is undefined, and is typically all 0."""
16
17 def __init__(self, _=None):
18 super().__init__(PCST_FID)
19
20 def render(self):
21 self.data = b"\x00" * 4
22 return super(PCST, self).render()
23
24
25 class TKWD(TextFrame):
26 """Podcast keywords."""
27
28 def __init__(self, _=None, **kwargs):
29 super().__init__(TKWD_FID, **kwargs)
30
31
32 class TDES(TextFrame):
33 """Podcast description. One encoding byte followed by text per encoding."""
34
35 def __init__(self, _=None, **kwargs):
36 super().__init__(TDES_FID, **kwargs)
37
38
39 class TGID(TextFrame):
40 """Podcast URL of the audio file. This should be a W frame!"""
41
42 def __init__(self, _=None, **kwargs):
43 super().__init__(TGID_FID, **kwargs)
44
45
46 class WFED(TextFrame):
47 """Another podcast URL, the feed URL it is said."""
48
49 def __init__(self, _=None, url=""):
50 super().__init__(WFED_FID, url)
51
52
53 class GRP1(TextFrame):
54 """Apple grouping, could be a TIT1 conversion."""
55
56 def __init__(self, _=None, **kwargs):
57 super().__init__(GRP1_FID, **kwargs)
0 import dataclasses
1 from io import BytesIO
2 from collections import namedtuple
3
4 from .. import core
5 from ..utils import requireUnicode, requireBytes
6 from ..utils.binfuncs import (
7 bin2bytes, bin2dec, bytes2bin, dec2bin, bytes2dec, dec2bytes,
8 signedInt162bytes, bytes2signedInt16,
9 )
10 from .. import Error
11 from . import ID3_V2, ID3_V2_2, ID3_V2_3, ID3_V2_4
12 from . import (LATIN1_ENCODING, UTF_8_ENCODING, UTF_16BE_ENCODING,
13 UTF_16_ENCODING, DEFAULT_LANG)
14 from .headers import FrameHeader
15 from ..utils import b
16 from ..utils.log import getLogger
17
18 log = getLogger(__name__)
19 ISO_8859_1 = "iso-8859-1"
20
21
22 class FrameException(Error):
23 pass
24
25
26 TITLE_FID = b"TIT2" # noqa
27 SUBTITLE_FID = b"TIT3" # noqa
28 ARTIST_FID = b"TPE1" # noqa
29 ALBUM_ARTIST_FID = b"TPE2" # noqa
30 ORIG_ARTIST_FID = b"TOPE" # noqa
31 COMPOSER_FID = b"TCOM" # noqa
32 ALBUM_FID = b"TALB" # noqa
33 TRACKNUM_FID = b"TRCK" # noqa
34 GENRE_FID = b"TCON" # noqa
35 COMMENT_FID = b"COMM" # noqa
36 USERTEXT_FID = b"TXXX" # noqa
37 OBJECT_FID = b"GEOB" # noqa
38 UNIQUE_FILE_ID_FID = b"UFID" # noqa
39 LYRICS_FID = b"USLT" # noqa
40 DISCNUM_FID = b"TPOS" # noqa
41 IMAGE_FID = b"APIC" # noqa
42 USERURL_FID = b"WXXX" # noqa
43 PLAYCOUNT_FID = b"PCNT" # noqa
44 BPM_FID = b"TBPM" # noqa
45 PUBLISHER_FID = b"TPUB" # noqa
46 CDID_FID = b"MCDI" # noqa
47 PRIVATE_FID = b"PRIV" # noqa
48 TOS_FID = b"USER" # noqa
49 POPULARITY_FID = b"POPM" # noqa
50 ENCODED_BY_FID = b"TENC" # noqa
51 COPYRIGHT_FID = b"TCOP" # noqa
52
53 URL_COMMERCIAL_FID = b"WCOM" # noqa
54 URL_COPYRIGHT_FID = b"WCOP" # noqa
55 URL_AUDIOFILE_FID = b"WOAF" # noqa
56 URL_ARTIST_FID = b"WOAR" # noqa
57 URL_AUDIOSRC_FID = b"WOAS" # noqa
58 URL_INET_RADIO_FID = b"WORS" # noqa
59 URL_PAYMENT_FID = b"WPAY" # noqa
60 URL_PUBLISHER_FID = b"WPUB" # noqa
61 URL_FIDS = [URL_COMMERCIAL_FID, URL_COPYRIGHT_FID, # noqa
62 URL_AUDIOFILE_FID, URL_ARTIST_FID, URL_AUDIOSRC_FID,
63 URL_INET_RADIO_FID, URL_PAYMENT_FID,
64 URL_PUBLISHER_FID]
65
66 TOC_FID = b"CTOC" # noqa
67 CHAPTER_FID = b"CHAP" # noqa
68
69 DEPRECATED_DATE_FIDS = [b"TDAT", b"TYER", b"TIME", b"TORY", b"TRDA",
70 # Nonstandard v2.3 only
71 b"XDOR",
72 ]
73 DATE_FIDS = [b"TDEN", b"TDOR", b"TDRC", b"TDRL", b"TDTG"]
74
75
76 class Frame(object):
77 @requireBytes(1)
78 def __init__(self, id):
79 self.id = id
80 self.header = None
81
82 self.decompressed_size = 0
83 self.group_id = None
84 self.encrypt_method = None
85 self.data = None
86 self.data_len = 0
87 self._encoding = None
88
89 @property
90 def header(self):
91 return self._header
92
93 @header.setter
94 def header(self, h):
95 self._header = h
96
97 @requireBytes(1)
98 def parse(self, data, frame_header):
99 self.id = frame_header.id
100 self.header = frame_header
101 self.data = self._disassembleFrame(data)
102
103 def render(self):
104 return self._assembleFrame(self.data)
105
106 def __lt__(self, other):
107 return self.id < other.id
108
109 @staticmethod
110 def decompress(data):
111 import zlib
112 log.debug("before decompression: %d bytes" % len(data))
113 data = zlib.decompress(data, 15)
114 log.debug("after decompression: %d bytes" % len(data))
115 return data
116
117 @staticmethod
118 def compress(data):
119 import zlib
120 log.debug("before compression: %d bytes" % len(data))
121 data = zlib.compress(data)
122 log.debug("after compression: %d bytes" % len(data))
123 return data
124
125 @staticmethod
126 def decrypt(data):
127 log.warning("Frame decryption not yet supported, leaving data as is.")
128 return data
129
130 @staticmethod
131 def encrypt(data):
132 log.warning("Frame encryption not yet supported, leaving data as is.")
133 return data
134
135 @requireBytes(1)
136 def _disassembleFrame(self, data):
137 assert self.header
138 header = self.header
139 # Format flags in the frame header may add extra data to the
140 # beginning of this data.
141 if header.minor_version <= 3:
142 # 2.3: compression(4), encryption(1), group(1)
143 if header.compressed:
144 self.decompressed_size = bin2dec(bytes2bin(data[:4]))
145 data = data[4:]
146 log.debug("Decompressed Size: %d" % self.decompressed_size)
147 if header.encrypted:
148 self.encrypt_method = bin2dec(bytes2bin(data[0:1]))
149 data = data[1:]
150 log.debug("Encryption Method: %d" % self.encrypt_method)
151 if header.grouped:
152 self.group_id = bin2dec(bytes2bin(data[0:1]))
153 data = data[1:]
154 log.debug("Group ID: %d" % self.group_id)
155 else:
156 # 2.4: group(1), encrypted(1), data_length_indicator (4,7)
157 if header.grouped:
158 self.group_id = bin2dec(bytes2bin(data[0:1]))
159 log.debug("Group ID: %d" % self.group_id)
160 data = data[1:]
161 if header.encrypted:
162 self.encrypt_method = bin2dec(bytes2bin(data[0:1]))
163 data = data[1:]
164 log.debug("Encryption Method: %d" % self.encrypt_method)
165 if header.data_length_indicator:
166 self.data_len = bin2dec(bytes2bin(data[:4], 7))
167 data = data[4:]
168 log.debug("Data Length: %d" % self.data_len)
169 if header.compressed:
170 self.decompressed_size = self.data_len
171 log.debug("Decompressed Size: %d" % self.decompressed_size)
172
173 if header.minor_version == 4 and header.unsync:
174 data = deunsyncData(data)
175 if header.encrypted:
176 data = self.decrypt(data)
177 if header.compressed:
178 data = self.decompress(data)
179
180 return data
181
182 @requireBytes(1)
183 def _assembleFrame(self, data):
184 assert self.header
185 header = self.header
186
187 # eyeD3 never writes unsync'd frames
188 header.unsync = False
189
190 format_data = b""
191 if header.minor_version == 3:
192 if header.compressed:
193 format_data += bin2bytes(dec2bin(len(data), 32))
194 if header.encrypted:
195 format_data += bin2bytes(dec2bin(self.encrypt_method, 8))
196 if header.grouped:
197 format_data += bin2bytes(dec2bin(self.group_id, 8))
198 else:
199 if header.grouped:
200 format_data += bin2bytes(dec2bin(self.group_id, 8))
201 if header.encrypted:
202 format_data += bin2bytes(dec2bin(self.encrypt_method, 8))
203 if header.compressed or header.data_length_indicator:
204 header.data_length_indicator = 1
205 format_data += bin2bytes(dec2bin(len(data), 32))
206
207 if header.compressed:
208 data = self.compress(data)
209
210 if header.encrypted:
211 data = self.encrypt(data)
212
213 self.data = format_data + data
214 return header.render(len(self.data)) + self.data
215
216 @property
217 def text_delim(self):
218 assert self.encoding is not None
219 return b"\x00\x00" if self.encoding in (UTF_16_ENCODING,
220 UTF_16BE_ENCODING) else b"\x00"
221
222 def _initEncoding(self):
223 assert self.header.version and len(self.header.version) == 3
224 curr_enc = self.encoding
225
226 if self.encoding is not None:
227 # Make sure the encoding is valid for this version
228 if self.header.version[:2] < (2, 4):
229 if self.header.version[0] == 1:
230 self.encoding = LATIN1_ENCODING
231 else:
232 if self.encoding > UTF_16_ENCODING:
233 # v2.3 cannot do utf16 BE or utf8
234 self.encoding = UTF_16_ENCODING
235 else:
236 if self.header.version[:2] < (2, 4):
237 if self.header.version[0] == 2:
238 self.encoding = UTF_16_ENCODING
239 else:
240 self.encoding = LATIN1_ENCODING
241 else:
242 self.encoding = UTF_8_ENCODING
243
244 log.debug(f"_initEncoding: was={curr_enc} now={self.encoding}")
245
246 @property
247 def encoding(self):
248 return self._encoding
249
250 @encoding.setter
251 def encoding(self, enc):
252 if not isinstance(enc, bytes):
253 raise TypeError("encoding argument must be a byte string.")
254 elif not LATIN1_ENCODING <= enc <= UTF_8_ENCODING:
255 log.warning("Unknown encoding value {}".format(enc))
256 enc = LATIN1_ENCODING
257 self._encoding = enc
258
259
260 class TextFrame(Frame):
261 """Text frames.
262 Data string format: encoding (one byte) + text
263 """
264 @requireUnicode("text")
265 def __init__(self, id, text=None):
266 super(TextFrame, self).__init__(id)
267 assert(self.id[0:1] == b'T' or self.id in [b"XSOA", b"XSOP", b"XSOT",
268 b"XDOR", b"WFED", b"GRP1"])
269 self.text = text or ""
270
271 @property
272 def text(self):
273 return self._text
274
275 @text.setter
276 @requireUnicode(1)
277 def text(self, txt):
278 self._text = txt
279
280 def parse(self, data, frame_header):
281 super().parse(data, frame_header)
282
283 try:
284 self.encoding = self.data[0:1]
285 text_data = self.data[1:]
286 except ValueError as err:
287 log.warning("TextFrame[{fid}] - {err}; using latin1"
288 .format(err=err, fid=self.id))
289 self.encoding = LATIN1_ENCODING
290 text_data = self.data[:]
291
292 try:
293 self.text = decodeUnicode(text_data, self.encoding)
294 except UnicodeDecodeError as err:
295 log.warning(f"Error decoding text frame {self.id}: {err}")
296 self.text = ""
297 log.debug("TextFrame text: %s" % self.text)
298
299 def render(self):
300 self._initEncoding()
301 self.data = (self.encoding +
302 self.text.encode(id3EncodingToString(self.encoding)))
303 assert type(self.data) is bytes
304 return super().render()
305
306
307 class UserTextFrame(TextFrame):
308 @requireUnicode("description", "text")
309 def __init__(self, id=USERTEXT_FID, description="", text=""):
310 super(UserTextFrame, self).__init__(id, text=text)
311 self.description = description
312
313 @property
314 def description(self):
315 return self._description
316
317 @description.setter
318 @requireUnicode(1)
319 def description(self, txt):
320 self._description = txt
321
322 def parse(self, data, frame_header):
323 """Data string format:
324 encoding (one byte) + description + b"\x00" + text """
325 # Calling Frame, not TextFrame implementation here since TextFrame
326 # does not know about description
327 Frame.parse(self, data, frame_header)
328
329 try:
330 self.encoding = self.data[0:1]
331 (d, t) = splitUnicode(self.data[1:], self.encoding)
332 except ValueError as err:
333 log.warning("UserTextFrame[{fid}] - {err}; using latin1"
334 .format(err=err, fid=self.id))
335 self.encoding = LATIN1_ENCODING
336 (d, t) = splitUnicode(self.data[:], self.encoding)
337
338 self.description = decodeUnicode(d, self.encoding)
339 log.debug("UserTextFrame description: %s" % self.description)
340 self.text = decodeUnicode(t, self.encoding)
341 log.debug("UserTextFrame text: %s" % self.text)
342
343 def render(self):
344 self._initEncoding()
345 data = (self.encoding +
346 self.description.encode(id3EncodingToString(self.encoding)) +
347 self.text_delim +
348 self.text.encode(id3EncodingToString(self.encoding)))
349 self.data = data
350 # Calling Frame, not the base
351 return Frame.render(self)
352
353
354 class DateFrame(TextFrame):
355 def __init__(self, id, date=""):
356 assert(id in DATE_FIDS or id in DEPRECATED_DATE_FIDS)
357 super().__init__(id, text=str(date))
358 self.date = self.text
359 self.encoding = LATIN1_ENCODING
360
361 def parse(self, data, frame_header):
362 super().parse(data, frame_header)
363 try:
364 if self.text:
365 _ = core.Date.parse(self.text) # noqa
366 except ValueError:
367 # Date is invalid, log it and reset.
368 core.parseError(FrameException(f"Invalid date: {self.text}"))
369 self.text = ""
370
371 @property
372 def date(self):
373 return core.Date.parse(self.text.encode("latin1")) if self.text else None
374
375 @date.setter
376 def date(self, date):
377 """Set value with a either an ISO 8601 date string or a eyed3.core.Date object."""
378 if not date:
379 self.text = ""
380 return
381
382 try:
383 if type(date) is str:
384 date = core.Date.parse(date)
385 elif type(date) is int:
386 # Date is year
387 date = core.Date(date)
388 elif not isinstance(date, core.Date):
389 raise TypeError("str, int, or eyed3.core.Date type expected")
390 except ValueError:
391 log.warning(f"Invalid date text: {date}")
392 self.text = ""
393 return
394
395 self.text = str(date)
396
397 def _initEncoding(self):
398 # Dates are always latin1 since they are always represented in ISO 8601
399 self.encoding = LATIN1_ENCODING
400
401
402 class UrlFrame(Frame):
403
404 def __init__(self, id, url=""):
405 assert(id in URL_FIDS or id == USERURL_FID)
406 super(UrlFrame, self).__init__(id)
407
408 self.encoding = LATIN1_ENCODING # Per the specs
409 self.url = url
410
411 @property
412 def url(self):
413 return self._url
414
415 @url.setter
416 def url(self, url):
417 if isinstance(url, bytes):
418 url = str(url, ISO_8859_1)
419 else:
420 url.encode(ISO_8859_1) # Likewise, it must encode
421
422 self._url = url
423
424 def parse(self, data, frame_header):
425 super().parse(data, frame_header)
426
427 try:
428 self.url = self.data
429 except UnicodeDecodeError:
430 log.warning("Non ascii url, clearing.")
431 self.url = ""
432
433 def render(self):
434 self.data = self.url.encode(ISO_8859_1)
435 return super(UrlFrame, self).render()
436
437
438 class UserUrlFrame(UrlFrame):
439 """
440 Data string format:
441 encoding (one byte) + description + b"\x00" + url (iso-8859-1)
442 """
443 @requireUnicode("description")
444 def __init__(self, id=USERURL_FID, description="", url=""):
445 UrlFrame.__init__(self, id, url=url)
446 assert(self.id == USERURL_FID)
447
448 self.description = description
449
450 @property
451 def description(self):
452 return self._description
453
454 @description.setter
455 @requireUnicode(1)
456 def description(self, desc):
457 self._description = desc
458
459 def parse(self, data, frame_header):
460 # Calling Frame and NOT UrlFrame to get the basic disassemble behavior
461 # UrlFrame would be confused by the encoding, desc, etc.
462 super().parse(data, frame_header)
463 self.encoding = encoding = self.data[0:1]
464
465 (d, u) = splitUnicode(self.data[1:], encoding)
466 self.description = decodeUnicode(d, encoding)
467 log.debug("UserUrlFrame description: %s" % self.description)
468 # The URL is ascii, ensure
469 try:
470 self.url = str(u, "ascii").encode("ascii")
471 except UnicodeDecodeError:
472 log.warning("Non ascii url, clearing.")
473 self.url = ""
474 log.debug("UserUrlFrame text: %s" % self.url)
475
476 def render(self):
477 self._initEncoding()
478 data = (self.encoding +
479 self.description.encode(id3EncodingToString(self.encoding)) +
480 self.text_delim + self.url.encode(ISO_8859_1))
481 self.data = data
482 # Calling Frame, not the base.
483 return Frame.render(self)
484
485
486 ##
487 # Data string format:
488 # <Header for 'Attached picture', ID: "APIC">
489 # Text encoding $xx
490 # MIME type <text string> $00
491 # Picture type $xx
492 # Description <text string according to encoding> $00 (00)
493 # Picture data <binary data>
494 class ImageFrame(Frame):
495 OTHER = 0x00 # noqa
496 ICON = 0x01 # 32x32 png only. # noqa
497 OTHER_ICON = 0x02 # noqa
498 FRONT_COVER = 0x03 # noqa
499 BACK_COVER = 0x04 # noqa
500 LEAFLET = 0x05 # noqa
501 MEDIA = 0x06 # label side of cd, vinyl, etc. # noqa
502 LEAD_ARTIST = 0x07 # noqa
503 ARTIST = 0x08 # noqa
504 CONDUCTOR = 0x09 # noqa
505 BAND = 0x0A # noqa
506 COMPOSER = 0x0B # noqa
507 LYRICIST = 0x0C # noqa
508 RECORDING_LOCATION = 0x0D # noqa
509 DURING_RECORDING = 0x0E # noqa
510 DURING_PERFORMANCE = 0x0F # noqa
511 VIDEO = 0x10 # noqa
512 BRIGHT_COLORED_FISH = 0x11 # There's always room for porno. # noqa
513 ILLUSTRATION = 0x12 # noqa
514 BAND_LOGO = 0x13 # noqa
515 PUBLISHER_LOGO = 0x14 # noqa
516 MIN_TYPE = OTHER # noqa
517 MAX_TYPE = PUBLISHER_LOGO # noqa
518
519 URL_MIME_TYPE = b"-->" # noqa
520 URL_MIME_TYPE_STR = "-->" # noqa
521 URL_MIME_TYPE_VALUES = (URL_MIME_TYPE, URL_MIME_TYPE_STR)
522
523 @requireUnicode("description")
524 def __init__(self, id=IMAGE_FID, description="",
525 image_data=None, image_url=None,
526 picture_type=None, mime_type=None):
527 assert(id == IMAGE_FID)
528 super(ImageFrame, self).__init__(id)
529 self.description = description
530 self.image_data = image_data
531 self.image_url = image_url
532
533 # XXX: Add this member as `type` and deprecate picture_type??
534 self.picture_type = picture_type
535 self.mime_type = mime_type
536
537 @property
538 def description(self):
539 return self._description
540
541 @description.setter
542 @requireUnicode(1)
543 def description(self, d):
544 self._description = d
545
546 @property
547 def mime_type(self):
548 return str(self._mime_type, "ascii")
549
550 @mime_type.setter
551 def mime_type(self, m):
552 m = m or b''
553 self._mime_type = m if isinstance(m, bytes) else m.encode('ascii')
554
555 @property
556 def picture_type(self):
557 return self._pic_type
558
559 @picture_type.setter
560 def picture_type(self, t):
561 if t is not None and (t < ImageFrame.MIN_TYPE or
562 t > ImageFrame.MAX_TYPE):
563 raise ValueError("Invalid picture_type: %d" % t)
564 self._pic_type = t
565
566 def parse(self, data, frame_header):
567 super().parse(data, frame_header)
568
569 input = BytesIO(self.data)
570 log.debug("APIC frame data size: %d" % len(self.data))
571 self.encoding = encoding = input.read(1)
572
573 # Mime type
574 self._mime_type = b""
575 if frame_header.minor_version != 2:
576 ch = input.read(1)
577 while ch and ch != b"\x00":
578 self._mime_type += ch
579 ch = input.read(1)
580 else:
581 # v2.2 (OBSOLETE) special case
582 self._mime_type = input.read(3)
583 log.debug("APIC mime type: %s" % self._mime_type)
584 if not self._mime_type:
585 core.parseError(FrameException("APIC frame does not contain a mime "
586 "type"))
587 if (self._mime_type != self.URL_MIME_TYPE and
588 self._mime_type.find(b"/") == -1):
589 self._mime_type = b"image/" + self._mime_type
590
591 pt = ord(input.read(1))
592 log.debug("Initial APIC picture type: %d" % pt)
593 if pt < self.MIN_TYPE or pt > self.MAX_TYPE:
594 core.parseError(FrameException("Invalid APIC picture type: %d" %
595 pt))
596 self.picture_type = self.OTHER
597 else:
598 self.picture_type = pt
599 log.debug("APIC picture type: %d" % self.picture_type)
600
601 self.desciption = ""
602
603 # Remaining data is a NULL separated description and image data
604 buffer = input.read()
605 input.close()
606
607 (desc, img) = splitUnicode(buffer, encoding)
608 log.debug("description len: %d" % len(desc))
609 log.debug("image len: %d" % len(img))
610 self.description = decodeUnicode(desc, encoding)
611 log.debug("APIC description: %s" % self.description)
612
613 if self._mime_type.find(self.URL_MIME_TYPE) != -1:
614 self.image_data = None
615 self.image_url = img
616 log.debug("APIC image URL: %s" %
617 len(self.image_url.decode("ascii")))
618 else:
619 self.image_data = img
620 self.image_url = None
621 log.debug("APIC image data: %d bytes" % len(self.image_data))
622 if not self.image_data and not self.image_url:
623 core.parseError(FrameException("APIC frame does not contain image "
624 "data/url"))
625
626 def render(self):
627 # some code has problems with image descriptions encoded <> latin1
628 # namely mp3diags: work around the problem by forcing latin1 encoding
629 # for empty descriptions, which is by far the most common case anyway
630 self._initEncoding()
631
632 if not self.image_data and self.image_url:
633 self._mime_type = self.URL_MIME_TYPE
634
635 data = (self.encoding + self._mime_type + b"\x00" +
636 bin2bytes(dec2bin(self.picture_type, 8)) +
637 self.description.encode(id3EncodingToString(self.encoding)) +
638 self.text_delim)
639
640 if self.image_data:
641 data += self.image_data
642 elif self.image_url:
643 data += self.image_url
644
645 self.data = data
646 return super(ImageFrame, self).render()
647
648 @staticmethod
649 def picTypeToString(t):
650 if t == ImageFrame.OTHER:
651 return "OTHER"
652 elif t == ImageFrame.ICON:
653 return "ICON"
654 elif t == ImageFrame.OTHER_ICON:
655 return "OTHER_ICON"
656 elif t == ImageFrame.FRONT_COVER:
657 return "FRONT_COVER"
658 elif t == ImageFrame.BACK_COVER:
659 return "BACK_COVER"
660 elif t == ImageFrame.LEAFLET:
661 return "LEAFLET"
662 elif t == ImageFrame.MEDIA:
663 return "MEDIA"
664 elif t == ImageFrame.LEAD_ARTIST:
665 return "LEAD_ARTIST"
666 elif t == ImageFrame.ARTIST:
667 return "ARTIST"
668 elif t == ImageFrame.CONDUCTOR:
669 return "CONDUCTOR"
670 elif t == ImageFrame.BAND:
671 return "BAND"
672 elif t == ImageFrame.COMPOSER:
673 return "COMPOSER"
674 elif t == ImageFrame.LYRICIST:
675 return "LYRICIST"
676 elif t == ImageFrame.RECORDING_LOCATION:
677 return "RECORDING_LOCATION"
678 elif t == ImageFrame.DURING_RECORDING:
679 return "DURING_RECORDING"
680 elif t == ImageFrame.DURING_PERFORMANCE:
681 return "DURING_PERFORMANCE"
682 elif t == ImageFrame.VIDEO:
683 return "VIDEO"
684 elif t == ImageFrame.BRIGHT_COLORED_FISH:
685 return "BRIGHT_COLORED_FISH"
686 elif t == ImageFrame.ILLUSTRATION:
687 return "ILLUSTRATION"
688 elif t == ImageFrame.BAND_LOGO:
689 return "BAND_LOGO"
690 elif t == ImageFrame.PUBLISHER_LOGO:
691 return "PUBLISHER_LOGO"
692 else:
693 raise ValueError("Invalid APIC picture type: %d" % t)
694
695 @staticmethod
696 def stringToPicType(s):
697 if s == "OTHER":
698 return ImageFrame.OTHER
699 elif s == "ICON":
700 return ImageFrame.ICON
701 elif s == "OTHER_ICON":
702 return ImageFrame.OTHER_ICON
703 elif s == "FRONT_COVER":
704 return ImageFrame.FRONT_COVER
705 elif s == "BACK_COVER":
706 return ImageFrame.BACK_COVER
707 elif s == "LEAFLET":
708 return ImageFrame.LEAFLET
709 elif s == "MEDIA":
710 return ImageFrame.MEDIA
711 elif s == "LEAD_ARTIST":
712 return ImageFrame.LEAD_ARTIST
713 elif s == "ARTIST":
714 return ImageFrame.ARTIST
715 elif s == "CONDUCTOR":
716 return ImageFrame.CONDUCTOR
717 elif s == "BAND":
718 return ImageFrame.BAND
719 elif s == "COMPOSER":
720 return ImageFrame.COMPOSER
721 elif s == "LYRICIST":
722 return ImageFrame.LYRICIST
723 elif s == "RECORDING_LOCATION":
724 return ImageFrame.RECORDING_LOCATION
725 elif s == "DURING_RECORDING":
726 return ImageFrame.DURING_RECORDING
727 elif s == "DURING_PERFORMANCE":
728 return ImageFrame.DURING_PERFORMANCE
729 elif s == "VIDEO":
730 return ImageFrame.VIDEO
731 elif s == "BRIGHT_COLORED_FISH":
732 return ImageFrame.BRIGHT_COLORED_FISH
733 elif s == "ILLUSTRATION":
734 return ImageFrame.ILLUSTRATION
735 elif s == "BAND_LOGO":
736 return ImageFrame.BAND_LOGO
737 elif s == "PUBLISHER_LOGO":
738 return ImageFrame.PUBLISHER_LOGO
739 else:
740 raise ValueError("Invalid APIC picture type: %s" % s)
741
742 def makeFileName(self, name=None):
743 name = ImageFrame.picTypeToString(self.picture_type) if not name \
744 else name
745 ext = self.mime_type.split("/")[1]
746 if ext == "jpeg":
747 ext = "jpg"
748 return ".".join([name, ext])
749
750
751 class ObjectFrame(Frame):
752 @requireUnicode("description", "filename")
753 def __init__(self, fid=OBJECT_FID, description="", filename="",
754 object_data=None, mime_type=None):
755 super().__init__(fid)
756 self.description = description
757 self.filename = filename
758 self.mime_type = mime_type
759 self.object_data = object_data
760
761 @property
762 def description(self):
763 return self._description
764
765 @description.setter
766 @requireUnicode(1)
767 def description(self, txt):
768 self._description = txt
769
770 @property
771 def mime_type(self):
772 return str(self._mime_type, "ascii")
773
774 @mime_type.setter
775 def mime_type(self, m):
776 m = m or b''
777 self._mime_type = m if isinstance(m, bytes) else m.encode('ascii')
778
779 @property
780 def filename(self):
781 return self._filename
782
783 @filename.setter
784 @requireUnicode(1)
785 def filename(self, txt):
786 self._filename = txt
787
788 def parse(self, data, frame_header):
789 """Parse the frame from ``data`` bytes using details from
790 ``frame_header``.
791
792 Data string format:
793 <Header for 'General encapsulated object', ID: "GEOB">
794 Text encoding $xx
795 MIME type <text string> $00
796 Filename <text string according to encoding> $00 (00)
797 Content description <text string according to encoding> $00 (00)
798 Encapsulated object <binary data>
799 """
800 super().parse(data, frame_header)
801
802 input = BytesIO(self.data)
803 log.debug("GEOB frame data size: " + str(len(self.data)))
804 self.encoding = encoding = input.read(1)
805
806 # Mime type
807 self._mime_type = b""
808 if self.header.minor_version != 2:
809 ch = input.read(1)
810 while ch != b"\x00":
811 self._mime_type += ch
812 ch = input.read(1)
813 else:
814 # v2.2 (OBSOLETE) special case
815 self._mime_type = input.read(3)
816 log.debug("GEOB mime type: %s" % self._mime_type)
817 if not self._mime_type:
818 core.parseError(FrameException("GEOB frame does not contain a "
819 "mime type"))
820 if self._mime_type.find(b"/") == -1:
821 core.parseError(FrameException("GEOB frame does not contain a "
822 "valid mime type"))
823
824 self.filename = ""
825 self.description = ""
826
827 # Remaining data is a NULL separated filename, description and object
828 # data
829 buffer = input.read()
830 input.close()
831
832 (filename, buffer) = splitUnicode(buffer, encoding)
833 (desc, obj) = splitUnicode(buffer, encoding)
834 self.filename = decodeUnicode(filename, encoding)
835 log.debug("GEOB filename: " + self.filename)
836 self.description = decodeUnicode(desc, encoding)
837 log.debug("GEOB description: " + self.description)
838
839 self.object_data = obj
840 log.debug("GEOB data: %d bytes " % len(self.object_data))
841 if not self.object_data:
842 core.parseError(FrameException("GEOB frame does not contain any "
843 "data"))
844
845 def render(self):
846 self._initEncoding()
847 data = (self.encoding + self._mime_type + b"\x00" +
848 self.filename.encode(id3EncodingToString(self.encoding)) +
849 self.text_delim +
850 self.description.encode(id3EncodingToString(self.encoding)) +
851 self.text_delim +
852 (self.object_data or b""))
853 self.data = data
854 return super(ObjectFrame, self).render()
855
856
857 class PrivateFrame(Frame):
858 """PRIV"""
859
860 def __init__(self, id=PRIVATE_FID, owner_id=b"", owner_data=b""):
861 super().__init__(id)
862 assert id == PRIVATE_FID
863 for arg in (owner_id, owner_data):
864 if type(arg) is not bytes:
865 raise ValueError("PRIV owner fields require bytes type")
866
867 self.owner_id = owner_id
868 self.owner_data = owner_data
869
870 def parse(self, data, frame_header):
871 super().parse(data, frame_header)
872 try:
873 self.owner_id, self.owner_data = self.data.split(b'\x00', 1)
874 except ValueError:
875 # If data doesn't contain required \x00
876 # all data is taken to be owner_id
877 self.owner_id = self.data
878
879 def render(self):
880 self.data = self.owner_id + b"\x00" + self.owner_data
881 return super(PrivateFrame, self).render()
882
883
884 class MusicCDIdFrame(Frame):
885
886 def __init__(self, id=CDID_FID, toc=b""):
887 super(MusicCDIdFrame, self).__init__(id)
888 assert(id == CDID_FID)
889 self.toc = toc
890
891 @property
892 def toc(self):
893 return self.data
894
895 @toc.setter
896 def toc(self, toc):
897 self.data = toc
898
899 def parse(self, data, frame_header):
900 super().parse(data, frame_header)
901 self.toc = self.data
902
903
904 class PlayCountFrame(Frame):
905 def __init__(self, id=PLAYCOUNT_FID, count=0):
906 super(PlayCountFrame, self).__init__(id)
907 assert(self.id == PLAYCOUNT_FID)
908
909 if count is None or count < 0:
910 raise ValueError("Invalid count value: %s" % str(count))
911 self.count = count
912
913 def parse(self, data, frame_header):
914 super().parse(data, frame_header)
915 # data of less then 4 bytes is handled with with 'sz' arg
916 if len(self.data) < 4:
917 log.warning("Fixing invalid PCNT frame: less than 32 bits")
918
919 self.count = bytes2dec(self.data)
920
921 def render(self):
922 self.data = dec2bytes(self.count, 32)
923 return super(PlayCountFrame, self).render()
924
925
926 class PopularityFrame(Frame):
927 """Frame type for 'POPM' frames; popularity.
928 Frame format:
929 <Header for 'Popularimeter', ID: "POPM">
930 Email to user <text string> $00
931 Rating $xx
932 Counter $xx xx xx xx (xx ...)
933 """
934 def __init__(self, id=POPULARITY_FID, email=b"", rating=0, count=0):
935 super(PopularityFrame, self).__init__(id)
936 assert(self.id == POPULARITY_FID)
937
938 self.email = email
939 self.rating = rating
940 if count is None or count < 0:
941 raise ValueError("Invalid count value: %s" % str(count))
942 self.count = count
943
944 @property
945 def rating(self):
946 return self._rating
947
948 @rating.setter
949 def rating(self, rating):
950 if rating < 0 or rating > 255:
951 raise ValueError("Popularity rating must be >= 0 and <=255")
952 self._rating = rating
953
954 @property
955 def email(self):
956 return self._email
957
958 @email.setter
959 def email(self, email):
960 # XXX: becoming a pattern?
961 if isinstance(email, str):
962 self._email = email.encode("ascii")
963 elif isinstance(email, bytes):
964 _ = email.decode("ascii") # noqa
965 self._email = email
966 else:
967 raise TypeError("bytes, str, unicode email required")
968
969 @property
970 def count(self):
971 return self._count
972
973 @count.setter
974 def count(self, count):
975 if count < 0:
976 raise ValueError("Popularity count must be > 0")
977 self._count = count
978
979 def parse(self, data, frame_header):
980 super().parse(data, frame_header)
981 data = self.data
982
983 null_byte = data.find(b'\x00')
984 try:
985 self.email = data[:null_byte]
986 except UnicodeDecodeError:
987 core.parseError(FrameException("Invalid (non-ascii) POPM email "
988 "address. Setting to 'BOGUS'"))
989 self.email = b"BOGUS"
990 data = data[null_byte + 1:]
991
992 self.rating = bytes2dec(data[0:1])
993
994 data = data[1:]
995 if len(self.data) < 4:
996 core.parseError(FrameException(
997 "Invalid POPM play count: less than 32 bits."))
998 self.count = bytes2dec(data)
999
1000 def render(self):
1001 data = (self.email or b"") + b'\x00'
1002 data += dec2bytes(self.rating)
1003 data += dec2bytes(self.count, 32)
1004
1005 self.data = data
1006 return super(PopularityFrame, self).render()
1007
1008
1009 class UniqueFileIDFrame(Frame):
1010 def __init__(self, id=UNIQUE_FILE_ID_FID, owner_id=b"", uniq_id=b""):
1011 super().__init__(id)
1012 assert(self.id == UNIQUE_FILE_ID_FID)
1013 self.owner_id = owner_id
1014 self.uniq_id = uniq_id
1015
1016 @property
1017 def owner_id(self):
1018 return self._owner_id
1019
1020 @owner_id.setter
1021 def owner_id(self, oid):
1022 self._owner_id = b(oid) if oid else b""
1023
1024 @property
1025 def uniq_id(self):
1026 return self._uniq_id
1027
1028 @uniq_id.setter
1029 def uniq_id(self, uid):
1030 self._uniq_id = b(uid) if uid else b""
1031
1032 def parse(self, data, frame_header):
1033 """
1034 Data format
1035 Owner identifier <text string> $00
1036 Identifier up to 64 bytes binary data>
1037 """
1038 super().parse(data, frame_header)
1039 split_data = self.data.split(b'\x00', 1)
1040 if len(split_data) == 2:
1041 (self.owner_id, self.uniq_id) = split_data
1042 else:
1043 self.owner_id, self.uniq_id = b"", b"".join(split_data[0:1])
1044 log.debug("UFID owner_id: %s" % self.owner_id)
1045 log.debug("UFID id: %s" % self.uniq_id)
1046 if not self.owner_id:
1047 dummy_owner_id = "http://www.id3.org/dummy/ufid.html"
1048 self.owner_id = dummy_owner_id
1049 core.parseError(FrameException("Invalid UFID, owner_id is empty. "
1050 "Setting to '%s'" % dummy_owner_id))
1051 elif 0 <= len(self.uniq_id) > 64:
1052 core.parseError(FrameException("Invalid UFID, ID is empty or too "
1053 "long: %s" % self.uniq_id))
1054
1055 def render(self):
1056 assert isinstance(self.owner_id, bytes)
1057 assert isinstance(self.uniq_id, bytes)
1058 self.data = self.owner_id + b"\x00" + self.uniq_id
1059 return super().render()
1060
1061
1062 class LanguageCodeMixin(object):
1063 @property
1064 def lang(self):
1065 assert self._lang is not None
1066 return self._lang
1067
1068 @lang.setter
1069 @requireBytes(1)
1070 def lang(self, lang):
1071 if not lang:
1072 self._lang = b""
1073 return
1074
1075 lang = lang.strip(b"\00")
1076 lang = lang[:3] if lang else DEFAULT_LANG
1077 try:
1078 if lang != DEFAULT_LANG:
1079 lang.decode("ascii")
1080 except UnicodeDecodeError:
1081 lang = DEFAULT_LANG
1082 assert len(lang) <= 3
1083 self._lang = lang
1084
1085 def _renderLang(self):
1086 lang = self.lang
1087 if len(lang) < 3:
1088 lang = lang + (b"\x00" * (3 - len(lang)))
1089 return lang
1090
1091
1092 class DescriptionLangTextFrame(Frame, LanguageCodeMixin):
1093 @requireBytes(1, 3)
1094 @requireUnicode(2, 4)
1095 def __init__(self, id, description, lang, text):
1096 super().__init__(id)
1097 self.lang = lang
1098 self.description = description
1099 self.text = text
1100
1101 @property
1102 def description(self):
1103 return self._description
1104
1105 @description.setter
1106 @requireUnicode(1)
1107 def description(self, description):
1108 self._description = description
1109
1110 @property
1111 def text(self):
1112 return self._text
1113
1114 @text.setter
1115 @requireUnicode(1)
1116 def text(self, text):
1117 self._text = text
1118
1119 def parse(self, data, frame_header):
1120 super().parse(data, frame_header)
1121
1122 self.encoding = self.data[0:1]
1123 self.lang = self.data[1:4]
1124 log.debug("%s lang: %s" % (self.id, self.lang))
1125
1126 try:
1127 (d, t) = splitUnicode(self.data[4:], self.encoding)
1128 self.description = decodeUnicode(d, self.encoding)
1129 log.debug("%s description: %s" % (self.id, self.description))
1130 self.text = decodeUnicode(t, self.encoding)
1131 log.debug("%s text: %s" % (self.id, self.text))
1132 except ValueError:
1133 log.warning("Invalid %s frame; no description/text" % self.id)
1134 self.description = ""
1135 self.text = ""
1136
1137 def render(self):
1138 lang = self._renderLang()
1139
1140 self._initEncoding()
1141 data = (self.encoding + lang +
1142 self.description.encode(id3EncodingToString(self.encoding)) +
1143 self.text_delim +
1144 self.text.encode(id3EncodingToString(self.encoding)))
1145 self.data = data
1146 return super(DescriptionLangTextFrame, self).render()
1147
1148
1149 class CommentFrame(DescriptionLangTextFrame):
1150 def __init__(self, id=COMMENT_FID, description="", lang=DEFAULT_LANG,
1151 text=""):
1152 super(CommentFrame, self).__init__(id, description, lang, text)
1153 assert(self.id == COMMENT_FID)
1154
1155
1156 class LyricsFrame(DescriptionLangTextFrame):
1157 def __init__(self, id=LYRICS_FID, description="", lang=DEFAULT_LANG,
1158 text=""):
1159 super(LyricsFrame, self).__init__(id, description, lang, text)
1160 assert(self.id == LYRICS_FID)
1161
1162
1163 class TermsOfUseFrame(Frame, LanguageCodeMixin):
1164 @requireUnicode("text")
1165 def __init__(self, id=b"USER", text="", lang=DEFAULT_LANG):
1166 super(TermsOfUseFrame, self).__init__(id)
1167 self.lang = lang
1168 self.text = text
1169
1170 @property
1171 def text(self):
1172 return self._text
1173
1174 @text.setter
1175 @requireUnicode(1)
1176 def text(self, text):
1177 self._text = text
1178
1179 def parse(self, data, frame_header):
1180 super().parse(data, frame_header)
1181
1182 self.encoding = encoding = self.data[0:1]
1183 self.lang = self.data[1:4]
1184 log.debug("%s lang: %s" % (self.id, self.lang))
1185 self.text = decodeUnicode(self.data[4:], encoding)
1186 log.debug("%s text: %s" % (self.id, self.text))
1187
1188 def render(self):
1189 lang = self._renderLang()
1190 self._initEncoding()
1191 self.data = (self.encoding + lang +
1192 self.text.encode(id3EncodingToString(self.encoding)))
1193 return super(TermsOfUseFrame, self).render()
1194
1195
1196 class TocFrame(Frame):
1197 """Table of content frame. There may be more than one, but only one may
1198 have the top-level flag set.
1199
1200 Data format:
1201 Element ID: <string>\x00
1202 TOC flags: %000000ab
1203 Entry count: %xx
1204 Child elem IDs: <string>\x00 (... num entry count)
1205 Description: TIT2 frame (optional)
1206 """
1207 TOP_LEVEL_FLAG_BIT = 6
1208 ORDERED_FLAG_BIT = 7
1209
1210 @requireBytes(1, 2)
1211 def __init__(self, id=TOC_FID, element_id=None, toplevel=True, ordered=True,
1212 child_ids=None, description=None):
1213 assert id == TOC_FID
1214 super().__init__(id)
1215
1216 self.element_id = element_id
1217 self.toplevel = toplevel
1218 self.ordered = ordered
1219 self.child_ids = child_ids or []
1220 self.description = description
1221
1222 def parse(self, data, frame_header):
1223 super().parse(data, frame_header)
1224
1225 data = self.data
1226 log.debug("CTOC frame data size: %d" % len(data))
1227
1228 null_byte = data.find(b'\x00')
1229 self.element_id = data[0:null_byte]
1230 data = data[null_byte + 1:]
1231
1232 flag_bits = bytes2bin(data[0:1])
1233 self.toplevel = bool(flag_bits[self.TOP_LEVEL_FLAG_BIT])
1234 self.ordered = bool(flag_bits[self.ORDERED_FLAG_BIT])
1235 entry_count = bytes2dec(data[1:2])
1236 data = data[2:]
1237
1238 self.child_ids = []
1239 for i in range(entry_count):
1240 null_byte = data.find(b'\x00')
1241 self.child_ids.append(data[:null_byte])
1242 data = data[null_byte + 1:]
1243
1244 # Any data remaining must be a TIT2 frame
1245 self.description = None
1246 if data and data[:4] != b"TIT2":
1247 log.warning("Invalid toc data, TIT2 frame expected")
1248 return
1249 elif data:
1250 data = BytesIO(data)
1251 frame_header = FrameHeader.parse(data, self.header.version)
1252 data = data.read()
1253 description_frame = TextFrame(TITLE_FID)
1254 description_frame.parse(data, frame_header)
1255
1256 self.description = description_frame.text
1257
1258 def render(self):
1259 flags = [0] * 8
1260 if self.toplevel:
1261 flags[self.TOP_LEVEL_FLAG_BIT] = 1
1262 if self.ordered:
1263 flags[self.ORDERED_FLAG_BIT] = 1
1264
1265 data = (self.element_id + b'\x00' +
1266 bin2bytes(flags) + dec2bytes(len(self.child_ids)))
1267
1268 for cid in self.child_ids:
1269 data += cid + b'\x00'
1270
1271 if self.description is not None:
1272 desc_frame = TextFrame(TITLE_FID, self.description)
1273 desc_frame.header = FrameHeader(TITLE_FID, self.header.version)
1274 data += desc_frame.render()
1275
1276 self.data = data
1277 return super().render()
1278
1279
1280 class RelVolAdjFrameV24(Frame):
1281 CHANNEL_TYPE_OTHER = 0
1282 CHANNEL_TYPE_MASTER = 1
1283 CHANNEL_TYPE_FRONT_RIGHT = 2
1284 CHANNEL_TYPE_FRONT_LEFT = 3
1285 CHANNEL_TYPE_BACK_RIGHT = 4
1286 CHANNEL_TYPE_BACK_LEFT = 5
1287 CHANNEL_TYPE_FRONT_CENTER = 6
1288 CHANNEL_TYPE_BACK_CENTER = 7
1289 CHANNEL_TYPE_BASS = 8
1290
1291 @property
1292 def identifier(self):
1293 return str(self._identifier, "latin1")
1294
1295 @identifier.setter
1296 def identifier(self, ident):
1297 if type(ident) != bytes:
1298 ident = ident.encode("latin1")
1299 self._identifier = ident
1300
1301 @property
1302 def channel_type(self):
1303 return self._channel_type
1304
1305 @channel_type.setter
1306 def channel_type(self, t):
1307 if 0 <= t <= 8:
1308 self._channel_type = t
1309 else:
1310 raise ValueError(f"Invalid type {t}")
1311
1312 @property
1313 def adjustment(self):
1314 return (self._adjustment or 0) / 512
1315
1316 @adjustment.setter
1317 def adjustment(self, adj):
1318 self._adjustment = adj * 512
1319
1320 @property
1321 def peak(self):
1322 return self._peak
1323
1324 @peak.setter
1325 def peak(self, v):
1326 self._peak = v
1327
1328 def __init__(self, fid=b"RVA2", identifier=None, channel_type=None, adjustment=None, peak=None):
1329 assert fid == b"RVA2"
1330 super().__init__(fid)
1331
1332 self.identifier = identifier or ""
1333 self.channel_type = channel_type or self.CHANNEL_TYPE_OTHER
1334 self.adjustment = adjustment or 0
1335 self.peak = peak or 0
1336
1337 def parse(self, data, frame_header):
1338 super().parse(data, frame_header)
1339 if self.header.version != ID3_V2_4:
1340 raise FrameException(f"Invalid frame version: {self.header.version}")
1341
1342 data = self.data
1343
1344 self.identifier, data = data.split(b"\x00", maxsplit=1)
1345 self.channel_type = data[0]
1346 self._adjustment = bytes2signedInt16(data[1:3])
1347 if len(data) > 3:
1348 bits_per_peak = data[3]
1349 if bits_per_peak:
1350 self._peak = bytes2dec(data[4:4 + (bits_per_peak // 8)])
1351
1352 log.debug(f"Parsed RVA2: identifier={self.identifier} channel_type={self.channel_type} "
1353 f"adjustment={self.adjustment} _adjustment={self._adjustment} peak={self.peak}")
1354
1355 def render(self):
1356 assert self._channel_type is not None
1357 if self.header is None:
1358 self.header = FrameHeader(self.id, ID3_V2_4)
1359 assert self.header.version == ID3_V2_4
1360
1361 self.data =\
1362 self._identifier + b"\x00" +\
1363 dec2bytes(self._channel_type) +\
1364 signedInt162bytes(self._adjustment or 0)
1365
1366 if self._peak:
1367 peak_data = b""
1368 num_pk_bits = len(dec2bin(self._peak))
1369 for sz in (8, 16, 32):
1370 if num_pk_bits > sz:
1371 continue
1372 peak_data += dec2bytes(sz, 8) + dec2bytes(self._peak, sz)
1373 break
1374
1375 if not peak_data:
1376 raise ValueError(f"Peak value out of range: {self._peak}")
1377 self.data += peak_data
1378
1379 return super().render()
1380
1381
1382 class RelVolAdjFrameV23(Frame):
1383 FRONT_CHANNEL_RIGHT_BIT = 0
1384 FRONT_CHANNEL_LEFT_BIT = 1
1385 BACK_CHANNEL_RIGHT_BIT = 2
1386 BACK_CHANNEL_LEFT_BIT = 3
1387 FRONT_CENTER_CHANNEL_BIT = 4
1388 BASS_CHANNEL_BIT = 5
1389
1390 CHANNEL_DEFN = [("front_right", FRONT_CHANNEL_RIGHT_BIT),
1391 ("front_left", FRONT_CHANNEL_LEFT_BIT),
1392 ("front_right_peak", None),
1393 ("front_left_peak", None),
1394 ("back_right", BACK_CHANNEL_RIGHT_BIT),
1395 ("back_left", BACK_CHANNEL_LEFT_BIT),
1396 ("back_right_peak", None),
1397 ("back_left_peak", None),
1398 ("front_center", FRONT_CENTER_CHANNEL_BIT),
1399 ("front_center_peak", None),
1400 ("bass", BASS_CHANNEL_BIT),
1401 ("bass_peak", None),
1402 ]
1403
1404 @dataclasses.dataclass
1405 class VolumeAdjustments:
1406 master: int = 0
1407 master_peak: int = 0
1408
1409 front_right: int = 0
1410 front_left: int = 0
1411 front_right_peak: int = 0
1412 front_left_peak: int = 0
1413
1414 back_right: int = 0
1415 back_left: int = 0
1416 back_right_peak: int = 0
1417 back_left_peak: int = 0
1418
1419 front_center: int = 0
1420 front_center_peak: int = 0
1421
1422 back_center: int = 0
1423 back_center_peak: int = 0
1424
1425 bass: int = 0
1426 bass_peak: int = 0
1427
1428 other: int = 0
1429 other_peak: int = 0
1430
1431 _channel_map = {
1432 RelVolAdjFrameV24.CHANNEL_TYPE_MASTER: "master",
1433 RelVolAdjFrameV24.CHANNEL_TYPE_OTHER: "other",
1434 RelVolAdjFrameV24.CHANNEL_TYPE_FRONT_RIGHT: "front_right",
1435 RelVolAdjFrameV24.CHANNEL_TYPE_FRONT_LEFT: "front_left",
1436 RelVolAdjFrameV24.CHANNEL_TYPE_BACK_RIGHT: "back_right",
1437 RelVolAdjFrameV24.CHANNEL_TYPE_BACK_LEFT: "back_left",
1438 RelVolAdjFrameV24.CHANNEL_TYPE_FRONT_CENTER: "front_center",
1439 RelVolAdjFrameV24.CHANNEL_TYPE_BACK_CENTER: "back_center",
1440 RelVolAdjFrameV24.CHANNEL_TYPE_BASS: "bass",
1441 }
1442
1443 @property
1444 def has_master_channel(self) -> bool:
1445 return bool(self.master or self.master_peak)
1446
1447 @property
1448 def has_front_channel(self) -> bool:
1449 return bool(
1450 self.front_right or self.front_left or self.front_right_peak or self.front_left_peak
1451 )
1452
1453 @property
1454 def has_back_channel(self) -> bool:
1455 return bool(
1456 self.back_right or self.back_left or self.back_right_peak or self.back_left_peak
1457 )
1458
1459 @property
1460 def has_front_center_channel(self) -> bool:
1461 return bool(self.front_center or self.front_center_peak)
1462
1463 @property
1464 def has_back_center_channel(self) -> bool:
1465 return bool(self.back_center or self.back_center_peak)
1466
1467 @property
1468 def has_bass_channel(self) -> bool:
1469 return bool(self.bass or self.bass_peak)
1470
1471 @property
1472 def has_other_channel(self) -> bool:
1473 return bool(self.other or self.other_peak)
1474
1475 def boundsCheck(self):
1476 invalids = []
1477 for name, value in dataclasses.asdict(self).items():
1478
1479 if value > 65536 or value < -65536:
1480 invalids.append(name)
1481 if invalids:
1482 raise ValueError(f"Invalid RVAD channel values: {','.join(invalids)}")
1483
1484 def setChannelAdj(self, chan_type, value):
1485 setattr(self, self._channel_map[chan_type], value)
1486
1487 def setChannelPeak(self, chan_type, value):
1488 setattr(self, f"{self._channel_map[chan_type]}_peak", value)
1489
1490 def __init__(self, fid=b"RVAD"):
1491 assert fid == b"RVAD"
1492 super().__init__(fid)
1493 self.adjustments = None
1494
1495 def toV24(self) -> list:
1496 """Return a list of RVA2 frames"""
1497 converted = []
1498
1499 def append(ch_type, ch_adj, ch_peak):
1500 if not ch_adj and not ch_peak:
1501 return
1502 converted.append(
1503 RelVolAdjFrameV24(channel_type=ch_type, adjustment=ch_adj / 512, peak=ch_peak)
1504 )
1505
1506 for channel in ["front_right", "front_left", "back_right", "back_left",
1507 "front_center", "bass"]:
1508 chtype = getattr(RelVolAdjFrameV24, f"CHANNEL_TYPE_{channel.upper()}")
1509 adj = getattr(self.adjustments, channel)
1510 pk = getattr(self.adjustments, f"{channel}_peak")
1511
1512 append(chtype, adj, pk)
1513
1514 return converted
1515
1516 def parse(self, data, frame_header):
1517 super().parse(data, frame_header)
1518 if self.header.version not in (ID3_V2_3, ID3_V2_2):
1519 raise FrameException("Invalid v2.4 frame: RVAD")
1520 data = self.data
1521
1522 inc_dec_bit_list = bytes2bin(bytes([data[0]]))
1523 inc_dec_bit_list.reverse()
1524 bytes_per_vol = data[1] // 8
1525 if bytes_per_vol > 2:
1526 raise FrameException("RVAD volume adj out of bounds")
1527
1528 self.adjustments = self.VolumeAdjustments()
1529 offset = 2
1530 for adj_name, inc_dec_bit in self.CHANNEL_DEFN:
1531 if offset >= len(data):
1532 break
1533
1534 adj_val = bytes2dec(data[offset:offset + bytes_per_vol])
1535 offset += bytes_per_vol
1536
1537 if (inc_dec_bit is not None
1538 and adj_val
1539 and inc_dec_bit_list[inc_dec_bit] == 0):
1540 # Decrement
1541 adj_val = -adj_val
1542
1543 setattr(self.adjustments, adj_name, adj_val)
1544
1545 try:
1546 log.debug(f"Parsed RVAD frames adjustments: {self.adjustments}")
1547 self.adjustments.boundsCheck()
1548 except ValueError: # pragma: nocover
1549 self.adjustments = None
1550 raise
1551
1552 def render(self):
1553 data = b""
1554 inc_dec_bits = [0] * 8
1555
1556 if self.header is None:
1557 self.header = FrameHeader(self.id, ID3_V2_3)
1558 assert self.header.version == ID3_V2_3
1559
1560 self.adjustments.boundsCheck() # May raise ValueError
1561
1562 # Only the front channel is required
1563 inc_dec_bits[self.FRONT_CHANNEL_RIGHT_BIT] = 1 if self.adjustments.front_right > 0 else 0
1564 inc_dec_bits[self.FRONT_CHANNEL_LEFT_BIT] = 1 if self.adjustments.front_left > 0 else 0
1565 data += dec2bytes(abs(self.adjustments.front_right), p=16)
1566 data += dec2bytes(abs(self.adjustments.front_left), p=16)
1567 data += dec2bytes(abs(self.adjustments.front_right_peak), p=16)
1568 data += dec2bytes(abs(self.adjustments.front_left_peak), p=16)
1569
1570 # Back channel
1571 if True in (self.adjustments.has_bass_channel, self.adjustments.has_front_center_channel,
1572 self.adjustments.has_back_channel):
1573 inc_dec_bits[self.BACK_CHANNEL_RIGHT_BIT] = 1 if self.adjustments.back_right > 0 else 0
1574 inc_dec_bits[self.BACK_CHANNEL_LEFT_BIT] = 1 if self.adjustments.back_left > 0 else 0
1575 data += dec2bytes(abs(self.adjustments.back_right), p=16)
1576 data += dec2bytes(abs(self.adjustments.back_left), p=16)
1577 data += dec2bytes(abs(self.adjustments.back_right_peak), p=16)
1578 data += dec2bytes(abs(self.adjustments.back_left_peak), p=16)
1579
1580 # Center (front) channel
1581 if True in (self.adjustments.has_bass_channel, self.adjustments.has_front_center_channel):
1582 inc_dec_bits[self.FRONT_CENTER_CHANNEL_BIT] = 1 if self.adjustments.front_center > 0 \
1583 else 0
1584 data += dec2bytes(abs(self.adjustments.front_center), p=16)
1585 data += dec2bytes(abs(self.adjustments.front_center_peak), p=16)
1586
1587 # Bass channel
1588 if self.adjustments.has_bass_channel:
1589 inc_dec_bits[self.BASS_CHANNEL_BIT] = 1 if self.adjustments.bass > 0 else 0
1590 data += dec2bytes(abs(self.adjustments.bass), p=16)
1591 data += dec2bytes(abs(self.adjustments.bass_peak), p=16)
1592
1593 self.data = bin2bytes(reversed(inc_dec_bits)) + b"\x10" + data
1594 return super().render()
1595
1596
1597 StartEndTuple = namedtuple("StartEndTuple", ["start", "end"])
1598 """A 2-tuple, with names 'start' and 'end'."""
1599
1600
1601 class ChapterFrame(Frame):
1602 """Frame type for chapter/section of the audio file.
1603 <ID3v2.3 or ID3v2.4 frame header, ID: "CHAP"> (10 bytes)
1604 Element ID <text string> $00
1605 Start time $xx xx xx xx
1606 End time $xx xx xx xx
1607 Start offset $xx xx xx xx
1608 End offset $xx xx xx xx
1609 <Optional embedded sub-frames>
1610 """
1611
1612 NO_OFFSET = 4294967295
1613 """No offset value, aka '0xff0xff0xff0xff'"""
1614
1615 def __init__(self, id=CHAPTER_FID, element_id=None, times=None,
1616 offsets=None, sub_frames=None):
1617 assert(id == CHAPTER_FID)
1618 super(ChapterFrame, self).__init__(id)
1619 self.element_id = element_id
1620 self.times = times or StartEndTuple(None, None)
1621 self.offsets = offsets or StartEndTuple(None, None)
1622 self.sub_frames = sub_frames or FrameSet()
1623
1624 def parse(self, data, frame_header):
1625 from .headers import TagHeader, ExtendedTagHeader
1626
1627 super().parse(data, frame_header)
1628
1629 data = self.data
1630 log.debug("CTOC frame data size: %d" % len(data))
1631
1632 null_byte = data.find(b'\x00')
1633 self.element_id = data[0:null_byte]
1634 data = data[null_byte + 1:]
1635
1636 start = bytes2dec(data[:4])
1637 data = data[4:]
1638 end = bytes2dec(data[:4])
1639 data = data[4:]
1640 self.times = StartEndTuple(start, end)
1641
1642 start = bytes2dec(data[:4])
1643 data = data[4:]
1644 end = bytes2dec(data[:4])
1645 data = data[4:]
1646 self.offsets = StartEndTuple(start if start != self.NO_OFFSET else None,
1647 end if end != self.NO_OFFSET else None)
1648
1649 if data:
1650 dummy_tag_header = TagHeader(self.header.version)
1651 dummy_tag_header.tag_size = len(data)
1652 _ = self.sub_frames.parse(BytesIO(data), dummy_tag_header, # noqa
1653 ExtendedTagHeader())
1654 else:
1655 self.sub_frames = FrameSet()
1656
1657 def render(self):
1658 data = self.element_id + b'\x00'
1659
1660 for n in self.times + self.offsets:
1661 if n is not None:
1662 data += dec2bytes(n, 32)
1663 else:
1664 data += b'\xff\xff\xff\xff'
1665
1666 for f in self.sub_frames.getAllFrames():
1667 f.header = FrameHeader(f.id, self.header.version)
1668 data += f.render()
1669
1670 self.data = data
1671 return super(ChapterFrame, self).render()
1672
1673 @property
1674 def title(self):
1675 if TITLE_FID in self.sub_frames:
1676 return self.sub_frames[TITLE_FID][0].text
1677 return None
1678
1679 @title.setter
1680 def title(self, title):
1681 self.sub_frames.setTextFrame(TITLE_FID, title)
1682
1683 @property
1684 def subtitle(self):
1685 if SUBTITLE_FID in self.sub_frames:
1686 return self.sub_frames[SUBTITLE_FID][0].text
1687 return None
1688
1689 @subtitle.setter
1690 def subtitle(self, subtitle):
1691 self.sub_frames.setTextFrame(SUBTITLE_FID, subtitle)
1692
1693 @property
1694 def user_url(self):
1695 if USERURL_FID in self.sub_frames:
1696 frame = self.sub_frames[USERURL_FID][0]
1697 # Not returning frame description, it is always the same since it
1698 # allows only 1 URL.
1699 return frame.url
1700 return None
1701
1702 @user_url.setter
1703 def user_url(self, url):
1704 DESCRIPTION = "chapter url"
1705
1706 if url is None:
1707 del self.sub_frames[USERURL_FID]
1708 else:
1709 if USERURL_FID in self.sub_frames:
1710 for frame in self.sub_frames[USERURL_FID]:
1711 if frame.description == DESCRIPTION:
1712 frame.url = url
1713 return
1714
1715 self.sub_frames[USERURL_FID] = UserUrlFrame(USERURL_FID,
1716 DESCRIPTION, url)
1717
1718
1719 # XXX: This data structure pretty much sucks, or it is beautiful anarchy
1720 class FrameSet(dict):
1721 def __init__(self):
1722 dict.__init__(self)
1723
1724 def parse(self, f, tag_header, extended_header):
1725 """Read frames starting from the current read position of the file
1726 object. Returns the amount of padding which occurs after the tag, but
1727 before the audio content. A return valule of 0 does not mean error."""
1728 self.clear()
1729
1730 padding_size = 0
1731 size_left = tag_header.tag_size - extended_header.size
1732 consumed_size = 0
1733
1734 # Handle a tag-level unsync. Some frames may have their own unsync bit
1735 # set instead.
1736 tag_data = f.read(size_left)
1737
1738 # If the tag is 2.3 and the tag header unsync bit is set then all the
1739 # frame data is deunsync'd at once, otherwise it will happen on a per
1740 # frame basis.
1741 if tag_header.unsync and tag_header.version <= ID3_V2_3:
1742 log.debug("De-unsynching %d bytes at once (<= 2.3 tag)" %
1743 len(tag_data))
1744 og_size = len(tag_data)
1745 tag_data = deunsyncData(tag_data)
1746 size_left = len(tag_data)
1747 log.debug("De-unsynch'd %d bytes at once (<= 2.3 tag) to %d bytes" %
1748 (og_size, size_left))
1749
1750 # Adding bytes to simulate the tag header(s) in the buffer. This keeps
1751 # f.tell() values matching the file offsets for logging.
1752 prepadding = b'\x00' * 10 # Tag header
1753 prepadding += b'\x00' * extended_header.size
1754 tag_buffer = BytesIO(prepadding + tag_data)
1755 tag_buffer.seek(len(prepadding))
1756
1757 frame_count = 0
1758 while size_left > 0:
1759 log.debug("size_left: " + str(size_left))
1760 if size_left < (10 + 1): # The size of the smallest frame.
1761 log.debug("FrameSet: Implied padding (size_left<minFrameSize)")
1762 padding_size = size_left
1763 break
1764
1765 log.debug("+++++++++++++++++++++++++++++++++++++++++++++++++")
1766 log.debug("FrameSet: Reading Frame #" + str(frame_count + 1))
1767 frame_header = FrameHeader.parse(tag_buffer, tag_header.version)
1768 if not frame_header:
1769 log.debug("No frame found, implied padding of %d bytes" %
1770 size_left)
1771 padding_size = size_left
1772 break
1773
1774 # Frame data.
1775 if frame_header.data_size:
1776 log.debug("FrameSet: Reading %d (0x%X) bytes of data from byte "
1777 "pos %d (0x%X)" % (frame_header.data_size,
1778 frame_header.data_size,
1779 tag_buffer.tell(),
1780 tag_buffer.tell()))
1781 data = tag_buffer.read(frame_header.data_size)
1782
1783 log.debug("FrameSet: %d bytes of data read" % len(data))
1784 consumed_size += (frame_header.size +
1785 frame_header.data_size)
1786 try:
1787 frame = createFrame(tag_header, frame_header, data)
1788 except FrameException as frame_ex:
1789 log.warning(f"Frame error: {frame_ex}")
1790 else:
1791 self[frame.id] = frame
1792 frame_count += 1
1793
1794 # Each frame contains data_size + headerSize bytes.
1795 size_left -= (frame_header.size +
1796 frame_header.data_size)
1797
1798 return padding_size
1799
1800 @requireBytes(1)
1801 def __getitem__(self, fid):
1802 if fid in self:
1803 return dict.__getitem__(self, fid)
1804 else:
1805 return None
1806
1807 @requireBytes(1)
1808 def __setitem__(self, fid, frame):
1809 assert(fid == frame.id)
1810
1811 if fid in self:
1812 self[fid].append(frame)
1813 else:
1814 dict.__setitem__(self, fid, [frame])
1815
1816 def getAllFrames(self):
1817 """Return all the frames in the set as a list. The list is sorted
1818 in an arbitrary but consistent order."""
1819 frames = []
1820 for flist in list(self.values()):
1821 frames += flist
1822 frames.sort()
1823 return frames
1824
1825 @requireBytes(1)
1826 @requireUnicode(2)
1827 def setTextFrame(self, fid, text):
1828 """Set a text frame value.
1829 Text frame IDs must be unique. If a frame with
1830 the same Id is already in the list it's value is changed, otherwise
1831 the frame is added.
1832 """
1833 assert(fid[0:1] == b"T" and (fid in ID3_FRAMES or
1834 fid in NONSTANDARD_ID3_FRAMES))
1835
1836 if fid in self:
1837 self[fid][0].text = text
1838 else:
1839 if fid in (DATE_FIDS + DEPRECATED_DATE_FIDS):
1840 self[fid] = DateFrame(fid, date=text)
1841 else:
1842 self[fid] = TextFrame(fid, text=text)
1843
1844 @requireBytes(1)
1845 def __contains__(self, fid):
1846 return dict.__contains__(self, fid)
1847
1848
1849 def deunsyncData(data):
1850 output = []
1851 safe = True
1852 for val in [bytes([b]) for b in data]:
1853 if safe:
1854 output.append(val)
1855 safe = (val != b'\xff')
1856 else:
1857 if val != b'\x00':
1858 output.append(val)
1859 safe = True
1860 return b''.join(output)
1861
1862
1863 # Create and return the appropriate frame.
1864 def createFrame(tag_header, frame_header, data):
1865 fid = frame_header.id
1866 if fid in ID3_FRAMES:
1867 (desc, ver, FrameClass) = ID3_FRAMES[fid]
1868 elif fid in NONSTANDARD_ID3_FRAMES:
1869 log.verbose("Non standard frame '%s' encountered" % fid)
1870 (desc, ver, FrameClass) = NONSTANDARD_ID3_FRAMES[fid]
1871 else:
1872 log.warning(f"Unknown ID3 frame ID: {fid}")
1873 (desc, ver, FrameClass) = ("Unknown", None, Frame)
1874 log.debug(f"createFrame (desc:{desc}) - {ver} - {FrameClass}")
1875
1876 # FrameClass may still be None if the frame is standard but does not
1877 # yet have a concrete type.
1878 if not FrameClass:
1879 log.warning(f"Frame '{fid.decode('ascii')}' is not yet supported, using raw Frame to parse")
1880 FrameClass = Frame
1881
1882 log.debug(f"createFrame '{fid}' with class '{FrameClass}'")
1883 if tag_header.version[:2] == ID3_V2_4 and tag_header.unsync:
1884 frame_header.unsync = True
1885
1886 frame = FrameClass(fid)
1887 frame.parse(data, frame_header)
1888 return frame
1889
1890
1891 def decodeUnicode(bites, encoding):
1892 for obj, obj_name in ((bites, "bites"), (encoding, "encoding")):
1893 if not isinstance(obj, bytes):
1894 raise TypeError("%s argument must be a byte string." % obj_name)
1895
1896 codec = id3EncodingToString(encoding)
1897 log.debug("Unicode encoding: %s" % codec)
1898 if (codec.startswith("utf_16") and
1899 len(bites) % 2 != 0 and bites[-1:] == b"\x00"):
1900 # Catch and fix bad utf16 data, it is everywhere.
1901 log.warning("Fixing utf16 data with extra zero bytes")
1902 bites = bites[:-1]
1903 return str(bites, codec).rstrip("\x00")
1904
1905
1906 def splitUnicode(data, encoding):
1907 try:
1908 if encoding == LATIN1_ENCODING or encoding == UTF_8_ENCODING:
1909 (d, t) = data.split(b"\x00", 1)
1910 elif encoding == UTF_16_ENCODING or encoding == UTF_16BE_ENCODING:
1911 # Two null bytes split, but since each utf16 char is also two
1912 # bytes we need to ensure we found a proper boundary.
1913 (d, t) = data.split(b"\x00\x00", 1)
1914 if (len(d) % 2) != 0:
1915 (d, t) = data.split(b"\x00\x00\x00", 1)
1916 d += b"\x00"
1917 else:
1918 raise NotImplementedError(f"Unknown ID3 encoding: {encoding}")
1919 except ValueError as ex:
1920 log.warning(f"Invalid 2-tuple ID3 frame data: {ex}")
1921 d, t = data, b""
1922
1923 return d, t
1924
1925
1926 def id3EncodingToString(encoding):
1927 if not isinstance(encoding, bytes):
1928 raise TypeError("encoding argument must be a byte string.")
1929
1930 if encoding == LATIN1_ENCODING:
1931 return "latin_1"
1932 elif encoding == UTF_8_ENCODING:
1933 return "utf_8"
1934 elif encoding == UTF_16_ENCODING:
1935 return "utf_16"
1936 elif encoding == UTF_16BE_ENCODING:
1937 return "utf_16_be"
1938 else:
1939 raise ValueError("Encoding unknown: %s" % encoding)
1940
1941
1942 def stringToEncoding(s):
1943 s = s.replace('-', '_')
1944 if s in ("latin_1", "latin1"):
1945 return LATIN1_ENCODING
1946 elif s in ("utf_8", "utf8"):
1947 return UTF_8_ENCODING
1948 elif s in ("utf_16", "utf16"):
1949 return UTF_16_ENCODING
1950 elif s in ("utf_16_be", "utf16_be"):
1951 return UTF_16BE_ENCODING
1952 else:
1953 raise ValueError("Encoding unknown: %s" % s)
1954
1955
1956 # { frame-id : (frame-description, valid-id3-version, frame-class) }
1957 ID3_FRAMES = {b"AENC": ("Audio encryption",
1958 ID3_V2,
1959 None),
1960 b"APIC": ("Attached picture",
1961 ID3_V2,
1962 ImageFrame),
1963 b"ASPI": ("Audio seek point index",
1964 ID3_V2_4,
1965 None),
1966
1967 b"COMM": ("Comments", ID3_V2, CommentFrame),
1968 b"COMR": ("Commercial frame", ID3_V2, None),
1969
1970 b"CTOC": ("Table of contents", ID3_V2, TocFrame),
1971 b"CHAP": ("Chapter", ID3_V2, ChapterFrame),
1972
1973 b"ENCR": ("Encryption method registration", ID3_V2, None),
1974 b"EQUA": ("Equalisation", ID3_V2_3, None),
1975 b"EQU2": ("Equalisation (2)", ID3_V2_4, None),
1976 b"ETCO": ("Event timing codes", ID3_V2, None),
1977
1978 b"GEOB": ("General encapsulated object", ID3_V2, ObjectFrame),
1979 b"GRID": ("Group identification registration", ID3_V2, None),
1980
1981 b"IPLS": ("Involved people list", ID3_V2_3, None),
1982
1983 b"LINK": ("Linked information", ID3_V2, None),
1984
1985 b"MCDI": ("Music CD identifier", ID3_V2, MusicCDIdFrame),
1986 b"MLLT": ("MPEG location lookup table", ID3_V2, None),
1987
1988 b"OWNE": ("Ownership frame", ID3_V2, None),
1989
1990 b"PRIV": ("Private frame", ID3_V2, PrivateFrame),
1991 b"PCNT": ("Play counter", ID3_V2, PlayCountFrame),
1992 b"POPM": ("Popularimeter", ID3_V2, PopularityFrame),
1993 b"POSS": ("Position synchronisation frame", ID3_V2, None),
1994
1995 b"RBUF": ("Recommended buffer size", ID3_V2, None),
1996 b"RVAD": ("Relative volume adjustment", ID3_V2_3, RelVolAdjFrameV23),
1997 b"RVA2": ("Relative volume adjustment (2)", ID3_V2_4, RelVolAdjFrameV24),
1998 b"RVRB": ("Reverb", ID3_V2, None),
1999
2000 b"SEEK": ("Seek frame", ID3_V2_4, None),
2001 b"SIGN": ("Signature frame", ID3_V2_4, None),
2002 b"SYLT": ("Synchronised lyric/text", ID3_V2, None),
2003 b"SYTC": ("Synchronised tempo codes", ID3_V2, None),
2004
2005 b"TALB": ("Album/Movie/Show title", ID3_V2, TextFrame),
2006 b"TBPM": ("BPM (beats per minute)", ID3_V2, TextFrame),
2007 b"TCOM": ("Composer", ID3_V2, TextFrame),
2008 b"TCON": ("Content type", ID3_V2, TextFrame),
2009 b"TCOP": ("Copyright message", ID3_V2, TextFrame),
2010 b"TDAT": ("Date", ID3_V2_3, DateFrame),
2011 b"TDEN": ("Encoding time", ID3_V2_4, DateFrame),
2012 b"TDLY": ("Playlist delay", ID3_V2, TextFrame),
2013 b"TDOR": ("Original release time", ID3_V2_4, DateFrame),
2014 b"TDRC": ("Recording time", ID3_V2_4, DateFrame),
2015 b"TDRL": ("Release time", ID3_V2_4, DateFrame),
2016 b"TDTG": ("Tagging time", ID3_V2_4, DateFrame),
2017 b"TENC": ("Encoded by", ID3_V2, TextFrame),
2018 b"TEXT": ("Lyricist/Text writer", ID3_V2, TextFrame),
2019 b"TFLT": ("File type", ID3_V2, TextFrame),
2020 b"TIME": ("Time", ID3_V2_3, DateFrame),
2021 b"TIPL": ("Involved people list", ID3_V2_4, TextFrame),
2022 b"TIT1": ("Content group description", ID3_V2, TextFrame),
2023 b"TIT2": ("Title/songname/content description", ID3_V2,
2024 TextFrame),
2025 b"TIT3": ("Subtitle/Description refinement", ID3_V2, TextFrame),
2026 b"TKEY": ("Initial key", ID3_V2, TextFrame),
2027 b"TLAN": ("Language(s)", ID3_V2, TextFrame),
2028 b"TLEN": ("Length", ID3_V2, TextFrame),
2029 b"TMCL": ("Musician credits list", ID3_V2_4, TextFrame),
2030 b"TMED": ("Media type", ID3_V2, TextFrame),
2031 b"TMOO": ("Mood", ID3_V2_4, TextFrame),
2032 b"TOAL": ("Original album/movie/show title", ID3_V2, TextFrame),
2033 b"TOFN": ("Original filename", ID3_V2, TextFrame),
2034 b"TOLY": ("Original lyricist(s)/text writer(s)", ID3_V2,
2035 TextFrame),
2036 b"TOPE": ("Original artist(s)/performer(s)", ID3_V2, TextFrame),
2037 b"TORY": ("Original release year", ID3_V2_3, DateFrame),
2038 b"TOWN": ("File owner/licensee", ID3_V2, TextFrame),
2039 b"TPE1": ("Lead performer(s)/Soloist(s)", ID3_V2, TextFrame),
2040 b"TPE2": ("Band/orchestra/accompaniment", ID3_V2, TextFrame),
2041 b"TPE3": ("Conductor/performer refinement", ID3_V2, TextFrame),
2042 b"TPE4": ("Interpreted, remixed, or otherwise modified by",
2043 ID3_V2, TextFrame),
2044 b"TPOS": ("Part of a set", ID3_V2, TextFrame),
2045 b"TPRO": ("Produced notice", ID3_V2_4, TextFrame),
2046 b"TPUB": ("Publisher", ID3_V2, TextFrame),
2047 b"TRCK": ("Track number/Position in set", ID3_V2, TextFrame),
2048 b"TRDA": ("Recording dates", ID3_V2_3, DateFrame),
2049 b"TRSN": ("Internet radio station name", ID3_V2, TextFrame),
2050 b"TRSO": ("Internet radio station owner", ID3_V2, TextFrame),
2051 b"TSOA": ("Album sort order", ID3_V2_4, TextFrame),
2052 b"TSOP": ("Performer sort order", ID3_V2_4, TextFrame),
2053 b"TSOT": ("Title sort order", ID3_V2_4, TextFrame),
2054 b"TSIZ": ("Size", ID3_V2_3, TextFrame),
2055 b"TSRC": ("ISRC (international standard recording code)", ID3_V2,
2056 TextFrame),
2057 b"TSSE": ("Software/Hardware and settings used for encoding",
2058 ID3_V2, TextFrame),
2059 b"TSST": ("Set subtitle", ID3_V2_4, TextFrame),
2060 b"TYER": ("Year", ID3_V2_3, DateFrame),
2061 b"TXXX": ("User defined text information frame", ID3_V2,
2062 UserTextFrame),
2063
2064 b"UFID": ("Unique file identifier", ID3_V2, UniqueFileIDFrame),
2065 b"USER": ("Terms of use", ID3_V2, TermsOfUseFrame),
2066 b"USLT": ("Unsynchronised lyric/text transcription", ID3_V2,
2067 LyricsFrame),
2068
2069 b"WCOM": ("Commercial information", ID3_V2, UrlFrame),
2070 b"WCOP": ("Copyright/Legal information", ID3_V2, UrlFrame),
2071 b"WOAF": ("Official audio file webpage", ID3_V2, UrlFrame),
2072 b"WOAR": ("Official artist/performer webpage", ID3_V2, UrlFrame),
2073 b"WOAS": ("Official audio source webpage", ID3_V2, UrlFrame),
2074 b"WORS": ("Official Internet radio station homepage", ID3_V2,
2075 UrlFrame),
2076 b"WPAY": ("Payment", ID3_V2, UrlFrame),
2077 b"WPUB": ("Publishers official webpage", ID3_V2, UrlFrame),
2078 b"WXXX": ("User defined URL link frame", ID3_V2, UserUrlFrame),
2079 }
2080
2081
2082 def map2_2FrameId(orig_id):
2083 if orig_id not in TAGS2_2_TO_TAGS_2_3_AND_4:
2084 return orig_id
2085 return TAGS2_2_TO_TAGS_2_3_AND_4[orig_id]
2086
2087
2088 # mapping of 2.2 frames to 2.3/2.4
2089 TAGS2_2_TO_TAGS_2_3_AND_4 = {
2090 b"TT1": b"TIT1", # CONTENTGROUP content group description
2091 b"TT2": b"TIT2", # TITLE title/songname/content description
2092 b"TT3": b"TIT3", # SUBTITLE subtitle/description refinement
2093 b"TP1": b"TPE1", # ARTIST lead performer(s)/soloist(s)
2094 b"TP2": b"TPE2", # BAND band/orchestra/accompaniment
2095 b"TP3": b"TPE3", # CONDUCTOR conductor/performer refinement
2096 b"TP4": b"TPE4", # MIXARTIST interpreted, remixed, modified by
2097 b"TCM": b"TCOM", # COMPOSER composer
2098 b"TXT": b"TEXT", # LYRICIST lyricist/text writer
2099 b"TLA": b"TLAN", # LANGUAGE language(s)
2100 b"TCO": b"TCON", # CONTENTTYPE content type
2101 b"TAL": b"TALB", # ALBUM album/movie/show title
2102 b"TRK": b"TRCK", # TRACKNUM track number/position in set
2103 b"TPA": b"TPOS", # PARTINSET part of set
2104 b"TRC": b"TSRC", # ISRC international standard recording code
2105 b"TDA": b"TDAT", # DATE date
2106 b"TYE": b"TYER", # YEAR year
2107 b"TIM": b"TIME", # TIME time
2108 b"TRD": b"TRDA", # RECORDINGDATES recording dates
2109 b"TOR": b"TORY", # ORIGYEAR original release year
2110 b"TBP": b"TBPM", # BPM beats per minute
2111 b"TMT": b"TMED", # MEDIATYPE media type
2112 b"TFT": b"TFLT", # FILETYPE file type
2113 b"TCR": b"TCOP", # COPYRIGHT copyright message
2114 b"TPB": b"TPUB", # PUBLISHER publisher
2115 b"TEN": b"TENC", # ENCODEDBY encoded by
2116 b"TSS": b"TSSE", # ENCODERSETTINGS software/hardware+settings for encoding
2117 b"TLE": b"TLEN", # SONGLEN length (ms)
2118 b"TSI": b"TSIZ", # SIZE size (bytes)
2119 b"TDY": b"TDLY", # PLAYLISTDELAY playlist delay
2120 b"TKE": b"TKEY", # INITIALKEY initial key
2121 b"TOT": b"TOAL", # ORIGALBUM original album/movie/show title
2122 b"TOF": b"TOFN", # ORIGFILENAME original filename
2123 b"TOA": b"TOPE", # ORIGARTIST original artist(s)/performer(s)
2124 b"TOL": b"TOLY", # ORIGLYRICIST original lyricist(s)/text writer(s)
2125 b"TXX": b"TXXX", # USERTEXT user defined text information frame
2126 b"WAF": b"WOAF", # WWWAUDIOFILE official audio file webpage
2127 b"WAR": b"WOAR", # WWWARTIST official artist/performer webpage
2128 b"WAS": b"WOAS", # WWWAUDIOSOURCE official audion source webpage
2129 b"WCM": b"WCOM", # WWWCOMMERCIALINFO commercial information
2130 b"WCP": b"WCOP", # WWWCOPYRIGHT copyright/legal information
2131 b"WPB": b"WPUB", # WWWPUBLISHER publishers official webpage
2132 b"WXX": b"WXXX", # WWWUSER user defined URL link frame
2133 b"IPL": b"IPLS", # INVOLVEDPEOPLE involved people list
2134 b"ULT": b"USLT", # UNSYNCEDLYRICS unsynchronised lyrics/text transcription
2135 b"COM": b"COMM", # COMMENT comments
2136 b"UFI": b"UFID", # UNIQUEFILEID unique file identifier
2137 b"MCI": b"MCDI", # CDID music CD identifier
2138 b"ETC": b"ETCO", # EVENTTIMING event timing codes
2139 b"MLL": b"MLLT", # MPEGLOOKUP MPEG location lookup table
2140 b"STC": b"SYTC", # SYNCEDTEMPO synchronised tempo codes
2141 b"SLT": b"SYLT", # SYNCEDLYRICS synchronised lyrics/text
2142 b"RVA": b"RVAD", # VOLUMEADJ relative volume adjustment
2143 b"EQU": b"EQUA", # EQUALIZATION equalization
2144 b"REV": b"RVRB", # REVERB reverb
2145 b"PIC": b"APIC", # PICTURE attached picture
2146 b"GEO": b"GEOB", # GENERALOBJECT general encapsulated object
2147 b"CNT": b"PCNT", # PLAYCOUNTER play counter
2148 b"POP": b"POPM", # POPULARIMETER popularimeter
2149 b"BUF": b"RBUF", # BUFFERSIZE recommended buffer size
2150 b"CRA": b"AENC", # AUDIOCRYPTO audio encryption
2151 b"LNK": b"LINK", # LINKEDINFO linked information
2152 # Extension workarounds i.e., ignore them
2153 b"TCP": b"TCMP", # iTunes "extension" for compilation marking
2154 b"TST": b"TSOT", # iTunes "extension" for title sort
2155 b"TSP": b"TSOP", # iTunes "extension" for artist sort
2156 b"TSA": b"TSOA", # iTunes "extension" for album sort
2157 b"TS2": b"TSO2", # iTunes "extension" for album artist sort
2158 b"TSC": b"TSOC", # iTunes "extension" for composer sort
2159 b"TDR": b"TDRL", # iTunes "extension" for release date
2160 b"TDS": b"TDES", # iTunes "extension" for podcast description
2161 b"TID": b"TGID", # iTunes "extension" for podcast identifier
2162 b"WFD": b"WFED", # iTunes "extension" for podcast feed URL
2163 b"CM1": b"CM1 ", # Seems to be some script kiddie tagging the tag.
2164 # For example, [rH] join #rH on efnet [rH]
2165 b"PCS": b"PCST", # iTunes extension for podcast marking.
2166 }
2167
2168 from . import apple # noqa
2169 NONSTANDARD_ID3_FRAMES = {
2170 b"NCON": ("Undefined MusicMatch extension", ID3_V2, Frame),
2171 b"TCMP": ("iTunes complilation flag extension", ID3_V2, TextFrame),
2172 b"XSOA": ("Album sort-order string extension for v2.3",
2173 ID3_V2_3, TextFrame),
2174 b"XSOP": ("Performer sort-order string extension for v2.3",
2175 ID3_V2_3, TextFrame),
2176 b"XSOT": ("Title sort-order string extension for v2.3",
2177 ID3_V2_3, TextFrame),
2178 b"XDOR": ("MusicBrainz release date (full) extension for v2.3",
2179 ID3_V2_3, DateFrame),
2180
2181 b"TSO2": ("Album artist sort-order used in iTunes and Picard",
2182 ID3_V2, TextFrame),
2183 b"TSOC": ("Composer sort-order used in iTunes and Picard",
2184 ID3_V2, TextFrame),
2185
2186 b"PCST": ("iTunes extension; marks the file as a podcast",
2187 ID3_V2, apple.PCST),
2188 b"TKWD": ("iTunes extension; podcast keywords?",
2189 ID3_V2, apple.TKWD),
2190 b"TDES": ("iTunes extension; podcast description?",
2191 ID3_V2, apple.TDES),
2192 b"TGID": ("iTunes extension; podcast ?????",
2193 ID3_V2, apple.TGID),
2194 b"WFED": ("iTunes extension; podcast feed URL?",
2195 ID3_V2, apple.WFED),
2196 b"TCAT": ("iTunes extension; podcast category.",
2197 ID3_V2, TextFrame),
2198 b"GRP1": ("iTunes extension; grouping.",
2199 ID3_V2, apple.GRP1),
2200 }
0 import math
1 import logging
2 import binascii
3 from ..utils import requireBytes
4 from ..utils.binfuncs import (bin2dec, bytes2bin, bin2bytes,
5 bin2synchsafe, dec2bin)
6 from .. import core
7 from . import ID3_DEFAULT_VERSION, isValidVersion, normalizeVersion
8
9 from ..utils.log import getLogger
10 log = getLogger(__name__)
11
12 NULL_FRAME_FLAGS = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
13
14
15 class TagHeader(object):
16 SIZE = 10
17
18 def __init__(self, version=ID3_DEFAULT_VERSION):
19 self.clear()
20 self.version = version
21
22 def clear(self):
23 self.tag_size = 0
24 # Flag bits
25 self.unsync = False
26 self.extended = False
27 self.experimental = False
28 # v2.4 addition
29 self.footer = False
30
31 @property
32 def version(self):
33 return tuple([v for v in self._version])
34
35 @version.setter
36 def version(self, v):
37 v = normalizeVersion(v)
38 if not isValidVersion(v, fully_qualified=True):
39 raise ValueError("Invalid version: %s" % str(v))
40 self._version = v
41
42 @property
43 def major_version(self):
44 return self._version[0]
45
46 @property
47 def minor_version(self):
48 return self._version[1]
49
50 @property
51 def rev_version(self):
52 return self._version[2]
53
54 def parse(self, f):
55 """Parse an ID3 v2 header starting at the current position of `f`.
56
57 If a header is parsed `True` is returned, otherwise `False`. If
58 a header is found but malformed an `eyed3.id3.tag.TagException` is
59 thrown.
60 """
61 from .tag import TagException
62
63 self.clear()
64
65 # 3 bytes: v2 header is "ID3".
66 if f.read(3) != b"ID3":
67 return False
68 log.debug("Located ID3 v2 tag")
69
70 # 2 bytes: the minor and revision versions.
71 version = f.read(2)
72 if len(version) != 2:
73 return False
74 major = 2
75 minor = version[0]
76 rev = version[1]
77 log.debug("TagHeader [major]: %d " % major)
78 log.debug("TagHeader [minor]: %d " % minor)
79 log.debug("TagHeader [rev]: %d " % rev)
80 if not (major == 2 and (minor >= 2 and minor <= 4)):
81 raise TagException("ID3 v%d.%d is not supported" % (major, minor))
82 self.version = (major, minor, rev)
83
84 # 1 byte (first 4 bits): flags
85 data = f.read(1)
86 if not data:
87 return False
88 (self.unsync,
89 self.extended,
90 self.experimental,
91 self.footer) = (bool(b) for b in bytes2bin(data)[0:4])
92 log.debug("TagHeader [flags]: unsync(%d) extended(%d) "
93 "experimental(%d) footer(%d)" % (self.unsync, self.extended,
94 self.experimental,
95 self.footer))
96
97 # 4 bytes: The size of the extended header (if any), frames, and padding
98 # afer unsynchronization. This is a sync safe integer, so only the
99 # bottom 7 bits of each byte are used.
100 tag_size_bytes = f.read(4)
101 if len(tag_size_bytes) != 4:
102 return False
103 log.debug("TagHeader [size string]: 0x%02x%02x%02x%02x" %
104 (tag_size_bytes[0], tag_size_bytes[1],
105 tag_size_bytes[2], tag_size_bytes[3]))
106 self.tag_size = bin2dec(bytes2bin(tag_size_bytes, 7))
107 log.debug("TagHeader [size]: %d (0x%x)" % (self.tag_size,
108 self.tag_size))
109
110 return True
111
112 def render(self, tag_len=None):
113 if tag_len is not None:
114 self.tag_size = tag_len
115
116 if self.unsync:
117 raise NotImplementedError("eyeD3 does not write (only reads) "
118 "unsync'd data")
119
120 data = b"ID3"
121 data += bytes([self.minor_version]) + bytes([self.rev_version])
122 data += bin2bytes([int(self.unsync),
123 int(self.extended),
124 int(self.experimental),
125 int(self.footer),
126 0, 0, 0, 0])
127 log.debug("Setting tag size to %d" % self.tag_size)
128 data += bin2bytes(bin2synchsafe(dec2bin(self.tag_size, 32)))
129 log.debug("TagHeader rendered %d bytes" % len(data))
130 return data
131
132
133 class ExtendedTagHeader(object):
134 RESTRICT_TAG_SZ_LARGE = 0x00
135 RESTRICT_TAG_SZ_MED = 0x01
136 RESTRICT_TAG_SZ_SMALL = 0x02
137 RESTRICT_TAG_SZ_TINY = 0x03
138
139 RESTRICT_TEXT_ENC_NONE = 0x00
140 RESTRICT_TEXT_ENC_UTF8 = 0x01
141
142 RESTRICT_TEXT_LEN_NONE = 0x00
143 RESTRICT_TEXT_LEN_1024 = 0x01
144 RESTRICT_TEXT_LEN_128 = 0x02
145 RESTRICT_TEXT_LEN_30 = 0x03
146
147 RESTRICT_IMG_ENC_NONE = 0x00
148 RESTRICT_IMG_ENC_PNG_JPG = 0x01
149
150 RESTRICT_IMG_SZ_NONE = 0x00
151 RESTRICT_IMG_SZ_256 = 0x01
152 RESTRICT_IMG_SZ_64 = 0x02
153 RESTRICT_IMG_SZ_64_EXACT = 0x03
154
155 def __init__(self):
156 self.size = 0
157 self._flags = 0
158 self.crc = None
159 self._restrictions = 0
160
161 @property
162 def update_bit(self):
163 return bool(self._flags & 0x40)
164
165 @update_bit.setter
166 def update_bit(self, v):
167 if v:
168 self._flags |= 0x40
169 else:
170 self._flags &= ~0x40
171
172 @property
173 def crc_bit(self):
174 return bool(self._flags & 0x20)
175
176 @crc_bit.setter
177 def crc_bit(self, v):
178 if v:
179 self._flags |= 0x20
180 else:
181 self._flags &= ~0x20
182
183 @property
184 def crc(self):
185 return self._crc
186
187 @crc.setter
188 def crc(self, v):
189 self.crc_bit = 1 if v else 0
190 self._crc = v
191
192 @property
193 def restrictions_bit(self):
194 return bool(self._flags & 0x10)
195
196 @restrictions_bit.setter
197 def restrictions_bit(self, v):
198 if v:
199 self._flags |= 0x10
200 else:
201 self._flags &= ~0x10
202
203 @property
204 def tag_size_restriction(self):
205 return self._restrictions >> 6
206
207 @tag_size_restriction.setter
208 def tag_size_restriction(self, v):
209 assert(v >= 0 and v <= 3)
210 self.restrictions_bit = 1
211 self._restrictions = (v << 6) | (self._restrictions & 0x3f)
212
213 @property
214 def tag_size_restriction_description(self):
215 val = self.tag_size_restriction
216 if val == ExtendedTagHeader.RESTRICT_TAG_SZ_LARGE:
217 return "No more than 128 frames and 1 MB total tag size"
218 elif val == ExtendedTagHeader.RESTRICT_TAG_SZ_MED:
219 return "No more than 64 frames and 128 KB total tag size"
220 elif val == ExtendedTagHeader.RESTRICT_TAG_SZ_SMALL:
221 return "No more than 32 frames and 40 KB total tag size"
222 elif val == ExtendedTagHeader.RESTRICT_TAG_SZ_TINY:
223 return "No more than 32 frames and 4 KB total tag size"
224
225 @property
226 def text_enc_restriction(self):
227 return (self._restrictions & 0x20) >> 5
228
229 @text_enc_restriction.setter
230 def text_enc_restriction(self, v):
231 assert(v == 0 or v == 1)
232 self.restrictions_bit = 1
233 self._restrictions ^= 0x20
234
235 @property
236 def text_enc_restriction_description(self):
237 if self.text_enc_restriction:
238 return "Strings are only encoded with ISO-8859-1 or UTF-8"
239 else:
240 return "None"
241
242 @property
243 def text_length_restriction(self):
244 return (self._restrictions >> 3) & 0x03
245
246 @text_length_restriction.setter
247 def text_length_restriction(self, v):
248 assert(v >= 0 and v <= 3)
249 self.restrictions_bit = 1
250 self._restrictions = (v << 3) | (self._restrictions & 0xe7)
251
252 @property
253 def text_length_restriction_description(self):
254 val = self.text_length_restriction
255 if val == ExtendedTagHeader.RESTRICT_TEXT_LEN_NONE:
256 return "None"
257 elif val == ExtendedTagHeader.RESTRICT_TEXT_LEN_1024:
258 return "No string is longer than 1024 characters."
259 elif val == ExtendedTagHeader.RESTRICT_TEXT_LEN_128:
260 return "No string is longer than 128 characters."
261 elif val == ExtendedTagHeader.RESTRICT_TEXT_LEN_30:
262 return "No string is longer than 30 characters."
263
264 @property
265 def image_enc_restriction(self):
266 return (self._restrictions & 0x04) >> 2
267
268 @image_enc_restriction.setter
269 def image_enc_restriction(self, v):
270 assert(v == 0 or v == 1)
271 self.restrictions_bit = 1
272 self._restrictions ^= 0x04
273
274 @property
275 def image_enc_restriction_description(self):
276 if self.image_enc_restriction:
277 return "Images are encoded only with PNG [PNG] or JPEG [JFIF]."
278 else:
279 return "None"
280
281 @property
282 def image_size_restriction(self):
283 return self._restrictions & 0x03
284
285 @image_size_restriction.setter
286 def image_size_restriction(self, v):
287 assert(v >= 0 and v <= 3)
288 self.restrictions_bit = 1
289 self._restrictions = v | (self._restrictions & 0xfc)
290
291 @property
292 def image_size_restriction_description(self):
293 val = self.image_size_restriction
294 if val == ExtendedTagHeader.RESTRICT_IMG_SZ_NONE:
295 return "None"
296 elif val == ExtendedTagHeader.RESTRICT_IMG_SZ_256:
297 return "All images are 256x256 pixels or smaller."
298 elif val == ExtendedTagHeader.RESTRICT_IMG_SZ_64:
299 return "All images are 64x64 pixels or smaller."
300 elif val == ExtendedTagHeader.RESTRICT_IMG_SZ_64_EXACT:
301 return "All images are exactly 64x64 pixels, unless required "\
302 "otherwise."
303
304 def _syncsafeCRC(self):
305 return bytes([
306 (self.crc >> 28) & 0x7f,
307 (self.crc >> 21) & 0x7f,
308 (self.crc >> 14) & 0x7f,
309 (self.crc >> 7) & 0x7f,
310 (self.crc >> 0) & 0x7f,
311 ])
312
313 def render(self, version, frame_data, padding=0):
314 assert(version[0] == 2)
315
316 data = b""
317 if version[1] == 4:
318 # Version 2.4
319 size = 6
320 # Extended flags.
321 if self.update_bit:
322 data += b"\x00"
323 if self.crc_bit:
324 data += b"\x05"
325 # XXX: Using the absolute value of the CRC. The spec is unclear
326 # about the type of this data.
327 self.crc = int(math.fabs(binascii.crc32(frame_data +
328 (b"\x00" * padding))))
329 crc_data = self._syncsafeCRC()
330 if len(crc_data) < 5:
331 # pad if necessary
332 crc_data = (b"\x00" * (5 - len(crc_data))) + crc_data
333 assert(len(crc_data) == 5)
334 data += crc_data
335 if self.restrictions_bit:
336 data += b"\x01"
337 data += bytes([self._restrictions])
338 log.debug("Rendered extended header data (%d bytes)" % len(data))
339
340 # Extended header size.
341 size = bin2bytes(bin2synchsafe(dec2bin(len(data) + 6, 32)))
342 assert(len(size) == 4)
343
344 data = size + b"\x01" + bin2bytes(dec2bin(self._flags)) + data
345 log.debug("Rendered extended header of size %d" % len(data))
346 else:
347 # Version 2.3
348 size = 6 # Note, the 4 size bytes are not included in the size
349 # Extended flags.
350 f = [0] * 16
351 crc = None
352 if self.crc_bit:
353 f[0] = 1
354 # XXX: Using the absolute value of the CRC. The spec is unclear
355 # about the type of this value.
356 self.crc = int(math.fabs(binascii.crc32(frame_data +
357 (b"\x00" * padding))))
358 crc = bin2bytes(dec2bin(self.crc))
359 assert(len(crc) == 4)
360 size += 4
361 flags = bin2bytes(f)
362 assert(len(flags) == 2)
363 # Extended header size.
364 size = bin2bytes(dec2bin(size, 32))
365 assert(len(size) == 4)
366 # Padding size
367 padding_size = bin2bytes(dec2bin(padding, 32))
368
369 data = size + flags + padding_size
370 if crc:
371 data += crc
372
373 return data
374
375 # Only call this when you *know* there is an extened header.
376 def parse(self, fp, version):
377 '''Parse an ID3 v2 extended header starting at the current position
378 of ``fp`` and per the format defined by ``version``. This method
379 should only be called when the presence of an extended header is known
380 since it moves the file position. If a header is found but malformed
381 an ``eyed3.id3.tag.TagException`` is thrown. The return value is
382 ``None``.
383 '''
384 from .tag import TagException
385 assert(version[0] == 2)
386
387 log.debug("Parsing extended header @ 0x%x" % fp.tell())
388 # First 4 bytes is the size of the extended header.
389 data = fp.read(4)
390 if version[1] == 4:
391 # sync-safe
392 sz = bin2dec(bytes2bin(data, 7))
393 self.size = sz
394 log.debug("Extended header size (includes the 4 size bytes): %d" %
395 sz)
396 data = fp.read(sz - 4)
397
398 # Number of flag bytes
399 if data[0] != 1 or (data[1] & 0x8f):
400 # As of 2.4 the first byte is 1 and the second can only have
401 # bits 6, 5, and 4 set.
402 raise TagException("Invalid Extended Header")
403
404 self._flags = data[1]
405 log.debug("Extended header flags: %x" % self._flags)
406
407 offset = 2
408 if self.update_bit:
409 log.debug("Extended header has update bit set")
410 assert(data[offset] == 0)
411 offset += 1
412 if self.crc_bit:
413 log.debug("Extended header has CRC bit set")
414 assert(data[offset] == 5)
415 offset += 1
416 crc_data = data[offset:offset + 5]
417 # This is sync-safe.
418 self.crc = bin2dec(bytes2bin(crc_data, 7))
419 log.debug("Extended header CRC: %d" % self.crc)
420 offset += 5
421 if self.restrictions_bit:
422 log.debug("Extended header has restrictions bit set")
423 assert(data[offset] == 1)
424 offset += 1
425 self._restrictions = data[offset]
426 offset += 1
427 else:
428 # v2.3 is totally different... *sigh*
429 sz = bin2dec(bytes2bin(data))
430 self.size = sz
431 log.debug("Extended header size (not including 4 size bytes): %d" %
432 sz)
433 tmpFlags = fp.read(2)
434 # Read the padding size, but it'll be computed during the parse.
435 ps = fp.read(4)
436 log.debug("Extended header says there is %d bytes of padding" %
437 bin2dec(bytes2bin(ps)))
438 # Make this look like a v2.4 mask.
439 self._flags = tmpFlags[0] >> 2
440 if self.crc_bit:
441 log.debug("Extended header has CRC bit set")
442 crc_data = fp.read(4)
443 self.crc = bin2dec(bytes2bin(crc_data))
444 log.debug("Extended header CRC: %d" % self.crc)
445
446
447 class FrameHeader:
448 """A header for each and every ID3 frame in a tag."""
449
450 # 2.4 not only added flag bits, but also reordered the previously defined
451 # flags. So these are mapped once the ID3 version is known. Access through
452 # 'self', always
453 TAG_ALTER = None
454 FILE_ALTER = None
455 READ_ONLY = None
456 COMPRESSED = None
457 ENCRYPTED = None
458 GROUPED = None
459 UNSYNC = None
460 DATA_LEN = None
461
462 # Constructor.
463 @requireBytes(1)
464 def __init__(self, fid, version):
465 self._version = version
466 self._setBitMask()
467 # _setBitMask will throw if the version is no good
468
469 # Correctly set size of header (v2.2 is smaller)
470 self.size = 10 if self.minor_version != 2 else 6
471
472 # The frame header itself...
473 self.id = fid # First 4 bytes, frame ID
474 self._flags = [0] * 16 # 16 bits, represented here as a list
475 self.data_size = 0 # 4 bytes, size of frame data
476
477 def copyFlags(self, rhs):
478 self.tag_alter = rhs._flags[rhs.TAG_ALTER]
479 self.file_alter = rhs._flags[rhs.FILE_ALTER]
480 self.read_only = rhs._flags[rhs.READ_ONLY]
481 self.compressed = rhs._flags[rhs.COMPRESSED]
482 self.encrypted = rhs._flags[rhs.ENCRYPTED]
483 self.grouped = rhs._flags[rhs.GROUPED]
484 self.unsync = rhs._flags[rhs.UNSYNC]
485 self.data_length_indicator = rhs._flags[rhs.DATA_LEN]
486
487 @property
488 def major_version(self):
489 return self._version[0]
490
491 @property
492 def minor_version(self):
493 return self._version[1]
494
495 @property
496 def version(self):
497 return self._version
498
499 @property
500 def tag_alter(self):
501 return self._flags[self.TAG_ALTER]
502
503 @tag_alter.setter
504 def tag_alter(self, b):
505 self._flags[self.TAG_ALTER] = int(bool(b))
506
507 @property
508 def file_alter(self):
509 return self._flags[self.FILE_ALTER]
510
511 @file_alter.setter
512 def file_alter(self, b):
513 self._flags[self.FILE_ALTER] = int(bool(b))
514
515 @property
516 def read_only(self):
517 return self._flags[self.READ_ONLY]
518
519 @read_only.setter
520 def read_only(self, b):
521 self._flags[self.READ_ONLY] = int(bool(b))
522
523 @property
524 def compressed(self):
525 return self._flags[self.COMPRESSED]
526
527 @compressed.setter
528 def compressed(self, b):
529 self._flags[self.COMPRESSED] = int(bool(b))
530
531 @property
532 def encrypted(self):
533 return self._flags[self.ENCRYPTED]
534
535 @encrypted.setter
536 def encrypted(self, b):
537 self._flags[self.ENCRYPTED] = int(bool(b))
538
539 @property
540 def grouped(self):
541 return self._flags[self.GROUPED]
542
543 @grouped.setter
544 def grouped(self, b):
545 self._flags[self.GROUPED] = int(bool(b))
546
547 @property
548 def unsync(self):
549 return self._flags[self.UNSYNC]
550
551 @unsync.setter
552 def unsync(self, b):
553 self._flags[self.UNSYNC] = int(bool(b))
554
555 @property
556 def data_length_indicator(self):
557 return self._flags[self.DATA_LEN]
558
559 @data_length_indicator.setter
560 def data_length_indicator(self, b):
561 self._flags[self.DATA_LEN] = int(bool(b))
562
563 def _setBitMask(self):
564 major = self.major_version
565 minor = self.minor_version
566
567 # 1.x tags are converted to 2.4 frames internally. These frames are
568 # created with frame flags \x00.
569
570 if (major == 2 and minor in (3, 2)):
571 # v2.2 does not contain flags, but set anyway, as long as the
572 # values remain 0 all is good
573 self.TAG_ALTER = 0
574 self.FILE_ALTER = 1
575 self.READ_ONLY = 2
576 self.COMPRESSED = 8
577 self.ENCRYPTED = 9
578 self.GROUPED = 10
579 # This is not in 2.3 frame header flags, map to unused
580 self.UNSYNC = 14
581 # This is not in 2.3 frame header flags, map to unused
582 self.DATA_LEN = 4
583 elif ((major == 2 and minor == 4) or (major == 1 and minor in (0, 1))):
584 self.TAG_ALTER = 1
585 self.FILE_ALTER = 2
586 self.READ_ONLY = 3
587 self.COMPRESSED = 12
588 self.ENCRYPTED = 13
589 self.GROUPED = 9
590 self.UNSYNC = 14
591 self.DATA_LEN = 15
592 else:
593 raise ValueError("ID3 v" + str(major) + "." + str(minor) +
594 " is not supported.")
595
596 def render(self, data_size):
597 data = b''
598
599 assert(type(self.id) is bytes)
600 data += self.id
601
602 self.data_size = data_size
603
604 if self.minor_version == 3:
605 data += bin2bytes(dec2bin(data_size, 32))
606 else:
607 data += bin2bytes(bin2synchsafe(dec2bin(data_size, 32)))
608
609 if self.unsync:
610 raise NotImplementedError("eyeD3 does not write (only reads) "
611 "unsync'd data")
612 data += bin2bytes(self._flags)
613
614 return data
615
616 @staticmethod
617 def _parse2_2(f, version):
618 from .frames import map2_2FrameId
619 from .frames import FrameException
620 frame_id_22 = f.read(3)
621 frame_id = map2_2FrameId(frame_id_22)
622 if FrameHeader._isValidFrameId(frame_id):
623 log.debug("FrameHeader [id]: %s (0x%x%x%x)" %
624 (frame_id_22, frame_id_22[0], frame_id_22[1], frame_id_22[2]))
625 frame_header = FrameHeader(frame_id, version)
626 # data_size corresponds to the size of the data segment after
627 # encryption, compression, and unsynchronization.
628 sz = f.read(3)
629 frame_header.data_size = bin2dec(bytes2bin(sz, 8))
630 log.debug("FrameHeader [data size]: %d (0x%X)" %
631 (frame_header.data_size, frame_header.data_size))
632 return frame_header
633 elif frame_id == b'\x00\x00\x00':
634 log.debug("FrameHeader: Null frame id found at byte %d" % f.tell())
635 else:
636 core.parseError(FrameException("FrameHeader: Illegal Frame ID: %s" %
637 frame_id))
638
639 return None
640
641 @staticmethod
642 def parse(f, version):
643 from .frames import FrameException
644 log.debug("FrameHeader [start byte]: %d (0x%X)" % (f.tell(),
645 f.tell()))
646 major_version, minor_version = version[:2]
647 if minor_version == 2:
648 return FrameHeader._parse2_2(f, version)
649
650 frame_id = f.read(4)
651 if FrameHeader._isValidFrameId(frame_id):
652 log.debug("FrameHeader [id]: %s (0x%x%x%x%x)" %
653 (frame_id, frame_id[0], frame_id[1], frame_id[2], frame_id[3]))
654 frame_header = FrameHeader(frame_id, version)
655 # data_size corresponds to the size of the data segment after
656 # encryption, compression, and unsynchronization.
657 sz = f.read(4)
658 # In ID3 v2.4 this value became a synch-safe integer, meaning only
659 # the low 7 bits are used per byte.
660 if minor_version == 3:
661 frame_header.data_size = bin2dec(bytes2bin(sz, 8))
662 else:
663 frame_header.data_size = bin2dec(bytes2bin(sz, 7))
664 log.debug("FrameHeader [data size]: %d (0x%X)" %
665 (frame_header.data_size, frame_header.data_size))
666
667 # Frame flags.
668 flags = f.read(2)
669 frame_header._flags = bytes2bin(flags)
670 if log.getEffectiveLevel() <= logging.DEBUG:
671 log.debug("FrameHeader [flags]: ta(%d) fa(%d) ro(%d) co(%d) "
672 "en(%d) gr(%d) un(%d) dl(%d)" %
673 (frame_header.tag_alter,
674 frame_header.file_alter, frame_header.read_only,
675 frame_header.compressed, frame_header.encrypted,
676 frame_header.grouped, frame_header.unsync,
677 frame_header.data_length_indicator))
678 if (frame_header.minor_version >= 4 and frame_header.compressed and
679 not frame_header.data_length_indicator):
680 core.parseError(FrameException("Invalid frame; compressed with "
681 "no data length indicator"))
682
683 return frame_header
684 elif frame_id == b'\x00' * 4:
685 log.debug("FrameHeader: Null frame id found at byte %d" % f.tell())
686 else:
687 core.parseError(FrameException("FrameHeader: Illegal Frame ID: %s" %
688 frame_id))
689
690 return None
691
692 @staticmethod
693 def _isValidFrameId(id):
694 import re
695 return re.compile(b"^[A-Z0-9][A-Z0-9][A-Z0-9][A-Z0-9]$").match(id)
0 import os
1 import string
2 import shutil
3 import tempfile
4 import textwrap
5 from codecs import ascii_encode
6
7
8 from ..utils import requireUnicode, chunkCopy, datePicker, b
9 from .. import core
10 from ..core import TXXX_ALBUM_TYPE, TXXX_ARTIST_ORIGIN, ALBUM_TYPE_IDS, ArtistOrigin
11 from .. import Error
12 from . import (ID3_ANY_VERSION, ID3_DEFAULT_VERSION, ID3_V1, ID3_V1_0, ID3_V1_1,
13 ID3_V2, ID3_V2_2, ID3_V2_3, ID3_V2_4, versionToString)
14 from . import DEFAULT_LANG
15 from . import Genre
16 from . import frames
17 from .headers import TagHeader, ExtendedTagHeader
18
19 from ..utils.log import getLogger
20 log = getLogger(__name__)
21
22 ID3_V1_COMMENT_DESC = "ID3v1.x Comment"
23 ID3_V1_MAX_TEXTLEN = 30
24 ID3_V1_STRIP_CHARS = string.whitespace.encode("latin1") + b"\x00"
25 DEFAULT_PADDING = 256
26
27
28 class TagException(Error):
29 pass
30
31
32 class Tag(core.Tag):
33 def __init__(self, version=ID3_DEFAULT_VERSION, **kwargs):
34 self.file_info = None
35 self.header = None
36 self.extended_header = None
37 self.frame_set = None
38
39 self._comments = None
40 self._images = None
41 self._lyrics = None
42 self._objects = None
43 self._privates = None
44 self._user_texts = None
45 self._unique_file_ids = None
46 self._user_urls = None
47 self._chapters = None
48 self._tocs = None
49 self._popularities = None
50
51 self.file_info = None
52 self.clear(version=version)
53 super().__init__(**kwargs)
54
55 def clear(self, *, version=ID3_DEFAULT_VERSION):
56 """Reset all tag data."""
57 # ID3 tag header
58 self.header = TagHeader(version=version)
59 # Optional extended header in v2 tags.
60 self.extended_header = ExtendedTagHeader()
61 # Contains the tag's frames. ID3v1 fields are read and converted
62 # the the corresponding v2 frame.
63 self.frame_set = frames.FrameSet()
64 self._comments = CommentsAccessor(self.frame_set)
65 self._images = ImagesAccessor(self.frame_set)
66 self._lyrics = LyricsAccessor(self.frame_set)
67 self._objects = ObjectsAccessor(self.frame_set)
68 self._privates = PrivatesAccessor(self.frame_set)
69 self._user_texts = UserTextsAccessor(self.frame_set)
70 self._unique_file_ids = UniqueFileIdAccessor(self.frame_set)
71 self._user_urls = UserUrlsAccessor(self.frame_set)
72 self._chapters = ChaptersAccessor(self.frame_set)
73 self._tocs = TocAccessor(self.frame_set)
74 self._popularities = PopularitiesAccessor(self.frame_set)
75
76 def parse(self, fileobj, version=ID3_ANY_VERSION):
77 self.clear()
78 version = version or ID3_ANY_VERSION
79
80 close_file = False
81 try:
82 filename = fileobj.name
83 except AttributeError:
84 if type(fileobj) is str:
85 filename = fileobj
86 fileobj = open(filename, "rb")
87 close_file = True
88 else:
89 raise ValueError(f"Invalid type: {type(fileobj)}")
90
91 self.file_info = FileInfo(filename)
92
93 try:
94 tag_found = False
95 padding = 0
96 # The & is for supporting the "meta" versions, any, etc.
97 if version[0] & 2:
98 tag_found, padding = self._loadV2Tag(fileobj)
99
100 if not tag_found and version[0] & 1:
101 tag_found, padding = self._loadV1Tag(fileobj)
102 if tag_found:
103 self.extended_header = None
104
105 if tag_found and self.isV2:
106 self.file_info.tag_size = (TagHeader.SIZE +
107 self.header.tag_size)
108 if tag_found:
109 self.file_info.tag_padding_size = padding
110
111 finally:
112 if close_file:
113 fileobj.close()
114
115 return tag_found
116
117 def _loadV2Tag(self, fp):
118 """Returns (tag_found, padding_len)"""
119 fp.seek(0)
120
121 # Look for a tag and if found load it.
122 if not self.header.parse(fp):
123 return False, 0
124
125 # Read the extended header if present.
126 if self.header.extended:
127 self.extended_header.parse(fp, self.header.version)
128
129 # Header is definitely there so at least one frame *must* follow.
130 padding = self.frame_set.parse(fp, self.header,
131 self.extended_header)
132
133 log.debug("Tag contains %d bytes of padding." % padding)
134 return True, padding
135
136 def _loadV1Tag(self, fp):
137 v1_enc = "latin1"
138
139 # Seek to the end of the file where all v1x tags are written.
140 # v1.x tags are 128 bytes min and max
141 fp.seek(0, 2)
142 if fp.tell() < 128:
143 return False, 0
144 fp.seek(-128, 2)
145 tag_data = fp.read(128)
146
147 if tag_data[0:3] != b"TAG":
148 return False, 0
149
150 log.debug("Located ID3 v1 tag")
151 # v1.0 is implied until a v1.1 feature is recognized.
152 self.version = ID3_V1_0
153
154 title = tag_data[3:33].strip(ID3_V1_STRIP_CHARS)
155 log.debug("Title: %s" % title)
156 if title:
157 self.title = str(title, v1_enc)
158
159 artist = tag_data[33:63].strip(ID3_V1_STRIP_CHARS)
160 log.debug("Artist: %s" % artist)
161 if artist:
162 self.artist = str(artist, v1_enc)
163
164 album = tag_data[63:93].strip(ID3_V1_STRIP_CHARS)
165 log.debug("Album: %s" % album)
166 if album:
167 self.album = str(album, v1_enc)
168
169 year = tag_data[93:97].strip(ID3_V1_STRIP_CHARS)
170 log.debug("Year: %s" % year)
171 try:
172 if year and int(year):
173 # Values here typically mean the year of release
174 self.release_date = int(year)
175 except ValueError:
176 # Bogus year strings.
177 log.warn("ID3v1.x tag contains invalid year: %s" % year)
178 pass
179
180 # Can't use ID3_V1_STRIP_CHARS here, since the final byte is numeric
181 comment = tag_data[97:127].rstrip(b"\x00")
182 # Track numbers stuffed in the comment field is what makes v1.1
183 if comment:
184 if (len(comment) >= 2 and
185 # Python the slices (the chars), so this is really
186 # comment[2] and comment[-1]
187 comment[-2:-1] == b"\x00"):
188 log.debug("Track Num found, setting version to v1.1")
189 self.version = ID3_V1_1
190
191 track = comment[-1]
192 self.track_num = (track, None)
193 log.debug("Track: " + str(track))
194 comment = comment[:-2].strip(ID3_V1_STRIP_CHARS)
195
196 # There may only have been a track #
197 if comment:
198 log.debug(f"Comment: {comment}")
199 self.comments.set(str(comment, v1_enc), ID3_V1_COMMENT_DESC)
200
201 genre = ord(tag_data[127:128])
202 log.debug(f"Genre ID: {genre}")
203 try:
204 self.genre = genre
205 except ValueError as ex:
206 log.warning(ex)
207 self.genre = None
208
209 return True, 0
210
211 @property
212 def version(self):
213 return self.header.version
214
215 @version.setter
216 def version(self, v):
217 # Tag version changes required possible frame conversion
218 std, non = self._checkForConversions(v)
219 converted = []
220 if non:
221 converted = self._convertFrames(std, non, v)
222 if converted:
223 self.frame_set.clear()
224 for frame in (std + converted):
225 self.frame_set[frame.id] = frame
226
227 self.header.version = v
228
229 def isV1(self):
230 """Test ID3 major version for v1.x"""
231 return self.header.major_version == 1
232
233 def isV2(self):
234 """Test ID3 major version for v2.x"""
235 return self.header.major_version == 2
236
237 @requireUnicode(2)
238 def setTextFrame(self, fid: bytes, txt: str):
239 fid = b(fid, ascii_encode)
240 if not fid.startswith(b"T") or fid.startswith(b"TX"):
241 raise ValueError("Invalid frame-id for text frame")
242
243 if not txt and self.frame_set[fid]:
244 del self.frame_set[fid]
245 elif txt:
246 self.frame_set.setTextFrame(fid, txt)
247
248 # FIXME: is returning data not a Frame.
249 def getTextFrame(self, fid: bytes):
250 fid = b(fid, ascii_encode)
251 if not fid.startswith(b"T") or fid.startswith(b"TX"):
252 raise ValueError("Invalid frame-id for text frame")
253 f = self.frame_set[fid]
254 return f[0].text if f else None
255
256 @requireUnicode(1)
257 def _setArtist(self, val):
258 self.setTextFrame(frames.ARTIST_FID, val)
259
260 def _getArtist(self):
261 return self.getTextFrame(frames.ARTIST_FID)
262
263 @requireUnicode(1)
264 def _setAlbumArtist(self, val):
265 self.setTextFrame(frames.ALBUM_ARTIST_FID, val)
266
267 def _getAlbumArtist(self):
268 return self.getTextFrame(frames.ALBUM_ARTIST_FID)
269
270 @requireUnicode(1)
271 def _setComposer(self, val):
272 self.setTextFrame(frames.COMPOSER_FID, val)
273
274 def _getComposer(self):
275 return self.getTextFrame(frames.COMPOSER_FID)
276
277 @property
278 def composer(self):
279 return self._getComposer()
280
281 @composer.setter
282 def composer(self, v):
283 self._setComposer(v)
284
285 @requireUnicode(1)
286 def _setAlbum(self, val):
287 self.setTextFrame(frames.ALBUM_FID, val)
288
289 def _getAlbum(self):
290 return self.getTextFrame(frames.ALBUM_FID)
291
292 @requireUnicode(1)
293 def _setTitle(self, val):
294 self.setTextFrame(frames.TITLE_FID, val)
295
296 def _getTitle(self):
297 return self.getTextFrame(frames.TITLE_FID)
298
299 def _setTrackNum(self, val):
300 self._setNum(frames.TRACKNUM_FID, val)
301
302 def _getTrackNum(self):
303 return self._splitNum(frames.TRACKNUM_FID)
304
305 def _setDiscNum(self, val):
306 self._setNum(frames.DISCNUM_FID, val)
307
308 def _getDiscNum(self):
309 return self._splitNum(frames.DISCNUM_FID)
310
311 def _splitNum(self, fid):
312 f = self.frame_set[fid]
313 first, second = None, None
314 if f and f[0].text:
315 n = f[0].text.split('/')
316 try:
317 first = int(n[0])
318 second = int(n[1]) if len(n) == 2 else None
319 except ValueError as ex:
320 log.warning(str(ex))
321 return first, second
322
323 def _setNum(self, fid, val):
324 if type(val) is str:
325 val = int(val)
326
327 if type(val) is tuple:
328 if len(val) != 2:
329 raise ValueError("A 2-tuple of int values is required.")
330 else:
331 tn, tt = tuple([int(v) if v is not None else None for v in val])
332 elif type(val) is int:
333 tn, tt = val, None
334 elif val is None:
335 tn, tt = None, None
336 else:
337 raise TypeError("Invalid value, should int 2-tuple, int, or None: "
338 f"{val} ({val.__class__.__name__})")
339
340 n = (tn, tt)
341
342 if n[0] is None and n[1] is None:
343 if self.frame_set[fid]:
344 del self.frame_set[fid]
345 return
346
347 total_str = ""
348 if n[1] is not None:
349 if 0 <= n[1] <= 9:
350 total_str = "0" + str(n[1])
351 else:
352 total_str = str(n[1])
353
354 t = n[0] if n[0] else 0
355 track_str = str(t)
356
357 # Pad with zeros according to how large the total count is.
358 if len(track_str) == 1:
359 track_str = "0" + track_str
360 if len(track_str) < len(total_str):
361 track_str = ("0" * (len(total_str) - len(track_str))) + track_str
362
363 final_str = ""
364 if track_str and total_str:
365 final_str = "%s/%s" % (track_str, total_str)
366 elif track_str and not total_str:
367 final_str = track_str
368
369 self.frame_set.setTextFrame(fid, str(final_str))
370
371 @property
372 def comments(self):
373 return self._comments
374
375 def _getBpm(self):
376 from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
377
378 bpm = None
379 if frames.BPM_FID in self.frame_set:
380 bpm_str = self.frame_set[frames.BPM_FID][0].text or "0"
381 try:
382 # Round floats since the spec says this is an integer. Python3
383 # changed how 'round' works, hence the using of decimal
384 bpm = int(Decimal(bpm_str).quantize(1, ROUND_HALF_UP))
385 except (InvalidOperation, ValueError) as ex:
386 log.warning(ex)
387 return bpm
388
389 def _setBpm(self, bpm):
390 assert(bpm >= 0)
391 self.setTextFrame(frames.BPM_FID, str(bpm))
392
393 bpm = property(_getBpm, _setBpm)
394
395 @property
396 def play_count(self):
397 if frames.PLAYCOUNT_FID in self.frame_set:
398 pc = self.frame_set[frames.PLAYCOUNT_FID][0]
399 return pc.count
400 else:
401 return None
402
403 @play_count.setter
404 def play_count(self, count):
405 if count is None:
406 del self.frame_set[frames.PLAYCOUNT_FID]
407 return
408
409 if count < 0:
410 raise ValueError("Invalid play count value: %d" % count)
411
412 if self.frame_set[frames.PLAYCOUNT_FID]:
413 pc = self.frame_set[frames.PLAYCOUNT_FID][0]
414 pc.count = count
415 else:
416 self.frame_set[frames.PLAYCOUNT_FID] = \
417 frames.PlayCountFrame(count=count)
418
419 def _getPublisher(self):
420 if frames.PUBLISHER_FID in self.frame_set:
421 pub = self.frame_set[frames.PUBLISHER_FID]
422 return pub[0].text
423 else:
424 return None
425
426 @requireUnicode(1)
427 def _setPublisher(self, p):
428 self.setTextFrame(frames.PUBLISHER_FID, p)
429
430 publisher = property(_getPublisher, _setPublisher)
431
432 @property
433 def cd_id(self):
434 if frames.CDID_FID in self.frame_set:
435 return self.frame_set[frames.CDID_FID][0].toc
436 else:
437 return None
438
439 @cd_id.setter
440 def cd_id(self, toc):
441 if len(toc) > 804:
442 raise ValueError("CD identifier table of contents can be no "
443 "greater than 804 bytes")
444
445 if self.frame_set[frames.CDID_FID]:
446 cdid = self.frame_set[frames.CDID_FID][0]
447 cdid.toc = bytes(toc)
448 else:
449 self.frame_set[frames.CDID_FID] = \
450 frames.MusicCDIdFrame(toc=toc)
451
452 @property
453 def images(self):
454 return self._images
455
456 def _getEncodingDate(self):
457 return self._getDate(b"TDEN")
458
459 def _setEncodingDate(self, date):
460 self._setDate(b"TDEN", date)
461 encoding_date = property(_getEncodingDate, _setEncodingDate)
462
463 @property
464 def best_release_date(self):
465 """This method tries its best to return a date of some sort, amongst
466 alll the possible date frames. The order of preference for a release
467 date is 1) date of original release 2) date of this versions release
468 3) the recording date. Or None is returned."""
469 import warnings
470 warnings.warn("Use Tag.getBestDate() instead", DeprecationWarning,
471 stacklevel=2)
472 return (self.original_release_date or
473 self.release_date or
474 self.recording_date)
475
476 def getBestDate(self, prefer_recording_date=False):
477 """This method returns a date of some sort, amongst all the possible
478 date frames. The order of preference is:
479
480 1) date of original release
481 2) date of this versions release
482 3) the recording date.
483
484 Unless ``prefer_recording_date`` is ``True`` in which case the order is
485 3, 1, 2.
486
487 ``None`` will be returned if no dates are available."""
488 return datePicker(self, prefer_recording_date)
489
490 def _getReleaseDate(self):
491 if self.version == ID3_V2_3:
492 # v2.3 does NOT have a release date, only TORY, so that is what is returned
493 return self._getV23OriginalReleaseDate()
494 else:
495 return self._getDate(b"TDRL")
496
497 def _setReleaseDate(self, date):
498 if self.version == ID3_V2_3:
499 # v2.3 does NOT have a release date, only TORY, so that is what is set
500 self._setOriginalReleaseDate(date)
501 else:
502 self._setDate(b"TDRL", date)
503
504 release_date = property(_getReleaseDate, _setReleaseDate)
505 release_date.__doc__ = textwrap.dedent("""
506 The date the audio was released. This is NOT the original date the
507 work was released, instead it is more like the pressing or version of the
508 release. Original release date is usually what is intended but many programs
509 use this frame and/or don't distinguish between the two.
510
511 NOTE: ID3v2.3 only has original release date, so setting release_date is the same as
512 original_release_value; they both set TORY.
513 """)
514
515 def _getOrigReleaseDate(self):
516 if self.version == ID3_V2_3:
517 return self._getV23OriginalReleaseDate()
518 else:
519 return self._getDate(b"TDOR") or self._getV23OriginalReleaseDate()
520 _getOriginalReleaseDate = _getOrigReleaseDate
521
522 def _setOrigReleaseDate(self, date):
523 if self.version == ID3_V2_3:
524 self._setDate(b"TORY", date)
525 else:
526 self._setDate(b"TDOR", date)
527 _setOriginalReleaseDate = _setOrigReleaseDate
528
529 original_release_date = property(_getOrigReleaseDate, _setOrigReleaseDate)
530 original_release_date.__doc__ = textwrap.dedent("""
531 The date the work was originally released.
532
533 NOTE: ID3v2.3 only stores year. If the Date object is more precise it is store in `XDOR`, and
534 XDOR is preferred when acessing. The year-only date is stored in the standard `TORY` frame as
535 well.
536 """)
537
538 def _getRecordingDate(self):
539 if self.version == ID3_V2_3:
540 return self._getV23RecordingDate()
541 else:
542 return self._getDate(b"TDRC")
543
544 def _setRecordingDate(self, date):
545 if date in (None, ""):
546 for fid in (b"TDRC", b"TYER", b"TDAT", b"TIME"):
547 self._setDate(fid, None)
548 elif self.version == ID3_V2_4:
549 self._setDate(b"TDRC", date)
550 else:
551 if not isinstance(date, core.Date):
552 date = core.Date.parse(date)
553 self._setDate(b"TYER", str(date.year))
554 if None not in (date.month, date.day):
555 date_str = "%s%s" % (str(date.day).rjust(2, "0"),
556 str(date.month).rjust(2, "0"))
557 self._setDate(b"TDAT", date_str)
558 if None not in (date.hour, date.minute):
559 date_str = "%s%s" % (str(date.hour).rjust(2, "0"),
560 str(date.minute).rjust(2, "0"))
561 self._setDate(b"TIME", date_str)
562
563 recording_date = property(_getRecordingDate, _setRecordingDate)
564 """The date of the recording. Many applications use this for release date
565 regardless of the fact that this value is rarely known, and release dates
566 are more correct."""
567
568 def _getV23RecordingDate(self):
569 # v2.3 TYER (yyyy), TDAT (DDMM), TIME (HHmm)
570 date = None
571 try:
572 date_str = b""
573 if b"TYER" in self.frame_set:
574 date_str = self.frame_set[b"TYER"][0].text.encode("latin1")
575 date = core.Date.parse(date_str)
576 if b"TDAT" in self.frame_set:
577 text = self.frame_set[b"TDAT"][0].text.encode("latin1")
578 date_str += b"-%s-%s" % (text[2:], text[:2])
579 date = core.Date.parse(date_str)
580 if b"TIME" in self.frame_set:
581 text = self.frame_set[b"TIME"][0].text.encode("latin1")
582 date_str += b"T%s:%s" % (text[:2], text[2:])
583 date = core.Date.parse(date_str)
584 except ValueError as ex:
585 log.warning("Invalid v2.3 TYER, TDAT, or TIME frame: %s" % ex)
586
587 return date
588
589 def _getV23OriginalReleaseDate(self):
590 date, date_str = None, None
591 try:
592 # XDOR is preferred since it can gave a full date, whereas TORY is year only.
593 for fid in (b"XDOR", b"TORY"):
594 if fid in self.frame_set:
595 date_str = self.frame_set[fid][0].text.encode("latin1")
596 break
597 if date_str:
598 date = core.Date.parse(date_str)
599 except ValueError as ex:
600 log.warning(f"Invalid v2.3 TORY/XDOR frame: {ex}")
601
602 return date
603
604 def _getTaggingDate(self):
605 return self._getDate(b"TDTG")
606
607 def _setTaggingDate(self, date):
608 self._setDate(b"TDTG", date)
609 tagging_date = property(_getTaggingDate, _setTaggingDate)
610
611 def _setDate(self, fid, date):
612 def removeFrame(frame_id):
613 try:
614 del self.frame_set[frame_id]
615 except KeyError:
616 pass
617
618 def setFrame(frame_id, date_val):
619 if frame_id in self.frame_set:
620 self.frame_set[frame_id][0].date = date_val
621 else:
622 self.frame_set[frame_id] = frames.DateFrame(frame_id, str(date_val))
623
624 assert fid in frames.DATE_FIDS or fid in frames.DEPRECATED_DATE_FIDS
625 if fid == b"XDOR":
626 raise ValueError("Set TORY with a full date (i.e. more than year)")
627
628 clean_fids = [fid]
629 if fid == b"TORY":
630 clean_fids.append(b"XDOR")
631
632 if date in (None, ""):
633 for cid in clean_fids:
634 removeFrame(cid)
635 return
636
637 # Special casing the conversion to DATE objects cuz TDAT and TIME won't
638 if fid not in (b"TDAT", b"TIME"):
639 # Convert to ISO format which is what FrameSet wants.
640 date_type = type(date)
641 if date_type is int:
642 # The integer year
643 date = core.Date(date)
644 elif date_type is str:
645 date = core.Date.parse(date)
646 elif not isinstance(date, core.Date):
647 raise TypeError(f"Invalid type: {date_type}")
648
649 if fid == b"TORY":
650 setFrame(fid, date.year)
651 if date.month:
652 setFrame(b"XDOR", date)
653 else:
654 removeFrame(b"XDOR")
655 else:
656 setFrame(fid, date)
657
658 def _getDate(self, fid):
659 if fid in (b"TORY", b"XDOR"):
660 return self._getV23OriginalReleaseDate()
661
662 if fid in self.frame_set:
663 if fid in (b"TYER", b"TDAT", b"TIME"):
664 if fid == b"TYER":
665 # Contain years only, date conversion can happen
666 return core.Date(int(self.frame_set[fid][0].text))
667 else:
668 return self.frame_set[fid][0].text
669 else:
670 return self.frame_set[fid][0].date
671 else:
672 return None
673
674 @property
675 def lyrics(self):
676 return self._lyrics
677
678 @property
679 def disc_num(self):
680 return self._getDiscNum()
681
682 @disc_num.setter
683 def disc_num(self, val):
684 self._setDiscNum(val)
685
686 @property
687 def objects(self):
688 return self._objects
689
690 @property
691 def privates(self):
692 return self._privates
693
694 @property
695 def popularities(self):
696 return self._popularities
697
698 def _getGenre(self, id3_std=True):
699 f = self.frame_set[frames.GENRE_FID]
700 if f and f[0].text:
701 try:
702 return Genre.parse(f[0].text, id3_std=id3_std)
703 except ValueError: # pragma: nocover
704 return None
705 else:
706 return None
707
708 def _setGenre(self, g, id3_std=True):
709 """Set the genre.
710 Four types are accepted for the ``g`` argument.
711 A Genre object, an acceptable (see Genre.parse) genre string,
712 or an integer genre ID all will set the value. A value of None will
713 remove the genre."""
714 if g in ("", None):
715 if self.frame_set[frames.GENRE_FID]:
716 del self.frame_set[frames.GENRE_FID]
717 return
718
719 if isinstance(g, str):
720 g = Genre.parse(g, id3_std=id3_std)
721 elif isinstance(g, int):
722 g = Genre(id=g)
723 elif not isinstance(g, Genre):
724 raise TypeError(f"Invalid genre data type: {type(g)}")
725
726 assert g
727 self.frame_set.setTextFrame(frames.GENRE_FID, f"{g.name if g.name else g.id}")
728
729 # genre property
730 genre = property(_getGenre, _setGenre)
731
732 def _getNonStdGenre(self):
733 return self._getGenre(id3_std=False)
734
735 def _setNonStdGenre(self, val):
736 self._setGenre(val, id3_std=False)
737
738 # non-standard genre (unparsed, unmapped) property
739 non_std_genre = property(_getNonStdGenre, _setNonStdGenre)
740
741 @property
742 def user_text_frames(self):
743 return self._user_texts
744
745 def _setUrlFrame(self, fid, url):
746 if fid not in frames.URL_FIDS:
747 raise ValueError("Invalid URL frame-id")
748
749 if self.frame_set[fid]:
750 if not url:
751 del self.frame_set[fid]
752 else:
753 self.frame_set[fid][0].url = url
754 else:
755 self.frame_set[fid] = frames.UrlFrame(fid, url)
756
757 def _getUrlFrame(self, fid):
758 if fid not in frames.URL_FIDS:
759 raise ValueError("Invalid URL frame-id")
760 f = self.frame_set[fid]
761 return f[0].url if f else None
762
763 @property
764 def commercial_url(self):
765 return self._getUrlFrame(frames.URL_COMMERCIAL_FID)
766
767 @commercial_url.setter
768 def commercial_url(self, url):
769 self._setUrlFrame(frames.URL_COMMERCIAL_FID, url)
770
771 @property
772 def copyright_url(self):
773 return self._getUrlFrame(frames.URL_COPYRIGHT_FID)
774
775 @copyright_url.setter
776 def copyright_url(self, url):
777 self._setUrlFrame(frames.URL_COPYRIGHT_FID, url)
778
779 @property
780 def audio_file_url(self):
781 return self._getUrlFrame(frames.URL_AUDIOFILE_FID)
782
783 @audio_file_url.setter
784 def audio_file_url(self, url):
785 self._setUrlFrame(frames.URL_AUDIOFILE_FID, url)
786
787 @property
788 def audio_source_url(self):
789 return self._getUrlFrame(frames.URL_AUDIOSRC_FID)
790
791 @audio_source_url.setter
792 def audio_source_url(self, url):
793 self._setUrlFrame(frames.URL_AUDIOSRC_FID, url)
794
795 @property
796 def artist_url(self):
797 return self._getUrlFrame(frames.URL_ARTIST_FID)
798
799 @artist_url.setter
800 def artist_url(self, url):
801 self._setUrlFrame(frames.URL_ARTIST_FID, url)
802
803 @property
804 def internet_radio_url(self):
805 return self._getUrlFrame(frames.URL_INET_RADIO_FID)
806
807 @internet_radio_url.setter
808 def internet_radio_url(self, url):
809 self._setUrlFrame(frames.URL_INET_RADIO_FID, url)
810
811 @property
812 def payment_url(self):
813 return self._getUrlFrame(frames.URL_PAYMENT_FID)
814
815 @payment_url.setter
816 def payment_url(self, url):
817 self._setUrlFrame(frames.URL_PAYMENT_FID, url)
818
819 @property
820 def publisher_url(self):
821 return self._getUrlFrame(frames.URL_PUBLISHER_FID)
822
823 @publisher_url.setter
824 def publisher_url(self, url):
825 self._setUrlFrame(frames.URL_PUBLISHER_FID, url)
826
827 @property
828 def user_url_frames(self):
829 return self._user_urls
830
831 @property
832 def unique_file_ids(self):
833 return self._unique_file_ids
834
835 @property
836 def terms_of_use(self):
837 if self.frame_set[frames.TOS_FID]:
838 return self.frame_set[frames.TOS_FID][0].text
839
840 @terms_of_use.setter
841 def terms_of_use(self, tos):
842 """Set the terms of use text.
843 To specify a language (other than DEFAULT_LANG) code with the text pass
844 a tuple:
845 (text, lang)
846 Language codes are 3 *bytes* of ascii data.
847 """
848 if isinstance(tos, tuple):
849 tos, lang = tos
850 else:
851 lang = DEFAULT_LANG
852 if self.frame_set[frames.TOS_FID]:
853 self.frame_set[frames.TOS_FID][0].text = tos
854 self.frame_set[frames.TOS_FID][0].lang = lang
855 else:
856 self.frame_set[frames.TOS_FID] = frames.TermsOfUseFrame(text=tos, lang=lang)
857
858 def _setCopyright(self, copyrt):
859 self.setTextFrame(frames.COPYRIGHT_FID, copyrt)
860
861 def _getCopyright(self):
862 if frames.COPYRIGHT_FID in self.frame_set:
863 return self.frame_set[frames.COPYRIGHT_FID][0].text
864
865 copyright = property(_getCopyright, _setCopyright)
866
867 def _setEncodedBy(self, enc):
868 self.setTextFrame(frames.ENCODED_BY_FID, enc)
869
870 def _getEncodedBy(self):
871 if frames.ENCODED_BY_FID in self.frame_set:
872 return self.frame_set[frames.ENCODED_BY_FID][0].text
873
874 encoded_by = property(_getEncodedBy, _setEncodedBy)
875
876 def _raiseIfReadonly(self):
877 if self.read_only:
878 raise RuntimeError("Tag is set read only.")
879
880 def save(self, filename=None, version=None, encoding=None, backup=False,
881 preserve_file_time=False, max_padding=None):
882 """Save the tag. If ``filename`` is not give the value from the
883 ``file_info`` member is used, or a ``TagException`` is raised. The
884 ``version`` argument can be used to select an ID3 version other than
885 the version read. ``Select text encoding with ``encoding`` or use
886 the existing (or default) encoding. If ``backup`` is True the orignal
887 file is preserved; likewise if ``preserve_file_time`` is True the
888 file´s modification/access times are not updated.
889 """
890 self._raiseIfReadonly()
891
892 if not (filename or self.file_info):
893 raise TagException("No file")
894 elif filename:
895 self.file_info = FileInfo(filename)
896
897 version = version if version else self.version
898 if version == ID3_V2_2:
899 raise NotImplementedError("Unable to write ID3 v2.2")
900 self.version = version
901
902 if backup and os.path.isfile(self.file_info.name):
903 backup_name = "%s.%s" % (self.file_info.name, "orig")
904 i = 1
905 while os.path.isfile(backup_name):
906 backup_name = "%s.%s.%d" % (self.file_info.name, "orig", i)
907 i += 1
908 shutil.copyfile(self.file_info.name, backup_name)
909
910 if version[0] == 1:
911 self._saveV1Tag(version)
912 elif version[0] == 2:
913 self._saveV2Tag(version, encoding, max_padding)
914 else:
915 assert(not "Version bug: %s" % str(version))
916
917 if preserve_file_time and None not in (self.file_info.atime,
918 self.file_info.mtime):
919 self.file_info.touch((self.file_info.atime, self.file_info.mtime))
920 else:
921 self.file_info.initStatTimes()
922
923 def _saveV1Tag(self, version):
924 self._raiseIfReadonly()
925
926 assert(version[0] == 1)
927
928 def pack(s, n):
929 assert(type(s) is bytes)
930 if len(s) > n:
931 log.warning(f"ID3 v1.x text value truncated to length {n}")
932 return s.ljust(n, b'\x00')[:n]
933
934 def encode(s):
935 return s.encode("latin_1", "replace")
936
937 # Build tag buffer.
938 tag = b"TAG"
939 tag += pack(encode(self.title) if self.title else b"", ID3_V1_MAX_TEXTLEN)
940 tag += pack(encode(self.artist) if self.artist else b"", ID3_V1_MAX_TEXTLEN)
941 tag += pack(encode(self.album) if self.album else b"", ID3_V1_MAX_TEXTLEN)
942
943 release_date = self.getBestDate()
944 year = str(release_date.year).encode("ascii") if release_date else b""
945 tag += pack(year, 4)
946
947 cmt = ""
948 for c in self.comments:
949 if c.description == ID3_V1_COMMENT_DESC:
950 cmt = c.text
951 # We prefer this one over ""
952 break
953 elif c.description == "":
954 cmt = c.text
955 # Keep searching in case we find the description eyeD3 uses.
956 cmt = pack(encode(cmt), ID3_V1_MAX_TEXTLEN)
957
958 if version != ID3_V1_0:
959 track = self.track_num[0]
960 if track is not None:
961 cmt = cmt[0:28] + b"\x00" + bytes([int(track) & 0xff])
962 tag += cmt
963
964 if not self.genre or self.genre.id is None:
965 genre = 12 # Other
966 else:
967 genre = self.genre.id
968 tag += bytes([genre & 0xff])
969
970 assert len(tag) == 128
971
972 mode = "rb+" if os.path.isfile(self.file_info.name) else "w+b"
973 with open(self.file_info.name, mode) as tag_file:
974 # Write the tag over top an original or append it.
975 try:
976 tag_file.seek(-128, 2)
977 if tag_file.read(3) == b"TAG":
978 tag_file.seek(-128, 2)
979 else:
980 tag_file.seek(0, 2)
981 except IOError:
982 # File is smaller than 128 bytes.
983 tag_file.seek(0, 2)
984
985 tag_file.write(tag)
986 tag_file.flush()
987
988 def _checkForConversions(self, target_version):
989 """Check the current frame set against `target_version` for frames
990 requiring conversion.
991 :param: The version the frames need to map to.
992 :returns: A 2-tuple where the first element is a list of frames that
993 are accepted for `target_version`, and the second a list of frames
994 requiring conversion.
995 """
996 std_frames = []
997 non_std_frames = []
998 for f in self.frame_set.getAllFrames():
999 try:
1000 _, fversion, _ = frames.ID3_FRAMES[f.id]
1001 if fversion in (target_version, ID3_V2):
1002 std_frames.append(f)
1003 else:
1004 non_std_frames.append(f)
1005 except KeyError:
1006 # Not a standard frame (ID3_FRAMES)
1007 try:
1008 _, fversion, _ = frames.NONSTANDARD_ID3_FRAMES[f.id]
1009 # but is it one we can handle.
1010 if fversion in (target_version, ID3_V2):
1011 std_frames.append(f)
1012 else:
1013 non_std_frames.append(f)
1014 except KeyError:
1015 # Don't know anything about this pass it on for the error
1016 # check there.
1017 non_std_frames.append(f)
1018
1019 return std_frames, non_std_frames
1020
1021 def _render(self, version, curr_tag_size, max_padding_size):
1022 converted_frames = []
1023 std_frames, non_std_frames = self._checkForConversions(version)
1024 if non_std_frames:
1025 converted_frames = self._convertFrames(std_frames, non_std_frames,
1026 version)
1027
1028 # Render all frames first so the data size is known for the tag header.
1029 frame_data = b""
1030 for f in std_frames + converted_frames:
1031 frame_header = frames.FrameHeader(f.id, version)
1032 if f.header:
1033 frame_header.copyFlags(f.header)
1034 f.header = frame_header
1035
1036 log.debug("Rendering frame: %s" % frame_header.id)
1037 raw_frame = f.render()
1038 log.debug("Rendered %d bytes" % len(raw_frame))
1039 frame_data += raw_frame
1040
1041 log.debug("Rendered %d total frame bytes" % len(frame_data))
1042
1043 # eyeD3 never writes unsync'd data
1044 self.header.unsync = False
1045
1046 pending_size = TagHeader.SIZE + len(frame_data)
1047 if self.header.extended:
1048 # Using dummy data and padding, the actual size of this header
1049 # will be the same regardless, it's more about the flag bits
1050 tmp_ext_header_data = self.extended_header.render(version,
1051 b"\x00", 0)
1052 pending_size += len(tmp_ext_header_data)
1053
1054 if pending_size > curr_tag_size:
1055 # current tag (minus padding) larger than the current (plus padding)
1056 padding_size = DEFAULT_PADDING
1057 rewrite_required = True
1058 else:
1059 padding_size = curr_tag_size - pending_size
1060 if max_padding_size is not None and padding_size > max_padding_size:
1061 padding_size = min(DEFAULT_PADDING, max_padding_size)
1062 rewrite_required = True
1063 else:
1064 rewrite_required = False
1065
1066 assert(padding_size >= 0)
1067 log.debug("Using %d bytes of padding" % padding_size)
1068
1069 # Extended header
1070 ext_header_data = b""
1071 if self.header.extended:
1072 log.debug("Rendering extended header")
1073 ext_header_data += self.extended_header.render(self.header.version,
1074 frame_data,
1075 padding_size)
1076
1077 # Render the tag header.
1078 total_size = pending_size + padding_size
1079 log.debug("Rendering %s tag header with size %d" %
1080 (versionToString(version),
1081 total_size - TagHeader.SIZE))
1082 header_data = self.header.render(total_size - TagHeader.SIZE)
1083
1084 # Assemble the entire tag.
1085 tag_data = (header_data +
1086 ext_header_data +
1087 frame_data)
1088 assert(len(tag_data) == (total_size - padding_size))
1089 return rewrite_required, tag_data, b"\x00" * padding_size
1090
1091 def _saveV2Tag(self, version, encoding, max_padding):
1092 self._raiseIfReadonly()
1093
1094 assert(version[0] == 2 and version[1] != 2)
1095
1096 log.debug("Rendering tag version: %s" % versionToString(version))
1097
1098 file_exists = os.path.exists(self.file_info.name)
1099
1100 if encoding:
1101 # Any invalid encoding is going to get coersed to a valid value
1102 # when the frame is rendered.
1103 for f in self.frame_set.getAllFrames():
1104 f.encoding = frames.stringToEncoding(encoding)
1105
1106 curr_tag_size = 0
1107
1108 if file_exists:
1109 # We may be converting from 1.x to 2.x so we need to find any
1110 # current v2.x tag otherwise we're gonna hork the file.
1111 # This also resets all offsets, state, etc. and makes me feel safe.
1112 tmp_tag = Tag()
1113 if tmp_tag.parse(self.file_info.name, ID3_V2):
1114 log.debug("Found current v2.x tag:")
1115 curr_tag_size = tmp_tag.file_info.tag_size
1116 log.debug("Current tag size: %d" % curr_tag_size)
1117
1118 rewrite_required, tag_data, padding = self._render(version,
1119 curr_tag_size,
1120 max_padding)
1121 log.debug("Writing %d bytes of tag data and %d bytes of "
1122 "padding" % (len(tag_data), len(padding)))
1123 if rewrite_required:
1124 # Open tmp file
1125 with tempfile.NamedTemporaryFile("wb", delete=False) \
1126 as tmp_file:
1127 tmp_file.write(tag_data + padding)
1128
1129 # Copy audio data in chunks
1130 with open(self.file_info.name, "rb") as tag_file:
1131 if curr_tag_size != 0:
1132 seek_point = curr_tag_size
1133 else:
1134 seek_point = 0
1135 log.debug("Seeking to beginning of audio data, "
1136 "byte %d (%x)" % (seek_point, seek_point))
1137 tag_file.seek(seek_point)
1138 chunkCopy(tag_file, tmp_file)
1139
1140 tmp_file.flush()
1141
1142 # Move tmp to orig.
1143 shutil.copyfile(tmp_file.name, self.file_info.name)
1144 os.unlink(tmp_file.name)
1145
1146 else:
1147 with open(self.file_info.name, "r+b") as tag_file:
1148 tag_file.write(tag_data + padding)
1149
1150 else:
1151 _, tag_data, padding = self._render(version, 0, None)
1152 with open(self.file_info.name, "wb") as tag_file:
1153 tag_file.write(tag_data + padding)
1154
1155 log.debug("Tag write complete. Updating FileInfo state.")
1156 self.file_info.tag_size = len(tag_data) + len(padding)
1157
1158 def _convertFrames_v1(self, std_frames, convert_list, version) -> list:
1159 assert version[0] == 1
1160 converted_frames = []
1161
1162 track_num_frame = None
1163 for frame in std_frames:
1164 if frame.id == frames.TRACKNUM_FID:
1165 # Find track_num so it can be enforced for 1.1
1166 track_num_frame = frame
1167 elif frame.id == frames.COMMENT_FID and frame.description == ID3_V1_COMMENT_DESC:
1168 # Comments truncated to make room for v1.1 track
1169 if version == ID3_V1_1:
1170 if len(frame.text) > ID3_V1_MAX_TEXTLEN - 2:
1171 trunc_text = frame.text[:ID3_V1_MAX_TEXTLEN - 2]
1172 log.info(f"Truncating ID3 v1 comment due to tag conversion: {frame.text}")
1173 frame.text = trunc_text
1174
1175 # v1.1 must have a track num
1176 if track_num_frame is None and version == ID3_V1_1:
1177 log.info("ID3 v1.0->v1.1 conversion forces track number, defaulting to 1")
1178 std_frames.append(frames.TextFrame(frames.TRACKNUM_FID, "1"))
1179 # v1.0 must not
1180 elif track_num_frame is not None and version == ID3_V1_0:
1181 log.info("ID3 v1.1->v1.0 conversion forces deleting track number")
1182 std_frames.remove(track_num_frame)
1183
1184 for frame in list(convert_list):
1185 # Let date frames thru, the right thing will happen on save
1186 if isinstance(frame, frames.DateFrame):
1187 converted_frames.append(frame)
1188 convert_list.remove(frame)
1189
1190 return converted_frames
1191
1192 def _convertFrames(self, std_frames, convert_list, version) -> list:
1193 """Maps frame incompatibilities between ID3 tag versions.
1194
1195 The items in ``std_frames`` need no conversion, but the list/frames
1196 may be edited if necessary (e.g. a converted frame replaces a frame
1197 in the list). The items in ``convert_list`` are the frames to convert
1198 and return. The ``version`` is the target ID3 version."""
1199 from . import versionToString
1200 from .frames import DATE_FIDS, DEPRECATED_DATE_FIDS, DateFrame, TextFrame
1201
1202 if version[0] == 1:
1203 return self._convertFrames_v1(std_frames, convert_list, version)
1204
1205 # Only ID3 v2.x onward
1206 assert version[0] != 1
1207 converted_frames = []
1208 flist = list(convert_list)
1209
1210 # Date frame conversions.
1211 date_frames = {}
1212 for f in flist:
1213 if version == ID3_V2_4:
1214 if f.id in DEPRECATED_DATE_FIDS:
1215 date_frames[f.id] = f
1216 else:
1217 if f.id in DATE_FIDS:
1218 date_frames[f.id] = f
1219
1220 if date_frames:
1221 def fidHandled(_fid):
1222 # A duplicate text frame (illegal ID3 but oft seen) may exist. The date_frames dict
1223 # will have one, but the flist has multiple, hence the loop.
1224 for _frame in list(flist):
1225 if _frame.id == _fid:
1226 flist.remove(_frame)
1227 del date_frames[_fid]
1228
1229 if version == ID3_V2_4:
1230 if b"TORY" in date_frames or b"XDOR" in date_frames:
1231 # XDOR -> TDOR (full date)
1232 # TORY -> TDOR (year only)
1233 date = self._getV23OriginalReleaseDate()
1234 if date:
1235 converted_frames.append(DateFrame(b"TDOR", date))
1236 for fid in (b"TORY", b"XDOR"):
1237 if fid in flist:
1238 fidHandled(fid)
1239
1240 # TYER, TDAT, TIME -> TDRC
1241 if (b"TYER" in date_frames or b"TDAT" in date_frames or b"TIME" in date_frames):
1242 date = self._getV23RecordingDate()
1243 if date:
1244 converted_frames.append(DateFrame(b"TDRC", date))
1245 for fid in [b"TYER", b"TDAT", b"TIME"]:
1246 if fid in date_frames:
1247 fidHandled(fid)
1248
1249 elif version == ID3_V2_3:
1250 if b"TDOR" in date_frames:
1251 date = date_frames[b"TDOR"].date
1252 if date:
1253 # TORY is year only
1254 converted_frames.append(DateFrame(b"TORY", str(date.year)))
1255 if date and date.month:
1256 converted_frames.append(DateFrame(b"XDOR", str(date)))
1257 fidHandled(b"TDOR")
1258
1259 if b"TDRC" in date_frames:
1260 date = date_frames[b"TDRC"].date
1261
1262 if date:
1263 converted_frames.append(DateFrame(b"TYER", str(date.year)))
1264 if None not in (date.month, date.day):
1265 date_str = "%s%s" %\
1266 (str(date.day).rjust(2, "0"),
1267 str(date.month).rjust(2, "0"))
1268 converted_frames.append(TextFrame(b"TDAT",
1269 date_str))
1270 if None not in (date.hour, date.minute):
1271 date_str = "%s%s" %\
1272 (str(date.hour).rjust(2, "0"),
1273 str(date.minute).rjust(2, "0"))
1274 converted_frames.append(TextFrame(b"TIME",
1275 date_str))
1276
1277 fidHandled(b"TDRC")
1278
1279 if b"TDRL" in date_frames:
1280 # TDRL -> Nothing
1281 log.warning("TDRL value dropped.")
1282 fidHandled(b"TDRL")
1283
1284 # All other date frames have no conversion
1285 for fid in date_frames:
1286 log.warning(f"{str(fid, 'ascii')} frame being dropped due to conversion to "
1287 f"{versionToString(version)}")
1288 flist.remove(date_frames[fid])
1289
1290 # Convert sort order frames 2.3 (XSO*) <-> 2.4 (TSO*)
1291 prefix = b"X" if version == ID3_V2_4 else b"T"
1292 fids = [prefix + suffix for suffix in [b"SOA", b"SOP", b"SOT"]]
1293 soframes = [f for f in flist if f.id in fids]
1294
1295 for frame in soframes:
1296 frame.id = (b"X" if prefix == b"T" else b"T") + frame.id[1:]
1297 flist.remove(frame)
1298 converted_frames.append(frame)
1299
1300 # TSIZ (v2.3) are completely deprecated, remove them
1301 if version == ID3_V2_4:
1302 flist = [f for f in flist if f.id != b"TSIZ"]
1303
1304 # TSST (v2.4) --> TIT3 (2.3)
1305 if version == ID3_V2_3 and b"TSST" in [f.id for f in flist]:
1306 tsst_frame = [f for f in flist if f.id == b"TSST"][0]
1307 flist.remove(tsst_frame)
1308 tsst_frame = frames.UserTextFrame(
1309 description="Subtitle (converted)", text=tsst_frame.text)
1310 converted_frames.append(tsst_frame)
1311
1312 # RVAD (v2.3) --> RVA2* (2.4)
1313 if version == ID3_V2_4 and b"RVAD" in [f.id for f in flist]:
1314 rvad = [f for f in flist if f.id == b"RVAD"][0]
1315 for rva2 in rvad.toV24():
1316 converted_frames.append(rva2)
1317 flist.remove(rvad)
1318 # RVA2* (v2.4) --> RVAD (2.3)
1319 elif version == ID3_V2_3 and b"RVA2" in [f.id for f in flist]:
1320 adj = frames.RelVolAdjFrameV23.VolumeAdjustments()
1321 for rva2 in [f for f in flist if f.id == b"RVA2"]:
1322 adj.setChannelAdj(rva2.channel_type, rva2.adjustment * 512)
1323 adj.setChannelPeak(rva2.channel_type, rva2.peak)
1324 flist.remove(rva2)
1325
1326 rvad = frames.RelVolAdjFrameV23()
1327 rvad.adjustments = adj
1328 converted_frames.append(rvad)
1329
1330 # Raise an error for frames that could not be converted.
1331 if len(flist) != 0:
1332 unconverted = ", ".join([f.id.decode("ascii") for f in flist])
1333 if version[0] != 1:
1334 raise TagException("Unable to convert the following frames to "
1335 f"version {versionToString(version)}: {unconverted}")
1336
1337 # Some frames in converted_frames may replace/edit frames in std_frames.
1338 for cframe in converted_frames:
1339 for sframe in std_frames:
1340 if cframe.id == sframe.id:
1341 std_frames.remove(sframe)
1342
1343 return converted_frames
1344
1345 @staticmethod
1346 def remove(filename, version=ID3_ANY_VERSION, preserve_file_time=False):
1347 tag = None
1348 retval = False
1349
1350 if version[0] & ID3_V1[0]:
1351 # ID3 v1.x
1352 tag = Tag()
1353 with open(filename, "r+b") as tag_file:
1354 found = tag.parse(tag_file, ID3_V1)
1355 if found:
1356 tag_file.seek(-128, 2)
1357 log.debug("Removing ID3 v1.x Tag")
1358 tag_file.truncate()
1359 retval |= True
1360
1361 if version[0] & ID3_V2[0]:
1362 tag = Tag()
1363 with open(filename, "rb") as tag_file:
1364 found = tag.parse(tag_file, ID3_V2)
1365 if found:
1366 log.debug("Removing ID3 %s tag" %
1367 versionToString(tag.version))
1368 tag_file.seek(tag.file_info.tag_size)
1369
1370 # Open tmp file
1371 with tempfile.NamedTemporaryFile("wb", delete=False) \
1372 as tmp_file:
1373 chunkCopy(tag_file, tmp_file)
1374
1375 # Move tmp to orig
1376 shutil.copyfile(tmp_file.name, filename)
1377 os.unlink(tmp_file.name)
1378
1379 retval |= True
1380
1381 if preserve_file_time and retval and None not in (tag.file_info.atime,
1382 tag.file_info.mtime):
1383 tag.file_info.touch((tag.file_info.atime, tag.file_info.mtime))
1384
1385 return retval
1386
1387 @property
1388 def chapters(self):
1389 return self._chapters
1390
1391 @property
1392 def table_of_contents(self):
1393 return self._tocs
1394
1395 @property
1396 def album_type(self):
1397 if TXXX_ALBUM_TYPE in self.user_text_frames:
1398 return self.user_text_frames.get(TXXX_ALBUM_TYPE).text
1399 else:
1400 return None
1401
1402 @album_type.setter
1403 def album_type(self, t):
1404 if not t:
1405 self.user_text_frames.remove(TXXX_ALBUM_TYPE)
1406 elif t in ALBUM_TYPE_IDS:
1407 self.user_text_frames.set(t, TXXX_ALBUM_TYPE)
1408 else:
1409 raise ValueError("Invalid album_type: %s" % t)
1410
1411 @property
1412 def artist_origin(self):
1413 """Returns None or a `ArtistOrigin` dataclass: (city, state, country) Any may be ``None``.
1414 """
1415 if TXXX_ARTIST_ORIGIN not in self.user_text_frames:
1416 return None
1417
1418 origin = self.user_text_frames.get(TXXX_ARTIST_ORIGIN).text
1419 vals = origin.split('\t')
1420
1421 vals.extend([None] * (3 - len(vals)))
1422 vals = [None if not v else v for v in vals]
1423 return ArtistOrigin(*vals)
1424
1425 @artist_origin.setter
1426 def artist_origin(self, origin: ArtistOrigin):
1427 if origin is None or origin == (None, None, None):
1428 self.user_text_frames.remove(TXXX_ARTIST_ORIGIN)
1429 else:
1430 self.user_text_frames.set(origin.id3Encode(), TXXX_ARTIST_ORIGIN)
1431
1432 def frameiter(self, fids=None):
1433 """A iterator for tag frames. If ``fids`` is passed it must be a list
1434 of frame IDs to filter and return."""
1435 fids = fids or []
1436 fids = [(b(f, ascii_encode) if isinstance(f, str) else f) for f in fids]
1437 for f in self.frame_set.getAllFrames():
1438 if not fids or f.id in fids:
1439 yield f
1440
1441 def _getOrigArtist(self):
1442 return self.getTextFrame(frames.ORIG_ARTIST_FID)
1443
1444 def _setOrigArtist(self, name):
1445 self.setTextFrame(frames.ORIG_ARTIST_FID, name)
1446
1447 @property
1448 def original_artist(self):
1449 return self._getOrigArtist()
1450
1451 @original_artist.setter
1452 def original_artist(self, name):
1453 self._setOrigArtist(name)
1454
1455
1456 class FileInfo:
1457 """
1458 This class is for storing information about a parsed file. It contains info
1459 such as the filename, original tag size, and amount of padding; all of which
1460 can make rewriting faster.
1461 """
1462 def __init__(self, file_name, tagsz=0, tpadd=0):
1463 from .. import LOCAL_FS_ENCODING
1464
1465 if type(file_name) is str:
1466 self.name = file_name
1467 else:
1468 try:
1469 self.name = str(file_name, LOCAL_FS_ENCODING)
1470 except UnicodeDecodeError:
1471 # Work around the local encoding not matching that of a mounted
1472 # filesystem
1473 log.warning("Mismatched file system encoding for file '%s'" %
1474 repr(file_name))
1475 self.name = file_name
1476
1477 self.tag_size = tagsz or 0 # This includes the padding byte count.
1478 self.tag_padding_size = tpadd or 0
1479
1480 self.atime, self.mtime = None, None
1481 self.initStatTimes()
1482
1483 def initStatTimes(self):
1484 try:
1485 s = os.stat(self.name)
1486 except OSError:
1487 self.atime, self.mtime = None, None
1488 else:
1489 self.atime, self.mtime = s.st_atime, s.st_mtime
1490
1491 def touch(self, times):
1492 """times is a 2-tuple of (atime, mtime)."""
1493 os.utime(self.name, times)
1494 self.initStatTimes()
1495
1496
1497 class AccessorBase:
1498 def __init__(self, fid, fs, match_func=None):
1499 self._fid = fid
1500 self._fs = fs
1501 self._match_func = match_func
1502
1503 def __iter__(self):
1504 for f in self._fs[self._fid] or []:
1505 yield f
1506
1507 def __len__(self):
1508 return len(self._fs[self._fid] or [])
1509
1510 def __getitem__(self, i):
1511 frames = self._fs[self._fid]
1512 if not frames:
1513 raise IndexError("list index out of range")
1514 return frames[i]
1515
1516 def get(self, *args, **kwargs):
1517 for frame in self._fs[self._fid] or []:
1518 if self._match_func(frame, *args, **kwargs):
1519 return frame
1520 return None
1521
1522 def remove(self, *args, **kwargs):
1523 """Returns the removed item or ``None`` if not found."""
1524 fid_frames = self._fs[self._fid] or []
1525 for frame in fid_frames:
1526 if self._match_func(frame, *args, **kwargs):
1527 fid_frames.remove(frame)
1528 return frame
1529 return None
1530
1531
1532 class DltAccessor(AccessorBase):
1533 """Access matching tag frames by "description" and/or "lang" values."""
1534 def __init__(self, FrameClass, fid, fs):
1535 def match_func(frame, description, lang=DEFAULT_LANG):
1536 return (frame.description == description and
1537 frame.lang == (lang if isinstance(lang, bytes)
1538 else lang.encode("ascii")))
1539
1540 super().__init__(fid, fs, match_func)
1541 self.FrameClass = FrameClass
1542
1543 @requireUnicode(1, 2)
1544 def set(self, text, description="", lang=DEFAULT_LANG):
1545 lang = lang or DEFAULT_LANG
1546 for f in self._fs[self._fid] or []:
1547 if f.description == description and f.lang == lang:
1548 # Exists, update text
1549 f.text = text
1550 return f
1551
1552 new_frame = self.FrameClass(description=description, lang=lang,
1553 text=text)
1554 self._fs[self._fid] = new_frame
1555 return new_frame
1556
1557 @requireUnicode(1)
1558 def remove(self, description, lang=DEFAULT_LANG):
1559 return super().remove(description, lang=lang or DEFAULT_LANG)
1560
1561 @requireUnicode(1)
1562 def get(self, description, lang=DEFAULT_LANG):
1563 return super().get(description, lang=lang or DEFAULT_LANG)
1564
1565
1566 class CommentsAccessor(DltAccessor):
1567 def __init__(self, fs):
1568 super().__init__(frames.CommentFrame, frames.COMMENT_FID, fs)
1569
1570
1571 class LyricsAccessor(DltAccessor):
1572 def __init__(self, fs):
1573 super().__init__(frames.LyricsFrame, frames.LYRICS_FID, fs)
1574
1575
1576 class ImagesAccessor(AccessorBase):
1577 def __init__(self, fs):
1578 def match_func(frame, description):
1579 return frame.description == description
1580 super().__init__(frames.IMAGE_FID, fs, match_func)
1581
1582 @requireUnicode("description")
1583 def set(self, type_, img_data, mime_type, description="", img_url=None):
1584 """Add an image of ``type_`` (a type constant from ImageFrame).
1585 The ``img_data`` is either bytes or ``None``. In the latter case
1586 ``img_url`` MUST be the URL to the image. In this case ``mime_type``
1587 is ignored and "-->" is used to signal this as a link and not data
1588 (per the ID3 spec)."""
1589 img_url = b(img_url) if img_url else None
1590
1591 if not img_data and not img_url:
1592 raise ValueError("img_url MUST not be none when no image data")
1593
1594 mime_type = mime_type if img_data else frames.ImageFrame.URL_MIME_TYPE
1595 mime_type = b(mime_type)
1596
1597 images = self._fs[frames.IMAGE_FID] or []
1598 for img in images:
1599 if img.description == description:
1600 # update
1601 if not img_data:
1602 img.image_url = img_url
1603 img.image_data = None
1604 img.mime_type = frames.ImageFrame.URL_MIME_TYPE
1605 else:
1606 img.image_url = None
1607 img.image_data = img_data
1608 img.mime_type = mime_type
1609 img.picture_type = type_
1610 return img
1611
1612 img_frame = frames.ImageFrame(description=description,
1613 image_data=img_data,
1614 image_url=img_url,
1615 mime_type=mime_type,
1616 picture_type=type_)
1617 self._fs[frames.IMAGE_FID] = img_frame
1618 return img_frame
1619
1620 @requireUnicode(1)
1621 def remove(self, description):
1622 return super().remove(description)
1623
1624 @requireUnicode(1)
1625 def get(self, description):
1626 return super().get(description)
1627
1628
1629 class ObjectsAccessor(AccessorBase):
1630 def __init__(self, fs):
1631 def match_func(frame, description):
1632 return frame.description == description
1633 super().__init__(frames.OBJECT_FID, fs, match_func)
1634
1635 @requireUnicode("description", "filename")
1636 def set(self, data, mime_type, description="", filename=""):
1637 objects = self._fs[frames.OBJECT_FID] or []
1638 for obj in objects:
1639 if obj.description == description:
1640 # update
1641 obj.object_data = data
1642 obj.mime_type = mime_type
1643 obj.filename = filename
1644 return obj
1645
1646 obj_frame = frames.ObjectFrame(description=description,
1647 filename=filename,
1648 object_data=data,
1649 mime_type=mime_type)
1650 self._fs[frames.OBJECT_FID] = obj_frame
1651 return obj_frame
1652
1653 @requireUnicode(1)
1654 def remove(self, description):
1655 return super().remove(description)
1656
1657 @requireUnicode(1)
1658 def get(self, description):
1659 return super().get(description)
1660
1661
1662 class PrivatesAccessor(AccessorBase):
1663 def __init__(self, fs):
1664 def match_func(frame, owner_id):
1665 return frame.owner_id == owner_id
1666 super().__init__(frames.PRIVATE_FID, fs, match_func)
1667
1668 def set(self, data, owner_id):
1669 priv_frames = self._fs[frames.PRIVATE_FID] or []
1670 for f in priv_frames:
1671 if f.owner_id == owner_id:
1672 # update
1673 f.owner_data = data
1674 return f
1675
1676 priv_frame = frames.PrivateFrame(owner_id=owner_id,
1677 owner_data=data)
1678 self._fs[frames.PRIVATE_FID] = priv_frame
1679 return priv_frame
1680
1681 def remove(self, owner_id):
1682 return super().remove(owner_id)
1683
1684 def get(self, owner_id):
1685 return super().get(owner_id)
1686
1687
1688 class UserTextsAccessor(AccessorBase):
1689 def __init__(self, fs):
1690 def match_func(frame, description):
1691 return frame.description == description
1692 super().__init__(frames.USERTEXT_FID, fs, match_func)
1693
1694 @requireUnicode(1, "description")
1695 def set(self, text, description=""):
1696 flist = self._fs[frames.USERTEXT_FID] or []
1697 for utf in flist:
1698 if utf.description == description:
1699 # update
1700 utf.text = text
1701 return utf
1702
1703 utf = frames.UserTextFrame(description=description,
1704 text=text)
1705 self._fs[frames.USERTEXT_FID] = utf
1706 return utf
1707
1708 @requireUnicode(1)
1709 def remove(self, description):
1710 return super().remove(description)
1711
1712 @requireUnicode(1)
1713 def get(self, description):
1714 return super().get(description)
1715
1716 @requireUnicode(1)
1717 def __contains__(self, description):
1718 return bool(self.get(description))
1719
1720
1721 class UniqueFileIdAccessor(AccessorBase):
1722 def __init__(self, fs):
1723 def match_func(frame, owner_id):
1724 return frame.owner_id == owner_id
1725 super().__init__(frames.UNIQUE_FILE_ID_FID, fs, match_func)
1726
1727 def set(self, data, owner_id):
1728 data, owner_id = b(data), b(owner_id)
1729 if len(data) > 64:
1730 raise TagException("UFID data must be 64 bytes or less")
1731
1732 flist = self._fs[frames.UNIQUE_FILE_ID_FID] or []
1733 for f in flist:
1734 if f.owner_id == owner_id:
1735 # update
1736 f.uniq_id = data
1737 return f
1738
1739 uniq_id_frame = frames.UniqueFileIDFrame(owner_id=owner_id,
1740 uniq_id=data)
1741 self._fs[frames.UNIQUE_FILE_ID_FID] = uniq_id_frame
1742 return uniq_id_frame
1743
1744 def remove(self, owner_id):
1745 owner_id = b(owner_id)
1746 return super().remove(owner_id)
1747
1748 def get(self, owner_id):
1749 owner_id = b(owner_id)
1750 return super().get(owner_id)
1751
1752
1753 class UserUrlsAccessor(AccessorBase):
1754 def __init__(self, fs):
1755 def match_func(frame, description):
1756 return frame.description == description
1757 super().__init__(frames.USERURL_FID, fs, match_func)
1758
1759 @requireUnicode("description")
1760 def set(self, url, description=""):
1761 flist = self._fs[frames.USERURL_FID] or []
1762 for uuf in flist:
1763 if uuf.description == description:
1764 # update
1765 uuf.url = url
1766 return uuf
1767
1768 uuf = frames.UserUrlFrame(description=description, url=url)
1769 self._fs[frames.USERURL_FID] = uuf
1770 return uuf
1771
1772 @requireUnicode(1)
1773 def remove(self, description):
1774 return super().remove(description)
1775
1776 @requireUnicode(1)
1777 def get(self, description):
1778 return super().get(description)
1779
1780
1781 class PopularitiesAccessor(AccessorBase):
1782 def __init__(self, fs):
1783 def match_func(frame, email):
1784 return frame.email == email
1785 super().__init__(frames.POPULARITY_FID, fs, match_func)
1786
1787 def set(self, email, rating, play_count):
1788 flist = self._fs[frames.POPULARITY_FID] or []
1789 for popm in flist:
1790 if popm.email == email:
1791 # update
1792 popm.rating = rating
1793 popm.count = play_count
1794 return popm
1795
1796 popm = frames.PopularityFrame(email=email, rating=rating,
1797 count=play_count)
1798 self._fs[frames.POPULARITY_FID] = popm
1799 return popm
1800
1801 def remove(self, email):
1802 return super().remove(email)
1803
1804 def get(self, email):
1805 return super().get(email)
1806
1807
1808 class ChaptersAccessor(AccessorBase):
1809 def __init__(self, fs):
1810 def match_func(frame, element_id):
1811 return frame.element_id == element_id
1812 super().__init__(frames.CHAPTER_FID, fs, match_func)
1813
1814 def set(self, element_id, times, offsets=(None, None), sub_frames=None):
1815 flist = self._fs[frames.CHAPTER_FID] or []
1816 for chap in flist:
1817 if chap.element_id == element_id:
1818 # update
1819 chap.times, chap.offsets = times, offsets
1820 if sub_frames:
1821 chap.sub_frames = sub_frames
1822 return chap
1823
1824 chap = frames.ChapterFrame(element_id=element_id,
1825 times=times, offsets=offsets,
1826 sub_frames=sub_frames)
1827 self._fs[frames.CHAPTER_FID] = chap
1828 return chap
1829
1830 def remove(self, element_id):
1831 return super().remove(element_id)
1832
1833 def get(self, element_id):
1834 return super().get(element_id)
1835
1836 def __getitem__(self, elem_id):
1837 """Overiding the index based __getitem__ for one indexed with chapter
1838 element IDs. These are stored in the tag's table of contents frames."""
1839 for chapter in (self._fs[frames.CHAPTER_FID] or []):
1840 if chapter.element_id == elem_id:
1841 return chapter
1842 raise IndexError("chapter '%s' not found" % elem_id)
1843
1844
1845 class TocAccessor(AccessorBase):
1846 def __init__(self, fs):
1847 def match_func(frame, element_id):
1848 return frame.element_id == element_id
1849 super().__init__(frames.TOC_FID, fs, match_func)
1850
1851 def __iter__(self):
1852 tocs = list(self._fs[self._fid] or [])
1853 for toc_frame in tocs:
1854 # Find and put top level at the front of the list
1855 if toc_frame.toplevel:
1856 tocs.remove(toc_frame)
1857 tocs.insert(0, toc_frame)
1858 break
1859
1860 for toc in tocs:
1861 yield toc
1862
1863 @requireUnicode("description")
1864 def set(self, element_id, toplevel=False, ordered=True, child_ids=None,
1865 description=""):
1866 flist = self._fs[frames.TOC_FID] or []
1867
1868 # Enforce one top-level
1869 if toplevel:
1870 for toc in flist:
1871 if toc.toplevel:
1872 raise ValueError("There may only be one top-level "
1873 "table of contents. Toc '%s' is current "
1874 "top-level." % toc.element_id)
1875 for toc in flist:
1876 if toc.element_id == element_id:
1877 # update
1878 toc.toplevel = toplevel
1879 toc.ordered = ordered
1880 toc.child_ids = child_ids
1881 toc.description = description
1882 return toc
1883
1884 toc = frames.TocFrame(element_id=element_id, toplevel=toplevel,
1885 ordered=ordered, child_ids=child_ids,
1886 description=description)
1887 self._fs[frames.TOC_FID] = toc
1888 return toc
1889
1890 def remove(self, element_id):
1891 return super().remove(element_id)
1892
1893 def get(self, element_id):
1894 return super().get(element_id)
1895
1896 def __getitem__(self, elem_id):
1897 """Overiding the index based __getitem__ for one indexed with table
1898 of contents element IDs."""
1899 for toc in (self._fs[frames.TOC_FID] or []):
1900 if toc.element_id == elem_id:
1901 return toc
1902 raise IndexError("toc '%s' not found" % elem_id)
1903
1904
1905 class TagTemplate(string.Template):
1906 idpattern = r'[_a-z][_a-z0-9:]*'
1907
1908 def __init__(self, pattern, path_friendly="-", dotted_dates=False):
1909 super().__init__(pattern)
1910
1911 if type(path_friendly) is bool and path_friendly:
1912 # Previous versions used boolean values, convert old default to new
1913 path_friendly = "-"
1914 self._path_friendly = path_friendly
1915
1916 self._dotted_dates = dotted_dates
1917
1918 def substitute(self, tag, zeropad=True):
1919 mapping = self._makeMapping(tag, zeropad)
1920
1921 # Helper function for .sub()
1922 def convert(mo):
1923 named = mo.group('named')
1924 if named is not None:
1925 try:
1926 if type(mapping[named]) is tuple:
1927 func, args = mapping[named][0], mapping[named][1:]
1928 return '%s' % func(tag, named, *args)
1929 # We use this idiom instead of str() because the latter
1930 # will fail if val is a Unicode containing non-ASCII
1931 return '%s' % (mapping[named],)
1932 except KeyError:
1933 return self.delimiter + named
1934 braced = mo.group('braced')
1935 if braced is not None:
1936 try:
1937 if type(mapping[braced]) is tuple:
1938 func, args = mapping[braced][0], mapping[braced][1:]
1939 return '%s' % func(tag, braced, *args)
1940 return '%s' % (mapping[braced],)
1941 except KeyError:
1942 return self.delimiter + '{' + braced + '}'
1943 if mo.group('escaped') is not None:
1944 return self.delimiter
1945 if mo.group('invalid') is not None:
1946 return self.delimiter
1947 raise ValueError('Unrecognized named group in pattern',
1948 self.pattern)
1949
1950 name = self.pattern.sub(convert, self.template)
1951 if self._path_friendly:
1952 name = name.replace("/", self._path_friendly)
1953 return name
1954
1955 safe_substitute = substitute
1956
1957 def _dates(self, tag, param):
1958 if param.startswith("release_"):
1959 date = tag.release_date
1960 elif param.startswith("recording_"):
1961 date = tag.recording_date
1962 elif param.startswith("original_release_"):
1963 date = tag.original_release_date
1964 else:
1965 date = tag.getBestDate(
1966 prefer_recording_date=":prefer_recording" in param)
1967
1968 if date and param.endswith(":year"):
1969 dstr = str(date.year)
1970 elif date:
1971 dstr = str(date)
1972 else:
1973 dstr = ""
1974
1975 if self._dotted_dates:
1976 dstr = dstr.replace('-', '.')
1977
1978 return dstr
1979
1980 @staticmethod
1981 def _nums(num_tuple, param, zeropad):
1982 nn, nt = ((str(n) if n else None) for n in num_tuple)
1983 if zeropad:
1984 if nt:
1985 nt = nt.rjust(2, "0")
1986 nn = nn.rjust(len(nt) if nt else 2, "0")
1987
1988 if param.endswith(":num"):
1989 return nn
1990 elif param.endswith(":total"):
1991 return nt
1992 else:
1993 raise ValueError("Unknown template param: %s" % param)
1994
1995 def _track(self, tag, param, zeropad):
1996 return self._nums(tag.track_num, param, zeropad)
1997
1998 def _disc(self, tag, param, zeropad):
1999 return self._nums(tag.disc_num, param, zeropad)
2000
2001 @staticmethod
2002 def _file(tag, param):
2003 assert(param.startswith("file"))
2004
2005 if param.endswith(":ext"):
2006 return os.path.splitext(tag.file_info.name)[1][1:]
2007 else:
2008 return tag.file_info.name
2009
2010 def _makeMapping(self, tag, zeropad):
2011 return {"artist": tag.artist if tag else None,
2012 "album_artist": tag.album_artist if tag else None,
2013 "album": tag.album if tag else None,
2014 "title": tag.title if tag else None,
2015 "track:num": (self._track, zeropad) if tag else None,
2016 "track:total": (self._track, zeropad) if tag else None,
2017 "release_date": (self._dates,) if tag else None,
2018 "release_date:year": (self._dates,) if tag else None,
2019 "recording_date": (self._dates,) if tag else None,
2020 "recording_date:year": (self._dates,) if tag else None,
2021 "original_release_date": (self._dates,) if tag else None,
2022 "original_release_date:year": (self._dates,) if tag else None,
2023 "best_date": (self._dates,) if tag else None,
2024 "best_date:year": (self._dates,) if tag else None,
2025 "best_date:prefer_recording": (self._dates,) if tag else None,
2026 "best_date:prefer_release": (self._dates,) if tag else None,
2027 "best_date:prefer_recording:year": (self._dates,) if tag
2028 else None,
2029 "best_date:prefer_release:year": (self._dates,) if tag
2030 else None,
2031 "file": (self._file,) if tag else None,
2032 "file:ext": (self._file,) if tag else None,
2033 "disc:num": (self._disc, zeropad) if tag else None,
2034 "disc:total": (self._disc, zeropad) if tag else None,
2035 }
0 import os
1 import sys
2 import textwrap
3 import warnings
4 import deprecation
5
6 from io import StringIO
7 from configparser import ConfigParser
8 from configparser import Error as ConfigParserError
9
10 import eyed3
11 import eyed3.utils
12 import eyed3.utils.console
13 import eyed3.plugins
14 import eyed3.__about__
15
16 from eyed3.utils.log import initLogging
17
18 DEFAULT_PLUGIN = "classic"
19 DEFAULT_CONFIG = os.path.expandvars("${HOME}/.config/eyeD3/config.ini")
20 USER_PLUGINS_DIR = os.path.expandvars("${HOME}/.config/eyeD3/plugins")
21 DEFAULT_CONFIG_DEPRECATED = os.path.expandvars("${HOME}/.eyeD3/config.ini")
22 USER_PLUGINS_DIR_DEPRECATED = os.path.expandvars("${HOME}/.eyeD3/plugins")
23
24
25 def main(args, config):
26 if "list_plugins" in args and args.list_plugins:
27 _listPlugins(config)
28 return 0
29
30 args.plugin.start(args, config)
31
32 recursive = False
33 if "non_recursive" in args:
34 recursive = not args.non_recursive
35 elif "recursive" in args:
36 recursive = args.recursive
37
38 # Process paths (files/directories)
39 for p in args.paths:
40 eyed3.utils.walk(args.plugin, p, excludes=args.excludes, fs_encoding=args.fs_encoding,
41 recursive=recursive)
42
43 retval = args.plugin.handleDone()
44
45 return retval or 0
46
47
48 def _listPlugins(config):
49 from eyed3.utils.console import Fore, Style
50
51 def header(name):
52 is_default = name == DEFAULT_PLUGIN
53 return (Style.BRIGHT + (Fore.GREEN if is_default else '') + "* " +
54 name + Style.RESET_ALL)
55
56 all_plugins = eyed3.plugins.load(reload=True, paths=_getPluginPath(config))
57 # Create a new dict for sorted display
58 plugin_names = []
59 for plugin in set(all_plugins.values()):
60 plugin_names.append(plugin.NAMES[0])
61
62 print("\nType 'eyeD3 --plugin=<name> --help' for more help\n")
63
64 plugin_names.sort()
65 for name in plugin_names:
66 plugin = all_plugins[name]
67
68 alt_names = plugin.NAMES[1:]
69 alt_names = f" ({', '.join(alt_names)})" if alt_names else ""
70
71 print(f"{header(name)} {alt_names}:")
72 for txt in textwrap.wrap(plugin.SUMMARY, initial_indent=' ' * 2, subsequent_indent=' ' * 2):
73 print(f"{Fore.YELLOW}{txt}{Style.RESET_ALL}")
74 print("")
75
76
77 @deprecation.deprecated(deprecated_in="0.9a2", removed_in="1.0",
78 current_version=eyed3.__about__.__version__,
79 details=f"Default eyeD3 config moved to {DEFAULT_CONFIG}")
80 def _deprecatedConfigFileCheck(_):
81 """This here to add deprecation."""
82
83
84 def _loadConfig(args):
85 config_files = []
86
87 if args.config:
88 config_files.append(os.path.abspath(args.config))
89
90 if args.no_config is False:
91 config_files.append(DEFAULT_CONFIG)
92 config_files.append(DEFAULT_CONFIG_DEPRECATED)
93
94 if not config_files:
95 return None
96
97 for config_file in config_files:
98 if os.path.isfile(config_file):
99 _deprecatedConfigFileCheck(config_file)
100
101 try:
102 config = ConfigParser()
103 config.read(config_file)
104 except ConfigParserError as ex:
105 eyed3.log.warning(f"User config error: {ex}")
106 return None
107 else:
108 return config
109 elif config_file != DEFAULT_CONFIG and config_file != DEFAULT_CONFIG_DEPRECATED:
110 raise IOError(f"User config not found: {config_file}")
111
112
113 def _getPluginPath(config):
114 plugin_path = [USER_PLUGINS_DIR]
115
116 if config and config.has_option("default", "plugin_path"):
117 val = config.get("default", "plugin_path")
118 plugin_path += [os.path.expanduser(os.path.expandvars(d)) for d
119 in val.split(':') if val]
120 return plugin_path
121
122
123 def profileMain(args, config): # pragma: no cover
124 """This is the main function for profiling
125 http://code.google.com/appengine/kb/commontasks.html#profiling
126 """
127 import cProfile
128 import pstats
129
130 eyed3.log.debug("driver profileMain")
131 prof = cProfile.Profile()
132 prof = prof.runctx("main(args)", globals(), locals())
133
134 stream = StringIO()
135 stats = pstats.Stats(prof, stream=stream)
136 stats.sort_stats("time") # Or cumulative
137 stats.print_stats(100) # 80 = how many to print
138
139 # The rest is optional.
140 stats.print_callees()
141 stats.print_callers()
142 sys.stderr.write("Profile data:\n%s\n" % stream.getvalue())
143
144 return 0
145
146
147 def setFileScannerOpts(arg_parser, default_recursive=False, paths_metavar="PATH",
148 paths_help="Files or directory paths"):
149
150 if default_recursive is False:
151 arg_parser.add_argument("-r", "--recursive", action="store_true", dest="recursive",
152 help="Recurse into subdirectories.")
153 else:
154 arg_parser.add_argument("-R", "--non-recursive", action="store_true", dest="non_recursive",
155 help="Do not recurse into subdirectories.")
156
157 arg_parser.add_argument("--exclude", action="append", metavar="PATTERN", dest="excludes",
158 help="A regular expression for path exclusion. May be specified "
159 "multiple times.")
160 arg_parser.add_argument("--fs-encoding", action="store", dest="fs_encoding",
161 default=eyed3.LOCAL_FS_ENCODING, metavar="ENCODING",
162 help="Use the specified file system encoding for filenames. "
163 f"Default as it was detected is '{eyed3.LOCAL_FS_ENCODING}' but "
164 "this option is still useful when reading from mounted file "
165 "systems.")
166 arg_parser.add_argument("paths", metavar=paths_metavar, nargs="*", help=paths_help)
167
168
169 def makeCmdLineParser(subparser=None):
170 from eyed3.utils import ArgumentParser
171
172 p = ArgumentParser(prog=eyed3.__about__.__project_name__, add_help=True)\
173 if not subparser else subparser
174
175 setFileScannerOpts(p)
176
177 p.add_argument("-L", "--plugins", action="store_true", default=False,
178 dest="list_plugins", help="List all available plugins")
179 p.add_argument("-P", "--plugin", action="store", dest="plugin",
180 default=None, metavar="NAME",
181 help=f"Specify which plugin to use. The default is '{DEFAULT_PLUGIN}'")
182 p.add_argument("-C", "--config", action="store", dest="config",
183 default=None, metavar="FILE",
184 help="Supply a configuration file. The default is "
185 f"'{DEFAULT_CONFIG}', although even that is optional.")
186 p.add_argument("--backup", action="store_true", dest="backup",
187 help="Plugins should honor this option such that "
188 "a backup is made of any file modified. The backup "
189 "is made in same directory with a '.orig' "
190 "extension added.")
191 p.add_argument("-Q", "--quiet", action="store_true", dest="quiet",
192 default=False, help="A hint to plugins to output less.")
193 p.add_argument("--no-color", action="store_true", dest="no_color",
194 help="Suppress color codes in console output. "
195 "This will happen automatically if the output is "
196 "not a TTY (e.g. when redirecting to a file)")
197 p.add_argument("--no-config",
198 action="store_true", dest="no_config",
199 help=f"Do not load the default user config '{DEFAULT_CONFIG}'. "
200 "The -c/--config options are still honored if present.")
201
202 return p
203
204
205 def parseCommandLine(cmd_line_args=None):
206
207 cmd_line_args = list(cmd_line_args) if cmd_line_args else list(sys.argv[1:])
208
209 # Remove any options not related to plugin/config for first parse. These
210 # determine the parser for the next stage.
211 stage_one_args = []
212 idx, auto_append = 0, False
213 while idx < len(cmd_line_args):
214 opt = cmd_line_args[idx]
215 if auto_append:
216 stage_one_args.append(opt)
217 auto_append = False
218
219 if opt in ("-C", "--config", "-P", "--plugin", "--no-config"):
220 stage_one_args.append(opt)
221 if opt != "--no-config":
222 auto_append = True
223 elif (opt.startswith("-C=") or opt.startswith("--config=") or
224 opt.startswith("-P=") or opt.startswith("--plugin=")):
225 stage_one_args.append(opt)
226 idx += 1
227
228 parser = makeCmdLineParser()
229 args = parser.parse_args(stage_one_args)
230
231 config = _loadConfig(args)
232
233 if args.plugin:
234 # Plugin on the command line takes precedence over config.
235 plugin_name = args.plugin
236 elif config and config.has_option("default", "plugin"):
237 # Get default plugin from config or use DEFAULT_CONFIG
238 plugin_name = config.get("default", "plugin")
239 if not plugin_name:
240 plugin_name = DEFAULT_PLUGIN
241 else:
242 plugin_name = DEFAULT_PLUGIN
243 assert plugin_name
244
245 PluginClass = eyed3.plugins.load(plugin_name, paths=_getPluginPath(config))
246 if PluginClass is None:
247 eyed3.utils.console.printError("Plugin not found: %s" % plugin_name)
248 parser.exit(1)
249 plugin = PluginClass(parser)
250
251 if config and config.has_option("default", "options"):
252 cmd_line_args.extend(config.get("default", "options").split())
253 if config and config.has_option(plugin_name, "options"):
254 cmd_line_args.extend(config.get(plugin_name, "options").split())
255
256 # Re-parse the command line including options from the config.
257 args = parser.parse_args(args=cmd_line_args)
258
259 args.plugin = plugin
260 eyed3.log.debug("command line args: %s", args)
261 eyed3.log.debug("plugin is: %s", plugin)
262
263 return args, parser, config
264
265
266 def _main():
267 """Entry point"""
268 initLogging()
269
270 args = None
271 try:
272 args, _, config = parseCommandLine()
273
274 eyed3.utils.console.AnsiCodes.init(not args.no_color)
275
276 mainFunc = main if args.debug_profile is False else profileMain
277 retval = mainFunc(args, config)
278 except KeyboardInterrupt:
279 retval = 0
280 except (StopIteration, IOError) as ex:
281 eyed3.utils.console.printError(str(ex))
282 retval = 1
283 except Exception as ex:
284 eyed3.utils.console.printError(f"Uncaught exception: {ex}\n")
285 eyed3.log.exception(ex)
286 retval = 1
287
288 if args.debug_pdb:
289 try:
290 with warnings.catch_warnings():
291 warnings.simplefilter("ignore", PendingDeprecationWarning)
292 # Must delay the import of ipdb as say as possible because
293 # of https://github.com/gotcha/ipdb/issues/48
294 import ipdb as pdb # noqa
295 except ImportError:
296 import pdb # noqa
297
298 e, m, tb = sys.exc_info()
299 pdb.post_mortem(tb)
300
301 sys.exit(retval)
302
303
304 if __name__ == "__main__": # pragma: no cover
305 _main()
0 import pathlib
1 import filetype
2 from io import BytesIO
3 from .id3 import ID3_MIME_TYPE, ID3_MIME_TYPE_EXTENSIONS
4 from .mp3 import MIME_TYPES as MP3_MIME_TYPES
5 from .utils.log import getLogger
6 from filetype.utils import _NUM_SIGNATURE_BYTES
7
8 log = getLogger(__name__)
9
10
11 def guessMimetype(filename):
12 """Return the mime-type for `filename`."""
13
14 path = pathlib.Path(filename) if not isinstance(filename, pathlib.Path) else filename
15
16 with path.open("rb") as signature:
17 # Since filetype only reads 262 of file many mp3s starting with null bytes will not find
18 # a header, so ignoring null bytes and using the bytes interface...
19 buf = b""
20 while not buf:
21 data = signature.read(_NUM_SIGNATURE_BYTES)
22 if not data:
23 break
24
25 data = data.lstrip(b"\x00")
26 if data:
27 data_len = len(data)
28 if data_len >= _NUM_SIGNATURE_BYTES:
29 buf = data[:_NUM_SIGNATURE_BYTES]
30 else:
31 buf = data + signature.read(_NUM_SIGNATURE_BYTES - data_len)
32
33 # Special casing .id3/.tag because extended filetype with add_type() prepends, meaning
34 # all mp3 would be labeled mimetype id3, while appending would mean each .id3 would be
35 # mime mpeg.
36 if path.suffix in ID3_MIME_TYPE_EXTENSIONS:
37 if Id3Tag().match(buf) or Id3TagExt().match(buf):
38 return Id3TagExt.MIME
39
40 return filetype.guess_mime(buf)
41
42
43 class Mp2x(filetype.Type):
44 """Implements the MP2.x audio type matcher."""
45 MIME = MP3_MIME_TYPES[0]
46 EXTENSION = "mp3"
47
48 def __init__(self):
49 super().__init__(mime=self.__class__.MIME, extension=self.__class__.EXTENSION)
50
51 def match(self, buf):
52 from .mp3.headers import findHeader
53
54 return (len(buf) > 2 and
55 buf[0] == 0xff and buf[1] in (0xf3, 0xe3) and
56 findHeader(BytesIO(buf), 0)[1])
57
58
59 class Mp3Invalids(filetype.Type):
60 """Implements a MP3 audio type matcher this is odd or/corrupt mp3."""
61 MIME = MP3_MIME_TYPES[0]
62 EXTENSION = "mp3"
63
64 def __init__(self):
65 super().__init__(mime=self.__class__.MIME, extension=self.__class__.EXTENSION)
66
67 def match(self, buf):
68 from .mp3.headers import findHeader
69
70 header = findHeader(BytesIO(buf), 0)[1]
71 log.debug(f"Mp3Invalid, found: {header}")
72 return bool(header)
73
74
75 class Id3Tag(filetype.Type):
76 """Implements a MP3 audio type matcher this is odd or/corrupt mp3."""
77 MIME = ID3_MIME_TYPE
78 EXTENSION = "id3"
79
80 def __init__(self):
81 super().__init__(mime=self.__class__.MIME, extension=self.__class__.EXTENSION)
82
83 def match(self, buf):
84 return buf[:3] in (b"ID3", b"TAG") or len(buf) == 0
85
86
87 class Id3TagExt(Id3Tag):
88 EXTENSION = "tag"
89
90
91 class M3u(filetype.Type):
92 """Implements the m3u playlist matcher."""
93 MIME = "audio/x-mpegurl"
94 EXTENSION = "m3u"
95
96 def __init__(self):
97 super().__init__(mime=self.__class__.MIME, extension=self.__class__.EXTENSION)
98
99 def match(self, buf):
100 return len(buf) > 6 and buf.startswith(b"#EXTM3U")
101
102
103 # Not using `add_type()`, to append
104 filetype.types.append(Mp2x())
105 filetype.types.append(M3u())
106 filetype.types.append(Mp3Invalids())
0 import os
1 import re
2
3 from .. import Error
4 from .. import id3
5 from .. import core
6
7 from ..utils.log import getLogger
8 log = getLogger(__name__)
9
10
11 class Mp3Exception(Error):
12 """Used to signal mp3-related errors."""
13 pass
14
15
16 NAME = "mpeg"
17 # Mime-types that are recognized at MP3
18 MIME_TYPES = ["audio/mpeg", "audio/mp3", "audio/x-mp3", "audio/x-mpeg",
19 "audio/mpeg3", "audio/x-mpeg3", "audio/mpg", "audio/x-mpg",
20 "audio/x-mpegaudio", "audio/mpegapplication/x-tar",
21 ]
22
23 # Mime-types that have been seen to contain mp3 data.
24 OTHER_MIME_TYPES = ['application/octet-stream', # ???
25 'audio/x-hx-aac-adts', # ???
26 'audio/x-wav', # RIFF wrapped mp3s
27 ]
28
29 # Valid file extensions.
30 EXTENSIONS = [".mp3"]
31
32
33 class Mp3AudioInfo(core.AudioInfo):
34 def __init__(self, file_obj, start_offset, tag):
35 from . import headers
36 from .headers import timePerFrame
37
38 log.debug("mp3 header search starting @ %x" % start_offset)
39 core.AudioInfo.__init__(self)
40
41 self.mp3_header = None
42 self.xing_header = None
43 self.vbri_header = None
44 # If not ``None``, the Lame header.
45 # See :class:`eyed3.mp3.headers.LameHeader`
46 self.lame_tag = None
47 # 2-tuple, (vrb?:boolean, bitrate:int)
48 self.bit_rate = (None, None)
49
50 header_pos = 0
51 while self.mp3_header is None:
52 # Find first mp3 header
53 (header_pos,
54 header_int,
55 header_bytes) = headers.findHeader(file_obj, start_offset)
56 if not header_int:
57 try:
58 fname = file_obj.name
59 except AttributeError:
60 fname = 'unknown'
61 raise headers.Mp3Exception(
62 "Unable to find a valid mp3 frame in '%s'" % fname)
63
64 try:
65 self.mp3_header = headers.Mp3Header(header_int)
66 log.debug("mp3 header %x found at position: 0x%x" %
67 (header_int, header_pos))
68 except headers.Mp3Exception as ex:
69 log.debug("Invalid mp3 header: %s" % str(ex))
70 # keep looking...
71 start_offset += 4
72
73 file_obj.seek(header_pos)
74 mp3_frame = file_obj.read(self.mp3_header.frame_length)
75 if re.compile(b'Xing|Info').search(mp3_frame):
76 # Check for Xing/Info header information.
77 self.xing_header = headers.XingHeader()
78 if not self.xing_header.decode(mp3_frame):
79 log.debug("Ignoring corrupt Xing header")
80 self.xing_header = None
81 elif mp3_frame.find(b'VBRI') >= 0:
82 # Check for VBRI header information.
83 self.vbri_header = headers.VbriHeader()
84 if not self.vbri_header.decode(mp3_frame):
85 log.debug("Ignoring corrupt VBRI header")
86 self.vbri_header = None
87
88 # Check for LAME Tag
89 self.lame_tag = headers.LameHeader(mp3_frame)
90
91 # Set file size
92 import stat
93 self.size_bytes = os.stat(file_obj.name)[stat.ST_SIZE]
94
95 # Compute track play time.
96 if self.xing_header and self.xing_header.vbr:
97 tpf = timePerFrame(self.mp3_header, True)
98 self.time_secs = tpf * self.xing_header.numFrames
99 elif self.vbri_header and self.vbri_header.version == 1:
100 tpf = timePerFrame(self.mp3_header, True)
101 self.time_secs = tpf * self.vbri_header.num_frames
102 else:
103 tpf = timePerFrame(self.mp3_header, False)
104 length = self.size_bytes
105 if tag and tag.isV2():
106 length -= tag.header.SIZE + tag.header.tag_size
107 # Handle the case where there is a v2 tag and a v1 tag.
108 file_obj.seek(-128, 2)
109 if file_obj.read(3) == "TAG":
110 length -= 128
111 elif tag and tag.isV1():
112 length -= 128
113 self.time_secs = (length / self.mp3_header.frame_length) * tpf
114
115 # Compute bitrate
116 if (self.xing_header and self.xing_header.vbr and
117 self.xing_header.numFrames): # if xing_header.numFrames == 0, ZeroDivisionError
118 br = int((self.xing_header.numBytes * 8) /
119 (tpf * self.xing_header.numFrames * 1000))
120 vbr = True
121 else:
122 br = self.mp3_header.bit_rate
123 vbr = False
124 self.bit_rate = (vbr, br)
125
126 self.sample_freq = self.mp3_header.sample_freq
127 self.mode = self.mp3_header.mode
128
129 ##
130 # Helper to get the bitrate as a string. The prefix '~' is used to denote
131 # variable bit rates.
132 @property
133 def bit_rate_str(self):
134 (vbr, bit_rate) = self.bit_rate
135 return f"{'~' if vbr else ''}{bit_rate} kb/s"
136
137
138 class Mp3AudioFile(core.AudioFile):
139 """Audio file container for mp3 files."""
140
141 def __init__(self, path, version=id3.ID3_ANY_VERSION):
142 self._tag_version = version
143
144 super().__init__(path)
145 assert self.type == core.AUDIO_MP3
146
147 def _read(self):
148 with open(self.path, "rb") as file_obj:
149 self._tag = id3.Tag()
150 tag_found = self._tag.parse(file_obj, self._tag_version)
151
152 # Compute offset for starting mp3 data search
153 if tag_found and self._tag.isV1():
154 mp3_offset = 0
155 elif tag_found and self._tag.isV2():
156 mp3_offset = self._tag.header.SIZE + self._tag.header.tag_size
157 else:
158 mp3_offset = 0
159 self._tag = None
160
161 try:
162 self._info = Mp3AudioInfo(file_obj, mp3_offset, self._tag)
163 except Mp3Exception as ex:
164 # Only logging a warning here since we can still operate on
165 # the tag.
166 log.warning(ex)
167 self._info = None
168
169 self.type = core.AUDIO_MP3
170
171 def initTag(self, version=id3.ID3_DEFAULT_VERSION):
172 """Add a id3.Tag to the file (removing any existing tag if one exists)."""
173 self.tag = id3.Tag()
174 self.tag.version = version
175 self.tag.file_info = id3.FileInfo(self.path)
176 return self.tag
177
178 @core.AudioFile.tag.setter
179 def tag(self, t):
180 if t:
181 t.file_info = id3.FileInfo(self.path)
182 if self._tag and self._tag.file_info:
183 t.file_info.tag_size = self._tag.file_info.tag_size
184 t.file_info.tag_padding_size = \
185 self._tag.file_info.tag_padding_size
186 self._tag = t
0 import deprecation
1 from math import log10
2
3 from . import Mp3Exception
4 from ..utils.binfuncs import bytes2bin, bytes2dec, bin2dec
5 from ..utils.log import getLogger
6 from ..__about__ import __version__
7
8 log = getLogger(__name__)
9
10
11 def isValidHeader(header):
12 """Determine if ``header`` (an integer, 4 bytes compared) is a valid mp3
13 frame header."""
14 # Test for the mp3 frame sync: 11 set bits.
15 sync = (header >> 16)
16 if sync & 0xffe0 != 0xffe0:
17 # ffe0 is 11 sync bits, 12 are not used in order to support identifying
18 # mpeg v2.5 (bits 20,19)
19 return False
20
21 # All the remaining tests are not entirely required, but do help in
22 # finding false syncs
23
24 version = (header >> 19) & 0x3
25 if version == 1:
26 # This is a "reserved" version
27 log.debug("invalid mpeg version")
28 return False
29
30 layer = (header >> 17) & 0x3
31 if layer == 0:
32 # This is a "reserved" layer
33 log.debug("invalid mpeg layer")
34 return False
35
36 bitrate = (header >> 12) & 0xf
37 if bitrate in (0, 0xf):
38 # free and bad bitrate values
39 log.debug("invalid mpeg bitrate")
40 return False
41
42 sample_rate = (header >> 10) & 0x3
43 if sample_rate == 0x3:
44 # this is a "reserved" sample rate
45 log.debug("invalid mpeg sample rate")
46 return False
47
48 return True
49
50
51 def findHeader(fp, start_pos=0):
52 """Locate the first mp3 header in file stream ``fp`` starting a offset
53 ``start_pos`` (defaults to 0). Returned is a 3-tuple containing the offset
54 where the header was found, the header as an integer, and the header as 4
55 bytes. If no header is found header_int will equal 0.
56 """
57
58 def isBOM(buffer, pos):
59 """Check for unicode BOM"""
60 try:
61 if pos - 1 >= 0:
62 if buffer[pos - 1] == 254:
63 return True
64 return buffer[pos + 1] == 254
65 except IndexError:
66 return False
67
68 def find_sync(_fp, _pos=0):
69 chunk_sz = 8192 # Measured as optimal
70
71 _fp.seek(_pos)
72 data = _fp.read(chunk_sz)
73
74 while data:
75 pos = 0
76 while 0 <= pos < chunk_sz:
77 pos = data.find(b"\xff", pos)
78 if pos == -1:
79 break
80
81 if not isBOM(data, pos):
82 h = data[pos:pos + 4]
83 if len(h) == 4:
84 return tuple([_pos + pos, h])
85
86 pos += 1
87
88 _pos += len(data)
89 data = _fp.read(chunk_sz)
90
91 return None, None
92
93 sync_pos, header_bytes = find_sync(fp, start_pos)
94 while sync_pos is not None:
95 header = bytes2dec(header_bytes)
96 if isValidHeader(header):
97 return tuple([sync_pos, header, header_bytes])
98 sync_pos, header_bytes = find_sync(fp, start_pos + sync_pos + 2)
99
100 return None, None, None
101
102
103 def timePerFrame(mp3_header, vbr):
104 """Computes the number of seconds per mp3 frame. It can be used to
105 compute overall playtime and bitrate. The mp3 layer and sample
106 rate from ``mp3_header`` are used to compute the number of seconds
107 (fractional float point value) per mp3 frame. Be sure to set ``vbr`` True
108 when dealing with VBR, otherwise playtimes may be incorrect."""
109 # https://bitbucket.org/nicfit/eyed3/issue/32/mp3audioinfotime_secs-incorrect-for-mpeg2
110 if mp3_header.version >= 2.0 and vbr:
111 row = _mp3VersionKey(mp3_header.version)
112 else:
113 row = 0
114 return (float(SAMPLES_PER_FRAME_TABLE[row][mp3_header.layer]) /
115 float(mp3_header.sample_freq))
116
117
118 @deprecation.deprecated(deprecated_in="0.9a2", removed_in="1.0", current_version=__version__,
119 details="Use timePerFrame instead")
120 def compute_time_per_frame(mp3_header):
121 if mp3_header is not None:
122 return timePerFrame(mp3_header, False)
123
124
125 class Mp3Header:
126 """Header container for MP3 frames."""
127 def __init__(self, header_data=None):
128 self.version = None
129 self.layer = None
130 self.error_protection = None
131 self.bit_rate = None
132 self.sample_freq = None
133 self.padding = None
134 self.private_bit = None
135 self.copyright = None
136 self.original = None
137 self.emphasis = None
138 self.mode = None
139 # This value is left as is: 0<=mode_extension<=3.
140 # See http://www.dv.co.yu/mpgscript/mpeghdr.htm for how to interpret
141 self.mode_extension = None
142 self.frame_length = None
143
144 if header_data:
145 self.decode(header_data)
146
147 # This may throw an Mp3Exception if the header is malformed.
148 def decode(self, header):
149 if not isValidHeader(header):
150 raise Mp3Exception("Invalid MPEG header")
151
152 # MPEG audio version from bits 19 and 20.
153 version = (header >> 19) & 0x3
154 self.version = [2.5, None, 2.0, 1.0][version]
155 if self.version is None:
156 raise Mp3Exception("Illegal MPEG version")
157
158 # MPEG layer
159 self.layer = 4 - ((header >> 17) & 0x3)
160 if self.layer == 4:
161 raise Mp3Exception("Illegal MPEG layer")
162
163 # Decode some simple values.
164 self.error_protection = not (header >> 16) & 0x1
165 self.padding = (header >> 9) & 0x1
166 self.private_bit = (header >> 8) & 0x1
167 self.copyright = (header >> 3) & 0x1
168 self.original = (header >> 2) & 0x1
169
170 # Obtain sampling frequency.
171 sample_bits = (header >> 10) & 0x3
172 self.sample_freq = \
173 SAMPLE_FREQ_TABLE[sample_bits][_mp3VersionKey(self.version)]
174 if not self.sample_freq:
175 raise Mp3Exception("Illegal MPEG sampling frequency")
176
177 # Compute bitrate.
178 bit_rate_row = (header >> 12) & 0xf
179 if int(self.version) == 1 and self.layer == 1:
180 bit_rate_col = 0
181 elif int(self.version) == 1 and self.layer == 2:
182 bit_rate_col = 1
183 elif int(self.version) == 1 and self.layer == 3:
184 bit_rate_col = 2
185 elif int(self.version) == 2 and self.layer == 1:
186 bit_rate_col = 3
187 elif int(self.version) == 2 and (self.layer == 2 or
188 self.layer == 3):
189 bit_rate_col = 4
190 else:
191 raise Mp3Exception("Mp3 version %f and layer %d is an invalid "
192 "combination" % (self.version, self.layer))
193 self.bit_rate = BIT_RATE_TABLE[bit_rate_row][bit_rate_col]
194 if self.bit_rate is None:
195 raise Mp3Exception("Invalid bit rate")
196 # We know know the bit rate specified in this frame, but if the file
197 # is VBR we need to obtain the average from the Xing header.
198 # This is done by the caller since right now all we have is the frame
199 # header.
200
201 # Emphasis; whatever that means??
202 emph = header & 0x3
203 if emph == 0:
204 self.emphasis = EMPHASIS_NONE
205 elif emph == 1:
206 self.emphasis = EMPHASIS_5015
207 elif emph == 2:
208 self.emphasis = EMPHASIS_CCIT
209 else:
210 raise Mp3Exception("Illegal mp3 emphasis value: %d" % emph)
211
212 # Channel mode.
213 mode_bits = (header >> 6) & 0x3
214 if mode_bits == 0:
215 self.mode = MODE_STEREO
216 elif mode_bits == 1:
217 self.mode = MODE_JOINT_STEREO
218 elif mode_bits == 2:
219 self.mode = MODE_DUAL_CHANNEL_STEREO
220 else:
221 self.mode = MODE_MONO
222 self.mode_extension = (header >> 4) & 0x3
223
224 # Layer II has restrictions wrt to mode and bit rate. This code
225 # enforces them.
226 if self.layer == 2:
227 m = self.mode
228 br = self.bit_rate
229 if (br in [32, 48, 56, 80] and (m != MODE_MONO)):
230 raise Mp3Exception("Invalid mode/bitrate combination for layer "
231 "II")
232 if (br in [224, 256, 320, 384] and (m == MODE_MONO)):
233 raise Mp3Exception("Invalid mode/bitrate combination for layer "
234 "II")
235
236 br = self.bit_rate * 1000
237 sf = self.sample_freq
238 p = self.padding
239 if self.layer == 1:
240 # Layer 1 uses 32 bit slots for padding.
241 p = self.padding * 4
242 self.frame_length = int((((12 * br) / sf) + p) * 4)
243 else:
244 # Layer 2 and 3 uses 8 bit slots for padding.
245 p = self.padding * 1
246 self.frame_length = int(((144 * br) / sf) + p)
247
248 # Dump the state.
249 log.debug("MPEG audio version: " + str(self.version))
250 log.debug("MPEG audio layer: " + ("I" * self.layer))
251 log.debug("MPEG sampling frequency: " + str(self.sample_freq))
252 log.debug("MPEG bit rate: " + str(self.bit_rate))
253 log.debug("MPEG channel mode: " + self.mode)
254 log.debug("MPEG channel mode extension: " + str(self.mode_extension))
255 log.debug("MPEG CRC error protection: " + str(self.error_protection))
256 log.debug("MPEG original: " + str(self.original))
257 log.debug("MPEG copyright: " + str(self.copyright))
258 log.debug("MPEG private bit: " + str(self.private_bit))
259 log.debug("MPEG padding: " + str(self.padding))
260 log.debug("MPEG emphasis: " + str(self.emphasis))
261 log.debug("MPEG frame length: " + str(self.frame_length))
262
263
264 class VbriHeader(object):
265 def __init__(self):
266 self.vbr = True
267 self.version = None
268
269 ##
270 # \brief Decode the VBRI info from \a frame.
271 # http://www.codeproject.com/audio/MPEGAudioInfo.asp#VBRIHeader
272 def decode(self, frame):
273
274 # The header is 32 bytes after the end of the first MPEG audio header,
275 # therefore 4 + 32 = 36
276 offset = 36
277 head = frame[offset:offset + 4]
278 if head != 'VBRI':
279 return False
280 log.debug("VBRI header detected @ %x" % (offset))
281 offset += 4
282
283 self.version = bin2dec(bytes2bin(frame[offset:offset + 2]))
284 offset += 2
285
286 self.delay = bin2dec(bytes2bin(frame[offset:offset + 2]))
287 offset += 2
288
289 self.quality = bin2dec(bytes2bin(frame[offset:offset + 2]))
290 offset += 2
291
292 self.num_bytes = bin2dec(bytes2bin(frame[offset:offset + 4]))
293 offset += 4
294
295 self.num_frames = bin2dec(bytes2bin(frame[offset:offset + 4]))
296 offset += 4
297
298 return True
299
300
301 class XingHeader:
302 """Header class for the Xing header extensions."""
303
304 def __init__(self):
305 self.numFrames = int()
306 self.numBytes = int()
307 self.toc = [0] * 100
308 self.vbrScale = int()
309
310 # Pass in the first mp3 frame from the file as a byte string.
311 # If an Xing header is present in the file it'll be in the first mp3
312 # frame. This method returns true if the Xing header is found in the
313 # frame, and false otherwise.
314 def decode(self, frame):
315 # mp3 version
316 version = (frame[1] >> 3) & 0x1
317 # channel mode.
318 mode = (frame[3] >> 6) & 0x3
319
320 # Find the start of the Xing header.
321 if version:
322 # +4 in all of these to skip initial mp3 frame header.
323 if mode != 3:
324 pos = 32 + 4
325 else:
326 pos = 17 + 4
327 else:
328 if mode != 3:
329 pos = 17 + 4
330 else:
331 pos = 9 + 4
332 head = frame[pos:pos + 4]
333 self.vbr = (head == b'Xing') and True or False
334 if head not in [b'Xing', b'Info']:
335 return False
336 log.debug("%s header detected @ %x" % (head, pos))
337 pos += 4
338
339 # Read Xing flags.
340 headFlags = bin2dec(bytes2bin(frame[pos:pos + 4]))
341 pos += 4
342 log.debug("%s header flags: 0x%x" % (head, headFlags))
343
344 # Read frames header flag and value if present
345 if headFlags & FRAMES_FLAG:
346 self.numFrames = bin2dec(bytes2bin(frame[pos:pos + 4]))
347 pos += 4
348 log.debug("%s numFrames: %d" % (head, self.numFrames))
349
350 # Read bytes header flag and value if present
351 if headFlags & BYTES_FLAG:
352 self.numBytes = bin2dec(bytes2bin(frame[pos:pos + 4]))
353 pos += 4
354 log.debug("%s numBytes: %d" % (head, self.numBytes))
355
356 # Read TOC header flag and value if present
357 if headFlags & TOC_FLAG:
358 self.toc = frame[pos:pos + 100]
359 pos += 100
360 log.debug("%s TOC (100 bytes): PRESENT" % head)
361 else:
362 log.debug("%s TOC (100 bytes): NOT PRESENT" % head)
363
364 # Read vbr scale header flag and value if present
365 if headFlags & VBR_SCALE_FLAG and head == b'Xing':
366 self.vbrScale = bin2dec(bytes2bin(frame[pos:pos + 4]))
367 pos += 4
368 log.debug("%s vbrScale: %d" % (head, self.vbrScale))
369
370 return True
371
372
373 class LameHeader(dict):
374 r""" Mp3 Info tag (AKA LAME Tag)
375
376 Lame (and some other encoders) write a tag containing various bits of info
377 about the options used at encode time. If available, the following are
378 parsed and stored in the LameHeader dict:
379
380 encoder_version: short encoder version [str]
381 tag_revision: revision number of the tag [int]
382 vbr_method: VBR method used for encoding [str]
383 lowpass_filter: lowpass filter frequency in Hz [int]
384 replaygain: if available, radio and audiofile gain (see below) [dict]
385 encoding_flags: encoding flags used [list]
386 nogap: location of gaps when --nogap was used [list]
387 ath_type: ATH type [int]
388 bitrate: bitrate and type (Constant, Target, Minimum) [tuple]
389 encoder_delay: samples added at the start of the mp3 [int]
390 encoder_padding: samples added at the end of the mp3 [int]
391 noise_shaping: noise shaping method [int]
392 stereo_mode: stereo mode used [str]
393 unwise_settings: whether unwise settings were used [boolean]
394 sample_freq: source sample frequency [str]
395 mp3_gain: mp3 gain adjustment (rarely used) [float]
396 preset: preset used [str]
397 surround_info: surround information [str]
398 music_length: length in bytes of original mp3 [int]
399 music_crc: CRC-16 of the mp3 music data [int]
400 infotag_crc: CRC-16 of the info tag [int]
401
402 Prior to ~3.90, Lame simply stored the encoder version in the first frame.
403 If the infotag_crc is invalid, then we try to read this version string. A
404 simple way to tell if the LAME Tag is complete is to check for the
405 infotag_crc key.
406
407 Replay Gain data is only available since Lame version 3.94b. If set, the
408 replaygain dict has the following structure:
409
410 \code
411 peak_amplitude: peak signal amplitude [float]
412 radio:
413 name: name of the gain adjustment [str]
414 adjustment: gain adjustment [float]
415 originator: originator of the gain adjustment [str]
416 audiofile: [same as radio]
417 \endcode
418
419 Note that as of 3.95.1, Lame uses 89dB as a reference level instead of the
420 83dB that is specified in the Replay Gain spec. This is not automatically
421 compensated for. You can do something like this if you want:
422
423 \code
424 import eyeD3
425 af = eyeD3.mp3.Mp3AudioFile('/path/to/some.mp3')
426 lamever = af.lameTag['encoder_version']
427 name, ver = lamever[:4], lamever[4:]
428 gain = af.lameTag['replaygain']['radio']['adjustment']
429 if name == 'LAME' and eyeD3.mp3.lamevercmp(ver, '3.95') > 0:
430 gain -= 6
431 \endcode
432
433 Radio and Audiofile Replay Gain are often referrered to as Track and Album
434 gain, respectively. See http://replaygain.hydrogenaudio.org/ for futher
435 details on Replay Gain.
436
437 See http://gabriel.mp3-tech.org/mp3infotag.html for the gory details of the
438 LAME Tag.
439 """
440
441 # from the LAME source:
442 # http://lame.cvs.sourceforge.net/*checkout*/lame/lame/libmp3lame/VbrTag.c
443 _crc16_table = [
444 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
445 0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440,
446 0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40,
447 0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841,
448 0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40,
449 0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41,
450 0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641,
451 0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040,
452 0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240,
453 0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441,
454 0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41,
455 0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840,
456 0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41,
457 0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40,
458 0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640,
459 0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041,
460 0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240,
461 0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441,
462 0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41,
463 0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840,
464 0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41,
465 0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40,
466 0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640,
467 0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041,
468 0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241,
469 0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440,
470 0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40,
471 0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841,
472 0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40,
473 0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41,
474 0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641,
475 0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040]
476
477 ENCODER_FLAGS = {
478 'NSPSYTUNE': 0x0001,
479 'NSSAFEJOINT': 0x0002,
480 'NOGAP_NEXT': 0x0004,
481 'NOGAP_PREV': 0x0008}
482
483 PRESETS = {
484 0: 'Unknown',
485 # 8 to 320 are reserved for ABR bitrates
486 410: 'V9',
487 420: 'V8',
488 430: 'V7',
489 440: 'V6',
490 450: 'V5',
491 460: 'V4',
492 470: 'V3',
493 480: 'V2',
494 490: 'V1',
495 500: 'V0',
496 1000: 'r3mix',
497 1001: 'standard',
498 1002: 'extreme',
499 1003: 'insane',
500 1004: 'standard/fast',
501 1005: 'extreme/fast',
502 1006: 'medium',
503 1007: 'medium/fast'}
504
505 REPLAYGAIN_NAME = {
506 0: 'Not set',
507 1: 'Radio',
508 2: 'Audiofile'}
509
510 REPLAYGAIN_ORIGINATOR = {
511 0: 'Not set',
512 1: 'Set by artist',
513 2: 'Set by user',
514 3: 'Set automatically',
515 100: 'Set by simple RMS average'}
516
517 SAMPLE_FREQUENCIES = {
518 0: '<= 32 kHz',
519 1: '44.1 kHz',
520 2: '48 kHz',
521 3: '> 48 kHz'}
522
523 STEREO_MODES = {
524 0: 'Mono',
525 1: 'Stereo',
526 2: 'Dual',
527 3: 'Joint',
528 4: 'Force',
529 5: 'Auto',
530 6: 'Intensity',
531 7: 'Undefined'}
532
533 SURROUND_INFO = {
534 0: 'None',
535 1: 'DPL encoding',
536 2: 'DPL2 encoding',
537 3: 'Ambisonic encoding',
538 8: 'Reserved'}
539
540 VBR_METHODS = {
541 0: 'Unknown',
542 1: 'Constant Bitrate',
543 2: 'Average Bitrate',
544 3: 'Variable Bitrate method1 (old/rh)',
545 4: 'Variable Bitrate method2 (mtrh)',
546 5: 'Variable Bitrate method3 (mt)',
547 6: 'Variable Bitrate method4',
548 8: 'Constant Bitrate (2 pass)',
549 9: 'Average Bitrate (2 pass)',
550 15: 'Reserved'}
551
552 def __init__(self, frame):
553 """Read the LAME info tag.
554 frame should be the first frame of an mp3.
555 """
556 super().__init__()
557 self.decode(frame)
558
559 def _crc16(self, data, val=0):
560 """Compute a CRC-16 checksum on a data stream."""
561 for c in [bytes([b]) for b in data]:
562 val = self._crc16_table[ord(c) ^ (val & 0xff)] ^ (val >> 8)
563 return val
564
565 def decode(self, frame):
566 """Decode the LAME info tag."""
567 try:
568 pos = frame.index(b"LAME")
569 except ValueError:
570 return
571
572 log.debug(f"Lame info tag found at position {pos}")
573
574 # check the info tag crc. If it's not valid, no point parsing much more.
575 lamecrc = bin2dec(bytes2bin(frame[190:192]))
576 if self._crc16(frame[:190]) != lamecrc:
577 log.warning("Lame tag CRC check failed")
578 else:
579 log.debug("Lame tag CRC OK")
580
581 try:
582 # Encoder short VersionString, 9 bytes
583 self['encoder_version'] = str(frame[pos:pos + 9].rstrip(), "latin1")
584 log.debug('Lame Encoder Version: %s' % self['encoder_version'])
585 pos += 9
586
587 # Info Tag revision + VBR method, 1 byte
588 self['tag_revision'] = bin2dec(bytes2bin(frame[pos:pos + 1])[:5])
589 vbr_method = bin2dec(bytes2bin(frame[pos:pos + 1])[5:])
590 self['vbr_method'] = self.VBR_METHODS.get(vbr_method, 'Unknown')
591 log.debug('Lame info tag version: %s' % self['tag_revision'])
592 log.debug('Lame VBR method: %s' % self['vbr_method'])
593 pos += 1
594
595 # Lowpass filter value, 1 byte
596 self['lowpass_filter'] = bin2dec(
597 bytes2bin(frame[pos:pos + 1])) * 100
598 log.debug('Lame Lowpass filter value: %s Hz' %
599 self['lowpass_filter'])
600 pos += 1
601
602 # Replay Gain, 8 bytes total
603 replaygain = {}
604
605 # Peak signal amplitude, 4 bytes
606 peak = bin2dec(bytes2bin(frame[pos:pos + 4])) << 5
607 if peak > 0:
608 peak /= float(1 << 28)
609 db = 20 * log10(peak)
610 replaygain['peak_amplitude'] = peak
611 log.debug('Lame Peak signal amplitude: %.8f (%+.1f dB)' %
612 (peak, db))
613 pos += 4
614
615 # Radio and Audiofile Gain, AKA track and album, 2 bytes each
616 for gaintype in ['radio', 'audiofile']:
617 name = bin2dec(bytes2bin(frame[pos:pos + 2])[:3])
618 orig = bin2dec(bytes2bin(frame[pos:pos + 2])[3:6])
619 sign = bin2dec(bytes2bin(frame[pos:pos + 2])[6:7])
620 adj = bin2dec(bytes2bin(frame[pos:pos + 2])[7:]) / 10.0
621 if sign:
622 adj *= -1
623
624 # Lame 3.95.1 and above use 89dB as a reference instead of 83dB as defined by the
625 # Replay Gain spec. This will be compensated for with `adj -= 6`
626 lamever = self['encoder_version']
627 if lamever[:4] == 'LAME' and lamevercmp(lamever[4:], "3.95") > 0:
628 adj -= 6
629
630 if orig:
631 name = self.REPLAYGAIN_NAME.get(name, 'Unknown')
632 orig = self.REPLAYGAIN_ORIGINATOR.get(orig, 'Unknown')
633 replaygain[gaintype] = {'name': name, 'adjustment': adj,
634 'originator': orig}
635 log.debug('Lame %s Replay Gain: %s dB (%s)' %
636 (name, adj, orig))
637 pos += 2
638 if replaygain:
639 self['replaygain'] = replaygain
640
641 # Encoding flags + ATH Type, 1 byte
642 encflags = bin2dec(bytes2bin(frame[pos:pos + 1])[:4])
643 (self['encoding_flags'],
644 self['nogap']) = self._parse_encflags(encflags)
645 self['ath_type'] = bin2dec(bytes2bin(frame[pos:pos + 1])[4:])
646 log.debug('Lame Encoding flags: %s' %
647 ' '.join(self['encoding_flags']))
648 if self['nogap']:
649 log.debug('Lame No gap: %s' % ' and '.join(self['nogap']))
650 log.debug('Lame ATH type: %s' % self['ath_type'])
651 pos += 1
652
653 # if ABR {specified bitrate} else {minimal bitrate}, 1 byte
654 btype = 'Constant'
655 if 'Average' in self['vbr_method']:
656 btype = 'Target'
657 elif 'Variable' in self['vbr_method']:
658 btype = 'Minimum'
659 # bitrate may be modified below after preset is read
660 self['bitrate'] = (bin2dec(bytes2bin(frame[pos:pos + 1])), btype)
661 log.debug('Lame Bitrate (%s): %s' % (btype, self['bitrate'][0]))
662 pos += 1
663
664 # Encoder delays, 3 bytes
665 self['encoder_delay'] = bin2dec(bytes2bin(frame[pos:pos + 3])[:12])
666 self['encoder_padding'] = bin2dec(
667 bytes2bin(frame[pos:pos + 3])[12:])
668 log.debug('Lame Encoder delay: %s samples' % self['encoder_delay'])
669 log.debug('Lame Encoder padding: %s samples' %
670 self['encoder_padding'])
671 pos += 3
672
673 # Misc, 1 byte
674 sample_freq = bin2dec(bytes2bin(frame[pos:pos + 1])[:2])
675 unwise_settings = bin2dec(bytes2bin(frame[pos:pos + 1])[2:3])
676 stereo_mode = bin2dec(bytes2bin(frame[pos:pos + 1])[3:6])
677 self['noise_shaping'] = bin2dec(bytes2bin(frame[pos:pos + 1])[6:])
678 self['sample_freq'] = self.SAMPLE_FREQUENCIES.get(sample_freq,
679 'Unknown')
680 self['unwise_settings'] = bool(unwise_settings)
681 self['stereo_mode'] = self.STEREO_MODES.get(stereo_mode, 'Unknown')
682 log.debug('Lame Source Sample Frequency: %s' % self['sample_freq'])
683 log.debug('Lame Unwise settings used: %s' % self['unwise_settings'])
684 log.debug('Lame Stereo mode: %s' % self['stereo_mode'])
685 log.debug('Lame Noise Shaping: %s' % self['noise_shaping'])
686 pos += 1
687
688 # MP3 Gain, 1 byte
689 sign = bytes2bin(frame[pos:pos + 1])[0]
690 gain = bin2dec(bytes2bin(frame[pos:pos + 1])[1:])
691 if sign:
692 gain *= -1
693 self['mp3_gain'] = gain
694 db = gain * 1.5
695 log.debug('Lame MP3 Gain: %s (%+.1f dB)' % (self['mp3_gain'], db))
696 pos += 1
697
698 # Preset and surround info, 2 bytes
699 surround = bin2dec(bytes2bin(frame[pos:pos + 2])[2:5])
700 preset = bin2dec(bytes2bin(frame[pos:pos + 2])[5:])
701 if preset in range(8, 321):
702 if self['bitrate'][0] >= 255:
703 # the value from preset is better in this case
704 self['bitrate'] = (preset, btype)
705 log.debug('Lame Bitrate (%s): %s' %
706 (btype, self['bitrate'][0]))
707 if 'Average' in self['vbr_method']:
708 preset = 'ABR %s' % preset
709 else:
710 preset = 'CBR %s' % preset
711 else:
712 preset = self.PRESETS.get(preset, preset)
713 self['surround_info'] = self.SURROUND_INFO.get(surround, surround)
714 self['preset'] = preset
715 log.debug('Lame Surround Info: %s' % self['surround_info'])
716 log.debug('Lame Preset: %s' % self['preset'])
717 pos += 2
718
719 # MusicLength, 4 bytes
720 self['music_length'] = bin2dec(bytes2bin(frame[pos:pos + 4]))
721 log.debug('Lame Music Length: %s bytes' % self['music_length'])
722 pos += 4
723
724 # MusicCRC, 2 bytes
725 self['music_crc'] = bin2dec(bytes2bin(frame[pos:pos + 2]))
726 log.debug('Lame Music CRC: %04X' % self['music_crc'])
727 pos += 2
728
729 # CRC-16 of Info Tag, 2 bytes
730 self['infotag_crc'] = lamecrc # we read this earlier
731 log.debug('Lame Info Tag CRC: %04X' % self['infotag_crc'])
732 pos += 2
733 except IndexError:
734 log.warning("Truncated LAME info header, values incomplete.")
735
736 def _parse_encflags(self, flags):
737 """Parse encoder flags.
738
739 Returns a tuple containing lists of encoder flags and nogap data in
740 human readable format.
741 """
742
743 encoder_flags, nogap = [], []
744
745 if not flags:
746 return encoder_flags, nogap
747
748 if flags & self.ENCODER_FLAGS['NSPSYTUNE']:
749 encoder_flags.append('--nspsytune')
750 if flags & self.ENCODER_FLAGS['NSSAFEJOINT']:
751 encoder_flags.append('--nssafejoint')
752
753 NEXT = self.ENCODER_FLAGS['NOGAP_NEXT']
754 PREV = self.ENCODER_FLAGS['NOGAP_PREV']
755 if flags & (NEXT | PREV):
756 encoder_flags.append('--nogap')
757 if flags & PREV:
758 nogap.append('before')
759 if flags & NEXT:
760 nogap.append('after')
761 return encoder_flags, nogap
762
763
764 def lamevercmp(x, y):
765 """Compare LAME version strings.
766
767 alpha and beta versions are considered older.
768 Versions with sub minor parts or end with 'r' are considered newer.
769
770 :param x: The first version to compare.
771 :param y: The second version to compare.
772 :returns: Return negative if x<y, zero if x==y, positive if x>y.
773 """
774
775 def cmp(a, b):
776 # This is Python2's built-in `cmp`, which was removed from Python3
777 # And depends on bool - bool yielding the integer -1, 0, 1
778 return (a > b) - (a < b)
779
780 x = x.ljust(5)
781 y = y.ljust(5)
782 if x[:5] == y[:5]:
783 return 0
784 ret = cmp(x[:4], y[:4])
785 if ret:
786 return ret
787 xmaj, xmin = x.split('.')[:2]
788 ymaj, ymin = y.split('.')[:2]
789 minparts = ['.']
790 # lame 3.96.1 added the use of r in the very short version for post releases
791 if (xmaj == '3' and xmin >= '96') or (ymaj == '3' and ymin >= '96'):
792 minparts.append('r')
793 if x[4] in minparts:
794 return 1
795 if y[4] in minparts:
796 return -1
797 if x[4] == ' ':
798 return 1
799 if y[4] == ' ':
800 return -1
801 return cmp(x[4], y[4])
802
803
804 # MPEG1 MPEG2 MPEG2.5
805 SAMPLE_FREQ_TABLE = ((44100, 22050, 11025),
806 (48000, 24000, 12000),
807 (32000, 16000, 8000),
808 (None, None, None))
809
810 # V1/L1 V1/L2 V1/L3 V2/L1 V2/L2&L3
811 BIT_RATE_TABLE = ((0, 0, 0, 0, 0), # noqa
812 (32, 32, 32, 32, 8), # noqa
813 (64, 48, 40, 48, 16), # noqa
814 (96, 56, 48, 56, 24), # noqa
815 (128, 64, 56, 64, 32), # noqa
816 (160, 80, 64, 80, 40), # noqa
817 (192, 96, 80, 96, 48), # noqa
818 (224, 112, 96, 112, 56), # noqa
819 (256, 128, 112, 128, 64), # noqa
820 (288, 160, 128, 144, 80), # noqa
821 (320, 192, 160, 160, 96), # noqa
822 (352, 224, 192, 176, 112), # noqa
823 (384, 256, 224, 192, 128), # noqa
824 (416, 320, 256, 224, 144), # noqa
825 (448, 384, 320, 256, 160), # noqa
826 (None, None, None, None, None))
827
828 # Rows 1 and 2 (mpeg 2.x) are only used for those versions *and* VBR.
829 # L1 L2 L3
830 SAMPLES_PER_FRAME_TABLE = ((None, 384, 1152, 1152), # MPEG 1
831 (None, 384, 1152, 576), # MPEG 2
832 (None, 384, 1152, 576), # MPEG 2.5
833 )
834
835 # Emphasis constants
836 EMPHASIS_NONE = "None"
837 EMPHASIS_5015 = "50/15 ms"
838 EMPHASIS_CCIT = "CCIT J.17"
839
840 # Mode constants
841 MODE_STEREO = "Stereo"
842 MODE_JOINT_STEREO = "Joint stereo"
843 MODE_DUAL_CHANNEL_STEREO = "Dual channel stereo"
844 MODE_MONO = "Mono"
845
846 # Xing flag bits
847 FRAMES_FLAG = 0x0001
848 BYTES_FLAG = 0x0002
849 TOC_FLAG = 0x0004
850 VBR_SCALE_FLAG = 0x0008
851
852
853 def _mp3VersionKey(version):
854 """Map mp3 version float to a data structure index.
855 1 -> 0, 2 -> 1, 2.5 -> 2
856 """
857 key = None
858 if version == 2.5:
859 key = 2
860 else:
861 key = int(version - 1)
862 assert(0 <= key <= 2)
863 return key
0 (*
1 ################################################################################
2 # Copyright (C) 2016 Sebastian Patschorke <physicspatschi@gmx.de>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18
19 parser generation:
20 $ python -m grako --name DisplayPattern \
21 -o eyed3/plugins/_display_parser.py \
22 eyed3/plugins/DisplayPattern.ebnf
23
24
25 *)
26
27 start = pattern $ ;
28 pattern = { text | tag | function }* ;
29 tag = tag:( "%" name:string { "," parameters+:(parameter) }* "%" );
30 function = function:("$" name:string "(" [ parameters+:(parameter) { "," parameters+:(parameter) }* ] ")" );
31 parameter = [ {" "}* name:string "=" ] [ value:pattern ] ;
32 text = text:?/(\\\\|\\%|\\\$|\\,|\\\(|\\\)|\\=|\\n|\\t|[^\\%$,()])+/? ;
33 string = ?/([^\\%$,()=])+/? ;
0 import os
1 import sys
2 import pathlib
3
4 from eyed3 import core, utils
5 from eyed3.utils.log import getLogger
6 from eyed3.utils import guessMimetype, formatSize
7 from eyed3.utils.console import printMsg, printError, HEADER_COLOR, boldText, Fore
8
9 _PLUGINS = {}
10
11 log = getLogger(__name__)
12
13
14 def load(name=None, reload=False, paths=None):
15 """Returns the eyed3.plugins.Plugin *class* identified by ``name``.
16 If ``name`` is ``None`` then the full list of plugins is returned.
17 Once a plugin is loaded its class object is cached, and future calls to
18 this function will returned the cached version. Use ``reload=True`` to
19 refresh the cache."""
20 global _PLUGINS
21
22 if len(list(_PLUGINS.keys())) and not reload:
23 # Return from the cache if possible
24 try:
25 return _PLUGINS[name] if name else _PLUGINS
26 except KeyError:
27 # It's not in the cache, look again and refresh cash
28 _PLUGINS = {}
29 else:
30 _PLUGINS = {}
31
32 def _isValidModule(f, d):
33 """Determine if file `f` is a valid module file name."""
34 # 1) tis a file
35 # 2) does not start with '_', or '.'
36 # 3) avoid the .pyc dup
37 return bool(os.path.isfile(os.path.join(d, f)) and
38 f[0] not in ('_', '.') and f.endswith(".py"))
39
40 log.debug(f"Extra plugin paths: {paths}")
41 for d in [os.path.dirname(__file__)] + (paths if paths else []):
42 log.debug(f"Searching '{d}' for plugins")
43 if not os.path.isdir(d):
44 continue
45
46 if d not in sys.path:
47 sys.path.append(d)
48 try:
49 for f in os.listdir(d):
50 if not _isValidModule(f, d):
51 continue
52
53 mod_name = os.path.splitext(f)[0]
54 try:
55 mod = __import__(mod_name, globals=globals(), locals=locals())
56 except ImportError as ex:
57 log.verbose(f"Plugin {(f, d)} requires packages that are not installed: {ex}")
58 continue
59 except Exception:
60 log.exception(f"Bad plugin {(f, d)}")
61 continue
62
63 for attr in [getattr(mod, a) for a in dir(mod)]:
64 if type(attr) == type and issubclass(attr, Plugin):
65 # This is a eyed3.plugins.Plugin
66 PluginClass = attr
67 if (PluginClass not in list(_PLUGINS.values()) and
68 len(PluginClass.NAMES)):
69 log.debug(f"loading plugin '{mod}' from '{d}{os.path.sep}{f}'")
70 # Setting the main name outside the loop to ensure
71 # there is at least one, otherwise a KeyError is
72 # thrown.
73 main_name = PluginClass.NAMES[0]
74 _PLUGINS[main_name] = PluginClass
75 for alias in PluginClass.NAMES[1:]:
76 # Add alternate names
77 _PLUGINS[alias] = PluginClass
78
79 # If 'plugin' is found return it immediately
80 if name and name in PluginClass.NAMES:
81 return PluginClass
82
83 finally:
84 if d in sys.path:
85 sys.path.remove(d)
86
87 log.debug(f"Plugins loaded: {_PLUGINS}")
88 if name:
89 # If a specific plugin was requested and we've not returned yet...
90 return None
91 return _PLUGINS
92
93
94 class Plugin(utils.FileHandler):
95 """Base class for all eyeD3 plugins"""
96
97 # One line about the plugin
98 SUMMARY = "eyeD3 plugin"
99
100 # Detailed info about the plugin
101 DESCRIPTION = ""
102
103 # A list of **at least** one name for invoking the plugin, values [1:] are treated as alias
104 NAMES = []
105
106 def __init__(self, arg_parser):
107 self.arg_parser = arg_parser
108 self.arg_group = arg_parser.add_argument_group("Plugin options",
109 f"{self.SUMMARY}\n{self.DESCRIPTION}")
110
111 def start(self, args, config):
112 """Called after command line parsing but before any paths are
113 processed. The ``self.args`` argument (the parsed command line) and
114 ``self.config`` (the user config, if any) is set here."""
115 self.args = args
116 self.config = config
117
118 def handleFile(self, f):
119 pass
120
121 def handleDone(self):
122 """Called after all file/directory processing; before program exit.
123 The return value is passed to sys.exit (None results in 0)."""
124 pass
125
126 @staticmethod
127 def _getHardRule(width):
128 return "-" * width
129
130 @staticmethod
131 def _getFileHeader(path, width):
132 path = pathlib.Path(path)
133 file_size = path.stat().st_size
134 path_str = str(path)
135 size_str = formatSize(file_size)
136 size_len = len(size_str) + 5
137 if len(path_str) + size_len >= width:
138 path_str = "..." + str(path)[-(75 - size_len):]
139 padding_len = width - len(path_str) - size_len
140
141 return "{path}{color}{padding}[ {size} ]{reset}"\
142 .format(path=boldText(path_str, c=HEADER_COLOR()),
143 color=HEADER_COLOR(),
144 padding=" " * padding_len,
145 size=size_str,
146 reset=Fore.RESET)
147
148
149 class LoaderPlugin(Plugin):
150 """A base class that provides auto loading of audio files"""
151
152 def __init__(self, arg_parser, cache_files=False, track_images=False):
153 """Constructor. If ``cache_files`` is True (off by default) then each
154 AudioFile is appended to ``_file_cache`` during ``handleFile`` and
155 the list is cleared by ``handleDirectory``."""
156 super().__init__(arg_parser)
157 self._num_loaded = 0
158 self._file_cache = [] if cache_files else None
159 self._dir_images = [] if track_images else None
160 self.audio_file = None
161
162 def handleFile(self, f, *args, **kwargs):
163 """Loads ``f`` and sets ``self.audio_file`` to an instance of
164 :class:`eyed3.core.AudioFile` or ``None`` if an error occurred or the
165 file is not a recognized type.
166
167 The ``*args`` and ``**kwargs`` are passed to :func:`eyed3.core.load`.
168 """
169
170 try:
171 self.audio_file = core.load(f, *args, **kwargs)
172 except NotImplementedError as ex:
173 # Frame decryption, for instance...
174 printError(str(ex))
175 return
176
177 if self.audio_file:
178 self._num_loaded += 1
179 if self._file_cache is not None:
180 self._file_cache.append(self.audio_file)
181 elif self._dir_images is not None:
182 mt = guessMimetype(f)
183 if mt and mt.startswith("image/"):
184 self._dir_images.append(f)
185
186 def handleDirectory(self, d, _):
187 """Override to make use of ``self._file_cache``. By default the list
188 is cleared, subclasses should consider doing the same otherwise every
189 AudioFile will be cached."""
190 if self._file_cache is not None:
191 self._file_cache = []
192
193 if self._dir_images is not None:
194 self._dir_images = []
195
196 def handleDone(self):
197 """If no audio files were loaded this simply prints 'Nothing to do'."""
198 if self._num_loaded == 0:
199 printMsg("No audio files found.")
0 #!/usr/bin/env python
1
2 # CAVEAT UTILITOR
3 #
4 # This file was automatically generated by Grako.
5 #
6 # https://pypi.python.org/pypi/grako/
7 #
8 # Any changes you make to it will be overwritten the next time
9 # the file is generated.
10 from grako.buffering import Buffer
11 from grako.parsing import graken, Parser
12 from grako.util import generic_main # noqa
13
14
15 KEYWORDS = {}
16
17
18 class DisplayPatternBuffer(Buffer):
19 def __init__(
20 self,
21 text,
22 whitespace=None,
23 nameguard=None,
24 comments_re=None,
25 eol_comments_re=None,
26 ignorecase=None,
27 namechars='',
28 **kwargs
29 ):
30 super(DisplayPatternBuffer, self).__init__(
31 text,
32 whitespace=whitespace,
33 nameguard=nameguard,
34 comments_re=comments_re,
35 eol_comments_re=eol_comments_re,
36 ignorecase=ignorecase,
37 namechars=namechars,
38 **kwargs
39 )
40
41
42 class DisplayPatternParser(Parser):
43 def __init__(
44 self,
45 whitespace=None,
46 nameguard=None,
47 comments_re=None,
48 eol_comments_re=None,
49 ignorecase=None,
50 left_recursion=False,
51 parseinfo=True,
52 keywords=None,
53 namechars='',
54 buffer_class=DisplayPatternBuffer,
55 **kwargs
56 ):
57 if keywords is None:
58 keywords = KEYWORDS
59 super(DisplayPatternParser, self).__init__(
60 whitespace=whitespace,
61 nameguard=nameguard,
62 comments_re=comments_re,
63 eol_comments_re=eol_comments_re,
64 ignorecase=ignorecase,
65 left_recursion=left_recursion,
66 parseinfo=parseinfo,
67 keywords=keywords,
68 namechars=namechars,
69 buffer_class=buffer_class,
70 **kwargs
71 )
72
73 @graken()
74 def _start_(self):
75 self._pattern_()
76 self._check_eof()
77
78 @graken()
79 def _pattern_(self):
80
81 def block0():
82 with self._choice():
83 with self._option():
84 self._text_()
85 with self._option():
86 self._tag_()
87 with self._option():
88 self._function_()
89 self._error('no available options')
90 self._closure(block0)
91
92 @graken()
93 def _tag_(self):
94 with self._group():
95 self._token('%')
96 self._string_()
97 self.name_last_node('name')
98
99 def block2():
100 self._token(',')
101 with self._group():
102 self._parameter_()
103 self.add_last_node_to_name('parameters')
104 self._closure(block2)
105 self._token('%')
106 self.name_last_node('tag')
107 self.ast._define(
108 ['name', 'tag'],
109 ['parameters']
110 )
111
112 @graken()
113 def _function_(self):
114 with self._group():
115 self._token('$')
116 self._string_()
117 self.name_last_node('name')
118 self._token('(')
119 with self._optional():
120 with self._group():
121 self._parameter_()
122 self.add_last_node_to_name('parameters')
123
124 def block3():
125 self._token(',')
126 with self._group():
127 self._parameter_()
128 self.add_last_node_to_name('parameters')
129 self._closure(block3)
130 self._token(')')
131 self.name_last_node('function')
132 self.ast._define(
133 ['function', 'name'],
134 ['parameters']
135 )
136
137 @graken()
138 def _parameter_(self):
139 with self._optional():
140
141 def block0():
142 self._token(' ')
143 self._closure(block0)
144 self._string_()
145 self.name_last_node('name')
146 self._token('=')
147 with self._optional():
148 self._pattern_()
149 self.name_last_node('value')
150 self.ast._define(
151 ['name', 'value'],
152 []
153 )
154
155 @graken()
156 def _text_(self):
157 self._pattern(r'(\\\\|\\%|\\\$|\\,|\\\(|\\\)|\\=|\\n|\\t|[^\\%$,()])+')
158 self.name_last_node('text')
159 self.ast._define(
160 ['text'],
161 []
162 )
163
164 @graken()
165 def _string_(self):
166 self._pattern(r'([^\\%$,()=])+')
167
168
169 class DisplayPatternSemantics(object):
170 def start(self, ast):
171 return ast
172
173 def pattern(self, ast):
174 return ast
175
176 def tag(self, ast):
177 return ast
178
179 def function(self, ast):
180 return ast
181
182 def parameter(self, ast):
183 return ast
184
185 def text(self, ast):
186 return ast
187
188 def string(self, ast):
189 return ast
190
191
192 def main(filename, startrule, **kwargs):
193 with open(filename) as f:
194 text = f.read()
195 parser = DisplayPatternParser()
196 return parser.parse(text, startrule, filename=filename, **kwargs)
197
198
199 if __name__ == '__main__':
200 import json
201 from grako.util import asjson
202
203 ast = generic_main(main, DisplayPatternParser, name='DisplayPattern')
204 print('AST:')
205 print(ast)
206 print()
207 print('JSON:')
208 print(json.dumps(asjson(ast), indent=2))
209 print()
0 import io
1 import os
2 import hashlib
3 from pathlib import Path
4
5 from eyed3.utils import art
6 from eyed3 import log
7 from eyed3.mimetype import guessMimetype
8 from eyed3.plugins import LoaderPlugin
9 from eyed3.core import VARIOUS_ARTISTS
10 from eyed3.id3.frames import ImageFrame
11 from eyed3.utils import makeUniqueFileName
12 from eyed3.utils.console import printMsg, printWarning, cformat, Fore
13
14 DESCR_FNAME_PREFIX = "filename: "
15 md5_file_cache = {}
16
17
18 def _importMessage(missing):
19 return f"Missing dependencies {missing}. Install with `pip install eyeD3[art-plugin]`"
20
21
22 try:
23 import PIL # noqa
24 import requests
25 from eyed3.plugins.lastfm import getAlbumArt
26 _PLUGIN_ACTIVE = True
27 _IMPORT_ERROR = None
28 except ImportError as ex:
29 _PLUGIN_ACTIVE = False
30 _IMPORT_ERROR = ex
31
32
33 class ArtFile(object):
34 def __init__(self, file_path):
35 self.art_type = art.matchArtFile(file_path)
36 self.file_path = file_path
37 self.id3_art_type = (art.TO_ID3_ART_TYPES[self.art_type][0]
38 if self.art_type else None)
39 self._img_data = None
40 self._mime_type = None
41
42 @property
43 def image_data(self):
44 if self._img_data:
45 return self._img_data
46 with open(self.file_path, "rb") as f:
47 self._img_data = f.read()
48 return self._img_data
49
50 @property
51 def mime_type(self):
52 if self._mime_type:
53 return self._mime_type
54 self._mime_type = guessMimetype(self.file_path)
55 return self._mime_type
56
57
58 class ArtPlugin(LoaderPlugin):
59 SUMMARY = "Art for albums, artists, etc."
60 DESCRIPTION = ""
61 NAMES = ["art"]
62
63 def __init__(self, arg_parser):
64 super(ArtPlugin, self).__init__(arg_parser, cache_files=True,
65 track_images=True)
66 self._retval = 0
67
68 g = self.arg_group
69 g.add_argument("-F", "--update-files", action="store_true",
70 help="Write art files from tag images.")
71 g.add_argument("-T", "--update-tags", action="store_true",
72 help="Write tag image from art files.")
73 dl_help = "Attempt to download album art if missing."
74 g.add_argument("-D", "--download", action="store_true", help=dl_help)
75 g.add_argument("-v", "--verbose", action="store_true",
76 help="Show detailed information for all art found.")
77
78 def start(self, args, config):
79 if not _PLUGIN_ACTIVE:
80 err_msg = _importMessage([_IMPORT_ERROR.name])
81 log.critical(err_msg)
82 raise RuntimeError(err_msg)
83 if args.update_files and args.update_tags:
84 # Not using add_mutually_exclusive_group from argparse because
85 # the options belong to the plugin opts group (self.arg_group)
86 raise StopIteration("The --update-tags and --update-files options "
87 "are mutually exclusive, use only one at a "
88 "time.")
89 super(ArtPlugin, self).start(args, config)
90
91 def _verbose(self, s):
92 if self.args.verbose:
93 printMsg(s)
94
95 def handleDirectory(self, d, _):
96 global md5_file_cache
97 md5_file_cache.clear()
98
99 if not self._file_cache:
100 log.debug(f"{d}: nothing to do.")
101 return
102
103 try:
104 all_tags = sorted([f.tag for f in self._file_cache if f.tag],
105 key=lambda x: x.file_info.name)
106
107 # If not deemed an album, move on.
108 if len(set([t.album for t in all_tags])) > 1:
109 log.debug(f"Skipping directory '{d}', non-album.")
110 return
111
112 printMsg(cformat("\nChecking: ", Fore.BLUE) + d)
113
114 # File images
115 dir_art = []
116 for img_file in self._dir_images:
117 img_base = os.path.basename(img_file)
118 art_file = ArtFile(img_file)
119 try:
120 pil_img = pilImage(img_file)
121 except IOError as ex:
122 printWarning(str(ex))
123 continue
124
125 if art_file.art_type:
126 self._verbose(
127 f"file {img_base}: {art_file.art_type}\n\t{pilImageDetails(pil_img)}")
128 dir_art.append(art_file)
129 else:
130 self._verbose(f"file {img_base}: unknown (ignored)")
131
132 if not dir_art:
133 print(cformat("NONE", Fore.RED))
134 self._retval += 1
135 else:
136 print(cformat("OK", Fore.GREEN))
137
138 # --download handling
139 if not dir_art and self.args.download:
140 tag = all_tags[0]
141 artists = set([t.artist for t in all_tags])
142 if len(artists) > 1:
143 artist_query = VARIOUS_ARTISTS
144 else:
145 artist_query = tag.album_artist or tag.artist
146
147 try:
148 url = getAlbumArt(artist_query, tag.album)
149 print("Downloading album art...")
150 resp = requests.get(url)
151 if resp.status_code != 200:
152 raise ValueError()
153 except ValueError:
154 print("Album art download not found")
155 else:
156 img = pilImage(io.BytesIO(resp.content))
157 cover = Path(d) / "cover.{}".format(img.format.lower())
158 assert not cover.exists()
159 img.save(str(cover))
160 print("Save {cover}".format(cover=cover))
161
162 # Tag images
163 for tag in all_tags:
164 file_base = os.path.basename(tag.file_info.name)
165 for img in tag.images:
166 try:
167 pil_img = pilImage(img)
168 pil_img_details = pilImageDetails(pil_img)
169 except (OSError, IOError) as ex:
170 printWarning(str(ex))
171 continue
172
173 if img.picture_type in art.FROM_ID3_ART_TYPES:
174 img_type = art.FROM_ID3_ART_TYPES[img.picture_type]
175 self._verbose("tag %s: %s (Description: %s)\n\t%s" %
176 (file_base, img_type, img.description,
177 pil_img_details))
178 if self.args.update_files:
179 assert(not self.args.update_tags)
180 path = os.path.dirname(tag.file_info.name)
181 if img.description.startswith(DESCR_FNAME_PREFIX):
182 # Use filename from Image description
183 fname = img.description[
184 len(DESCR_FNAME_PREFIX):].strip()
185 fname = os.path.splitext(fname)[0]
186 else:
187 fname = art.FILENAMES[img_type][0].strip("*")
188 fname = img.makeFileName(name=fname)
189
190 if (md5File(os.path.join(path, fname)) ==
191 md5Data(img.image_data)):
192 printMsg("Skipping writing of %s, file "
193 "exists and is exactly the same." %
194 fname)
195 else:
196 img_file = makeUniqueFileName(
197 os.path.join(path, fname),
198 uniq=img.description)
199 printWarning("Writing %s..." % img_file)
200 with open(img_file, "wb") as fp:
201 fp.write(img.image_data)
202 else:
203 self._verbose(
204 "tag %s: unhandled image type %d (ignored)" %
205 (file_base, img.picture_type)
206 )
207
208 # Copy file art to tags.
209 if self.args.update_tags:
210 assert(not self.args.update_files)
211 for tag in all_tags:
212 for art_file in dir_art:
213 art_path = os.path.basename(art_file.file_path)
214 printMsg("Copying %s to tag '%s' image" %
215 (art_path, art_file.id3_art_type))
216
217 descr = "filename: %s" % os.path.splitext(art_path)[0]
218 tag.images.set(art_file.id3_art_type,
219 art_file.image_data, art_file.mime_type,
220 description=descr)
221 tag.save()
222
223 finally:
224 # Cleans up...
225 super(ArtPlugin, self).handleDirectory(d, _)
226
227 def handleDone(self):
228 return self._retval
229
230
231 def pilImage(source):
232 from PIL import Image
233
234 if isinstance(source, ImageFrame):
235 return Image.open(io.BytesIO(source.image_data))
236 else:
237 return Image.open(source)
238
239
240 def pilImageDetails(img):
241 return "[%dx%d %s md5:%s]" % (img.size[0], img.size[1],
242 img.format.lower(),
243 md5Data(img.tobytes())) if img else ""
244
245
246 def md5Data(data):
247 md5 = hashlib.md5()
248 md5.update(data)
249 return md5.hexdigest()
250
251
252 def md5File(file_name):
253 """Compute md5 hash for contents of ``file_name``."""
254
255 global md5_file_cache
256 if file_name in md5_file_cache:
257 return md5_file_cache[file_name]
258
259 md5 = hashlib.md5()
260 try:
261 with open(file_name, "rb") as f:
262 md5.update(f.read())
263
264 md5_file_cache[file_name] = md5.hexdigest()
265 return md5_file_cache[file_name]
266 except IOError:
267 return None
0 import os
1 import re
2 import dataclasses
3 from functools import partial
4 from argparse import ArgumentTypeError
5
6 from eyed3.plugins import LoaderPlugin
7 from eyed3 import core, id3, mp3
8 from eyed3.utils import makeUniqueFileName, b, formatTime
9 from eyed3.utils.console import (
10 printMsg, printError, printWarning, boldText, getTtySize,
11 )
12 from eyed3.id3.frames import ImageFrame
13 from eyed3.mimetype import guessMimetype
14
15 from eyed3.utils.log import getLogger
16 log = getLogger(__name__)
17
18 FIELD_DELIM = ':'
19
20 DEFAULT_MAX_PADDING = 64 * 1024
21
22
23 class ClassicPlugin(LoaderPlugin):
24 SUMMARY = "Classic eyeD3 interface for viewing and editing tags."
25 DESCRIPTION = """
26 All PATH arguments are parsed and displayed. Directory paths are searched
27 recursively. Any editing options (--artist, --title) are applied to each file
28 read.
29
30 All date options (-Y, --release-year excepted) follow ISO 8601 format. This is
31 ``yyyy-mm-ddThh:mm:ss``. The year is required, and each component thereafter is
32 optional. For example, 2012-03 is valid, 2012--12 is not.
33 """
34 NAMES = ["classic"]
35
36 def __init__(self, arg_parser):
37 super(ClassicPlugin, self).__init__(arg_parser)
38 g = self.arg_group
39
40 def PositiveIntArg(i):
41 i = int(i)
42 if i < 0:
43 raise ArgumentTypeError("positive number required")
44 return i
45
46 # Common options
47 g.add_argument("-a", "--artist", dest="artist",
48 metavar="STRING", help=ARGS_HELP["--artist"])
49 g.add_argument("-A", "--album", dest="album",
50 metavar="STRING", help=ARGS_HELP["--album"])
51 g.add_argument("-b", "--album-artist",
52 dest="album_artist", metavar="STRING",
53 help=ARGS_HELP["--album-artist"])
54 g.add_argument("-t", "--title", dest="title",
55 metavar="STRING", help=ARGS_HELP["--title"])
56 g.add_argument("-n", "--track", type=PositiveIntArg, dest="track",
57 metavar="NUM", help=ARGS_HELP["--track"])
58 g.add_argument("-N", "--track-total", type=PositiveIntArg,
59 dest="track_total", metavar="NUM",
60 help=ARGS_HELP["--track-total"])
61
62 g.add_argument("--track-offset", type=int, dest="track_offset",
63 metavar="N", help=ARGS_HELP["--track-offset"])
64
65 g.add_argument("--composer", dest="composer",
66 metavar="STRING", help=ARGS_HELP["--composer"])
67 g.add_argument("--orig-artist", dest="orig_artist",
68 metavar="STRING", help=ARGS_HELP["--orig-artist"])
69 g.add_argument("-d", "--disc-num", type=PositiveIntArg, dest="disc_num",
70 metavar="NUM", help=ARGS_HELP["--disc-num"])
71 g.add_argument("-D", "--disc-total", type=PositiveIntArg,
72 dest="disc_total", metavar="NUM",
73 help=ARGS_HELP["--disc-total"])
74 g.add_argument("-G", "--genre", dest="genre",
75 metavar="GENRE", help=ARGS_HELP["--genre"])
76 g.add_argument("--non-std-genres", dest="non_std_genres",
77 action="store_true", help=ARGS_HELP["--non-std-genres"])
78 g.add_argument("-Y", "--release-year", type=PositiveIntArg,
79 dest="release_year", metavar="YEAR",
80 help=ARGS_HELP["--release-year"])
81 g.add_argument("-c", "--comment", dest="simple_comment",
82 metavar="STRING",
83 help=ARGS_HELP["--comment"])
84 g.add_argument("--artist-city", metavar="STRING",
85 help="The artist's city of origin. "
86 f"Stored as a user text frame `{core.TXXX_ARTIST_ORIGIN}`")
87 g.add_argument("--artist-state", metavar="STRING",
88 help="The artist's state of origin. "
89 f"Stored as a user text frame `{core.TXXX_ARTIST_ORIGIN}`")
90 g.add_argument("--artist-country", metavar="STRING",
91 help="The artist's country of origin. "
92 f"Stored as a user text frame `{core.TXXX_ARTIST_ORIGIN}`")
93 g.add_argument("--rename", dest="rename_pattern", metavar="PATTERN",
94 help=ARGS_HELP["--rename"])
95
96 gid3 = arg_parser.add_argument_group("ID3 options")
97
98 def _splitArgs(arg, maxsplit=None):
99 NEW_DELIM = "#DELIM#"
100 arg = re.sub(r"\\%s" % FIELD_DELIM, NEW_DELIM, arg)
101 t = tuple(re.sub(NEW_DELIM, FIELD_DELIM, s)
102 for s in arg.split(FIELD_DELIM))
103 if maxsplit is not None and maxsplit < 2:
104 raise ValueError("Invalid maxsplit value: {}".format(maxsplit))
105 elif maxsplit and len(t) > maxsplit:
106 t = t[:maxsplit - 1] + (FIELD_DELIM.join(t[maxsplit - 1:]),)
107 assert len(t) <= maxsplit
108 return t
109
110 def DescLangArg(arg):
111 """DESCRIPTION[:LANG]"""
112 vals = _splitArgs(arg, 2)
113 desc = vals[0]
114 lang = vals[1] if len(vals) > 1 else id3.DEFAULT_LANG
115 return desc, b(lang)[:3] or id3.DEFAULT_LANG
116
117 def DescTextArg(arg):
118 """DESCRIPTION:TEXT"""
119 vals = _splitArgs(arg, 2)
120 desc = vals[0].strip()
121 text = FIELD_DELIM.join(vals[1:] if len(vals) > 1 else [])
122 return desc or "", text or ""
123 KeyValueArg = DescTextArg
124
125 def DescUrlArg(arg):
126 desc, url = DescTextArg(arg)
127 return desc, url.encode("latin1")
128
129 def FidArg(arg):
130 fid = arg.strip().encode("ascii")
131 if not fid:
132 raise ArgumentTypeError("No frame ID")
133 return fid
134
135 def TextFrameArg(arg):
136 """FID:TEXT"""
137 vals = _splitArgs(arg, 2)
138 fid = vals[0].strip().encode("ascii")
139 if not fid:
140 raise ArgumentTypeError("No frame ID")
141 text = vals[1] if len(vals) > 1 else ""
142 return fid, text
143
144 def UrlFrameArg(arg):
145 """FID:TEXT"""
146 fid, url = TextFrameArg(arg)
147 return fid, url.encode("latin1")
148
149 def DateArg(date_str):
150 return core.Date.parse(date_str) if date_str else ""
151
152 def CommentArg(arg):
153 """
154 COMMENT[:DESCRIPTION[:LANG]
155 """
156 vals = _splitArgs(arg, 3)
157 text = vals[0]
158 if not text:
159 raise ArgumentTypeError("text required")
160 desc = vals[1] if len(vals) > 1 else ""
161 lang = vals[2] if len(vals) > 2 else id3.DEFAULT_LANG
162 return text, desc, b(lang)[:3]
163
164 def LyricsArg(arg):
165 text, desc, lang = CommentArg(arg)
166 try:
167 with open(text, "r") as fp:
168 data = fp.read()
169 except Exception: # noqa: B901
170 raise ArgumentTypeError("Unable to read file")
171 return data, desc, lang
172
173 def PlayCountArg(pc):
174 if not pc:
175 raise ArgumentTypeError("value required")
176 increment = False
177 if pc[0] == "+":
178 pc = int(pc[1:])
179 increment = True
180 else:
181 pc = int(pc)
182 if pc < 0:
183 raise ArgumentTypeError("out of range")
184 return increment, pc
185
186 def BpmArg(bpm):
187 bpm = int(float(bpm) + 0.5)
188 if bpm <= 0:
189 raise ArgumentTypeError("out of range")
190 return bpm
191
192 def DirArg(d):
193 if not d or not os.path.isdir(d):
194 raise ArgumentTypeError("invalid directory: %s" % d)
195 return d
196
197 def ImageArg(s):
198 """PATH:TYPE[:DESCRIPTION]
199 Returns (path, type_id, mime_type, description)
200 """
201 args = _splitArgs(s, 3)
202 if len(args) < 2:
203 raise ArgumentTypeError("Format is: PATH:TYPE[:DESCRIPTION]")
204
205 path, type_str = args[:2]
206 desc = args[2] if len(args) > 2 else ""
207
208 try:
209 type_id = id3.frames.ImageFrame.stringToPicType(type_str)
210 except Exception: # noqa: B901
211 raise ArgumentTypeError("invalid pic type: {}".format(type_str))
212
213 if not path:
214 raise ArgumentTypeError("path required")
215 elif True in [path.startswith(prefix)
216 for prefix in ["http://", "https://"]]:
217 mt = ImageFrame.URL_MIME_TYPE
218 else:
219 if not os.path.isfile(path):
220 raise ArgumentTypeError("file does not exist")
221 mt = guessMimetype(path)
222 if mt is None:
223 raise ArgumentTypeError("Cannot determine mime-type")
224
225 return path, type_id, mt, desc
226
227 def ObjectArg(s):
228 """OBJ_PATH:MIME-TYPE[:DESCRIPTION[:FILENAME]],
229 Returns (path, mime_type, description, filename)
230 """
231 args = _splitArgs(s, 4)
232 if len(args) < 2:
233 raise ArgumentTypeError("too few parts")
234
235 path = args[0]
236 if path:
237 mt = args[1]
238 desc = args[2] if len(args) > 2 else ""
239 filename = args[3] \
240 if len(args) > 3 \
241 else os.path.basename(path)
242 if not os.path.isfile(path):
243 raise ArgumentTypeError("file does not exist")
244 if not mt:
245 raise ArgumentTypeError("mime-type required")
246 else:
247 raise ArgumentTypeError("path required")
248 return (path, mt, desc, filename)
249
250 def UniqFileIdArg(arg):
251 owner_id, id = KeyValueArg(arg)
252 if not owner_id:
253 raise ArgumentTypeError("owner_id required")
254 id = id.encode("latin1") # don't want to pass unicode
255 if len(id) > 64:
256 raise ArgumentTypeError("id must be <= 64 bytes")
257 return (owner_id, id)
258
259 def PopularityArg(arg):
260 """EMAIL:RATING[:PLAY_COUNT]
261 Returns (email, rating, play_count)
262 """
263 args = _splitArgs(arg, 3)
264 if len(args) < 2:
265 raise ArgumentTypeError("Incorrect number of argument components")
266 email = args[0]
267 rating = int(float(args[1]))
268 if rating < 0 or rating > 255:
269 raise ArgumentTypeError("Rating out-of-range")
270 play_count = 0
271 if len(args) > 2:
272 play_count = int(args[2])
273 if play_count < 0:
274 raise ArgumentTypeError("Play count out-of-range")
275 return (email, rating, play_count)
276
277 # Tag versions
278 gid3.add_argument("-1", "--v1", action="store_const", const=id3.ID3_V1,
279 dest="tag_version", default=id3.ID3_ANY_VERSION,
280 help=ARGS_HELP["--v1"])
281 gid3.add_argument("-2", "--v2", action="store_const", const=id3.ID3_V2,
282 dest="tag_version", default=id3.ID3_ANY_VERSION,
283 help=ARGS_HELP["--v2"])
284 gid3.add_argument("--to-v1.1", action="store_const", const=id3.ID3_V1_1,
285 dest="convert_version", help=ARGS_HELP["--to-v1.1"])
286 gid3.add_argument("--to-v2.3", action="store_const", const=id3.ID3_V2_3,
287 dest="convert_version", help=ARGS_HELP["--to-v2.3"])
288 gid3.add_argument("--to-v2.4", action="store_const", const=id3.ID3_V2_4,
289 dest="convert_version", help=ARGS_HELP["--to-v2.4"])
290
291 # Dates
292 gid3.add_argument("--release-date", type=DateArg, dest="release_date",
293 metavar="DATE",
294 help=ARGS_HELP["--release-date"])
295 gid3.add_argument("--orig-release-date", type=DateArg,
296 dest="orig_release_date", metavar="DATE",
297 help=ARGS_HELP["--orig-release-date"])
298 gid3.add_argument("--recording-date", type=DateArg,
299 dest="recording_date", metavar="DATE",
300 help=ARGS_HELP["--recording-date"])
301 gid3.add_argument("--encoding-date", type=DateArg, dest="encoding_date",
302 metavar="DATE", help=ARGS_HELP["--encoding-date"])
303 gid3.add_argument("--tagging-date", type=DateArg, dest="tagging_date",
304 metavar="DATE", help=ARGS_HELP["--tagging-date"])
305
306 # Misc
307 gid3.add_argument("--publisher", action="store",
308 dest="publisher", metavar="STRING",
309 help=ARGS_HELP["--publisher"])
310 gid3.add_argument("--play-count", type=PlayCountArg, dest="play_count",
311 metavar="<+>N", default=None,
312 help=ARGS_HELP["--play-count"])
313 gid3.add_argument("--bpm", type=BpmArg, dest="bpm", metavar="N",
314 default=None, help=ARGS_HELP["--bpm"])
315 gid3.add_argument("--unique-file-id", action="append",
316 type=UniqFileIdArg, dest="unique_file_ids",
317 metavar="OWNER_ID:ID", default=[],
318 help=ARGS_HELP["--unique-file-id"])
319
320 # Comments
321 gid3.add_argument("--add-comment", action="append", dest="comments",
322 metavar="COMMENT[:DESCRIPTION[:LANG]]", default=[],
323 type=CommentArg, help=ARGS_HELP["--add-comment"])
324 gid3.add_argument("--remove-comment", action="append", type=DescLangArg,
325 dest="remove_comment", default=[],
326 metavar="DESCRIPTION[:LANG]",
327 help=ARGS_HELP["--remove-comment"])
328 gid3.add_argument("--remove-all-comments", action="store_true",
329 dest="remove_all_comments",
330 help=ARGS_HELP["--remove-all-comments"])
331
332 gid3.add_argument("--add-lyrics", action="append", type=LyricsArg,
333 dest="lyrics", default=[],
334 metavar="LYRICS_FILE[:DESCRIPTION[:LANG]]",
335 help=ARGS_HELP["--add-lyrics"])
336 gid3.add_argument("--remove-lyrics", action="append", type=DescLangArg,
337 dest="remove_lyrics", default=[],
338 metavar="DESCRIPTION[:LANG]",
339 help=ARGS_HELP["--remove-lyrics"])
340 gid3.add_argument("--remove-all-lyrics", action="store_true",
341 dest="remove_all_lyrics",
342 help=ARGS_HELP["--remove-all-lyrics"])
343
344 gid3.add_argument("--text-frame", action="append", type=TextFrameArg,
345 dest="text_frames", metavar="FID:TEXT", default=[],
346 help=ARGS_HELP["--text-frame"])
347 gid3.add_argument("--user-text-frame", action="append",
348 type=DescTextArg,
349 dest="user_text_frames", metavar="DESC:TEXT",
350 default=[], help=ARGS_HELP["--user-text-frame"])
351
352 gid3.add_argument("--url-frame", action="append", type=UrlFrameArg,
353 dest="url_frames", metavar="FID:URL", default=[],
354 help=ARGS_HELP["--url-frame"])
355 gid3.add_argument("--user-url-frame", action="append", type=DescUrlArg,
356 dest="user_url_frames", metavar="DESCRIPTION:URL",
357 default=[], help=ARGS_HELP["--user-url-frame"])
358
359 gid3.add_argument("--add-image", action="append", type=ImageArg,
360 dest="images", metavar="IMG_PATH:TYPE[:DESCRIPTION]",
361 default=[], help=ARGS_HELP["--add-image"])
362 gid3.add_argument("--remove-image", action="append",
363 dest="remove_image", default=[],
364 metavar="DESCRIPTION",
365 help=ARGS_HELP["--remove-image"])
366 gid3.add_argument("--remove-all-images", action="store_true",
367 dest="remove_all_images",
368 help=ARGS_HELP["--remove-all-images"])
369 gid3.add_argument("--write-images", dest="write_images_dir",
370 metavar="DIR", type=DirArg,
371 help=ARGS_HELP["--write-images"])
372
373 gid3.add_argument("--add-object", action="append", type=ObjectArg,
374 dest="objects", default=[],
375 metavar="OBJ_PATH:MIME-TYPE[:DESCRIPTION[:FILENAME]]",
376 help=ARGS_HELP["--add-object"])
377 gid3.add_argument("--remove-object", action="append",
378 dest="remove_object", default=[],
379 metavar="DESCRIPTION",
380 help=ARGS_HELP["--remove-object"])
381 gid3.add_argument("--write-objects", action="store",
382 dest="write_objects_dir", metavar="DIR", default=None,
383 help=ARGS_HELP["--write-objects"])
384 gid3.add_argument("--remove-all-objects", action="store_true",
385 dest="remove_all_objects",
386 help=ARGS_HELP["--remove-all-objects"])
387
388 gid3.add_argument("--add-popularity", action="append",
389 type=PopularityArg, dest="popularities", default=[],
390 metavar="EMAIL:RATING[:PLAY_COUNT]",
391 help=ARGS_HELP["--add-popularity"])
392 gid3.add_argument("--remove-popularity", action="append", type=str,
393 dest="remove_popularity", default=[],
394 metavar="EMAIL",
395 help=ARGS_HELP["--remove-popularity"])
396
397 gid3.add_argument("--remove-v1", action="store_true", dest="remove_v1",
398 default=False, help=ARGS_HELP["--remove-v1"])
399 gid3.add_argument("--remove-v2", action="store_true", dest="remove_v2",
400 default=False, help=ARGS_HELP["--remove-v2"])
401 gid3.add_argument("--remove-all", action="store_true", default=False,
402 dest="remove_all", help=ARGS_HELP["--remove-all"])
403 gid3.add_argument("--remove-frame", action="append", default=[],
404 dest="remove_fids", metavar="FID", type=FidArg,
405 help=ARGS_HELP["--remove-frame"])
406
407 # 'True' means 'apply default max_padding, but only if saving anyhow'
408 gid3.add_argument("--max-padding", type=int, dest="max_padding",
409 default=True, metavar="NUM_BYTES",
410 help=ARGS_HELP["--max-padding"])
411 gid3.add_argument("--no-max-padding", dest="max_padding",
412 action="store_const", const=None,
413 help=ARGS_HELP["--no-max-padding"])
414
415 _encodings = ["latin1", "utf8", "utf16", "utf16-be"]
416 gid3.add_argument("--encoding", dest="text_encoding", default=None,
417 choices=_encodings, metavar='|'.join(_encodings),
418 help=ARGS_HELP["--encoding"])
419
420 # Misc options
421 gid4 = arg_parser.add_argument_group("Misc options")
422 gid4.add_argument("--force-update", action="store_true", default=False,
423 dest="force_update", help=ARGS_HELP["--force-update"])
424 gid4.add_argument("-v", "--verbose", action="store_true",
425 dest="verbose", help=ARGS_HELP["--verbose"])
426 gid4.add_argument("--preserve-file-times", action="store_true",
427 dest="preserve_file_time",
428 help=ARGS_HELP["--preserve-file-times"])
429
430 def handleFile(self, f):
431 parse_version = self.args.tag_version
432
433 try:
434 super().handleFile(f, tag_version=parse_version)
435 except id3.TagException as tag_ex:
436 printError(str(tag_ex))
437 return
438
439 if not self.audio_file:
440 return
441
442 self.terminal_width = getTtySize()[1]
443 self.printHeader(f)
444
445 if self.audio_file.tag and self.handleRemoves(self.audio_file.tag):
446 # Reload after removal
447 super(ClassicPlugin, self).handleFile(f, tag_version=parse_version)
448 if not self.audio_file:
449 return
450
451 new_tag = False
452 if not self.audio_file.tag:
453 self.audio_file.initTag(version=parse_version)
454 new_tag = True
455
456 try:
457 save_tag = (self.handleEdits(self.audio_file.tag) or
458 self.handlePadding(self.audio_file.tag) or
459 self.args.force_update or self.args.convert_version)
460 except ValueError as ex:
461 printError(str(ex))
462 return
463
464 self.printAudioInfo(self.audio_file.info)
465
466 if not save_tag and new_tag:
467 printError(f"No ID3 {id3.versionToString(self.args.tag_version)} tag found!")
468 return
469
470 self.printTag(self.audio_file.tag)
471
472 if self.args.write_images_dir:
473 for img in self.audio_file.tag.images:
474 if img.mime_type not in ImageFrame.URL_MIME_TYPE_VALUES:
475 img_path = "%s%s" % (self.args.write_images_dir,
476 os.path.sep)
477 if not os.path.isdir(img_path):
478 raise IOError("Directory does not exist: %s" % img_path)
479 img_file = makeUniqueFileName(
480 os.path.join(img_path, img.makeFileName()))
481 printWarning("Writing %s..." % img_file)
482 with open(img_file, "wb") as fp:
483 fp.write(img.image_data)
484
485 if save_tag:
486 # Use current tag version unless a convert was supplied
487 version = (self.args.convert_version or
488 self.audio_file.tag.version)
489 printWarning("Writing ID3 version %s" %
490 id3.versionToString(version))
491
492 # DEFAULT_MAX_PADDING is not set up as argument default,
493 # because we don't want to rewrite the file if the user
494 # did not trigger that explicitly:
495 max_padding = self.args.max_padding
496 if max_padding is True:
497 max_padding = DEFAULT_MAX_PADDING
498
499 self.audio_file.tag.save(
500 version=version, encoding=self.args.text_encoding,
501 backup=self.args.backup,
502 preserve_file_time=self.args.preserve_file_time,
503 max_padding=max_padding)
504
505 if self.args.rename_pattern:
506 # Handle file renaming.
507 from eyed3.id3.tag import TagTemplate
508 template = TagTemplate(self.args.rename_pattern)
509 name = template.substitute(self.audio_file.tag, zeropad=True)
510 orig = self.audio_file.path
511 try:
512 self.audio_file.rename(name)
513 printWarning(f"Renamed '{orig}' to '{self.audio_file.path}'")
514 except IOError as ex:
515 printError(str(ex))
516
517 printMsg(self._getHardRule(self.terminal_width))
518
519 def printHeader(self, file_path):
520 printMsg(self._getFileHeader(file_path, self.terminal_width))
521 printMsg(self._getHardRule(self.terminal_width))
522
523 def printAudioInfo(self, info):
524 if isinstance(info, mp3.Mp3AudioInfo):
525 printMsg(boldText("Time: ") +
526 "%s\tMPEG%d, Layer %s\t[ %s @ %s Hz - %s ]" %
527 (formatTime(info.time_secs),
528 info.mp3_header.version,
529 "I" * info.mp3_header.layer,
530 info.bit_rate_str,
531 info.mp3_header.sample_freq, info.mp3_header.mode))
532 printMsg(self._getHardRule(self.terminal_width))
533
534 @staticmethod
535 def _getDefaultNameForObject(obj_frame, suffix=""):
536 if obj_frame.filename:
537 name_str = obj_frame.filename
538 else:
539 name_str = obj_frame.description
540 name_str += ".%s" % obj_frame.mime_type.split("/")[1]
541 if suffix:
542 name_str += suffix
543 return name_str
544
545 def printTag(self, tag):
546 if isinstance(tag, id3.Tag):
547 if self.args.quiet:
548 printMsg(f"ID3 {id3.versionToString(tag.version)}: {len(tag.frame_set)} frames")
549 return
550 printMsg(f"ID3 {id3.versionToString(tag.version)}:")
551
552 artist = tag.artist if tag.artist else ""
553 title = tag.title if tag.title else ""
554 album = tag.album if tag.album else ""
555 printMsg("%s: %s" % (boldText("title"), title))
556 printMsg("%s: %s" % (boldText("artist"), artist))
557 printMsg("%s: %s" % (boldText("album"), album))
558 if tag.album_artist:
559 printMsg("%s: %s" % (boldText("album artist"),
560 tag.album_artist))
561 if tag.composer:
562 printMsg("%s: %s" % (boldText("composer"), tag.composer))
563 if tag.original_artist:
564 printMsg("%s: %s" % (boldText("original artist"), tag.original_artist))
565
566 for date, date_label in [
567 (tag.release_date, "release date"),
568 (tag.original_release_date, "original release date"),
569 (tag.recording_date, "recording date"),
570 (tag.encoding_date, "encoding date"),
571 (tag.tagging_date, "tagging date"),
572 ]:
573 if date:
574 printMsg("%s: %s" % (boldText(date_label), str(date)))
575
576 track_str = ""
577 (track_num, track_total) = tag.track_num
578 if track_num is not None:
579 track_str = str(track_num)
580 if track_total:
581 track_str += "/%d" % track_total
582
583 genre = tag.genre if not self.args.non_std_genres else tag.non_std_genre
584 genre_str = f"{boldText('genre')}: {genre.name} (id {genre.id})" if genre else ""
585 printMsg(f"{boldText('track')}: {track_str}\t\t{genre_str}")
586
587 (num, total) = tag.disc_num
588 if num is not None:
589 disc_str = str(num)
590 if total:
591 disc_str += "/%d" % total
592 printMsg("%s: %s" % (boldText("disc"), disc_str))
593
594 # PCNT
595 play_count = tag.play_count
596 if tag.play_count is not None:
597 printMsg("%s %d" % (boldText("Play Count:"), play_count))
598
599 # POPM
600 for popm in tag.popularities:
601 printMsg("%s [email: %s] [rating: %d] [play count: %d]" %
602 (boldText("Popularity:"), popm.email, popm.rating,
603 popm.count))
604
605 # TBPM
606 bpm = tag.bpm
607 if bpm is not None:
608 printMsg("%s %d" % (boldText("BPM:"), bpm))
609
610 # TPUB
611 pub = tag.publisher
612 if pub is not None:
613 printMsg("%s %s" % (boldText("Publisher/label:"), pub))
614
615 # UFID
616 for ufid in tag.unique_file_ids:
617 printMsg("%s [%s] : %s" %
618 (boldText("Unique File ID:"), ufid.owner_id,
619 ufid.uniq_id.decode("unicode_escape")))
620
621 # COMM
622 for c in tag.comments:
623 printMsg("%s: [Description: %s] [Lang: %s]\n%s" %
624 (boldText("Comment"), c.description or "",
625 c.lang.decode("ascii") or "", c.text or ""))
626
627 # USLT
628 for l in tag.lyrics:
629 printMsg("%s: [Description: %s] [Lang: %s]\n%s" %
630 (boldText("Lyrics"), l.description or "",
631 l.lang.decode("ascii") or "", l.text))
632
633 # TXXX
634 for f in tag.user_text_frames:
635 printMsg("%s: [Description: %s]\n%s" %
636 (boldText("UserTextFrame"), f.description, f.text))
637
638 # URL frames
639 for desc, url in (("Artist URL", tag.artist_url),
640 ("Audio source URL", tag.audio_source_url),
641 ("Audio file URL", tag.audio_file_url),
642 ("Internet radio URL", tag.internet_radio_url),
643 ("Commercial URL", tag.commercial_url),
644 ("Payment URL", tag.payment_url),
645 ("Publisher URL", tag.publisher_url),
646 ("Copyright URL", tag.copyright_url),
647 ):
648 if url:
649 printMsg("%s: %s" % (boldText(desc), url))
650
651 # user url frames
652 for u in tag.user_url_frames:
653 printMsg("%s [Description: %s]: %s" % (u.id, u.description,
654 u.url))
655
656 # APIC
657 for img in tag.images:
658 if img.mime_type not in ImageFrame.URL_MIME_TYPE_VALUES:
659 printMsg("%s: [Size: %d bytes] [Type: %s]" %
660 (boldText(img.picTypeToString(img.picture_type) +
661 " Image"),
662 len(img.image_data),
663 img.mime_type))
664 printMsg("Description: %s" % img.description)
665 printMsg("")
666 else:
667 printMsg("%s: [Type: %s] [URL: %s]" %
668 (boldText(img.picTypeToString(img.picture_type) +
669 " Image"),
670 img.mime_type, img.image_url))
671 printMsg("Description: %s" % img.description)
672 printMsg("")
673
674 # GOBJ
675 for obj in tag.objects:
676 printMsg("%s: [Size: %d bytes] [Type: %s]" %
677 (boldText("GEOB"), len(obj.object_data),
678 obj.mime_type))
679 printMsg("Description: %s" % obj.description)
680 printMsg("Filename: %s" % obj.filename)
681 printMsg("\n")
682 if self.args.write_objects_dir:
683 obj_path = "%s%s" % (self.args.write_objects_dir, os.sep)
684 if not os.path.isdir(obj_path):
685 raise IOError("Directory does not exist: %s" % obj_path)
686 obj_file = self._getDefaultNameForObject(obj)
687 count = 1
688 while os.path.exists(os.path.join(obj_path, obj_file)):
689 obj_file = self._getDefaultNameForObject(obj,
690 str(count))
691 count += 1
692 printWarning("Writing %s..." % os.path.join(obj_path,
693 obj_file))
694 with open(os.path.join(obj_path, obj_file), "wb") as fp:
695 fp.write(obj.object_data)
696
697 # PRIV
698 for p in tag.privates:
699 printMsg("%s: [Data: %d bytes]" % (boldText("PRIV"),
700 len(p.data)))
701 printMsg("Owner Id: %s" % p.owner_id.decode("ascii"))
702
703 # MCDI
704 if tag.cd_id:
705 printMsg("\n%s: [Data: %d bytes]" % (boldText("MCDI"),
706 len(tag.cd_id)))
707
708 # USER
709 if tag.terms_of_use:
710 printMsg("\nTerms of Use (%s): %s" % (boldText("USER"),
711 tag.terms_of_use))
712
713 # --verbose
714 if self.args.verbose:
715 printMsg(self._getHardRule(self.terminal_width))
716 printMsg("%d ID3 Frames:" % len(tag.frame_set))
717 for fid in tag.frame_set:
718 frames = tag.frame_set[fid]
719 num_frames = len(frames)
720 count = " x %d" % num_frames if num_frames > 1 else ""
721 if not tag.isV1():
722 total_bytes = sum(
723 tuple(frame.header.data_size + frame.header.size
724 for frame in frames if frame.header))
725 else:
726 total_bytes = 30
727 if total_bytes:
728 printMsg("%s%s (%d bytes)" % (fid.decode("ascii"),
729 count, total_bytes))
730 printMsg("%d bytes unused (padding)" %
731 (tag.file_info.tag_padding_size, ))
732 else:
733 raise TypeError("Unknown tag type: " + str(type(tag)))
734
735 def handleRemoves(self, tag):
736 remove_version = 0
737 status = False
738 rm_str = ""
739 if self.args.remove_all:
740 remove_version = id3.ID3_ANY_VERSION
741 rm_str = "v1.x and/or v2.x"
742 elif self.args.remove_v1:
743 remove_version = id3.ID3_V1
744 rm_str = "v1.x"
745 elif self.args.remove_v2:
746 remove_version = id3.ID3_V2
747 rm_str = "v2.x"
748
749 if remove_version:
750 status = id3.Tag.remove(tag.file_info.name, remove_version,
751 preserve_file_time=self.args.preserve_file_time)
752 printWarning(f"Removing ID3 {rm_str} tag: {'SUCCESS' if status else 'FAIL'}")
753
754 return status
755
756 def handlePadding(self, tag):
757 max_padding = self.args.max_padding
758 if max_padding is None or max_padding is True:
759 return False
760 padding = tag.file_info.tag_padding_size
761 needs_change = padding > max_padding
762 return needs_change
763
764 def handleEdits(self, tag):
765 retval = False
766
767 # --remove-all-*, Handling removes first means later options are still
768 # applied
769 for what, arg, fid in (("comments", self.args.remove_all_comments,
770 id3.frames.COMMENT_FID),
771 ("lyrics", self.args.remove_all_lyrics,
772 id3.frames.LYRICS_FID),
773 ("images", self.args.remove_all_images,
774 id3.frames.IMAGE_FID),
775 ("objects", self.args.remove_all_objects,
776 id3.frames.OBJECT_FID),
777 ):
778 if arg and tag.frame_set[fid]:
779 printWarning("Removing all %s..." % what)
780 del tag.frame_set[fid]
781 retval = True
782
783 # --artist, --title, etc. All common/simple text frames.
784 for (what, setFunc) in (
785 ("artist", partial(tag._setArtist, self.args.artist)),
786 ("album", partial(tag._setAlbum, self.args.album)),
787 ("album artist", partial(tag._setAlbumArtist,
788 self.args.album_artist)),
789 ("title", partial(tag._setTitle, self.args.title)),
790 ("genre", partial(tag._setGenre, self.args.genre,
791 id3_std=not self.args.non_std_genres)),
792 ("release date", partial(tag._setReleaseDate,
793 self.args.release_date)),
794 ("original release date", partial(tag._setOrigReleaseDate,
795 self.args.orig_release_date)),
796 ("recording date", partial(tag._setRecordingDate,
797 self.args.recording_date)),
798 ("encoding date", partial(tag._setEncodingDate,
799 self.args.encoding_date)),
800 ("tagging date", partial(tag._setTaggingDate,
801 self.args.tagging_date)),
802 ("beats per minute", partial(tag._setBpm, self.args.bpm)),
803 ("publisher", partial(tag._setPublisher, self.args.publisher)),
804 ("composer", partial(tag._setComposer, self.args.composer)),
805 ("orig-artist", partial(tag._setOrigArtist, self.args.orig_artist)),
806 ):
807 if setFunc.args[0] is not None:
808 printWarning("Setting %s: %s" % (what, setFunc.args[0]))
809 setFunc()
810 retval = True
811
812 def _checkNumberedArgTuples(curr, new):
813 n = None
814 if new not in [(None, None), curr]:
815 n = [None] * 2
816 for i in (0, 1):
817 if new[i] == 0:
818 n[i] = None
819 else:
820 n[i] = new[i] or curr[i]
821 n = tuple(n)
822 # Returning None means do nothing, (None, None) would clear both vals
823 return n
824
825 # --artist-{city,state,country}
826 origin = core.ArtistOrigin(self.args.artist_city,
827 self.args.artist_state,
828 self.args.artist_country)
829 if origin or (dataclasses.astuple(origin) != (None, None, None) and tag.artist_origin):
830 printWarning(f"Setting artist origin: {origin}")
831 tag.artist_origin = origin
832 retval = True
833
834 # --track, --track-total
835 track_info = _checkNumberedArgTuples(tag.track_num,
836 (self.args.track,
837 self.args.track_total))
838 if track_info is not None:
839 printWarning("Setting track info: %s" % str(track_info))
840 tag.track_num = track_info
841 retval = True
842
843 # --track-offset
844 if self.args.track_offset:
845 offset = self.args.track_offset
846 tag.track_num = (tag.track_num[0] + offset, tag.track_num[1])
847 printWarning("%s track info by %d: %d" %
848 ("Incrementing" if offset > 0 else "Decrementing",
849 offset, tag.track_num[0]))
850 retval = True
851
852 # --disc-num, --disc-total
853 disc_info = _checkNumberedArgTuples(tag.disc_num,
854 (self.args.disc_num,
855 self.args.disc_total))
856 if disc_info is not None:
857 printWarning("Setting disc info: %s" % str(disc_info))
858 tag.disc_num = disc_info
859 retval = True
860
861 # -Y, --release-year
862 if self.args.release_year is not None:
863 # empty string means clean, None means not given
864 year = self.args.release_year
865 printWarning(f"Setting release year: {year}")
866 tag.release_date = int(year) if year else None
867 retval = True
868
869 # -c , simple comment
870 if self.args.simple_comment:
871 # Just add it as if it came in --add-comment
872 self.args.comments.append((self.args.simple_comment, "",
873 id3.DEFAULT_LANG))
874
875 # --remove-comment, remove-lyrics, --remove-image, --remove-object
876 for what, arg, accessor in (("comment", self.args.remove_comment,
877 tag.comments),
878 ("lyrics", self.args.remove_lyrics,
879 tag.lyrics),
880 ("image", self.args.remove_image,
881 tag.images),
882 ("object", self.args.remove_object,
883 tag.objects),
884 ):
885 for vals in arg:
886 if type(vals) is str:
887 frame = accessor.remove(vals)
888 else:
889 frame = accessor.remove(*vals)
890 if frame:
891 printWarning("Removed %s %s" % (what, str(vals)))
892 retval = True
893 else:
894 printError("Removing %s failed, %s not found" %
895 (what, str(vals)))
896
897 # --add-comment, --add-lyrics
898 for what, arg, accessor in (("comment", self.args.comments,
899 tag.comments),
900 ("lyrics", self.args.lyrics, tag.lyrics),
901 ):
902 for text, desc, lang in arg:
903 printWarning("Setting %s: %s/%s" %
904 (what, desc, str(lang, "ascii")))
905 accessor.set(text, desc, b(lang))
906 retval = True
907
908 # --play-count
909 playcount_arg = self.args.play_count
910 if playcount_arg:
911 increment, pc = playcount_arg
912 if increment:
913 printWarning("Increment play count by %d" % pc)
914 tag.play_count += pc
915 else:
916 printWarning("Setting play count to %d" % pc)
917 tag.play_count = pc
918 retval = True
919
920 # --add-popularity
921 for email, rating, play_count in self.args.popularities:
922 tag.popularities.set(email.encode("latin1"), rating, play_count)
923 retval = True
924
925 # --remove-popularity
926 for email in self.args.remove_popularity:
927 popm = tag.popularities.remove(email.encode("latin1"))
928 if popm:
929 retval = True
930
931 # --text-frame, --url-frame
932 for what, arg, setter in (
933 ("text frame", self.args.text_frames, tag.setTextFrame),
934 ("url frame", self.args.url_frames, tag._setUrlFrame),
935 ):
936 for fid, text in arg:
937 if text:
938 printWarning("Setting %s %s to '%s'" % (fid, what, text))
939 else:
940 printWarning("Removing %s %s" % (fid, what))
941 setter(fid, text)
942 retval = True
943
944 # --user-text-frame, --user-url-frame
945 for what, arg, accessor in (
946 ("user text frame", self.args.user_text_frames,
947 tag.user_text_frames),
948 ("user url frame", self.args.user_url_frames,
949 tag.user_url_frames),
950 ):
951 for desc, text in arg:
952 if text:
953 printWarning(f"Setting '{desc}' {what} to '{text}'")
954 accessor.set(text, desc)
955 else:
956 printWarning(f"Removing '{desc}' {what}")
957 accessor.remove(desc)
958 retval = True
959
960 # --add-image
961 for img_path, img_type, img_mt, img_desc in self.args.images:
962 assert img_path
963 printWarning("Adding image %s" % img_path)
964 if img_mt not in ImageFrame.URL_MIME_TYPE_VALUES:
965 with open(img_path, "rb") as img_fp:
966 tag.images.set(img_type, img_fp.read(), img_mt, img_desc)
967 else:
968 tag.images.set(img_type, None, None, img_desc, img_url=img_path)
969 retval = True
970
971 # --add-object
972 for obj_path, obj_mt, obj_desc, obj_fname in self.args.objects or []:
973 assert obj_path
974 printWarning("Adding object %s" % obj_path)
975 with open(obj_path, "rb") as obj_fp:
976 tag.objects.set(obj_fp.read(), obj_mt, obj_desc, obj_fname)
977 retval = True
978
979 # --unique-file-id
980 for arg in self.args.unique_file_ids:
981 owner_id, id = arg
982 if not id:
983 if tag.unique_file_ids.remove(owner_id):
984 printWarning("Removed unique file ID '%s'" % owner_id)
985 retval = True
986 else:
987 printWarning("Unique file ID '%s' not found" % owner_id)
988 else:
989 tag.unique_file_ids.set(id, owner_id.encode("latin1"))
990 printWarning("Setting unique file ID '%s' to %s" %
991 (owner_id, id))
992 retval = True
993
994 # --remove-frame
995 for fid in self.args.remove_fids:
996 assert(isinstance(fid, bytes))
997 if fid in tag.frame_set:
998 del tag.frame_set[fid]
999 retval = True
1000
1001 return retval
1002
1003
1004 def _getTemplateKeys():
1005 keys = list(id3.TagTemplate("")._makeMapping(None, False).keys())
1006 keys.sort()
1007 return ", ".join(["$%s" % v for v in keys])
1008
1009
1010 ARGS_HELP = {
1011 "--artist": "Set the artist name.",
1012 "--album": "Set the album name.",
1013 "--album-artist": "Set the album artist name. '%s', for example. "
1014 "Another example is collaborations when the "
1015 "track artist might be 'Eminem featuring Proof' "
1016 "the album artist would be 'Eminem'." %
1017 core.VARIOUS_ARTISTS,
1018 "--title": "Set the track title.",
1019 "--track": "Set the track number. Use 0 to clear.",
1020 "--track-total": "Set total number of tracks. Use 0 to clear.",
1021 "--disc-num": "Set the disc number. Use 0 to clear.",
1022 "--disc-total": "Set total number of discs in set. Use 0 to clear.",
1023 "--genre": "Set the genre. If the argument is a standard ID3 genre "
1024 "name or number both will be set. Otherwise, any string "
1025 "can be used. Run 'eyeD3 --plugin=genres' for a list of "
1026 "standard ID3 genre names/ids.",
1027 "--non-std-genres": "Disables certain ID3 genre standards, such as the "
1028 "mapping of numeric value to genre names. For example, "
1029 "genre=1 is taken literally, not mapped to 'Classic Rock'.",
1030 "--release-year": "Set the year the track was released. Use the date "
1031 "options for more precise values or dates other "
1032 "than release.",
1033
1034 "--v1": "Only read and write ID3 v1.x tags. By default, v1.x tags are "
1035 "only read or written if there is not a v2 tag in the file.",
1036 "--v2": "Only read/write ID3 v2.x tags. This is the default unless "
1037 "the file only contains a v1 tag.",
1038
1039 "--to-v1.1": "Convert the file's tag to ID3 v1.1 (Or 1.0 if there is "
1040 "no track number)",
1041 "--to-v2.3": "Convert the file's tag to ID3 v2.3",
1042 "--to-v2.4": "Convert the file's tag to ID3 v2.4",
1043
1044 "--release-date": "Set the date the track/album was released",
1045 "--orig-release-date": "Set the original date the track/album was "
1046 "released",
1047 "--recording-date": "Set the date the track/album was recorded",
1048 "--encoding-date": "Set the date the file was encoded",
1049 "--tagging-date": "Set the date the file was tagged",
1050
1051 "--comment": "Set a comment. In ID3 tags this is the comment with "
1052 "an empty description. See --add-comment to add multiple "
1053 "comment frames.",
1054 "--add-comment":
1055 "Add or replace a comment. There may be more than one comment in a "
1056 "tag, as long as the DESCRIPTION and LANG values are unique. The "
1057 "default DESCRIPTION is '' and the default language code is '%s'." %
1058 str(id3.DEFAULT_LANG, "ascii"),
1059 "--remove-comment": "Remove comment matching DESCRIPTION and LANG. "
1060 "The default language code is '%s'." %
1061 str(id3.DEFAULT_LANG, "ascii"),
1062 "--remove-all-comments": "Remove all comments from the tag.",
1063
1064 "--add-lyrics":
1065 "Add or replace a lyrics. There may be more than one set of lyrics "
1066 "in a tag, as long as the DESCRIPTION and LANG values are unique. "
1067 "The default DESCRIPTION is '' and the default language code is "
1068 "'%s'." % str(id3.DEFAULT_LANG, "ascii"),
1069 "--remove-lyrics": "Remove lyrics matching DESCRIPTION and LANG. "
1070 "The default language code is '%s'." %
1071 str(id3.DEFAULT_LANG, "ascii"),
1072 "--remove-all-lyrics": "Remove all lyrics from the tag.",
1073
1074 "--publisher": "Set the publisher/label name",
1075 "--play-count": "Set the number of times played counter. If the "
1076 "argument value begins with '+' the tag's play count "
1077 "is incremented by N, otherwise the value is set to "
1078 "exactly N.",
1079 "--bpm": "Set the beats per minute value.",
1080
1081 "--text-frame": "Set the value of a text frame. To remove the "
1082 "frame, specify an empty value. For example, "
1083 "--text-frame='TDRC:'",
1084 "--user-text-frame": "Set the value of a user text frame (i.e., TXXX). "
1085 "To remove the frame, specify an empty value. "
1086 "e.g., --user-text-frame='SomeDesc:'",
1087 "--url-frame": "Set the value of a URL frame. To remove the frame, "
1088 "specify an empty value. e.g., --url-frame='WCOM:'",
1089 "--user-url-frame": "Set the value of a user URL frame (i.e., WXXX). "
1090 "To remove the frame, specify an empty value. "
1091 "e.g., --user-url-frame='SomeDesc:'",
1092
1093 "--add-image": "Add or replace an image. There may be more than one "
1094 "image in a tag, as long as the DESCRIPTION values are "
1095 "unique. The default DESCRIPTION is ''. If PATH begins "
1096 "with 'http[s]://' then it is interpreted as a URL "
1097 "instead of a file containing image data. The TYPE must "
1098 "be one of the following: %s."
1099 % (", ".join([ImageFrame.picTypeToString(t)
1100 for t in range(ImageFrame.MIN_TYPE,
1101 ImageFrame.MAX_TYPE + 1)]),
1102 ),
1103 "--remove-image": "Remove image matching DESCRIPTION.",
1104 "--remove-all-images": "Remove all images from the tag",
1105 "--write-images": "Causes all attached images (APIC frames) to be "
1106 "written to the specified directory.",
1107
1108 "--add-object": "Add or replace an object. There may be more than one "
1109 "object in a tag, as long as the DESCRIPTION values "
1110 "are unique. The default DESCRIPTION is ''.",
1111 "--remove-object": "Remove object matching DESCRIPTION.",
1112 "--remove-all-objects": "Remove all objects from the tag",
1113 "--write-objects": "Causes all attached objects (GEOB frames) to be "
1114 "written to the specified directory.",
1115
1116 "--add-popularity": "Adds a pupularity metric. There may be multiples "
1117 "popularity values, but each must have a unique "
1118 "email address component. The rating is a number "
1119 "between 0 (worst) and 255 (best). The play count "
1120 "is optional, and defaults to 0, since there is "
1121 "already a dedicated play count frame.",
1122 "--remove-popularity": "Removes the popularity frame with the "
1123 "specified email key.",
1124
1125 "--remove-v1": "Remove ID3 v1.x tag.",
1126 "--remove-v2": "Remove ID3 v2.x tag.",
1127 "--remove-all": "Remove ID3 v1.x and v2.x tags.",
1128
1129 "--remove-frame": "Remove all frames with the given ID. This option "
1130 "may be specified multiple times.",
1131
1132 "--max-padding": "Shrink file if tag padding (unused space) exceeds "
1133 "the given number of bytes. "
1134 "(Useful e.g. after removal of large cover art.) "
1135 "Default is 64 KiB, file will be rewritten with "
1136 "default padding (1 KiB) or max padding, whichever "
1137 "is smaller.",
1138 "--no-max-padding": "Disable --max-padding altogether.",
1139
1140 "--force-update": "Rewrite the tag despite there being no edit "
1141 "options.",
1142 "--verbose": "Show all available tag data",
1143 "--unique-file-id": "Add a unique file ID frame. If the ID arg is "
1144 "empty the frame is removed. An OWNER_ID is "
1145 "required. The ID may be no more than 64 bytes.",
1146 "--encoding": "Set the encoding that is used for all text frames. "
1147 "This option is only applied if the tag is updated "
1148 "as the result of an edit option (e.g. --artist, "
1149 "--title, etc.) or --force-update is specified.",
1150 "--rename": "Rename file (the extension is not affected) "
1151 "based on data in the tag using substitution "
1152 "variables: " + _getTemplateKeys(),
1153 "--preserve-file-times": "When writing, do not update file "
1154 "modification times.",
1155 "--track-offset": "Increment/decrement the track number by [-]N. "
1156 "This option is applied after --track=N is set.",
1157 "--composer": "Set the composer's name.",
1158 "--orig-artist": "Set the orignal artist's name. For example, a cover song can include "
1159 "the orignal author of the track.",
1160 }
0 import os
1 import re
2 import abc
3
4 from argparse import ArgumentTypeError
5
6 from eyed3 import id3
7 from eyed3.utils import console, formatSize, formatTime
8 from eyed3.plugins import LoaderPlugin
9 try:
10 from eyed3.plugins._display_parser import DisplayPatternParser
11 _have_grako = True
12 except ImportError:
13 _have_grako = False
14
15
16 class Pattern(object):
17
18 def __init__(self, text=None, sub_patterns=None):
19 self.__text = text
20 self.__sub_patterns = sub_patterns
21
22 def output_for(self, audio_file):
23 output = ""
24 for sub_pattern in self.sub_patterns or []:
25 output += sub_pattern.output_for(audio_file)
26 return output
27
28 def __get_sub_patterns(self):
29 if self.__sub_patterns is None and self.__text is not None:
30 self.__compile()
31 return self.__sub_patterns
32
33 def __set_sub_patterns(self, sub_patterns):
34 self.__sub_patterns = sub_patterns
35
36 sub_patterns = property(__get_sub_patterns, __set_sub_patterns)
37
38 def __compile(self):
39 # TODO: add support for comments in pattern
40 parser = DisplayPatternParser(whitespace='')
41 try:
42 asts = parser.parse(self.__text, rule_name='start')
43 self.sub_patterns = self.__compile_asts(asts)
44 self.__text = None
45 except BaseException as parsing_error:
46 raise PatternCompileException(str(parsing_error))
47
48 def __compile_asts(self, asts):
49 patterns = []
50 for ast in asts:
51 patterns.append(self.__compile_ast(ast))
52 return patterns
53
54 def __compile_ast(self, ast):
55 if ast is None:
56 return None
57 if "text" in ast:
58 return TextPattern(ast["text"])
59 if "tag" in ast:
60 parameters = self.__compile_parameters(ast["parameters"])
61 return self.__create_complex_pattern(TagPattern, ast["name"],
62 parameters)
63 if "function" in ast:
64 parameters = self.__compile_parameters(ast["parameters"])
65 if len(parameters) == 1 and parameters[0][0] is None and len(
66 parameters[0][1].sub_patterns) == 0:
67 parameters = []
68 return self.__create_complex_pattern(FunctionPattern, ast["name"],
69 parameters)
70
71 def __compile_parameters(self, parameter_asts):
72 parameters = []
73 for parameter_ast in parameter_asts:
74 sub_patterns = self.__compile_asts(parameter_ast["value"])
75 parameters.append((parameter_ast["name"],
76 Pattern(sub_patterns=sub_patterns)))
77 return parameters
78
79 def __create_complex_pattern(self, base_class, class_name, parameters):
80 pattern_class = self.__find_pattern_class(base_class, class_name)
81 if pattern_class is not None:
82 return pattern_class(class_name, parameters)
83 raise PatternCompileException("Unknown " + base_class.TYPE + " '" +
84 class_name + "'")
85
86 @staticmethod
87 def __find_pattern_class(base_class, class_name):
88 for pattern_class in Pattern.sub_pattern_classes(base_class):
89 if class_name in pattern_class.NAMES:
90 return pattern_class
91
92 @staticmethod
93 def sub_pattern_classes(base_class):
94 sub_classes = []
95 for pattern_class in base_class.__subclasses__():
96 if len(pattern_class.__subclasses__()) > 0:
97 sub_classes.extend(Pattern.sub_pattern_classes(pattern_class))
98 continue
99 sub_classes.append(pattern_class)
100 return sub_classes
101
102 @staticmethod
103 def pattern_class_parameters(pattern_class):
104 try:
105 return pattern_class.PARAMETERS
106 except AttributeError:
107 return []
108
109 def __repr__(self):
110 return self.__str__()
111
112 def __str__(self):
113 return str(self.__sub_patterns)
114
115
116 class TextPattern(Pattern):
117 SPECIAL_CHARACTERS = list("\\%$,()=nt")
118 SPECIAL_CHARACTERS_DESCRIPTIONS = list("\\%$,()=") + ["New line", "Tab"]
119
120 def __init__(self, text):
121 super(TextPattern, self).__init__(text)
122 self.__text = text
123 self.__replace_escapes()
124
125 def __replace_escapes(self):
126 escape_matches = list(re.compile('\\\\.').finditer(self.__text))
127 escape_matches.reverse()
128 for escape_match in escape_matches:
129 character = self.__text[escape_match.end() - 1]
130 if character not in TextPattern.SPECIAL_CHARACTERS:
131 raise PatternCompileException("Unknown escape character '" +
132 character + "'")
133 if character == 'n':
134 character = os.linesep
135 if character == 't':
136 character = '\t'
137 self.__text =\
138 f"{self.__text[:escape_match.start()]}{character}{self.__text[escape_match.end():]}"
139
140 def output_for(self, audio_file):
141 return self.__text
142
143 def __str__(self):
144 return "text:" + self.__text
145
146
147 class ComplexPattern(Pattern):
148 __metaclass__ = abc.ABCMeta
149
150 TYPE = "unknown"
151 NAMES = []
152 DESCRIPTION = ""
153 PARAMETERS = []
154
155 class ExpectedParameter(object):
156 def __init__(self, name, **kwargs):
157 self.name = name
158 if "default" in kwargs:
159 self.requried = False
160 self.default = kwargs["default"]
161 else:
162 self.requried = True
163
164 def __repr__(self):
165 return self.__str__()
166
167 def __str__(self):
168 if self.requried:
169 return self.name
170 return self.name + "(" + str(self.default) + ")"
171
172 class Parameter(object):
173 def __init__(self, value, provided):
174 self.value = value
175 self.provided = provided
176
177 def __repr__(self):
178 return self.__str__()
179
180 def __str__(self):
181 return str(self.value) + "(" + str(self.provided) + ")"
182
183 def __init__(self, name, parameters):
184 super(ComplexPattern, self).__init__()
185 self.__name = name
186 self.__import_parameters(parameters)
187
188 def output_for(self, audio_file):
189 output = self._get_output_for(audio_file)
190 return output or ""
191
192 @abc.abstractmethod
193 def _get_output_for(self, audio_file):
194 pass
195
196 def __import_parameters(self, parameters):
197 expected_parameters = Pattern.pattern_class_parameters(self.__class__)
198 self.__parameters = {}
199 for expected_parameter in expected_parameters:
200 found = False
201 for parameter in parameters:
202 if (parameter[0] is None or
203 parameter[0] == expected_parameter.name):
204 self.__parameters[expected_parameter.name] = \
205 ComplexPattern.Parameter(parameter[1], True)
206 parameters.remove(parameter)
207 found = True
208 break
209 if parameter[0] is not None:
210 break
211 if not expected_parameter.requried:
212 self.__parameters[expected_parameter.name] = \
213 ComplexPattern.Parameter(
214 Pattern(text=expected_parameter.default), True)
215 found = True
216 break
217 raise PatternCompileException(
218 self._error_message("Unexpected parameter"))
219 if not found:
220 if expected_parameter.requried:
221 raise PatternCompileException(self._error_message(
222 "Missing required parameter '" +
223 expected_parameter.name + "'"))
224 self.__parameters[expected_parameter.name] = \
225 ComplexPattern.Parameter(
226 Pattern(text=expected_parameter.default), False)
227 if len(parameters) > 0:
228 raise PatternCompileException(
229 self._error_message("Unexpected parameter"))
230
231 def __get_parameters(self):
232 return self.__parameters
233
234 parameters = property(__get_parameters)
235
236 def _parameter_value(self, name, audio_file):
237 return self.parameters[name].value.output_for(audio_file)
238
239 def _parameter_bool(self, name, audio_file):
240 value = self._parameter_value(name, audio_file)
241 return value.lower() in ("yes", "true", "y", "t", "1", "on")
242
243 def __get_name(self):
244 return self.__name
245
246 name = property(__get_name)
247
248 def _error_message(self, message):
249 return self.TYPE.capitalize() + " " + self.__name + ": " + message
250
251 def __str__(self):
252 return self.TYPE + ":" + self.name + str(self.parameters)
253
254
255 class PlaceholderUsagePattern(object):
256 __metaclass__ = abc.ABCMeta
257
258 def _replace_placeholders(self, text, replacements):
259 if len(replacements) == 0:
260 return text
261
262 replacement = replacements.pop(0)
263 subtexts = []
264 for subtext in text.split(replacement[0]):
265 subtexts.append(
266 self._replace_placeholders(subtext, list(replacements)))
267 return (str(replacement[1]) or "").join(subtexts)
268
269
270 class TagPattern(ComplexPattern):
271 __metaclass__ = abc.ABCMeta
272 TYPE = "tag"
273
274
275 class ArtistTagPattern(TagPattern):
276 NAMES = ["a", "artist"]
277 DESCRIPTION = "Artist"
278
279 def _get_output_for(self, audio_file):
280 return audio_file.tag.artist
281
282
283 class AlbumTagPattern(TagPattern):
284 NAMES = ["A", "album"]
285 DESCRIPTION = "Album"
286
287 def _get_output_for(self, audio_file):
288 return audio_file.tag.album
289
290
291 class AlbumArtistTagPattern(TagPattern):
292 NAMES = ["b", "album-artist"]
293 DESCRIPTION = "Album artist"
294
295 def _get_output_for(self, audio_file):
296 return audio_file.tag.album_artist
297
298
299 class ComposerTagPattern(TagPattern):
300 NAMES = ["C", "composer"]
301 DESCRIPTION = "Composer"
302
303 def _get_output_for(self, audio_file):
304 return audio_file.tag.composer
305
306
307 class TitleTagPattern(TagPattern):
308 NAMES = ["t", "title"]
309 DESCRIPTION = "Title"
310
311 def _get_output_for(self, audio_file):
312 return audio_file.tag.title
313
314
315 class TrackTagPattern(TagPattern):
316 NAMES = ["n", "track"]
317 DESCRIPTION = "Track number"
318
319 def _get_output_for(self, audio_file):
320 n = audio_file.tag.track_num[0]
321 return str(n or "")
322
323
324 class TrackTotalTagPattern(TagPattern):
325 NAMES = ["N", "track-total"]
326 DESCRIPTION = "Total track number"
327
328 def _get_output_for(self, audio_file):
329 n = audio_file.tag.track_num[1]
330 return str(n or "")
331
332
333 class DiscTagPattern(TagPattern):
334 NAMES = ["d", "disc", "disc-num"]
335 DESCRIPTION = "Disc number"
336
337 def _get_output_for(self, audio_file):
338 n = audio_file.tag.disc_num[0]
339 return str(n or "")
340
341
342 class DiscTotalTagPattern(TagPattern):
343 NAMES = ["D", "disc-total"]
344 DESCRIPTION = "Total disc number"
345
346 def _get_output_for(self, audio_file):
347 n = audio_file.tag.disc_num[1]
348 return str(n or "")
349
350
351 class GenreTagPattern(TagPattern):
352 NAMES = ["G", "genre"]
353 DESCRIPTION = "Genre"
354
355 def _get_output_for(self, audio_file):
356 return audio_file.tag.genre.name
357
358
359 class GenreIdTagPattern(TagPattern):
360 NAMES = ["genre-id"]
361 DESCRIPTION = "Genre ID"
362
363 def _get_output_for(self, audio_file):
364 return str(audio_file.tag.genre.id) if audio_file.tag.genre else None
365
366
367 class YearTagPattern(TagPattern):
368 NAMES = ["Y", "year"]
369 DESCRIPTION = "Release year"
370
371 def _get_output_for(self, audio_file):
372 return audio_file.tag.release_date.year
373
374
375 class DescriptableTagPattern(TagPattern):
376 __metaclass__ = abc.ABCMeta
377
378 PARAMETERS = [ComplexPattern.ExpectedParameter("description", default=None),
379 ComplexPattern.ExpectedParameter("language", default=None)]
380
381 def _get_matching_elements(self, elements, audio_file):
382 matching_elements = []
383 for element in elements:
384 if (self.__matches("description", element.description,
385 audio_file) and
386 self.__matches("language", element.lang, audio_file)):
387 matching_elements.append(element)
388 return matching_elements
389
390 def __matches(self, parameter_name, comment_attribute_value, audio_file):
391 if not self.parameters[parameter_name].provided:
392 return True
393 if self.parameters[parameter_name].value is None:
394 return (comment_attribute_value is None or
395 comment_attribute_value == "")
396 return (self._parameter_value(parameter_name, audio_file) ==
397 comment_attribute_value)
398
399
400 class CommentTagPattern(DescriptableTagPattern):
401 NAMES = ["c", "comment"]
402 PARAMETERS = DescriptableTagPattern.PARAMETERS
403 DESCRIPTION = "First comment that matches description and language."
404
405 def _get_output_for(self, audio_file):
406 matching_comments = self._get_matching_elements(audio_file.tag.comments,
407 audio_file)
408 return matching_comments[0].text if len(matching_comments) > 0 else None
409
410
411 class AllCommentsTagPattern(DescriptableTagPattern, PlaceholderUsagePattern):
412 NAMES = ["comments"]
413 PARAMETERS = DescriptableTagPattern.PARAMETERS + \
414 [ComplexPattern.ExpectedParameter("output",
415 default="Comment: [Description: #d] [Lang: #l]: #t"),
416 ComplexPattern.ExpectedParameter("separation", default="\\n")]
417 DESCRIPTION = "All comments that are matching description and language " \
418 "(with output placeholders #d as description, #l as " \
419 " language & #t as text)."
420
421 def _get_output_for(self, audio_file):
422 output_pattern = self._parameter_value("output", audio_file)
423 separation = self._parameter_value("separation", audio_file)
424 outputs = []
425 for comment in self._get_matching_elements(audio_file.tag.comments,
426 audio_file):
427 replacements = [["#d", comment.description],
428 ["#l", comment.lang.decode("ascii")],
429 ["#t", comment.text]]
430 outputs.append(self._replace_placeholders(output_pattern,
431 replacements))
432 return separation.join(outputs)
433
434
435 class AbstractDateTagPattern(TagPattern):
436 __metaclass__ = abc.ABCMeta
437
438 def _get_output_for(self, audio_file):
439 return str(self._get_date(audio_file) or "")
440
441 @abc.abstractmethod
442 def _get_date(self, audio_file):
443 pass
444
445
446 class ReleaseDateTagPattern(AbstractDateTagPattern):
447 NAMES = ["release-date"]
448 DESCRIPTION = "Relase date"
449
450 def _get_date(self, audio_file):
451 return audio_file.tag.release_date
452
453
454 class OriginalReleaseDateTagPattern(AbstractDateTagPattern):
455 NAMES = ["original-release-date"]
456 DESCRIPTION = "Original Relase date"
457
458 def _get_date(self, audio_file):
459 return audio_file.tag.original_release_date
460
461
462 class RecordingDateTagPattern(AbstractDateTagPattern):
463 NAMES = ["recording-date"]
464 DESCRIPTION = "Recording date"
465
466 def _get_date(self, audio_file):
467 return audio_file.tag.recording_date
468
469
470 class EncodingDateTagPattern(AbstractDateTagPattern):
471 NAMES = ["encoding-date"]
472 DESCRIPTION = "Encoding date"
473
474 def _get_date(self, audio_file):
475 return audio_file.tag.encoding_date
476
477
478 class TaggingDateTagPattern(AbstractDateTagPattern):
479 NAMES = ["tagging-date"]
480 DESCRIPTION = "Tagging date"
481
482 def _get_date(self, audio_file):
483 return audio_file.tag.tagging_date
484
485
486 class PlayCountTagPattern(TagPattern):
487 NAMES = ["play-count"]
488 DESCRIPTION = "Play count"
489
490 def _get_output_for(self, audio_file):
491 return audio_file.tag.play_count
492
493
494 class PopularitiesTagPattern(TagPattern, PlaceholderUsagePattern):
495 NAMES = ["popm", "popularities"]
496 PARAMETERS = [ComplexPattern.ExpectedParameter("output",
497 default="Popularity: [email: #e] [rating: #r] [play count: #c]"),
498 ComplexPattern.ExpectedParameter("separation", default="\\n")]
499 DESCRIPTION = "Popularities (with output placeholders #e as email, "\
500 "#r as rating & #c as count)"
501
502 def _get_output_for(self, audio_file):
503 output_pattern = self._parameter_value("output", audio_file)
504 separation = self._parameter_value("separation", audio_file)
505
506 outputs = []
507 for popularity in audio_file.tag.popularities:
508 replacements = [["#e", popularity.email],
509 ["#r", popularity.rating],
510 ["#c", popularity.count]]
511 outputs.append(self._replace_placeholders(output_pattern,
512 replacements))
513 return separation.join(outputs)
514
515
516 class BPMTagPattern(TagPattern):
517 NAMES = ["bpm"]
518 DESCRIPTION = "BPM"
519
520 def _get_output_for(self, audio_file):
521 return audio_file.tag.bpm
522
523
524 class PublisherTagPattern(TagPattern):
525 NAMES = ["publisher"]
526 DESCRIPTION = "Publisher"
527
528 def _get_output_for(self, audio_file):
529 return audio_file.tag.publisher
530
531
532 class UniqueFileIDTagPattern(TagPattern, PlaceholderUsagePattern):
533 NAMES = ["ufids", "unique-file-ids"]
534 PARAMETERS = [ComplexPattern.ExpectedParameter("output",
535 default="Unique File ID: [#o] : #i"),
536 ComplexPattern.ExpectedParameter("separation", default="\\n")]
537 DESCRIPTION = "Unique File IDs (with output placeholders #o as owner & #i "\
538 " as unique id)"
539
540 def _get_output_for(self, audio_file):
541 output_pattern = self._parameter_value("output", audio_file)
542 separation = self._parameter_value("separation", audio_file)
543
544 outputs = []
545 for ufid in audio_file.tag.unique_file_ids:
546 replacements = [["#o", ufid.owner_id],
547 ["#i", ufid.uniq_id.encode("string_escape")]]
548 outputs.append(self._replace_placeholders(output_pattern,
549 replacements))
550 return separation.join(outputs)
551
552
553 class LyricsTagPattern(DescriptableTagPattern, PlaceholderUsagePattern):
554 NAMES = ["lyrics"]
555 PARAMETERS = DescriptableTagPattern.PARAMETERS + \
556 [ComplexPattern.ExpectedParameter(
557 "output",
558 default="Lyrics: [Description: #d] [Lang: #l]: #t"),
559 ComplexPattern.ExpectedParameter("separation", default="\\n")]
560 DESCRIPTION = "All lyrics that are matching description and language " + \
561 "(with output placeholders #d as description, #l as "\
562 "language & #t as text)."
563
564 def _get_output_for(self, audio_file):
565 output_pattern = self._parameter_value("output", audio_file)
566 separation = self._parameter_value("separation", audio_file)
567
568 outputs = []
569 for l in self._get_matching_elements(audio_file.tag.lyrics, audio_file):
570 replacements = [["#d", l.description],
571 ["#l", l.lang.decode("ascii")],
572 ["#t", l.text]]
573 outputs.append(self._replace_placeholders(output_pattern,
574 replacements))
575 return separation.join(outputs)
576
577
578 class TextsTagPattern(TagPattern, PlaceholderUsagePattern):
579 NAMES = ["txxx", "texts"]
580 PARAMETERS = [
581 ComplexPattern.ExpectedParameter(
582 "output", default="UserTextFrame: [Description: #d] #t"),
583 ComplexPattern.ExpectedParameter("separation", default="\\n")]
584 DESCRIPTION = "User text frames (with output placeholders #d as "\
585 "description & #t as text)"
586
587 def _get_output_for(self, audio_file):
588 output_pattern = self._parameter_value("output", audio_file)
589 separation = self._parameter_value("separation", audio_file)
590
591 outputs = []
592 for frame in audio_file.tag.user_text_frames:
593 replacements = [["#d", frame.description],
594 ["#t", frame.text]]
595 outputs.append(self._replace_placeholders(output_pattern,
596 replacements))
597 return separation.join(outputs)
598
599
600 class ArtistURLTagPattern(TagPattern):
601 NAMES = ["artist-url"]
602 DESCRIPTION = "Artist URL"
603
604 def _get_output_for(self, audio_file):
605 return audio_file.tag.artist_url
606
607
608 class AudioSourceURLTagPattern(TagPattern):
609 NAMES = ["audio-source-url"]
610 DESCRIPTION = "Audio source URL"
611
612 def _get_output_for(self, audio_file):
613 return audio_file.tag.audio_source_url
614
615
616 class AudioFileURLTagPattern(TagPattern):
617 NAMES = ["audio-file-url"]
618 DESCRIPTION = "Audio file URL"
619
620 def _get_output_for(self, audio_file):
621 return audio_file.tag.audio_file_url
622
623
624 class InternetRadioURLTagPattern(TagPattern):
625 NAMES = ["internet-radio-url"]
626 DESCRIPTION = "Internet radio URL"
627
628 def _get_output_for(self, audio_file):
629 return audio_file.tag.internet_radio_url
630
631
632 class CommercialURLTagPattern(TagPattern):
633 NAMES = ["commercial-url"]
634 DESCRIPTION = "Comercial URL"
635
636 def _get_output_for(self, audio_file):
637 return audio_file.tag.copyright_url
638
639
640 class PaymentURLTagPattern(TagPattern):
641 NAMES = ["payment-url"]
642 DESCRIPTION = "Payment URL"
643
644 def _get_output_for(self, audio_file):
645 return audio_file.tag.payment_url
646
647
648 class PublisherURLTagPattern(TagPattern):
649 NAMES = ["publisher-url"]
650 DESCRIPTION = "Publisher URL"
651
652 def _get_output_for(self, audio_file):
653 return audio_file.tag.publisher_url
654
655
656 class CopyrightTagPattern(TagPattern):
657 NAMES = ["copyright-url"]
658 DESCRIPTION = "Copyright URL"
659
660 def _get_output_for(self, audio_file):
661 return audio_file.tag.copyright_url
662
663
664 class UserURLsTagPattern(TagPattern, PlaceholderUsagePattern):
665 NAMES = ["user-urls"]
666 PARAMETERS = [ComplexPattern.ExpectedParameter("output",
667 default="#i [Description: #d]: #u"),
668 ComplexPattern.ExpectedParameter("separation", default="\\n")]
669 DESCRIPTION = "User URL frames (with output placeholders #i as frame id, "\
670 "#d as description & #u as url)"
671
672 def _get_output_for(self, audio_file):
673 output_pattern = self._parameter_value("output", audio_file)
674 separation = self._parameter_value("separation", audio_file)
675
676 outputs = []
677 for frame in audio_file.tag.user_url_frames:
678 replacements = [["#i", frame.id],
679 ["#d", frame.description],
680 ["#u", frame.url]]
681 outputs.append(self._replace_placeholders(output_pattern,
682 replacements))
683 return separation.join(outputs)
684
685
686 class ImagesTagPattern(TagPattern, PlaceholderUsagePattern):
687 NAMES = ["images", "apic"]
688 PARAMETERS = [ComplexPattern.ExpectedParameter(
689 "output",
690 default="#t Image: [Type: #m] [Size: #s bytes] #d"),
691 ComplexPattern.ExpectedParameter("separation", default="\\n")]
692 DESCRIPTION = "Attached pictures (APIC)" \
693 "(with output placeholders #t as image type, "\
694 "#m as mime type, #s as size in bytes & #d as description)"
695
696 def _get_output_for(self, audio_file):
697 output_pattern = self._parameter_value("output", audio_file)
698 separation = self._parameter_value("separation", audio_file)
699
700 outputs = []
701 for img in audio_file.tag.images:
702 if img.mime_type not in id3.frames.ImageFrame.URL_MIME_TYPE_VALUES:
703 replacements = [["#t", img.picTypeToString(img.picture_type)],
704 ["#m", img.mime_type],
705 ["#s", len(img.image_data)],
706 ["#d", img.description]]
707 outputs.append(self._replace_placeholders(output_pattern,
708 replacements))
709 return separation.join(outputs)
710
711
712 class ImageURLsTagPattern(TagPattern, PlaceholderUsagePattern):
713 NAMES = ["image-urls"]
714 PARAMETERS = [ComplexPattern.ExpectedParameter(
715 "output", default="#t Image: [Type: #m] [URL: #u] #d"),
716 ComplexPattern.ExpectedParameter("separation", default="\\n")]
717 DESCRIPTION = "Attached pictures URLs" \
718 "(with output placeholders #t as image type, "\
719 "#m as mime type, #u as URL & #d as description)"
720
721 def _get_output_for(self, audio_file):
722 output_pattern = self._parameter_value("output", audio_file)
723 separation = self._parameter_value("separation", audio_file)
724
725 outputs = []
726 for img in audio_file.tag.images:
727 if img.mime_type in id3.frames.ImageFrame.URL_MIME_TYPE_VALUES:
728 replacements = [["#t", img.picTypeToString(img.picture_type)],
729 ["#m", img.mime_type],
730 ["#u", img.image_url],
731 ["#d", img.description]]
732 outputs.append(self._replace_placeholders(output_pattern,
733 replacements))
734 return separation.join(outputs)
735
736
737 class ObjectsTagPattern(TagPattern, PlaceholderUsagePattern):
738 NAMES = ["objects", "gobj"]
739 PARAMETERS = [ComplexPattern.ExpectedParameter("output",
740 default="GEOB: [Size: #s bytes] [Type: #t] "
741 "Description: #d | Filename: #f"),
742 ComplexPattern.ExpectedParameter("separation", default="\\n")]
743 DESCRIPTION = "Objects (GOBJ)" \
744 "(with output placeholders #s as size, #m as mime type, "\
745 "#d as description and #f as file name)"
746
747 def _get_output_for(self, audio_file):
748 output_pattern = self._parameter_value("output", audio_file)
749 separation = self._parameter_value("separation", audio_file)
750
751 outputs = []
752 for obj in audio_file.tag.objects:
753 replacements = [["#s", len(obj.object_data)],
754 ["#m", obj.mime_type],
755 ["#d", obj.description],
756 ["#f", obj.filename]]
757 outputs.append(self._replace_placeholders(output_pattern,
758 replacements))
759 return separation.join(outputs)
760
761
762 class PrivatesTagPattern(TagPattern, PlaceholderUsagePattern):
763 NAMES = ["privates", "priv"]
764 PARAMETERS = [ComplexPattern.ExpectedParameter("output",
765 default="PRIV-Content: #b bytes | Owner: #o"),
766 ComplexPattern.ExpectedParameter("separation", default="\\n")]
767 DESCRIPTION = "Privates (APIC) (with output placeholders #c as content, "\
768 "#b as number of bytes & #o as owner)"
769
770 def _get_output_for(self, audio_file):
771 output_pattern = self._parameter_value("output", audio_file)
772 separation = self._parameter_value("separation", audio_file)
773
774 outputs = []
775 for private in audio_file.tag.privates:
776 replacements = [["#b", "%i" % len(private.data)],
777 ["#c", private.data.decode("ascii")],
778 ["#o", private.owner_id.decode("ascii")]]
779 outputs.append(self._replace_placeholders(output_pattern,
780 replacements))
781 return separation.join(outputs)
782
783
784 class MusicCDIdTagPattern(TagPattern):
785 NAMES = ["music-cd-id", "mcdi"]
786 DESCRIPTION = "Music CD Identification"
787
788 def _get_output_for(self, audio_file):
789 if audio_file.tag.cd_id is not None:
790 return audio_file.tag.cd_id.decode("ascii")
791 else:
792 return None
793
794
795 class TermsOfUseTagPattern(TagPattern):
796 NAMES = ["terms-of-use"]
797 DESCRIPTION = "Terms of use"
798
799 def _get_output_for(self, audio_file):
800 return audio_file.tag.terms_of_use
801
802
803 class FunctionPattern(ComplexPattern):
804 __metaclass__ = abc.ABCMeta
805 TYPE = "function"
806
807
808 class FunctionFormatPattern(FunctionPattern):
809 NAMES = ["format"]
810 PARAMETERS = [ComplexPattern.ExpectedParameter("text"),
811 ComplexPattern.ExpectedParameter("bold", default=None),
812 ComplexPattern.ExpectedParameter("color", default=None)]
813 DESCRIPTION = "Formats text bold and colored (grey, red, green, yellow, "\
814 "blue, magenta, cyan or white)"
815
816 def _get_output_for(self, audio_file):
817 text = self._parameter_value("text", audio_file)
818 bold = self._parameter_bool("bold", audio_file)
819 color_name = self._parameter_value("color", audio_file)
820 return console.formatText(text, b=bold, c=self.__color(color_name))
821
822 @staticmethod
823 def __color(color_name):
824 return {"GREY": console.Fore.GREY,
825 "RED": console.Fore.RED,
826 "GREEN": console.Fore.GREEN,
827 "YELLOW": console.Fore.YELLOW,
828 "BLUE": console.Fore.BLUE,
829 "MAGENTA": console.Fore.MAGENTA,
830 "CYAN": console.Fore.CYAN,
831 "WHITE": console.Fore.WHITE}.get(color_name.upper(), None)
832
833
834 class FunctionNumberPattern(FunctionPattern):
835 NAMES = ["num", "number-format"]
836 PARAMETERS = [ComplexPattern.ExpectedParameter("number"),
837 ComplexPattern.ExpectedParameter("digits")]
838 DESCRIPTION = "Appends leading zeros"
839
840 def _get_output_for(self, audio_file):
841 number = self._parameter_value("number", audio_file)
842 digits = self._parameter_value("digits", audio_file)
843 try:
844 number = int(number)
845 except ValueError:
846 raise DisplayException(self._error_message("'" + number +
847 "' is not a number."))
848 try:
849 digits = int(digits)
850 except ValueError:
851 raise DisplayException(self._error_message("'" + digits +
852 "' is not a number."))
853
854 output = str(number)
855 return ("0" * max(0, digits - len(output))) + output
856
857
858 class FunctionFilenamePattern(FunctionPattern):
859 NAMES = ["filename", "fn"]
860 PARAMETERS = [ComplexPattern.ExpectedParameter("basename", default=None)]
861 DESCRIPTION = "File name"
862
863 def _get_output_for(self, audio_file):
864 if self._parameter_bool("basename", audio_file):
865 return os.path.basename(audio_file.path)
866 return audio_file.path
867
868
869 class FunctionFilesizePattern(FunctionPattern):
870 NAMES = ["filesize"]
871 DESCRIPTION = "Size of file"
872
873 def _get_output_for(self, audio_file):
874 from stat import ST_SIZE
875 file_size = os.stat(audio_file.path)[ST_SIZE]
876 return formatSize(file_size)
877
878
879 class FunctionTagVersionPattern(FunctionPattern):
880 NAMES = ["tag-version"]
881 DESCRIPTION = "Tag version"
882
883 def _get_output_for(self, audio_file):
884 return id3.versionToString(audio_file.tag.version)
885
886
887 class FunctionLengthPattern(FunctionPattern):
888 NAMES = ["length"]
889 DESCRIPTION = "Length of aufio file"
890
891 def _get_output_for(self, audio_file):
892 return formatTime(audio_file.info.time_secs)
893
894
895 class FunctionMPEGVersionPattern(FunctionPattern, PlaceholderUsagePattern):
896 NAMES = ["mpeg-version"]
897 PARAMETERS = [ComplexPattern.ExpectedParameter("output",
898 default=r"MPEG#v\, Layer #l")]
899 DESCRIPTION = "MPEG version (with output placeholders #v as version & "\
900 "#l as layer)"
901
902 def _get_output_for(self, audio_file):
903 output = self._parameter_value("output", audio_file)
904 replacements = [["#v", str(audio_file.info.mp3_header.version)],
905 ["#l", "I" * audio_file.info.mp3_header.layer]]
906 return self._replace_placeholders(output, replacements)
907
908
909 class FunctionBitRatePattern(FunctionPattern):
910 NAMES = ["bit-rate"]
911 DESCRIPTION = "Bit rate of aufio file"
912
913 def _get_output_for(self, audio_file):
914 return audio_file.info.bit_rate_str
915
916
917 class FunctionSampleFrequencePattern(FunctionPattern):
918 NAMES = ["sample-freq"]
919 DESCRIPTION = "Sample frequence of aufio file in Hz"
920
921 def _get_output_for(self, audio_file):
922 return str(audio_file.info.mp3_header.sample_freq)
923
924
925 class FunctionAudioModePattern(FunctionPattern):
926 NAMES = ["audio-mode"]
927 DESCRIPTION = "Mode of aufio file: mono/stereo"
928
929 def _get_output_for(self, audio_file):
930 return audio_file.info.mp3_header.mode
931
932
933 class FunctionNotEmptyPattern(FunctionPattern, PlaceholderUsagePattern):
934 NAMES = ["not-empty"]
935 PARAMETERS = [ComplexPattern.ExpectedParameter("text"),
936 ComplexPattern.ExpectedParameter("output", default="#t"),
937 ComplexPattern.ExpectedParameter("empty", default=None)]
938 DESCRIPTION = "If condition is not empty (with output placeholder #t as "\
939 "text)"
940
941 def _get_output_for(self, audio_file):
942 text = self._parameter_value("text", audio_file)
943 if len(text) > 0:
944 output = self._parameter_value("output", audio_file)
945 return self._replace_placeholders(output, [["#t", text]])
946 else:
947 return self._parameter_value("empty", audio_file)
948
949
950 class FunctionRepeatPattern(FunctionPattern):
951 NAMES = ["repeat"]
952 PARAMETERS = [ComplexPattern.ExpectedParameter("text"),
953 ComplexPattern.ExpectedParameter("count")]
954 DESCRIPTION = "Repeats text"
955
956 def _get_output_for(self, audio_file):
957 content = self._parameter_value("text", audio_file)
958 count = self._parameter_value("count", audio_file)
959 try:
960 count = int(count)
961 except ValueError:
962 raise DisplayException(self._error_message(f"'{count}' is not a number."))
963 return content * count
964
965
966 class DisplayPlugin(LoaderPlugin):
967 NAMES = ["display"]
968 SUMMARY = "Tag Display"
969 DESCRIPTION = """
970 Prints specific tag information.
971 """
972
973 def __init__(self, arg_parser):
974 super(DisplayPlugin, self).__init__(arg_parser)
975
976 def filename(fn):
977 if not os.path.exists(fn):
978 raise ArgumentTypeError("The file %s does not exist!" % fn)
979 return fn
980
981 pattern_group = \
982 self.arg_group.add_mutually_exclusive_group(required=True)
983 pattern_group.add_argument("--pattern-help", action="store_true",
984 dest="pattern_help",
985 help=ARGS_HELP["--pattern-help"])
986 pattern_group.add_argument("-p", "--pattern", dest="pattern_string",
987 metavar="STRING",
988 help=ARGS_HELP["--pattern"])
989 pattern_group.add_argument("-f", "--pattern-file", dest="pattern_file",
990 metavar="FILE", type=filename,
991 help=ARGS_HELP["--pattern-file"])
992 self.arg_group.add_argument("--no-newline", action="store_true",
993 dest="no_newline",
994 help=ARGS_HELP["--no-newline"])
995
996 self.__pattern = None
997 self.__return_code = 0
998 self.__output_ending = None
999
1000 def start(self, args, config):
1001 super(DisplayPlugin, self).start(args, config)
1002
1003 if args.pattern_help:
1004 self.__print_pattern_help()
1005 return
1006
1007 if not _have_grako:
1008 console.printError("Unknown module 'grako'" + os.linesep +
1009 "Please install grako! " +
1010 "E.g. $ pip install grako")
1011 self.__return_code = 2
1012 return
1013
1014 if args.pattern_string is not None:
1015 self.__pattern = Pattern(args.pattern_string)
1016 if args.pattern_file is not None:
1017 pfile = open(args.pattern_file, "r")
1018 self.__pattern = Pattern(''.join(pfile.read().splitlines()))
1019 pfile.close()
1020 self.__output_ending = "" if args.no_newline else os.linesep
1021
1022 def handleFile(self, f, *args, **kwargs):
1023 if self.args.pattern_help:
1024 return
1025 if self.__return_code != 0:
1026 return
1027
1028 super(DisplayPlugin, self).handleFile(f)
1029 if not self.audio_file:
1030 return
1031
1032 try:
1033 print(self.__pattern.output_for(self.audio_file),
1034 end=self.__output_ending)
1035 except PatternCompileException as e:
1036 self.__return_code = 1
1037 console.printError(e.message)
1038 except DisplayException as e:
1039 self.__return_code = 1
1040 console.printError(e.message)
1041
1042 def handleDone(self):
1043 return self.__return_code
1044
1045 def __print_pattern_help(self):
1046 print("\nAll pattern variable are of the form `%var%`\n")
1047
1048 # FIXME: Force some order
1049 print(console.formatText("ID3 Tags:", b=True))
1050 self.__print_complex_pattern_help(TagPattern)
1051 print(os.linesep)
1052
1053 print(console.formatText("Functions:", b=True))
1054 self.__print_complex_pattern_help(FunctionPattern)
1055 print(os.linesep)
1056
1057 print(console.formatText("Special characters:", b=True))
1058 print(console.formatText("\tescape seq. character"))
1059 for i in range(len(TextPattern.SPECIAL_CHARACTERS)):
1060 print(("\t\\%s" + (" " * 12) + "%s") %
1061 (TextPattern.SPECIAL_CHARACTERS[i],
1062 TextPattern.SPECIAL_CHARACTERS_DESCRIPTIONS[i]))
1063
1064 def __print_complex_pattern_help(self, base_class):
1065 rows = []
1066 # TODO line wrap for description
1067 for pattern_class in Pattern.sub_pattern_classes(base_class):
1068 rows.append([", ".join(pattern_class.NAMES),
1069 pattern_class.DESCRIPTION])
1070 parameters = Pattern.pattern_class_parameters(pattern_class)
1071 if len(parameters) > 0:
1072 rows.append(["", "Parameter" +
1073 ("s:" if len(parameters) > 1 else ":")])
1074 for parameter in parameters:
1075 parameter_desc = parameter.name
1076 if not parameter.requried:
1077 default = ", default='" + parameter.default + \
1078 "'" if parameter.default else ""
1079 parameter_desc += " (optional" + default + ")"
1080 rows.append(["", " " + parameter_desc])
1081 self.__print_rows(rows, "\t", " ")
1082
1083 @staticmethod
1084 def __print_rows(rows, indent, spacing):
1085 row_widths = []
1086 for row in rows:
1087 for n in range(len(row)):
1088 width = len(row[n])
1089 if len(row_widths) <= n:
1090 row_widths.append(width)
1091 else:
1092 row_widths[n] = max(row_widths[n], width)
1093 for row in rows:
1094 out = indent
1095 for n in range(len(row)):
1096 out += row[n]
1097 if n < len(row) - 1:
1098 out += (" " * (row_widths[n] - len(row[n]))) + spacing
1099 print(out)
1100
1101
1102 class DisplayException(Exception):
1103 def __init__(self, message):
1104 self.__message = message
1105
1106 def __get_message(self):
1107 return self.__message
1108
1109 message = property(__get_message)
1110
1111
1112 class PatternCompileException(Exception):
1113 def __init__(self, message):
1114 self.__message = message
1115
1116 def __get_message(self):
1117 return self.__message
1118
1119 message = property(__get_message)
1120
1121
1122 ARGS_HELP = {
1123 "--pattern-help": "Detailed pattern help",
1124 "--pattern": "Pattern string",
1125 "--pattern-file": "Pattern file",
1126 "--no-newline": "Print no newline after each output"
1127 }
0 import sys
1 import binascii
2 from pathlib import Path
3
4 import eyed3.id3
5 import eyed3.plugins
6 from eyed3.utils.log import getLogger
7
8 log = getLogger(__name__)
9
10
11 class ExtractPlugin(eyed3.plugins.LoaderPlugin):
12 NAMES = ["extract"]
13 SUMMARY = "Extract tags from audio files."
14
15 def __init__(self, arg_parser):
16 super().__init__(arg_parser, cache_files=True, track_images=False)
17 self.arg_group.add_argument("-o", "--output-file",
18 help="The the tag is written to this file in native format.")
19 self.arg_group.add_argument("-H", "--hex", action="store_true",
20 help="Output hexadecimal format.")
21 self.arg_group.add_argument("--strip-padding", action="store_true",
22 help="Exclude tag padding, if any.")
23
24 def handleFile(self, f, *args, **kwargs):
25 super().handleFile(f)
26 if self.audio_file is None or self.audio_file.tag is None:
27 return
28
29 tag = self.audio_file.tag
30 if not isinstance(tag, eyed3.id3.Tag):
31 print("Only ID3 tags can be extracted currently.", file=sys.stderr)
32 return 1
33
34 with open(tag.file_info.name, "rb") as tag_file:
35 if tag.version[0] != 1:
36 # info.tag_size includes padding.
37 tag_data = tag_file.read(tag.file_info.tag_size)
38 if self.args.strip_padding and tag.file_info.tag_padding_size:
39 # --strip-padding
40 tag_data = tag_data[:-tag.file_info.tag_padding_size]
41 else:
42 # ID3 v1.x
43 tag_data = tag_file.read()[-128:]
44
45 if self.args.output_file:
46 # --output-file
47
48 if Path(tag.file_info.name).resolve() == Path(self.args.output_file).resolve():
49 print("Input file overwriting not allowed, choose a different -o/--output-file",
50 file=sys.stderr)
51 return 1
52
53 with open(self.args.output_file, "wb") as out_file:
54 out_file.write(tag_data)
55 else:
56 if self.args.hex:
57 # --hex
58 tag_data = str(binascii.hexlify(tag_data), "ascii")
59
60 print(tag_data)
0 import os
1 from collections import defaultdict
2
3 from eyed3.id3 import ID3_V2_4
4 from eyed3.id3.tag import TagTemplate
5 from eyed3.plugins import LoaderPlugin
6 from eyed3.utils import art
7 from eyed3.utils.prompt import prompt
8 from eyed3.utils.console import printMsg, Style, Fore
9 from eyed3 import core
10
11 from eyed3.core import (ALBUM_TYPE_IDS, TXXX_ALBUM_TYPE, EP_MAX_SIZE_HINT,
12 LP_TYPE, EP_TYPE, COMP_TYPE, VARIOUS_TYPE, DEMO_TYPE,
13 LIVE_TYPE, SINGLE_TYPE, VARIOUS_ARTISTS)
14
15 NORMAL_FNAME_FORMAT = "${artist} - ${track:num} - ${title}"
16 VARIOUS_FNAME_FORMAT = "${track:num} - ${artist} - ${title}"
17 SINGLE_FNAME_FORMAT = "${artist} - ${title}"
18
19 NORMAL_DNAME_FORMAT = "${best_date:prefer_release} - ${album}"
20 LIVE_DNAME_FORMAT = "${best_date:prefer_recording} - ${album}"
21
22
23 def _printChecking(msg, end='\n'):
24 print(Style.BRIGHT + Fore.GREEN + "Checking" + Style.RESET_ALL + " %s" % msg, end=end)
25
26
27 def _fixCase(s):
28 if s:
29 fixed_values = []
30 for word in s.split():
31 fixed_values.append(word.capitalize())
32 return " ".join(fixed_values)
33 else:
34 return s
35
36
37 def dirDate(d):
38 s = str(d)
39 if "T" in s:
40 s = s.split("T")[0]
41 return s.replace('-', '.')
42
43
44 class FixupPlugin(LoaderPlugin):
45 NAMES = ["fixup"]
46 SUMMARY = "Performs various checks and fixes to directories of audio files."
47 DESCRIPTION = """
48 Operates on directories at a time, fixing each as a unit (album,
49 compilation, live set, etc.). All of these should have common dates,
50 for example but other characteristics may vary. The ``--type`` should be used
51 whenever possible, ``lp`` is the default.
52
53 The following test and fixes always apply:
54
55 1. Every file will be given an ID3 tag if one is missing.
56 2. Set ID3 v2.4.
57 3. Set a consistent album name for all files in the directory.
58 4. Set a consistent artist name for all files, unless the type is
59 ``various`` in which case the artist may vary (but must exist).
60 5. Ensure each file has a title.
61 6. Ensure each file has a track # and track total.
62 7. Ensure all files have a release and original release date, unless the
63 type is ``live`` in which case the recording date is set.
64 8. All ID3 frames of the following types are removed: USER, PRIV
65 9. All ID3 files have TLEN (track length in ms) set (or updated).
66 10. The album/dir type is set in the tag. Types of ``lp`` and ``various``
67 do not have this field set since the latter is the default and the
68 former can be determined during sync. In ID3 terms the value is in
69 TXXX (description: ``%(TXXX_ALBUM_TYPE)s``).
70 11. Files are renamed as follows:
71 - Type ``various``: %(VARIOUS_FNAME_FORMAT)s
72 - Type ``single``: %(SINGLE_FNAME_FORMAT)s
73 - All other types: %(NORMAL_FNAME_FORMAT)s
74 - A rename template can be supplied in --file-rename-pattern
75 12. Directories are renamed as follows:
76 - Type ``live``: %(LIVE_DNAME_FORMAT)s
77 - All other types: %(NORMAL_DNAME_FORMAT)s
78 - A rename template can be supplied in --dir-rename-pattern
79
80 Album types:
81
82 - ``lp``: A traditinal "album" of songs from a single artist.
83 No extra info is written to the tag since this is the default.
84 - ``ep``: A short collection of songs from a single artist. The string 'ep'
85 is written to the tag's ``%(TXXX_ALBUM_TYPE)s`` field.
86 - ``various``: A collection of songs from different artists. The string
87 'various' is written to the tag's ``%(TXXX_ALBUM_TYPE)s`` field.
88 - ``live``: A collection of live recordings from a single artist. The string
89 'live' is written to the tag's ``%(TXXX_ALBUM_TYPE)s`` field.
90 - ``compilation``: A collection of songs from various recordings by a single
91 artist. The string 'compilation' is written to the tag's
92 ``%(TXXX_ALBUM_TYPE)s`` field. Compilation dates, unlike other types, may
93 differ.
94 - ``demo``: A demo recording by a single artist. The string 'demo' is
95 written to the tag's ``%(TXXX_ALBUM_TYPE)s`` field.
96 - ``single``: A track that should no be associated with an album (even if
97 it has album metadata). The string 'single' is written to the tag's
98 ``%(TXXX_ALBUM_TYPE)s`` field.
99
100 """ % globals()
101
102 def __init__(self, arg_parser):
103 super(FixupPlugin, self).__init__(arg_parser, cache_files=True,
104 track_images=True)
105 g = self.arg_group
106 self._handled_one = False
107
108 g.add_argument("--type", choices=ALBUM_TYPE_IDS, dest="dir_type",
109 default=None, help=ARGS_HELP["--type"])
110 g.add_argument("--fix-case", action="store_true", dest="fix_case",
111 help=ARGS_HELP["--fix-case"])
112 g.add_argument("-n", "--dry-run", action="store_true", dest="dry_run",
113 help=ARGS_HELP["--dry-run"])
114 g.add_argument("--no-prompt", action="store_true", dest="no_prompt",
115 help=ARGS_HELP["--no-prompt"])
116 g.add_argument("--dotted-dates", action="store_true",
117 help=ARGS_HELP["--dotted-dates"])
118 g.add_argument("--file-rename-pattern", dest="file_rename_pattern",
119 help=ARGS_HELP["--file-rename-pattern"])
120 g.add_argument("--dir-rename-pattern", dest="dir_rename_pattern",
121 help=ARGS_HELP["--dir-rename-pattern"])
122 self._curr_dir_type = None
123 self._dir_files_to_remove = set()
124
125 def _getOne(self, key, values, default=None, Type=str,
126 required=True):
127 values = set(values)
128 if None in values:
129 values.remove(None)
130
131 if len(values) != 1:
132 printMsg(
133 "Detected %s %s names%s" %
134 ("0" if len(values) == 0 else "multiple",
135 key,
136 "." if not values
137 else (":\n\t%s" % "\n\t".join([str(v) for v in values])),
138 ))
139
140 value = prompt("Enter %s" % key.title(), default=default,
141 type_=Type, required=required)
142 else:
143 value = values.pop()
144
145 return value
146
147 def _getDates(self, audio_files):
148 tags = [f.tag for f in audio_files if f.tag]
149
150 rel_dates = set([t.release_date for t in tags if t.release_date])
151 orel_dates = set([t.original_release_date for t in tags
152 if t.original_release_date])
153 rec_dates = set([t.recording_date for t in tags if t.recording_date])
154
155 release_date, original_release_date, recording_date = None, None, None
156
157 def reduceDate(type_str, dates_set, default_date=None):
158 if len(dates_set or []) != 1:
159 reduced = self._getOne(type_str, dates_set,
160 default=str(default_date) if default_date
161 else None,
162 Type=core.Date.parse)
163 else:
164 reduced = dates_set.pop()
165 return reduced
166
167 if (False not in [a.tag.album_type == LIVE_TYPE for a in audio_files] or
168 self._curr_dir_type == LIVE_TYPE):
169 # The recording date is most meaningful for live music.
170 recording_date = reduceDate("recording date",
171 rec_dates | orel_dates | rel_dates)
172 rec_dates = {recording_date}
173
174 # Want when these set if they may recording time.
175 orel_dates.difference_update(rec_dates)
176 rel_dates.difference_update(rec_dates)
177
178 if orel_dates:
179 original_release_date = reduceDate("original release date",
180 orel_dates | rel_dates)
181 orel_dates = {original_release_date}
182
183 if rel_dates | orel_dates:
184 release_date = reduceDate("release date",
185 rel_dates | orel_dates)
186 elif (False not in [a.tag.album_type == COMP_TYPE
187 for a in audio_files] or
188 self._curr_dir_type == COMP_TYPE):
189 # The release date is most meaningful for comps, other track dates
190 # may differ.
191 if len(rel_dates) != 1:
192 release_date = reduceDate("release date", rel_dates | orel_dates)
193 else:
194 release_date = list(rel_dates)[0]
195 else:
196 if len(orel_dates) != 1:
197 # The original release date is most meaningful for studio music.
198 original_release_date = reduceDate("original release date",
199 orel_dates | rel_dates | rec_dates)
200 orel_dates = {original_release_date}
201 else:
202 original_release_date = list(orel_dates)[0]
203
204 if len(rel_dates) != 1:
205 release_date = reduceDate("release date", rel_dates | orel_dates)
206 rel_dates = {release_date}
207 else:
208 release_date = list(rel_dates)[0]
209
210 if rec_dates.difference(orel_dates | rel_dates):
211 recording_date = reduceDate("recording date", rec_dates)
212
213 return release_date, original_release_date, recording_date
214
215 def _resolveArtistInfo(self, audio_files):
216 assert(self._curr_dir_type != SINGLE_TYPE)
217
218 tags = [f.tag for f in audio_files if f.tag]
219 artists = set([t.album_artist for t in tags if t.album_artist])
220
221 # There can be 0 or 1 album artist values.
222 album_artist = None
223 if len(artists) > 1:
224 album_artist = self._getOne("album artist", artists, required=False)
225 elif artists:
226 album_artist = artists.pop()
227
228 artists = list(set([t.artist for t in tags if t.artist]))
229
230 if len(artists) > 1:
231 # There can be more then 1 artist when VARIOUS_TYPE or
232 # album_artist != None.
233 if not album_artist and self._curr_dir_type != VARIOUS_TYPE:
234 if prompt("Multiple artist names exist, process directory as "
235 "various artists", default=True):
236 self._curr_dir_type = VARIOUS_TYPE
237 else:
238 artists = [self._getOne("artist", artists, required=True)]
239 elif (album_artist == VARIOUS_ARTISTS and
240 self._curr_dir_type != VARIOUS_TYPE):
241 self._curr_dir_type = VARIOUS_TYPE
242 elif len(artists) == 0:
243 artists = [self._getOne("artist", [], required=True)]
244
245 # Fix up artist and album artist discrepancies
246 if len(artists) == 1 and album_artist:
247 artist = artists[0]
248 if album_artist != artist:
249 print("When there is only one artist it should match the "
250 "album artist. Choices are: ")
251 for s in [artist, album_artist]:
252 print("\t%s" % s)
253 album_artist = prompt("Select common artist and album artist",
254 choices=[artist, album_artist])
255 artists = [album_artist]
256
257 if self.args.fix_case:
258 album_artist = _fixCase(album_artist)
259 artists = [_fixCase(a) for a in artists]
260 return album_artist, artists
261
262 def _getAlbum(self, audio_files):
263 tags = [f.tag for f in audio_files if f.tag]
264 albums = set([t.album for t in tags if t.album])
265 album_name = (albums.pop() if len(albums) == 1
266 else self._getOne("album", albums))
267 assert album_name
268 return album_name if not self.args.fix_case else _fixCase(album_name)
269
270 def _checkCoverArt(self, directory, audio_files):
271 valid_cover = False
272
273 # Check for cover file.
274 _printChecking("for cover art...")
275 for dimg in self._dir_images:
276 art_type = art.matchArtFile(dimg)
277 if art_type == art.FRONT_COVER:
278 dimg_name = os.path.basename(dimg)
279 print("\t%s" % dimg_name)
280 valid_cover = True
281
282 if not valid_cover:
283 # FIXME: move the logic out fixup and into art.
284 # Look for a cover in the tags.
285 for tag in [af.tag for af in audio_files if af.tag]:
286 if valid_cover:
287 # It could be set below...
288 break
289 for img in tag.images:
290 if img.picture_type == img.FRONT_COVER:
291 file_name = img.makeFileName("cover")
292 print("\tFound front cover in tag, writing '%s'" %
293 file_name)
294 with open(os.path.join(directory, file_name),
295 "wb") as img_file:
296 img_file.write(img.image_data)
297 img_file.close()
298 valid_cover = True
299
300 return valid_cover
301
302 def start(self, args, config):
303 import eyed3.utils.prompt
304 eyed3.utils.prompt.DISABLE_PROMPT = "exit" if args.no_prompt else None
305
306 super(FixupPlugin, self).start(args, config)
307
308 def handleFile(self, f, *args, **kwargs):
309 super(FixupPlugin, self).handleFile(f, *args, **kwargs)
310 if not self.audio_file and f not in self._dir_images:
311 self._dir_files_to_remove.add(f)
312
313 def handleDirectory(self, directory, _):
314 if not self._file_cache:
315 return
316
317 directory = os.path.abspath(directory)
318 print("\n" + Style.BRIGHT + Fore.YELLOW +
319 "Scanning directory%s %s" % (Style.RESET_ALL, directory))
320
321 def _path(af):
322 return af.path
323
324 self._handled_one = True
325
326 # Make sure all of the audio files has a tag.
327 for f in self._file_cache:
328 if f.tag is None:
329 f.initTag()
330
331 audio_files = sorted(list(self._file_cache), key=_path)
332
333 self._file_cache = []
334 edited_files = set()
335 self._curr_dir_type = self.args.dir_type
336 if self._curr_dir_type is None:
337 types = set([a.tag.album_type for a in audio_files])
338 if len(types) == 1:
339 self._curr_dir_type = types.pop()
340
341 # Check for corrections to LP, EP, COMP
342 if (self._curr_dir_type is None and
343 len(audio_files) < EP_MAX_SIZE_HINT):
344 # Do you want EP?
345 if False in [a.tag.album_type == EP_TYPE for a in audio_files]:
346 if prompt("Only %d audio files, process directory as an EP" %
347 len(audio_files),
348 default=True):
349 self._curr_dir_type = EP_TYPE
350 else:
351 self._curr_dir_type = EP_TYPE
352 elif (self._curr_dir_type in (EP_TYPE, DEMO_TYPE) and
353 len(audio_files) > EP_MAX_SIZE_HINT):
354 # Do you want LP?
355 if prompt("%d audio files is large for type %s, process "
356 "directory as an LP" % (len(audio_files),
357 self._curr_dir_type),
358 default=True):
359 self._curr_dir_type = LP_TYPE
360
361 last = defaultdict(lambda: None)
362
363 album_artist = None
364 artists = set()
365 album = None
366
367 if self._curr_dir_type != SINGLE_TYPE:
368 album_artist, artists = self._resolveArtistInfo(audio_files)
369 print(Fore.BLUE + "Album artist: " + Style.RESET_ALL +
370 (album_artist or ""))
371 print(Fore.BLUE + "Artist" + ("s" if len(artists) > 1 else "") +
372 ": " + Style.RESET_ALL + ", ".join(artists))
373
374 album = self._getAlbum(audio_files)
375 print(Fore.BLUE + "Album: " + Style.RESET_ALL + album)
376
377 rel_date, orel_date, rec_date = self._getDates(audio_files)
378 for what, d in [("Release", rel_date),
379 ("Original", orel_date),
380 ("Recording", rec_date)]:
381 print(f"{Fore.BLUE} {what} date: {Style.RESET_ALL} {d}")
382
383 num_audio_files = len(audio_files)
384 track_nums = set([f.tag.track_num[0] for f in audio_files])
385 fix_track_nums = set(range(1, num_audio_files + 1)) != track_nums
386 new_track_nums = []
387
388 dir_type = self._curr_dir_type
389 for f in sorted(audio_files, key=_path):
390 print(Style.BRIGHT + Fore.GREEN + "Checking" + Fore.RESET +
391 Style.BRIGHT + (" %s" % os.path.basename(f.path)) +
392 Style.RESET_ALL)
393
394 if not f.tag:
395 print("\tAdding new tag")
396 f.initTag()
397 edited_files.add(f)
398 tag = f.tag
399
400 if tag.version != ID3_V2_4:
401 print("\tConverting to ID3 v2.4")
402 tag.version = ID3_V2_4
403 edited_files.add(f)
404
405 if dir_type != SINGLE_TYPE and album_artist != tag.album_artist:
406 print("\tSetting album artist: %s" % album_artist)
407 tag.album_artist = album_artist
408 edited_files.add(f)
409
410 if not tag.artist and dir_type in (VARIOUS_TYPE, SINGLE_TYPE):
411 # Prompt artist
412 tag.artist = prompt("Artist name", default=last["artist"])
413 last["artist"] = tag.artist
414 elif len(artists) == 1 and tag.artist != artists[0]:
415 assert(dir_type != SINGLE_TYPE)
416 print("\tSetting artist: %s" % artists[0])
417 tag.artist = artists[0]
418 edited_files.add(f)
419
420 if tag.album != album and dir_type != SINGLE_TYPE:
421 print("\tSetting album: %s" % album)
422 tag.album = album
423 edited_files.add(f)
424
425 orig_title = tag.title
426 if not tag.title:
427 tag.title = prompt("Track title")
428 tag.title = tag.title.strip()
429 if self.args.fix_case:
430 tag.title = _fixCase(tag.title)
431 if orig_title != tag.title:
432 print("\tSetting title: %s" % tag.title)
433 edited_files.add(f)
434
435 if dir_type != SINGLE_TYPE:
436 # Track numbers
437 tnum, ttot = tag.track_num
438 update = False
439 if ttot != num_audio_files:
440 update = True
441 ttot = num_audio_files
442
443 if fix_track_nums or not (1 <= tnum <= num_audio_files):
444 tnum = None
445 while tnum is None:
446 tnum = int(prompt("Track #", type_=int))
447 if not (1 <= tnum <= num_audio_files):
448 print(Fore.RED + "Out of range: " + Fore.RESET +
449 "1 <= %d <= %d" % (tnum, num_audio_files))
450 tnum = None
451 elif tnum in new_track_nums:
452 print(Fore.RED + "Duplicate value: " + Fore.RESET +
453 str(tnum))
454 tnum = None
455 else:
456 update = True
457 new_track_nums.append(tnum)
458
459 if update:
460 tag.track_num = (tnum, ttot)
461 print("\tSetting track numbers: %s" % str(tag.track_num))
462 edited_files.add(f)
463 else:
464 # Singles
465 if tag.track_num != (None, None):
466 tag.track_num = (None, None)
467 edited_files.add(f)
468
469 if dir_type != SINGLE_TYPE:
470 # Dates
471 if rec_date and tag.recording_date != rec_date:
472 print("\tSetting %s date (%s)" %
473 ("recording", str(rec_date)))
474 tag.recording_date = rec_date
475 edited_files.add(f)
476 if rel_date and tag.release_date != rel_date:
477 print("\tSetting %s date (%s)" % ("release", str(rel_date)))
478 tag.release_date = rel_date
479 edited_files.add(f)
480 if orel_date and tag.original_release_date != orel_date:
481 print("\tSetting %s date (%s)" % ("original release",
482 str(orel_date)))
483 tag.original_release_date = orel_date
484 edited_files.add(f)
485
486 for frame in list(tag.frameiter(["USER", "PRIV"])):
487 print("\tRemoving %s frames: %s" %
488 (frame.id,
489 frame.owner_id if frame.id == b"PRIV" else frame.text))
490 tag.frame_set[frame.id].remove(frame)
491 edited_files.add(f)
492
493 # Add TLEN
494 tlen = tag.getTextFrame("TLEN")
495 if tlen is not None:
496 real_tlen_ms = f.info.time_secs * 1000
497 tlen_ms = float(tlen)
498 if tlen_ms != real_tlen_ms:
499 print("\tSetting TLEN (%d)" % real_tlen_ms)
500 tag.setTextFrame("TLEN", str(real_tlen_ms))
501 edited_files.add(f)
502
503 # Add custom album type if special and otherwise not able to be
504 # determined.
505 curr_type = tag.album_type
506 if curr_type != dir_type:
507 print("\tSetting %s = %s" % (TXXX_ALBUM_TYPE, dir_type))
508 tag.album_type = dir_type
509 edited_files.add(f)
510
511 try:
512 if not self._checkCoverArt(directory, audio_files):
513 if not prompt("Proceed without valid cover file", default=True):
514 return
515 finally:
516 self._dir_images = []
517
518 # Determine other changes, like file and/or directory renames
519 # so they can be reported before save confirmation.
520
521 # File renaming
522 file_renames = []
523 if self.args.file_rename_pattern:
524 format_str = self.args.file_rename_pattern
525 else:
526 if dir_type == SINGLE_TYPE:
527 format_str = SINGLE_FNAME_FORMAT
528 elif dir_type in (VARIOUS_TYPE, COMP_TYPE):
529 format_str = VARIOUS_FNAME_FORMAT
530 else:
531 format_str = NORMAL_FNAME_FORMAT
532
533 for f in audio_files:
534 orig_name, orig_ext = os.path.splitext(os.path.basename(f.path))
535 new_name = TagTemplate(format_str).substitute(f.tag, zeropad=True)
536 if orig_name != new_name:
537 printMsg("Rename file to %s%s" % (new_name, orig_ext))
538 file_renames.append((f, new_name, orig_ext))
539
540 # Directory renaming
541 dir_rename = None
542 if dir_type != SINGLE_TYPE:
543 if self.args.dir_rename_pattern:
544 dir_format = self.args.dir_rename_pattern
545 else:
546 if dir_type == LIVE_TYPE:
547 dir_format = LIVE_DNAME_FORMAT
548 else:
549 dir_format = NORMAL_DNAME_FORMAT
550 template = TagTemplate(dir_format,
551 dotted_dates=self.args.dotted_dates)
552
553 pref_dir = template.substitute(audio_files[0].tag, zeropad=True)
554 if os.path.basename(directory) != pref_dir:
555 new_dir = os.path.join(os.path.dirname(directory), pref_dir)
556 printMsg("Rename directory to %s" % new_dir)
557 dir_rename = (directory, new_dir)
558
559 # Cruft files to remove
560 file_removes = []
561 if self._dir_files_to_remove:
562 for f in self._dir_files_to_remove:
563 print("Remove file: " + os.path.basename(f))
564 file_removes.append(f)
565 self._dir_files_to_remove = set()
566
567 if not self.args.dry_run:
568 confirmed = False
569
570 if (edited_files or file_renames or dir_rename or file_removes):
571 confirmed = prompt("\nSave changes", default=True)
572
573 if confirmed:
574 for f in edited_files:
575 print("Saving %s" % os.path.basename(f.path))
576 f.tag.save(version=ID3_V2_4, preserve_file_time=True)
577
578 for f, new_name, orig_ext in file_renames:
579 printMsg("Renaming file to %s%s" % (new_name, orig_ext))
580 f.rename(new_name, preserve_file_time=True)
581
582 if file_removes:
583 for f in file_removes:
584 printMsg("Removing file %s" % os.path.basename(f))
585 os.remove(f)
586
587 if dir_rename:
588 printMsg("Renaming directory to %s" % dir_rename[1])
589 s = os.stat(dir_rename[0])
590 os.rename(dir_rename[0], dir_rename[1])
591 # With a rename use the origianl access time
592 os.utime(dir_rename[1], (s.st_atime, s.st_atime))
593
594 else:
595 printMsg("\nNo changes made (run without -n/--dry-run)")
596
597 def handleDone(self):
598 if not self._handled_one:
599 printMsg("Nothing to do")
600
601
602 def _getTemplateKeys():
603 from eyed3.id3.tag import TagTemplate
604
605 keys = list(TagTemplate("")._makeMapping(None, False).keys())
606 keys.sort()
607 return ", ".join(["$%s" % v for v in keys])
608
609
610 ARGS_HELP = {
611 "--type": "How to treat each directory. The default is '%s', "
612 "although you may be prompted for an alternate choice "
613 "if the files look like another type." % ALBUM_TYPE_IDS[0],
614 "--fix-case": "Fix casing on each string field by capitalizing each "
615 "word.",
616 "--dry-run": "Only print the operations that would take place, but do "
617 "not execute them.",
618 "--no-prompt": "Exit if prompted.",
619 "--dotted-dates": "Separate date with '.' instead of '-' when naming "
620 "directories.",
621 "--file-rename-pattern": "Rename file (the extension is not affected) "
622 "based on data in the tag using substitution "
623 "variables: " + _getTemplateKeys(),
624 "--dir-rename-pattern": "Rename directory based on data in the tag "
625 "using substitution variables: " +
626 _getTemplateKeys(),
627 }
0 import math
1 from eyed3 import id3
2 from eyed3.plugins import Plugin
3
4
5 class GenreListPlugin(Plugin):
6 SUMMARY = "Display the full list of standard ID3 genres."
7 DESCRIPTION = "ID3 v1 defined a list of genres and mapped them to "\
8 "to numeric values so they can be stored as a single "\
9 "byte.\nIt is *recommended* that these genres are used "\
10 "although most newer software (including eyeD3) does not "\
11 "care."
12 NAMES = ["genres"]
13
14 def __init__(self, arg_parser):
15 super(GenreListPlugin, self).__init__(arg_parser)
16 self.arg_group.add_argument("-1", "--single-column", action="store_true",
17 help="List on genre per line.")
18
19 def start(self, args, config):
20 self._printGenres(args)
21
22 @staticmethod
23 def _printGenres(args):
24 # Filter out 'Unknown'
25 genre_ids = [i for i in id3.genres
26 if type(i) is int and id3.genres[i] is not None]
27 genre_ids.sort()
28
29 if args.single_column:
30 for gid in genre_ids:
31 print("%3d: %s" % (gid, id3.genres[gid]))
32 else:
33 offset = int(math.ceil(float(len(genre_ids)) / 2))
34 for i in range(offset):
35 if i < len(genre_ids):
36 c1 = "%3d: %s" % (i, id3.genres[i])
37 else:
38 c1 = ""
39 if (i * 2) < len(genre_ids):
40 try:
41 c2 = "%3d: %s" % (i + offset, id3.genres[i + offset])
42 except IndexError:
43 break
44 else:
45 c2 = ""
46 print(c1 + (" " * (40 - len(c1))) + c2)
47 print("")
0 from eyed3.plugins import LoaderPlugin
1 from eyed3.id3.apple import PCST, PCST_FID, WFED, WFED_FID
2
3
4 class Podcast(LoaderPlugin):
5 NAMES = ['itunes-podcast']
6 SUMMARY = "Adds (or removes) the tags necessary for Apple iTunes to "\
7 "identify the file as a podcast."
8
9 def __init__(self, arg_parser):
10 super(Podcast, self).__init__(arg_parser)
11 g = self.arg_group
12 g.add_argument("--add", action="store_true",
13 help="Add the podcast frames.")
14 g.add_argument("--remove", action="store_true",
15 help="Remove the podcast frames.")
16
17 def _add(self, tag):
18 save = False
19 if PCST_FID not in tag.frame_set:
20 tag.frame_set[PCST_FID] = PCST()
21 save = True
22 if WFED_FID not in tag.frame_set:
23 tag.frame_set[WFED_FID] = WFED("http://eyeD3.nicfit.net/")
24 save = True
25
26 if save:
27 print("\tAdding...")
28 tag.save(backup=self.args.backup)
29 self._printStatus(tag)
30
31 def _remove(self, tag):
32 save = False
33 for fid in [PCST_FID, WFED_FID]:
34 try:
35 del tag.frame_set[fid]
36 save = True
37 except KeyError:
38 continue
39
40 if save:
41 print("\tRemoving...")
42 tag.save(backup=self.args.backup)
43 self._printStatus(tag)
44
45 def _printStatus(self, tag):
46 status = ":-("
47 if PCST_FID in tag.frame_set:
48 status = ":-/"
49 if WFED_FID in tag.frame_set:
50 status = ":-)"
51 print("\tiTunes podcast? %s" % status)
52
53 def handleFile(self, f):
54 super(Podcast, self).handleFile(f)
55
56 if self.audio_file and self.audio_file.tag:
57 print(f)
58 tag = self.audio_file.tag
59 self._printStatus(tag)
60 if self.args.remove:
61 self._remove(self.audio_file.tag)
62 elif self.args.add:
63 self._add(self.audio_file.tag)
0 import base64
1 import inspect
2 from json import dumps
3
4 import eyed3.plugins
5 import eyed3.id3.tag
6 import eyed3.id3.headers
7
8 from eyed3.utils.log import getLogger
9
10 log = getLogger(__name__)
11
12
13 class JsonTagPlugin(eyed3.plugins.LoaderPlugin):
14 NAMES = ["json"]
15 SUMMARY = "Outputs all tags as JSON."
16
17 def __init__(self, arg_parser):
18 super().__init__(arg_parser, cache_files=True, track_images=False)
19 g = self.arg_group
20 g.add_argument("-c", "--compact", action="store_true",
21 help="Output in compact form, wound new lines or indentation.")
22 g.add_argument("-s", "--sort", action="store_true", help="Output JSON in sorted by key.")
23
24 def handleFile(self, f, *args, **kwargs):
25 super().handleFile(f)
26 if self.audio_file and self.audio_file.info and self.audio_file.tag:
27 json = audioFileToJson(self.audio_file)
28 print(dumps(json, indent=2 if not self.args.compact else None,
29 sort_keys=self.args.sort))
30
31
32 def audioFileToJson(audio_file):
33 tag = audio_file.tag
34
35 tdict = {"path": audio_file.path}
36
37 info = {"time_secs": int(audio_file.info.time_secs * 100.0) / 100.0,
38 "size_bytes": int(audio_file.info.size_bytes)}
39 tdict["info"] = info
40
41 # Tag fields
42 for name in [m for m in dir(tag) if not m.startswith("_") and m not in _tag_exclusions]:
43 member = getattr(tag, name)
44
45 if name not in _tag_map:
46 if not inspect.ismethod(member) and not inspect.isfunction(member):
47 log.warning(f"Unhandled Tag member: {name}")
48 continue
49 elif member is None:
50 continue
51 elif member.__class__ is not _tag_map[name]:
52 log.warning(f"Unexpected type for member {name}: {member.__class__}")
53 continue
54
55 if isinstance(member, (str, int, bool)):
56 tdict[name] = member
57 elif isinstance(member, eyed3.core.Date):
58 tdict[name] = str(member)
59 elif isinstance(member, eyed3.id3.Genre):
60 tdict[name] = member.name
61 elif isinstance(member, bytes):
62 tdict[name] = base64.b64encode(member).decode("ascii")
63 elif isinstance(member, eyed3.id3.tag.ArtistOrigin):
64 ... # TODO
65 elif isinstance(member, (list, tuple)):
66 ... # TODO
67 elif isinstance(member, eyed3.id3.tag.AccessorBase):
68 ... # TODO
69 elif isinstance(member, (eyed3.id3.tag.TagHeader, eyed3.id3.tag.ExtendedTagHeader,
70 eyed3.id3.tag.FileInfo, eyed3.id3.frames.FrameSet)):
71 ... # TODO
72 else:
73 log.warning(f"Unhandled tag member {name}, type {member.__class__.__name__})")
74
75 tdict["_eyeD3"] = eyed3.__about__.__version__
76 return tdict
77
78
79 _tag_map = {
80 'album': str,
81 'album_artist': str,
82 'album_type': str,
83 'artist': str,
84 'original_artist': str,
85 'artist_origin': list,
86 'artist_url': str,
87 'audio_file_url': str,
88 'audio_source_url': str,
89 'best_release_date': eyed3.core.Date,
90 'bpm': int,
91 'cd_id': bytes,
92 'chapters': eyed3.id3.tag.ChaptersAccessor,
93 'comments': eyed3.id3.tag.CommentsAccessor,
94 'commercial_url': str,
95 'composer': str,
96 'copyright_url': str,
97 'disc_num': tuple,
98 'encoding_date': eyed3.core.Date,
99 'extended_header': eyed3.id3.headers.ExtendedTagHeader,
100 'file_info': eyed3.id3.tag.FileInfo,
101 'frame_set': eyed3.id3.frames.FrameSet,
102 'genre': eyed3.id3.Genre,
103 'header': eyed3.id3.headers.TagHeader,
104 'images': eyed3.id3.tag.ImagesAccessor,
105 'internet_radio_url': str,
106 'lyrics': eyed3.id3.tag.LyricsAccessor,
107 'non_std_genre': eyed3.id3.Genre,
108 'objects': eyed3.id3.tag.ObjectsAccessor,
109 'original_release_date': eyed3.core.Date,
110 'payment_url': str,
111 'play_count': int,
112 'popularities': eyed3.id3.tag.PopularitiesAccessor,
113 'privates': eyed3.id3.tag.PrivatesAccessor,
114 'publisher': str,
115 'publisher_url': str,
116 'recording_date': eyed3.core.Date,
117 'release_date': eyed3.core.Date,
118 'table_of_contents': eyed3.id3.tag.TocAccessor,
119 'tagging_date': eyed3.core.Date,
120 'terms_of_use': str,
121 'title': str,
122 'track_num': tuple,
123 'unique_file_ids': eyed3.id3.tag.UniqueFileIdAccessor,
124 'user_text_frames': eyed3.id3.tag.UserTextsAccessor,
125 'user_url_frames': eyed3.id3.tag.UserUrlsAccessor,
126 'version': tuple,
127 }
128
129 _tag_exclusions = {
130 "read_only": bool,
131 }
0 import math
1 from eyed3.utils import formatSize
2 from eyed3.utils.console import printMsg, getTtySize
3 from eyed3.plugins import LoaderPlugin
4
5
6 class LameInfoPlugin(LoaderPlugin):
7 NAMES = ["lameinfo", "xing"]
8 SUMMARY = "Outputs lame header (if one exists) for file."
9 DESCRIPTION = (
10 "The 'lame' (or xing) header provides extra information about the mp3 "
11 "that is useful to players and encoders but not officially part of "
12 "the mp3 specification. Variable bit rate mp3s, for example, use this "
13 "header.\n\n"
14 "For more details see `here <http://gabriel.mp3-tech.org/mp3infotag.html>`_"
15 )
16
17 def printHeader(self, file_path):
18 w = getTtySize()[1]
19 printMsg(self._getFileHeader(file_path, w))
20 printMsg(self._getHardRule(w))
21
22 def handleFile(self, f, *_, **__):
23 super().handleFile(f)
24 if self.audio_file is None:
25 return
26
27 self.printHeader(f)
28 if (self.audio_file.info is None
29 or not self.audio_file.info.lame_tag):
30 printMsg("No LAME Tag")
31 return
32
33 lt = self.audio_file.info.lame_tag
34 if "infotag_crc" not in lt:
35 try:
36 printMsg(f"Encoder Version: {lt['encoder_version']}")
37 except KeyError:
38 pass
39 return
40
41 values = [("Encoder Version", lt['encoder_version']),
42 ("LAME Tag Revision", lt['tag_revision']),
43 ("VBR Method", lt['vbr_method']),
44 ("Lowpass Filter", lt['lowpass_filter']),
45 ]
46
47 if "replaygain" in lt:
48 try:
49 peak = lt["replaygain"]["peak_amplitude"]
50 db = 20 * math.log10(peak)
51 val = "%.8f (%+.1f dB)" % (peak, db)
52 values.append(("Peak Amplitude", val))
53 except KeyError:
54 pass
55 for type_ in ["radio", "audiofile"]:
56 try:
57 gain = lt["replaygain"][type_]
58 name = "%s Replay Gain" % gain['name'].capitalize()
59 val = "%s dB (%s)" % (gain['adjustment'],
60 gain['originator'])
61 values.append((name, val))
62 except KeyError:
63 pass
64
65 values.append(("Encoding Flags", " ".join((lt["encoding_flags"]))))
66 if lt["nogap"]:
67 values.append(("No Gap", " and ".join(lt["nogap"])))
68 values.append(("ATH Type", lt["ath_type"]))
69 values.append(("Bitrate (%s)" % lt["bitrate"][1], lt["bitrate"][0]))
70 values.append(("Encoder Delay", "%s samples" % lt["encoder_delay"]))
71 values.append(("Encoder Padding", "%s samples" % lt["encoder_padding"]))
72 values.append(("Noise Shaping", lt["noise_shaping"]))
73 values.append(("Stereo Mode", lt["stereo_mode"]))
74 values.append(("Unwise Settings", lt["unwise_settings"]))
75 values.append(("Sample Frequency", lt["sample_freq"]))
76 values.append(("MP3 Gain", "%s (%+.1f dB)" % (lt["mp3_gain"],
77 lt["mp3_gain"] * 1.5)))
78 values.append(("Preset", lt["preset"]))
79 values.append(("Surround Info", lt["surround_info"]))
80 values.append(("Music Length", "%s" % formatSize(lt["music_length"])))
81 values.append(("Music CRC-16", "%04X" % lt["music_crc"]))
82 values.append(("LAME Tag CRC-16", "%04X" % lt["infotag_crc"]))
83
84 for v in values:
85 printMsg(f"{v[0]:<20}: {v[1]}")
0 from pylast import SIZE_EXTRA_LARGE, SIZE_LARGE, SIZE_MEDIUM, SIZE_MEGA, SIZE_SMALL
1 from pylast import LastFMNetwork, WSError
2
3 api_k = "a5f0ac61e7db2481b054ba52ff9a654f"
4 api_s = "0c4a52ae5dcdbba1f9e782833a50b623"
5 _network = None
6
7
8 def Client():
9 global _network
10 if not _network:
11 _network = LastFMNetwork(api_key=api_k, api_secret=api_s)
12 _network.enable_rate_limit()
13 return _network
14
15
16 def getArtist(artist):
17 return Client().get_artist(artist)
18
19
20 def getAlbum(artist, title):
21 return Client().get_album(artist, title)
22
23
24 def getAlbumArt(artist, title, size=SIZE_EXTRA_LARGE):
25 return _getArt(getAlbum(artist, title), size=size)
26
27
28 def getArtistArt(artist, size=SIZE_EXTRA_LARGE):
29 return _getArt(getArtist(artist), size=size)
30
31
32 def _getArt(obj, size=SIZE_EXTRA_LARGE):
33 try:
34 return obj.get_cover_image(size)
35 except WSError:
36 raise ValueError("{} not found.".format(obj.__class__.__name__))
37
38
39 if __name__ == "__main__":
40 album = getAlbum("Melvins", "Houdini")
41 for sz in (SIZE_SMALL, SIZE_MEGA, SIZE_MEDIUM, SIZE_LARGE,
42 SIZE_EXTRA_LARGE):
43 print(album.get_cover_image(sz))
44
45 melvins = getArtist("Melvins")
46 print(melvins)
47 for sz in (SIZE_SMALL, SIZE_MEGA, SIZE_MEDIUM, SIZE_LARGE,
48 SIZE_EXTRA_LARGE):
49 print(melvins.get_cover_image(sz))
0 import time
1 import pprint
2 import eyed3
3 import eyed3.utils
4 from pathlib import Path
5 from collections import Counter
6 from eyed3.mimetype import guessMimetype
7 from eyed3.utils.log import getLogger
8
9 log = getLogger(__name__)
10
11 # python-magic
12 try:
13 import magic
14
15 class MagicTypes(magic.Magic):
16 def __init__(self):
17 magic.Magic.__init__(self, mime=True, mime_encoding=False, keep_going=True)
18
19 def guess_type(self, filename, all_types=False):
20 try:
21 types = self.from_file(filename)
22 except UnicodeEncodeError:
23 # https://github.com/ahupp/python-magic/pull/144
24 types = self.from_file(filename.encode("utf-8", 'surrogateescape'))
25
26 delim = r"\012- "
27 if all_types:
28 return types.split(delim)
29 else:
30 return types.split(delim)[0]
31
32 _python_magic = MagicTypes()
33
34 except ImportError:
35 _python_magic = None
36
37
38 class MimetypesPlugin(eyed3.plugins.LoaderPlugin):
39 NAMES = ["mimetypes"]
40
41 def __init__(self, arg_parser):
42 self._num_visited = 0
43 super().__init__(arg_parser, cache_files=False, track_images=False)
44
45 g = self.arg_group
46 g.add_argument("--status", action="store_true", help="Print dot status.")
47 g.add_argument("--parse-files", action="store_true", help="Parse each file.")
48 g.add_argument("--hide-notfound", action="store_true")
49 if _python_magic:
50 g.add_argument("-M", "--use-pymagic", action="store_true",
51 help="Use python-magic to determine mimetype.")
52 self.magic = None
53 self.start_t = None
54 self.mime_types = Counter()
55
56 def start(self, args, config):
57 super().start(args, config)
58 self.magic = "pymagic" if self.args.use_pymagic else "filetype"
59 self.start_t = time.time()
60
61 def handleFile(self, f, *args, **kwargs):
62
63 self._num_visited += 1
64 if self.args.parse_files:
65 try:
66 super().handleFile(f)
67 except Exception as ex:
68 log.critical(ex, exc_info=ex)
69 else:
70 self._num_loaded += 1
71
72 if self.magic == "pymagic":
73 mtype = _python_magic.guess_type(f)
74 else:
75 mtype = guessMimetype(f)
76
77 self.mime_types[mtype] += 1
78 if not self.args.hide_notfound:
79 if mtype is None and Path(f).suffix.lower() in (".mp3",):
80 print("None mimetype:", f)
81
82 if self.args.status:
83 print(".", end="", flush=True)
84
85 def handleDone(self):
86 t = time.time() - self.start_t
87 print(f"\nVisited {self._num_visited} files")
88 print(f"Processed {self._num_loaded} files")
89 print(f"magic: {self.magic}")
90 print(f"time: {eyed3.utils.formatTime(t)} seconds")
91 if self.mime_types:
92 pprint.pprint(self.mime_types)
0 import time
1 import eyed3
2 from eyed3.utils.console import printMsg
3 from eyed3.utils import formatSize, formatTime
4 from eyed3.id3 import versionToString
5 from eyed3.plugins import LoaderPlugin
6
7
8 class NfoPlugin(LoaderPlugin):
9 NAMES = ["nfo"]
10 SUMMARY = "Create NFO files for each directory scanned."
11 DESCRIPTION = "Each directory scanned is treated as an album and a "\
12 "`NFO <http://en.wikipedia.org/wiki/.nfo>`_ file is "\
13 "written to standard out.\n\n"\
14 "NFO files are often found in music archives."
15
16 def __init__(self, arg_parser):
17 super(NfoPlugin, self).__init__(arg_parser)
18 self.albums = {}
19
20 def handleFile(self, f):
21 super(NfoPlugin, self).handleFile(f)
22
23 if self.audio_file and self.audio_file.tag:
24 tag = self.audio_file.tag
25 album = tag.album
26 if album and album not in self.albums:
27 self.albums[album] = []
28 self.albums[album].append(self.audio_file)
29 elif album:
30 self.albums[album].append(self.audio_file)
31
32 def handleDone(self):
33 if not self.albums:
34 printMsg("No albums found.")
35 return
36
37 for album in self.albums:
38 audio_files = self.albums[album]
39 if not audio_files:
40 continue
41 audio_files.sort(key=lambda af: (af.tag.track_num[0] or 999,
42 af.tag.track_num[1] or 999))
43
44 max_title_len = 0
45 avg_bitrate = 0
46 encoder_info = ''
47 for audio_file in audio_files:
48 tag = audio_file.tag
49 # Compute maximum title length
50 title_len = len(tag.title or "")
51 if title_len > max_title_len:
52 max_title_len = title_len
53 # Compute average bitrate
54 avg_bitrate += audio_file.info.bit_rate[1]
55 # Grab the last lame version in case not all files have one
56 if "encoder_version" in audio_file.info.lame_tag:
57 version = audio_file.info.lame_tag['encoder_version']
58 encoder_info = (version or encoder_info)
59 avg_bitrate = avg_bitrate / len(audio_files)
60
61 printMsg("")
62 printMsg("Artist : %s" % audio_files[0].tag.artist)
63 printMsg("Album : %s" % album)
64 printMsg("Released : %s" %
65 (audio_files[0].tag.original_release_date or
66 audio_files[0].tag.release_date))
67 printMsg("Recorded : %s" % audio_files[0].tag.recording_date)
68 genre = audio_files[0].tag.genre
69 if genre:
70 genre = genre.name
71 else:
72 genre = ""
73 printMsg("Genre : %s" % genre)
74
75 printMsg("")
76 printMsg("Source : ")
77 printMsg("Encoder : %s" % encoder_info)
78 printMsg("Codec : mp3")
79 printMsg("Bitrate : ~%s K/s @ %s Hz, %s" %
80 (avg_bitrate, audio_files[0].info.sample_freq,
81 audio_files[0].info.mode))
82 printMsg("Tag : ID3 %s" %
83 versionToString(audio_files[0].tag.version))
84
85 printMsg("")
86 printMsg("Ripped By: ")
87
88 printMsg("")
89 printMsg("Track Listing")
90 printMsg("-------------")
91 count = 0
92 total_time = 0
93 total_size = 0
94 for audio_file in audio_files:
95 tag = audio_file.tag
96 count += 1
97
98 title = tag.title or ""
99 title_len = len(title)
100 padding = " " * ((max_title_len - title_len) + 3)
101 time_secs = audio_file.info.time_secs
102 total_time += time_secs
103 total_size += audio_file.info.size_bytes
104
105 zero_pad = "0" * (len(str(len(audio_files))) - len(str(count)))
106 printMsg(" %s%d. %s%s(%s)" %
107 (zero_pad, count, title, padding,
108 formatTime(time_secs)))
109
110 printMsg("")
111 printMsg("Total play time : %s" %
112 formatTime(total_time))
113 printMsg("Total size : %s" %
114 formatSize(total_size))
115
116 printMsg("")
117 printMsg("=" * 78)
118 printMsg(".NFO file created with eyeD3 %s on %s" %
119 (eyed3.version, time.asctime()))
120 printMsg("For more information about eyeD3 go to %s" %
121 "http://eyeD3.nicfit.net/")
122 printMsg("=" * 78)
0 import os
1 import importlib.machinery
2 from eyed3.plugins import LoaderPlugin
3
4 _DEFAULT_MOD = "eyeD3mod.py"
5
6
7 class PyModulePlugin(LoaderPlugin):
8 SUMMARY = "Imports a Python module file and calls its functions for the "\
9 "the various plugin events."
10 DESCRIPTION = """
11 If no module if provided a file named %(_DEFAULT_MOD)s in the current working directory is
12 imported. If any of the following methods exist they still be invoked:
13
14 def audioFile(audio_file):
15 '''Invoked for every audio file that is encountered. The ``audio_file``
16 is of type ``eyed3.core.AudioFile``; currently this is the concrete type
17 ``eyed3.mp3.Mp3AudioFile``.'''
18 pass
19
20 def audioDir(d, audio_files, images):
21 '''This function is invoked for any directory (``d``) that contains audio
22 (``audio_files``) or image (``images``) media.'''
23 pass
24
25 def done():
26 '''This method is invoke before successful exit.'''
27 pass
28 """ % globals()
29 NAMES = ["pymod"]
30
31 def __init__(self, arg_parser):
32 super(PyModulePlugin, self).__init__(arg_parser, cache_files=True,
33 track_images=True)
34 self._mod = None
35 self.arg_group.add_argument("-m", "--module", dest="module",
36 help="The Python module module to invoke. "
37 "The default is ./%s" % _DEFAULT_MOD)
38
39 def start(self, args, config):
40 mod_file = args.module or _DEFAULT_MOD
41 try:
42 mod_name = os.path.splitext(os.path.basename(mod_file))[0]
43 loader = importlib.machinery.SourceFileLoader(mod_name, mod_file)
44 mod = loader.load_module()
45 self._mod = mod
46 except IOError:
47 raise IOError("Module file not found: %s" % mod_file)
48 except (NameError, IndentationError, ImportError, SyntaxError) as ex:
49 raise IOError("Module load error: %s" % str(ex))
50
51 def handleFile(self, f):
52 super(PyModulePlugin, self).handleFile(f)
53 if not self.audio_file:
54 return
55
56 if "audioFile" in dir(self._mod):
57 self._mod.audioFile(self.audio_file)
58
59 def handleDirectory(self, d, _):
60 if not self._file_cache and not self._dir_images:
61 return
62
63 if "audioDir" in dir(self._mod):
64 self._mod.audioDir(d, self._file_cache, self._dir_images)
65
66 super(PyModulePlugin, self).handleDirectory(d, _)
67
68 def handleDone(self):
69 super(PyModulePlugin, self).handleDone()
70 if "done" in dir(self._mod):
71 self._mod.done()
0 import os
1 import sys
2 import operator
3 from collections import Counter
4
5 from eyed3 import id3, mp3
6 from eyed3.core import AUDIO_MP3
7 from eyed3.mimetype import guessMimetype
8 from eyed3.utils.console import Fore, Style, printMsg
9 from eyed3.plugins import LoaderPlugin
10 from eyed3.id3 import frames
11
12 ID3_VERSIONS = [id3.ID3_V1_0, id3.ID3_V1_1,
13 id3.ID3_V2_2, id3.ID3_V2_3, id3.ID3_V2_4]
14
15 _OP_STRINGS = {operator.le: "<=",
16 operator.lt: "< ",
17 operator.ge: ">=",
18 operator.gt: "> ",
19 operator.eq: "= ",
20 operator.ne: "!=",
21 }
22
23
24 class Rule:
25 def test(self, path, audio_file):
26 raise NotImplementedError()
27
28
29 PREFERRED_ID3_VERSIONS = [id3.ID3_V2_3,
30 id3.ID3_V2_4,
31 ]
32
33
34 class Id3TagRules(Rule):
35 def test(self, path, audio_file):
36 scores = []
37
38 if audio_file is None:
39 return None
40
41 if not audio_file.tag:
42 return [(-75, "Missing ID3 tag")]
43
44 tag = audio_file.tag
45 if tag.version not in PREFERRED_ID3_VERSIONS:
46 scores.append((-30, "ID3 version not in %s" %
47 PREFERRED_ID3_VERSIONS))
48 if not tag.title:
49 scores.append((-30, "Tag missing title"))
50 if not tag.artist:
51 scores.append((-28, "Tag missing artist"))
52 if not tag.album:
53 scores.append((-26, "Tag missing album"))
54 if not tag.track_num[0]:
55 scores.append((-24, "Tag missing track number"))
56 if not tag.track_num[1]:
57 scores.append((-22, "Tag missing total # of tracks"))
58
59 if not tag.getBestDate():
60 scores.append((-30, "Tag missing any useful dates"))
61 else:
62 if not tag.original_release_date:
63 # Original release date is so rarely used but is almost always
64 # what I mean or wanna know.
65 scores.append((-10, "No original release date"))
66 elif not tag.release_date:
67 scores.append((-5, "No release date"))
68
69 # TLEN, best gotten from audio_file.info.time_secs but having it in
70 # the tag is good, I guess.
71 if b"TLEN" not in tag.frame_set:
72 scores.append((-5, "No TLEN frame"))
73
74 return scores
75
76
77 class BitrateRule(Rule):
78 BITRATE_DEDUCTIONS = [(128, -20), (192, -10)]
79
80 def test(self, path, audio_file):
81 scores = []
82
83 if not audio_file:
84 return None
85
86 if not audio_file.info:
87 # Detected as an audio file but not real audio data found.
88 return [(-90, "No audio data found")]
89
90 is_vbr, bitrate = audio_file.info.bit_rate
91 for threshold, score in self.BITRATE_DEDUCTIONS:
92 if bitrate < threshold:
93 scores.append((score, "Bit rate < %d" % threshold))
94 break
95
96 return scores
97
98
99 VALID_MIME_TYPES = mp3.MIME_TYPES + ["image/png",
100 "image/gif",
101 "image/jpeg",
102 ]
103
104
105 class FileRule(Rule):
106 def test(self, path, audio_file):
107 mt = guessMimetype(path)
108
109 for name in os.path.split(path):
110 if name.startswith('.'):
111 return [(-100, "Hidden file type")]
112
113 if mt not in VALID_MIME_TYPES:
114 return [(-100, "Unsupported file type: %s" % mt)]
115 return None
116
117
118 VALID_ARTWORK_NAMES = ("cover", "cover-front", "cover-back")
119
120
121 class ArtworkRule(Rule):
122 def test(self, path, audio_file):
123 mt = guessMimetype(path)
124 if mt and mt.startswith("image/"):
125 name, ext = os.path.splitext(os.path.basename(path))
126 if name not in VALID_ARTWORK_NAMES:
127 return [(-10, "Artwork file not in %s" %
128 str(VALID_ARTWORK_NAMES))]
129
130 return None
131
132
133 BAD_FRAMES = [frames.PRIVATE_FID, frames.OBJECT_FID]
134
135
136 class Id3FrameRules(Rule):
137 def test(self, path, audio_file):
138 scores = []
139 if not audio_file or not audio_file.tag:
140 return
141
142 tag = audio_file.tag
143 for fid in tag.frame_set:
144 if fid[0] == 'T' and fid != "TXXX" and len(tag.frame_set[fid]) > 1:
145 scores.append((-10, "Multiple %s frames" % fid.decode('ascii')))
146 elif fid in BAD_FRAMES:
147 scores.append((-13, "%s frames are bad, mmmkay?" %
148 fid.decode('ascii')))
149
150 return scores
151
152
153 class Stat(Counter):
154 TOTAL = "total"
155
156 def __init__(self, *args, **kwargs):
157 super(Stat, self).__init__(*args, **kwargs)
158 self[self.TOTAL] = 0
159 self._key_names = {}
160
161 def compute(self, file, audio_file):
162 self[self.TOTAL] += 1
163 self._compute(file, audio_file)
164
165 def _compute(self, file, audio_file):
166 pass
167
168 def report(self):
169 self._report()
170
171 def _sortedKeys(self, most_common=False):
172 def keyDisplayName(k):
173 return self._key_names[k] if k in self._key_names else k
174
175 key_map = {}
176 for k in list(self.keys()):
177 key_map[keyDisplayName(k)] = k
178
179 if not most_common:
180 sorted_names = [k for k in key_map.keys() if k]
181 sorted_names.remove(self.TOTAL)
182 sorted_names.sort()
183 sorted_names.append(self.TOTAL)
184 else:
185 most_common = self.most_common()
186 sorted_names = []
187 remainder_names = []
188 for k, v in most_common:
189 if k != self.TOTAL and v > 0:
190 sorted_names.append(keyDisplayName(k))
191 elif k != self.TOTAL:
192 remainder_names.append(keyDisplayName(k))
193
194 remainder_names.sort()
195 sorted_names = sorted_names + remainder_names
196 sorted_names.append(self.TOTAL)
197
198 return [key_map[name] for name in sorted_names]
199
200 def _report(self, most_common=False):
201 keys = self._sortedKeys(most_common=most_common)
202
203 key_col_width = 0
204 val_col_width = 0
205 for key in keys:
206 key = self._key_names[key] if key in self._key_names else key
207 key_col_width = max(key_col_width, len(str(key)))
208 val_col_width = max(val_col_width, len(str(self[key])))
209 key_col_width += 1
210 val_col_width += 1
211
212 for k in keys:
213 key_name = self._key_names[k] if k in self._key_names else k
214 value = self[k]
215 percent = self.percent(k) if value and k != "total" else ""
216 print("{padding}{key}:{value}{percent}".format(
217 padding=' ' * 4,
218 key=str(key_name).ljust(key_col_width),
219 value=str(value).rjust(val_col_width),
220 percent=" ( %s%.2f%%%s )" % (Fore.GREEN, percent, Fore.RESET)
221 if percent else "",
222 ))
223
224 def percent(self, key):
225 return (float(self[key]) / float(self["total"])) * 100
226
227
228 class AudioStat(Stat):
229 def compute(self, audio_file):
230 assert audio_file
231 self["total"] += 1
232 self._compute(audio_file)
233
234 def _compute(self, audio_file):
235 pass
236
237
238 class FileCounterStat(Stat):
239 SUPPORTED_AUDIO = "audio"
240 UNSUPPORTED_AUDIO = "audio (unsupported)"
241 HIDDEN_FILES = "hidden"
242 OTHER_FILES = "other"
243
244 def __init__(self):
245 super(FileCounterStat, self).__init__()
246 for k in ("audio", "hidden", "audio (unsupported)"):
247 self[k] = 0
248
249 def _compute(self, file, audio_file):
250 mt = guessMimetype(file)
251
252 if audio_file:
253 self[self.SUPPORTED_AUDIO] += 1
254 elif mt and mt.startswith("audio/"):
255 self[self.UNSUPPORTED_AUDIO] += 1
256 elif os.path.basename(file).startswith('.'):
257 self[self.HIDDEN_FILES] += 1
258 else:
259 self[self.OTHER_FILES] += 1
260
261 def _report(self):
262 print(Style.BRIGHT + Fore.YELLOW + "Files:" + Style.RESET_ALL)
263 super(FileCounterStat, self)._report()
264
265
266 class MimeTypeStat(Stat):
267 def _compute(self, file, audio_file):
268 mt = guessMimetype(file)
269 self[mt] += 1
270
271 def _report(self):
272 print(Style.BRIGHT + Fore.YELLOW + "Mime-Types:" + Style.RESET_ALL)
273 super(MimeTypeStat, self)._report(most_common=True)
274
275
276 class Id3VersionCounter(AudioStat):
277 def __init__(self):
278 super(Id3VersionCounter, self).__init__()
279 for v in ID3_VERSIONS:
280 self[v] = 0
281 self._key_names[v] = id3.versionToString(v)
282
283 def _compute(self, audio_file):
284 if audio_file.tag:
285 self[audio_file.tag.version] += 1
286 else:
287 self[None] += 1
288
289 def _report(self):
290 print(Style.BRIGHT + Fore.YELLOW + "ID3 versions:" + Style.RESET_ALL)
291 super(Id3VersionCounter, self)._report()
292
293
294 class Id3FrameCounter(AudioStat):
295 def _compute(self, audio_file):
296 if audio_file.tag:
297 for frame_id in audio_file.tag.frame_set:
298 self[frame_id] += len(audio_file.tag.frame_set[frame_id])
299
300 def _report(self):
301 print(Style.BRIGHT + Fore.YELLOW + "ID3 frames:" + Style.RESET_ALL)
302 super(Id3FrameCounter, self)._report(most_common=True)
303
304
305 class BitrateCounter(AudioStat):
306 def __init__(self):
307 super(BitrateCounter, self).__init__()
308 self["cbr"] = 0
309 self["vbr"] = 0
310 self.bitrate_keys = [(operator.le, 96),
311 (operator.le, 112),
312 (operator.le, 128),
313 (operator.le, 160),
314 (operator.le, 192),
315 (operator.le, 256),
316 (operator.le, 320),
317 (operator.gt, 320),
318 ]
319 for k in self.bitrate_keys:
320 self[k] = 0
321 op, bitrate = k
322 self._key_names[k] = "%s %d" % (_OP_STRINGS[op], bitrate)
323
324 def _compute(self, audio_file):
325 if audio_file.type != AUDIO_MP3 or audio_file.info is None:
326 self["total"] -= 1
327 return
328
329 vbr, br = audio_file.info.bit_rate
330 if vbr:
331 self["vbr"] += 1
332 else:
333 self["cbr"] += 1
334
335 for key in self.bitrate_keys:
336 key_op, key_br = key
337 if key_op(br, key_br):
338 self[key] += 1
339 break
340
341 def _report(self):
342 print(Style.BRIGHT + Fore.YELLOW + "MP3 bitrates:" + Style.RESET_ALL)
343 super(BitrateCounter, self)._report(most_common=True)
344
345 def _sortedKeys(self, most_common=False):
346 keys = super(BitrateCounter, self)._sortedKeys(most_common=most_common)
347 keys.remove("cbr")
348 keys.remove("vbr")
349 keys.insert(0, "cbr")
350 keys.insert(1, "vbr")
351 return keys
352
353
354 class RuleViolationStat(Stat):
355 def _report(self):
356 print(Style.BRIGHT + Fore.YELLOW + "Rule Violations:" + Style.RESET_ALL)
357 super(RuleViolationStat, self)._report(most_common=True)
358
359
360 class Id3ImageTypeCounter(AudioStat):
361 def __init__(self):
362 super(Id3ImageTypeCounter, self).__init__()
363
364 self._key_names = {}
365 for attr in dir(frames.ImageFrame):
366 val = getattr(frames.ImageFrame, attr)
367 if isinstance(val, int) and not attr.endswith("_TYPE"):
368 self._key_names[val] = attr
369
370 for v in self._key_names:
371 self[v] = 0
372
373 def _compute(self, audio_file):
374 if audio_file.tag:
375 for img in audio_file.tag.images:
376 self[img.picture_type] += 1
377
378 def _report(self):
379 print(Style.BRIGHT + Fore.YELLOW + "APIC image types:" + Style.RESET_ALL)
380 super(Id3ImageTypeCounter, self)._report()
381
382
383 class StatisticsPlugin(LoaderPlugin):
384 NAMES = ['stats']
385 SUMMARY = "Computes statistics for all audio files scanned."
386
387 def __init__(self, arg_parser):
388 super(StatisticsPlugin, self).__init__(arg_parser)
389
390 self.arg_group.add_argument(
391 "--verbose", action="store_true", default=False,
392 help="Show details for each file with rule violations.")
393
394 self._stats = []
395 self._rules_stat = RuleViolationStat()
396
397 self._stats.append(FileCounterStat())
398 self._stats.append(MimeTypeStat())
399 self._stats.append(Id3VersionCounter())
400 self._stats.append(Id3FrameCounter())
401 self._stats.append(Id3ImageTypeCounter())
402 self._stats.append(BitrateCounter())
403
404 self._score_sum = 0
405 self._score_count = 0
406 self._rules_log = {}
407 self._rules = [Id3TagRules(),
408 FileRule(),
409 ArtworkRule(),
410 BitrateRule(),
411 Id3FrameRules(),
412 ]
413
414 def handleFile(self, path):
415 super(StatisticsPlugin, self).handleFile(path)
416 if not self.args.quiet:
417 sys.stdout.write('.')
418 sys.stdout.flush()
419
420 for stat in self._stats:
421 if isinstance(stat, AudioStat):
422 if self.audio_file:
423 stat.compute(self.audio_file)
424 else:
425 stat.compute(path, self.audio_file)
426
427 self._score_count += 1
428 total_score = 100
429 for rule in self._rules:
430 scores = rule.test(path, self.audio_file) or []
431 if scores:
432 if path not in self._rules_log:
433 self._rules_log[path] = []
434
435 for score, text in scores:
436 self._rules_stat[text] += 1
437 self._rules_log[path].append((score, text))
438 # += because negative values are returned
439 total_score += score
440
441 if total_score != 100:
442 self._rules_stat[Stat.TOTAL] += 1
443
444 self._score_sum += total_score
445
446 def handleDone(self):
447 if self._num_loaded == 0:
448 super(StatisticsPlugin, self).handleDone()
449 return
450
451 print()
452 for stat in self._stats + [self._rules_stat]:
453 stat.report()
454 print()
455
456 # Detailed rule violations
457 if self.args.verbose:
458 for path in self._rules_log:
459 printMsg(path) # does the right thing for unicode
460 for score, text in self._rules_log[path]:
461 print(f"\t{Fore.RED}{str(score).center(3)}{Fore.RESET} ({text})")
462
463 def prettyScore():
464 s = float(self._score_sum) / float(self._score_count)
465 if s > 80:
466 c = Fore.GREEN
467 elif s > 70:
468 c = Fore.YELLOW
469 else:
470 c = Fore.RED
471 return s, c
472
473 score, color = prettyScore()
474 print(f"{Style.BRIGHT}Score{Style.RESET_BRIGHT} = {color}{score}%%{Fore.RESET}")
475 if not self.args.verbose:
476 print("Run with --verbose to see files and their rule violations")
477 print()
0 from pathlib import Path
1 from xml.sax.saxutils import escape
2
3 from eyed3.plugins import LoaderPlugin
4 from eyed3.utils.console import printMsg
5
6
7 class Xep118Plugin(LoaderPlugin):
8 NAMES = ["xep-118"]
9 SUMMARY = "Outputs all tags in XEP-118 XML format. "\
10 "(see: http://xmpp.org/extensions/xep-0118.html)"
11
12 def __init__(self, arg_parser):
13 super().__init__(arg_parser, cache_files=True, track_images=False)
14 g = self.arg_group
15 g.add_argument("--no-pretty-print", action="store_true",
16 help="Output without new lines or indentation.")
17
18 def handleFile(self, f, *args, **kwargs):
19 super().handleFile(f)
20
21 if self.audio_file and self.audio_file.tag:
22 xml = self.getXML(self.audio_file)
23 printMsg(xml)
24
25 def getXML(self, audio_file):
26 tag = audio_file.tag
27
28 pprint = not self.args.no_pretty_print
29 nl = "\n" if pprint else ""
30 indent = (" " * 2) if pprint else ""
31
32 xml = f"<tune xmlns='http://jabber.org/protocol/tune'>{nl}"
33 if tag.artist:
34 xml += f"{indent}<artist>{escape(tag.artist)}</artist>{nl}"
35 if tag.title:
36 xml += f"{indent}<title>{escape(tag.title)}</title>{nl}"
37 if tag.album:
38 xml += f"{indent}<source>{escape(tag.album)}</source>{nl}"
39 xml += f"{indent}<track>file://{escape(str(Path(audio_file.path).absolute()))}</track>{nl}"
40 if audio_file.info:
41 xml += f"{indent}<length>{audio_file.info.time_secs:.2f}</length>{nl}"
42 xml += "</tune>"
43
44 return xml
0 import eyed3.plugins
1 from eyed3 import log
2 from eyed3.plugins.jsontag import audioFileToJson
3
4 _have_yaml = False
5 try:
6 import ruamel.yaml as yaml
7 _have_yaml = True
8 except ImportError:
9 try:
10 import yaml
11 _have_yaml = True
12 except ImportError:
13 log.info("yaml plugin: Install `ruamel.yaml` or `pyyaml` for YAML support.")
14
15
16 if _have_yaml:
17
18 class YamlTagPlugin(eyed3.plugins.LoaderPlugin):
19 NAMES = ["yaml"]
20 SUMMARY = "Outputs all tags as YAML."
21
22 def __init__(self, arg_parser):
23 super().__init__(arg_parser, cache_files=True, track_images=False)
24
25 def handleFile(self, f, *args, **kwargs):
26 super().handleFile(f)
27 if self.audio_file and self.audio_file.info and self.audio_file.tag:
28 print(yaml.safe_dump(audioFileToJson(self.audio_file),
29 indent=2, default_flow_style=False,
30 explicit_start=True))
0 import os
1 import re
2 import math
3 import pathlib
4 import logging
5 import argparse
6 import warnings
7 import functools
8
9 import deprecation
10
11 from ..utils.log import getLogger
12 from .. import LOCAL_FS_ENCODING
13 from ..__about__ import __version__, __release_name__, __version_txt__
14
15 if hasattr(os, "fwalk"):
16 os_walk = functools.partial(os.fwalk, follow_symlinks=True)
17
18 def os_walk_unpack(w):
19 return w[0:3]
20
21 else:
22 os_walk = functools.partial(os.walk, followlinks=True)
23
24 def os_walk_unpack(w):
25 return w
26
27 log = getLogger(__name__)
28
29
30 @deprecation.deprecated(deprecated_in="0.9a2", removed_in="1.0", current_version=__version__,
31 details="Use eyed3.mimetype.guessMimetype() instead.")
32 def guessMimetype(filename, with_encoding=False):
33 from .. import mimetype
34
35 retval = mimetype.guessMimetype(filename)
36
37 if not with_encoding:
38 return retval
39 else:
40 warnings.warn("File character encoding no longer returned, value is None",
41 UserWarning, stacklevel=2)
42 return retval, None
43
44
45 def walk(handler, path, excludes=None, fs_encoding=LOCAL_FS_ENCODING, recursive=False):
46 """A wrapper around os.walk which handles exclusion patterns and multiple
47 path types (str, pathlib.Path, bytes).
48 """
49 if isinstance(path, pathlib.Path):
50 path = str(path)
51 else:
52 path = str(path, fs_encoding) if type(path) is not str else path
53
54 excludes = excludes if excludes else []
55 excludes_re = []
56 for e in excludes:
57 excludes_re.append(re.compile(e))
58
59 def _isExcluded(_p):
60 for ex in excludes_re:
61 match = ex.match(_p)
62 if match:
63 return True
64 return False
65
66 if not os.path.exists(path):
67 raise IOError(f"file not found: {path}")
68 elif os.path.isfile(path) and not _isExcluded(path):
69 # If not given a directory, invoke the handler and return
70 handler.handleFile(os.path.abspath(path))
71 return
72
73 for root, dirs, files in [os_walk_unpack(w) for w in os_walk(path)]:
74 root = root if type(root) is str else str(root, fs_encoding)
75 dirs.sort()
76 files.sort()
77 for f in list(files):
78 f_key = f
79 f = f if type(f) is str else str(f, fs_encoding)
80 f = os.path.abspath(os.path.join(root, f))
81
82 if not os.path.isfile(f) or _isExcluded(f):
83 files.remove(f_key)
84 continue
85
86 try:
87 handler.handleFile(f)
88 except StopIteration:
89 return
90
91 if files:
92 handler.handleDirectory(root, files)
93
94 if not recursive:
95 break
96
97
98 class FileHandler(object):
99 """A handler interface for :func:`eyed3.utils.walk` callbacks."""
100
101 def handleFile(self, f):
102 """Called for each file walked. The file ``f`` is the full path and
103 the return value is ignored. If the walk should abort the method should
104 raise a ``StopIteration`` exception."""
105 pass
106
107 def handleDirectory(self, d, files):
108 """Called for each directory ``d`` **after** ``handleFile`` has been
109 called for each file in ``files``. ``StopIteration`` may be raised to
110 halt iteration."""
111 pass
112
113 def handleDone(self):
114 """Called when there are no more files to handle."""
115 pass
116
117
118 def _requireArgType(arg_type, *args):
119 arg_indices = []
120 kwarg_names = []
121 for a in args:
122 if type(a) is int:
123 arg_indices.append(a)
124 else:
125 kwarg_names.append(a)
126 assert(arg_indices or kwarg_names)
127
128 def wrapper(fn):
129 def wrapped_fn(*args, **kwargs):
130 for i in arg_indices:
131 if i >= len(args):
132 # The ith argument is not there, as in optional arguments
133 break
134 if args[i] is not None and not isinstance(args[i], arg_type):
135 raise TypeError("%s(argument %d) must be %s" %
136 (fn.__name__, i, str(arg_type)))
137 for name in kwarg_names:
138 if (name in kwargs and kwargs[name] is not None and
139 not isinstance(kwargs[name], arg_type)):
140 raise TypeError("%s(argument %s) must be %s" %
141 (fn.__name__, name, str(arg_type)))
142 return fn(*args, **kwargs)
143 return wrapped_fn
144 return wrapper
145
146
147 def requireUnicode(*args):
148 """Function decorator to enforce str/unicode argument types.
149 ``None`` is a valid argument value, in all cases, regardless of not being
150 unicode. ``*args`` Positional arguments may be numeric argument index
151 values (requireUnicode(1, 3) - requires argument 1 and 3 are unicode)
152 or keyword argument names (requireUnicode("title")) or a combination
153 thereof.
154 """
155 return _requireArgType(str, *args)
156
157
158 def requireBytes(*args):
159 """Function decorator to enforce byte string argument types.
160 """
161 return _requireArgType(bytes, *args)
162
163
164 def formatTime(seconds, total=None, short=False):
165 """
166 Format ``seconds`` (number of seconds) as a string representation.
167 When ``short`` is False (the default) the format is:
168
169 HH:MM:SS.
170
171 Otherwise, the format is exacly 6 characters long and of the form:
172
173 1w 3d
174 2d 4h
175 1h 5m
176 1m 4s
177 15s
178
179 If ``total`` is not None it will also be formatted and
180 appended to the result seperated by ' / '.
181 """
182 seconds = round(seconds)
183
184 def time_tuple(ts):
185 if ts is None or ts < 0:
186 ts = 0
187 hours = ts / 3600
188 mins = (ts % 3600) / 60
189 secs = (ts % 3600) % 60
190 tstr = '%02d:%02d' % (mins, secs)
191 if int(hours):
192 tstr = '%02d:%s' % (hours, tstr)
193 return (int(hours), int(mins), int(secs), tstr)
194
195 if not short:
196 hours, mins, secs, curr_str = time_tuple(seconds)
197 retval = curr_str
198 if total:
199 hours, mins, secs, total_str = time_tuple(total)
200 retval += ' / %s' % total_str
201 return retval
202 else:
203 units = [
204 ('y', 60 * 60 * 24 * 7 * 52),
205 ('w', 60 * 60 * 24 * 7),
206 ('d', 60 * 60 * 24),
207 ('h', 60 * 60),
208 ('m', 60),
209 ('s', 1),
210 ]
211
212 seconds = int(seconds)
213
214 if seconds < 60:
215 return ' {0:02d}s'.format(seconds)
216 for i in range(len(units) - 1):
217 unit1, limit1 = units[i]
218 unit2, limit2 = units[i + 1]
219 if seconds >= limit1:
220 return '{0:02d}{1}{2:02d}{3}'.format(
221 seconds // limit1, unit1,
222 (seconds % limit1) // limit2, unit2)
223 return ' ~inf'
224
225
226 # Number of bytes per KB (2^10)
227 KB_BYTES = 1024
228 # Number of bytes per MB (2^20)
229 MB_BYTES = 1048576
230 # Number of bytes per GB (2^30)
231 GB_BYTES = 1073741824
232 # Kilobytes abbreviation
233 KB_UNIT = "KB"
234 # Megabytes abbreviation
235 MB_UNIT = "MB"
236 # Gigabytes abbreviation
237 GB_UNIT = "GB"
238
239
240 def formatSize(size, short=False):
241 """Format ``size`` (nuber of bytes) into string format doing KB, MB, or GB
242 conversion where necessary.
243
244 When ``short`` is False (the default) the format is smallest unit of
245 bytes and largest gigabytes; '234 GB'.
246 The short version is 2-4 characters long and of the form
247
248 256b
249 64k
250 1.1G
251 """
252 if not short:
253 unit = "Bytes"
254 if size >= GB_BYTES:
255 size = float(size) / float(GB_BYTES)
256 unit = GB_UNIT
257 elif size >= MB_BYTES:
258 size = float(size) / float(MB_BYTES)
259 unit = MB_UNIT
260 elif size >= KB_BYTES:
261 size = float(size) / float(KB_BYTES)
262 unit = KB_UNIT
263 return "%.2f %s" % (size, unit)
264 else:
265 suffixes = ' kMGTPEH'
266 if size == 0:
267 num_scale = 0
268 else:
269 num_scale = int(math.floor(math.log(size) / math.log(1000)))
270 if num_scale > 7:
271 suffix = '?'
272 else:
273 suffix = suffixes[num_scale]
274 num_scale = int(math.pow(1000, num_scale))
275 value = size / num_scale
276 str_value = str(value)
277 if len(str_value) >= 3 and str_value[2] == '.':
278 str_value = str_value[:2]
279 else:
280 str_value = str_value[:3]
281 return "{0:>3s}{1}".format(str_value, suffix)
282
283
284 def formatTimeDelta(td):
285 """Format a timedelta object ``td`` into a string. """
286 days = td.days
287 hours = td.seconds / 3600
288 mins = (td.seconds % 3600) / 60
289 secs = (td.seconds % 3600) % 60
290 tstr = "%02d:%02d:%02d" % (hours, mins, secs)
291 if days:
292 tstr = "%d days %s" % (days, tstr)
293 return tstr
294
295
296 def chunkCopy(src_fp, dest_fp, chunk_sz=(1024 * 512)):
297 """Copy ``src_fp`` to ``dest_fp`` in ``chunk_sz`` byte increments."""
298 done = False
299 while not done:
300 data = src_fp.read(chunk_sz)
301 if data:
302 dest_fp.write(data)
303 else:
304 done = True
305 del data
306
307
308 class ArgumentParser(argparse.ArgumentParser):
309 """Subclass of argparse.ArgumentParser that adds version and log level
310 options."""
311
312 def __init__(self, *args, **kwargs):
313 from eyed3 import version as VERSION
314 from eyed3.utils.log import LEVELS
315 from eyed3.utils.log import MAIN_LOGGER
316
317 def pop_kwarg(name, default):
318 if name in kwargs:
319 value = kwargs.pop(name) or default
320 else:
321 value = default
322 return value
323 main_logger = pop_kwarg("main_logger", MAIN_LOGGER)
324 version = pop_kwarg("version", VERSION)
325
326 self.log_levels = [logging.getLevelName(l).lower() for l in LEVELS]
327
328 formatter = argparse.RawDescriptionHelpFormatter
329 super(ArgumentParser, self).__init__(*args, formatter_class=formatter,
330 **kwargs)
331
332 self.add_argument("--version", action="version", version=version,
333 help="Display version information and exit")
334 self.add_argument("--about", action="store_true", dest="about_eyed3",
335 help="Display full version, release name, additional info, and exit")
336
337 debug_group = self.add_argument_group("Debugging")
338 debug_group.add_argument(
339 "-l", "--log-level", metavar="LEVEL[:LOGGER]",
340 action=LoggingAction, main_logger=main_logger,
341 help="Set a log level. This option may be specified multiple "
342 "times. If a logger name is specified than the level "
343 "applies only to that logger, otherwise the level is set "
344 "on the top-level logger. Acceptable levels are %s. " %
345 (", ".join("'%s'" % l for l in self.log_levels)))
346 debug_group.add_argument("--profile", action="store_true",
347 default=False, dest="debug_profile",
348 help="Run using python profiler.")
349 debug_group.add_argument("--pdb", action="store_true", dest="debug_pdb",
350 help="Drop into 'pdb' when errors occur.")
351
352 def parse_args(self, *args, **kwargs):
353 args = super().parse_args(*args, **kwargs)
354 if "about_eyed3" in args and args.about_eyed3:
355 action = [a for a in self._actions if isinstance(a, argparse._VersionAction)][0]
356 version = action.version
357 release_name = f" {__release_name__}" if __release_name__ else ""
358 print(f"{version}{release_name}\n\n{__version_txt__}")
359 self.exit()
360 else:
361 return args
362
363
364 class LoggingAction(argparse._AppendAction):
365 def __init__(self, *args, **kwargs):
366 self.main_logger = kwargs.pop("main_logger")
367 super(LoggingAction, self).__init__(*args, **kwargs)
368
369 def __call__(self, parser, namespace, values, option_string=None):
370 values = values.split(':')
371 level, logger = values if len(values) > 1 else (values[0],
372 self.main_logger)
373
374 logger = logging.getLogger(logger)
375 try:
376 logger.setLevel(logging._nameToLevel[level.upper()])
377 except KeyError:
378 msg = f"invalid level choice: {level} (choose from {parser.log_levels})"
379 raise argparse.ArgumentError(self, msg)
380
381 super(LoggingAction, self).__call__(parser, namespace, values, option_string)
382
383
384 def datePicker(thing, prefer_recording_date=False):
385 """This function returns a date of some sort, amongst all the possible
386 dates (members called release_date, original_release_date,
387 and recording_date of type eyed3.core.Date).
388
389 The order of preference is:
390 1) date of original release
391 2) date of this versions release
392 3) the recording date.
393
394 Unless ``prefer_recording_date`` is ``True`` in which case the order is
395 3, 1, 2.
396
397 ``None`` will be returned if no dates are available."""
398 if not prefer_recording_date:
399 return (thing.original_release_date or
400 thing.release_date or
401 thing.recording_date)
402 else:
403 return (thing.recording_date or
404 thing.original_release_date or
405 thing.release_date)
406
407
408 def makeUniqueFileName(file_path, uniq=''):
409 """The ``file_path`` is the desired file name, and it is returned if the
410 file does not exist. In the case that it already exists the path is
411 adjusted to be unique. First, the ``uniq`` string is added, and then
412 a couter is used to find a unique name."""
413
414 path = os.path.dirname(file_path)
415 file = os.path.basename(file_path)
416 name, ext = os.path.splitext(file)
417 count = 1
418 while os.path.exists(os.path.join(path, file)):
419 if uniq:
420 name = "%s_%s" % (name, uniq)
421 file = "".join([name, ext])
422 uniq = ''
423 else:
424 file = "".join(["%s_%s" % (name, count), ext])
425 count += 1
426 return os.path.join(path, file)
427
428
429 def b(x, encoder=None):
430 """Converts `x` to a bytes string if not already.
431 :param x: The string.
432 :param encoder: Optional codec encoder to perform the conversion. The default is
433 `codecs.latin_1_encode`.
434 :return: The byte string if conversion was needed.
435 """
436 if isinstance(x, bytes):
437 return x
438 else:
439 import codecs
440 encoder = encoder or codecs.latin_1_encode
441 return encoder(x)[0]
0 from os.path import basename, splitext
1 from fnmatch import fnmatch
2 from ..id3.frames import ImageFrame
3
4
5 FRONT_COVER = "FRONT_COVER"
6 """Album front cover."""
7 BACK_COVER = "BACK_COVER"
8 """Album back cover."""
9 MISC_COVER = "MISC_COVER"
10 """Other part of the album cover; liner notes, gate-fold, etc."""
11 LOGO = "LOGO"
12 """Artist/band logo."""
13 ARTIST = "ARTIST"
14 """Artist/band images."""
15 LIVE = "LIVE"
16 """Artist/band images."""
17
18 FILENAMES = {
19 FRONT_COVER: ["cover-front", "cover-alternate*", "cover",
20 "folder", "front", "cover-front_*", "flier"],
21 BACK_COVER: ["cover-back", "back", "cover-back_*"],
22 MISC_COVER: ["cover-insert*", "cover-liner*", "cover-disc",
23 "cover-media*"],
24 LOGO: ["logo*"],
25 ARTIST: ["artist*"],
26 LIVE: ["live*"],
27 }
28 """A mapping of art types to lists of filename patterns (excluding file
29 extension): type -> [file_pattern, ..]."""
30
31 TO_ID3_ART_TYPES = {
32 FRONT_COVER: [ImageFrame.FRONT_COVER, ImageFrame.OTHER, ImageFrame.ICON,
33 ImageFrame.LEAFLET],
34 BACK_COVER: [ImageFrame.BACK_COVER],
35 MISC_COVER: [ImageFrame.MEDIA],
36 LOGO: [ImageFrame.BAND_LOGO],
37 ARTIST: [ImageFrame.LEAD_ARTIST, ImageFrame.ARTIST, ImageFrame.BAND],
38 LIVE: [ImageFrame.DURING_PERFORMANCE, ImageFrame.DURING_RECORDING]
39 }
40 """A mapping of art types to ID3 APIC (image) types: type -> [apic_type, ..]"""
41 # ID3 image types not mapped above:
42 # OTHER_ICON = 0x02
43 # CONDUCTOR = 0x09
44 # COMPOSER = 0x0B
45 # LYRICIST = 0x0C
46 # RECORDING_LOCATION = 0x0D
47 # VIDEO = 0x10
48 # BRIGHT_COLORED_FISH = 0x11
49 # ILLUSTRATION = 0x12
50 # PUBLISHER_LOGO = 0x14
51
52 FROM_ID3_ART_TYPES = {}
53 """A mapping of ID3 art types to eyeD3 art types; the opposite of
54 TO_ID3_ART_TYPES."""
55 for _type in TO_ID3_ART_TYPES:
56 for _id3_type in TO_ID3_ART_TYPES[_type]:
57 FROM_ID3_ART_TYPES[_id3_type] = _type
58
59
60 def matchArtFile(filename):
61 """Compares ``filename`` (case insensitive) with lists of common art file
62 names and returns the type of art that was matched, or None if no types
63 were matched."""
64 base = splitext(basename(filename))[0]
65 for type_ in FILENAMES.keys():
66 if True in [fnmatch(base.lower(), fname) for fname in FILENAMES[type_]]:
67 return type_
68 return None
69
70
71 def getArtFromTag(tag, type_=None):
72 """Returns a list of eyed3.id3.frames.ImageFrame objects matching ``type_``,
73 all if ``type_`` is None, or empty if tag does not contain art."""
74 art = []
75 for img in tag.images:
76 if not type_ or type_ == img.picture_type:
77 art.append(img)
78 return art
0 ################################################################################
1 # Copyright (C) 2001 Ryan Finne <ryan@finnie.org>
2 # Copyright (C) 2002-2011 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 import struct
19
20 MAX_INT16 = (2 ** 16) // 2
21 MIN_INT16 = -(MAX_INT16 - 1)
22
23
24 def bytes2bin(bites, sz=8):
25 """Accepts a string of ``bytes`` (chars) and returns an array of bits
26 representing the bytes in big endian byte order. An optional max ``sz`` for
27 each byte (default 8 bits/byte) which can be used to mask out higher
28 bits."""
29 if sz < 1 or sz > 8:
30 raise ValueError(f"Invalid sz value: {sz}")
31
32 retval = []
33 for b in [bytes([b]) for b in bites]:
34 bits = []
35 b = ord(b)
36 while b > 0:
37 bits.append(b & 1)
38 b >>= 1
39
40 if len(bits) < sz:
41 bits.extend([0] * (sz - len(bits)))
42 elif len(bits) > sz:
43 bits = bits[:sz]
44
45 # Big endian byte order.
46 bits.reverse()
47 retval.extend(bits)
48
49 return retval
50
51
52 def bin2bytes(x):
53 """Convert an array of bits (MSB first) into a string of characters."""
54 bits = []
55 bits.extend(x)
56 bits.reverse()
57
58 i = 0
59 out = b''
60 multi = 1
61 ttl = 0
62 for b in bits:
63 i += 1
64 ttl += b * multi
65 multi *= 2
66 if i == 8:
67 i = 0
68 out += bytes([ttl])
69 multi = 1
70 ttl = 0
71
72 if multi > 1:
73 out += bytes([ttl])
74
75 out = bytearray(out)
76 out.reverse()
77 out = bytes(out)
78 return out
79
80
81 def bin2dec(x):
82 """Convert ``x``, an array of "bits" (MSB first), to it's decimal value."""
83 bits = []
84 bits.extend(x)
85 bits.reverse() # MSB
86
87 multi = 1
88 value = 0
89 for b in bits:
90 value += b * multi
91 multi *= 2
92 return value
93
94
95 def bytes2dec(bites, sz=8):
96 return bin2dec(bytes2bin(bites, sz))
97
98
99 def dec2bin(n, p=1):
100 """Convert a decimal value ``n`` to an array of bits (MSB first).
101 Optionally, pad the overall size to ``p`` bits."""
102 assert n >= 0
103 if type(n) is not int:
104 n = int(n)
105 retval = []
106
107 while n > 0:
108 retval.append(n & 1)
109 n >>= 1
110
111 if p > 0:
112 retval.extend([0] * (p - len(retval)))
113 retval.reverse()
114 return retval
115
116
117 def dec2bytes(n, p=1):
118 return bin2bytes(dec2bin(n, p))
119
120
121 def bin2synchsafe(x):
122 """Convert ``x``, a list of bits (MSB first), to a synch safe list of bits.
123 (section 6.2 of the ID3 2.4 spec)."""
124 n = bin2dec(x)
125 if len(x) > 32 or n > 268435456: # 2^28
126 raise ValueError("Invalid value: %s" % str(x))
127 elif len(x) < 8:
128 return x
129
130 bites = bytes([(n >> 21) & 0x7f,
131 (n >> 14) & 0x7f,
132 (n >> 7) & 0x7f,
133 (n >> 0) & 0x7f,
134 ])
135 bits = bytes2bin(bites)
136 assert(len(bits) == 32)
137
138 return bits
139
140
141 def bytes2signedInt16(bites: bytes):
142 if len(bites) != 2:
143 raise ValueError("Signed 16 bit integer MUST be 2 bytes.")
144 i = struct.unpack(">h", bites)
145 return i[0]
146
147
148 def signedInt162bytes(n: int):
149 n = int(n)
150 if MIN_INT16 <= n <= MAX_INT16:
151 return struct.pack(">h", n)
152 raise ValueError(f"Signed int16 out of range: {n}")
0 import os
1 import struct
2 import sys
3 import time
4
5 try:
6 import fcntl
7 import termios
8 import signal
9 _CAN_RESIZE_TERMINAL = True
10 except ImportError:
11 _CAN_RESIZE_TERMINAL = False
12
13 from . import formatSize, formatTime
14
15
16 class AnsiCodes(object):
17 _USE_ANSI = False
18 _CSI = '\033['
19
20 def __init__(self, codes):
21 def code_to_chars(code):
22 return AnsiCodes._CSI + str(code) + 'm'
23
24 for name in dir(codes):
25 if not name.startswith('_'):
26 value = getattr(codes, name)
27 setattr(self, name, code_to_chars(value))
28
29 # Add color function
30 for reset_name in ("RESET_%s" % name, "RESET"):
31 if hasattr(codes, reset_name):
32 reset_value = getattr(codes, reset_name)
33 setattr(self, "%s" % name.lower(),
34 AnsiCodes._mkfunc(code_to_chars(value),
35 code_to_chars(reset_value)))
36 break
37
38 @staticmethod
39 def _mkfunc(color, reset):
40 def _cwrap(text, *styles):
41 if not AnsiCodes._USE_ANSI:
42 return text
43
44 s = ''
45 for st in styles:
46 s += st
47 s += color + text + reset
48 if styles:
49 s += Style.RESET_ALL
50 return s
51 return _cwrap
52
53 def __getattribute__(self, name):
54 attr = super(AnsiCodes, self).__getattribute__(name)
55 if (hasattr(attr, "startswith") and
56 attr.startswith(AnsiCodes._CSI) and
57 not AnsiCodes._USE_ANSI):
58 return ""
59 else:
60 return attr
61
62 def __getitem__(self, name):
63 return getattr(self, name.upper())
64
65 @classmethod
66 def init(cls, allow_colors):
67 cls._USE_ANSI = allow_colors and cls._term_supports_color()
68
69 @staticmethod
70 def _term_supports_color():
71 if (os.environ.get("TERM") == "dumb" or
72 os.environ.get("OS") == "Windows_NT"):
73 return False
74 return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
75
76
77 class AnsiFore:
78 GREY = 30 # noqa
79 RED = 31 # noqa
80 GREEN = 32 # noqa
81 YELLOW = 33 # noqa
82 BLUE = 34 # noqa
83 MAGENTA = 35 # noqa
84 CYAN = 36 # noqa
85 WHITE = 37 # noqa
86 RESET = 39 # noqa
87
88
89 class AnsiBack:
90 GREY = 40 # noqa
91 RED = 41 # noqa
92 GREEN = 42 # noqa
93 YELLOW = 43 # noqa
94 BLUE = 44 # noqa
95 MAGENTA = 45 # noqa
96 CYAN = 46 # noqa
97 WHITE = 47 # noqa
98 RESET = 49 # noqa
99
100
101 class AnsiStyle:
102 RESET_ALL = 0 # noqa
103 BRIGHT = 1 # noqa
104 RESET_BRIGHT = 22 # noqa
105 DIM = 2 # noqa
106 RESET_DIM = RESET_BRIGHT # noqa
107 ITALICS = 3 # noqa
108 RESET_ITALICS = 23 # noqa
109 UNDERLINE = 4 # noqa
110 RESET_UNDERLINE = 24 # noqa
111 BLINK_SLOW = 5 # noqa
112 RESET_BLINK_SLOW = 25 # noqa
113 BLINK_FAST = 6 # noqa
114 RESET_BLINK_FAST = 26 # noqa
115 INVERSE = 7 # noqa
116 RESET_INVERSE = 27 # noqa
117 STRIKE_THRU = 9 # noqa
118 RESET_STRIKE_THRU = 29 # noqa
119
120
121 Fore = AnsiCodes(AnsiFore)
122 Back = AnsiCodes(AnsiBack)
123 Style = AnsiCodes(AnsiStyle)
124
125
126 def ERROR_COLOR():
127 return Fore.RED
128
129
130 def WARNING_COLOR():
131 return Fore.YELLOW
132
133
134 def HEADER_COLOR():
135 return Fore.GREEN
136
137
138 class Spinner(object):
139 """
140 A class to display a spinner in the terminal.
141
142 It is designed to be used with the `with` statement::
143
144 with Spinner("Reticulating splines", "green") as s:
145 for item in enumerate(items):
146 s.next()
147 """
148 _default_unicode_chars = "◓◑◒◐"
149 _default_ascii_chars = "-/|\\"
150
151 def __init__(self, msg, file=None, step=1,
152 chars=None, use_unicode=True, print_done=True):
153
154 self._msg = msg
155 self._file = file or sys.stdout
156 self._step = step
157 if not chars:
158 if use_unicode:
159 chars = self._default_unicode_chars
160 else:
161 chars = self._default_ascii_chars
162 self._chars = chars
163
164 self._silent = not self._file.isatty()
165 self._print_done = print_done
166
167 def _iterator(self):
168 chars = self._chars
169 index = 0
170 write = self._file.write
171 flush = self._file.flush
172
173 while True:
174 write("\r")
175 write(self._msg)
176 write(" ")
177 write(chars[index])
178 flush()
179 yield
180
181 for i in range(self._step):
182 yield
183
184 index += 1
185 if index == len(chars):
186 index = 0
187
188 def __enter__(self):
189 if self._silent:
190 return self._silent_iterator()
191 else:
192 return self._iterator()
193
194 def __exit__(self, exc_type, exc_value, traceback):
195 write = self._file.write
196 flush = self._file.flush
197
198 if not self._silent:
199 write("\r")
200 write(self._msg)
201 if self._print_done:
202 if exc_type is None:
203 write(Fore.GREEN + ' [Done]\n')
204 else:
205 write(Fore.RED + ' [Failed]\n')
206 else:
207 write("\n")
208 flush()
209
210 def _silent_iterator(self):
211 self._file.write(self._msg)
212 self._file.flush()
213
214 while True:
215 yield
216
217
218 class ProgressBar(object):
219 """
220 A class to display a progress bar in the terminal.
221
222 It is designed to be used either with the `with` statement::
223
224 with ProgressBar(len(items)) as bar:
225 for item in enumerate(items):
226 bar.update()
227
228 or as a generator::
229
230 for item in ProgressBar(items):
231 item.process()
232 """
233 def __init__(self, total_or_items, file=None):
234 """
235 total_or_items : int or sequence
236 If an int, the number of increments in the process being
237 tracked. If a sequence, the items to iterate over.
238
239 file : writable file-like object, optional
240 The file to write the progress bar to. Defaults to
241 `sys.stdout`. If `file` is not a tty (as determined by
242 calling its `isatty` member, if any), the scrollbar will
243 be completely silent.
244 """
245 self._file = file or sys.stdout
246
247 if not self._file.isatty():
248 self.update = self._silent_update
249 self._silent = True
250 else:
251 self._silent = False
252
253 try:
254 self._items = iter(total_or_items)
255 self._total = len(total_or_items)
256 except TypeError:
257 try:
258 self._total = int(total_or_items)
259 self._items = iter(range(self._total))
260 except TypeError:
261 raise TypeError("First argument must be int or sequence")
262
263 self._start_time = time.time()
264
265 self._should_handle_resize = (
266 _CAN_RESIZE_TERMINAL and self._file.isatty())
267 self._handle_resize()
268 if self._should_handle_resize:
269 signal.signal(signal.SIGWINCH, self._handle_resize)
270 self._signal_set = True
271 else:
272 self._signal_set = False
273
274 self.update(0)
275
276 def _handle_resize(self, signum=None, frame=None):
277 self._terminal_width = getTtySize(self._file,
278 self._should_handle_resize)[1]
279
280 def __enter__(self):
281 return self
282
283 def __exit__(self, exc_type, exc_value, traceback):
284 if not self._silent:
285 if exc_type is None:
286 self.update(self._total)
287 self._file.write('\n')
288 self._file.flush()
289 if self._signal_set:
290 signal.signal(signal.SIGWINCH, signal.SIG_DFL)
291
292 def __iter__(self):
293 return self
294
295 def next(self):
296 try:
297 rv = next(self._items)
298 except StopIteration:
299 self.__exit__(None, None, None)
300 raise
301 else:
302 self.update()
303 return rv
304
305 def update(self, value=None):
306 """
307 Update the progress bar to the given value (out of the total
308 given to the constructor).
309 """
310 if value is None:
311 value = self._current_value = self._current_value + 1
312 else:
313 self._current_value = value
314 if self._total == 0:
315 frac = 1.0
316 else:
317 frac = float(value) / float(self._total)
318
319 file = self._file
320 write = file.write
321
322 suffix = self._formatSuffix(value, frac)
323 self._bar_length = self._terminal_width - 37
324
325 bar_fill = int(float(self._bar_length) * frac)
326 write("\r|")
327 write(Fore.BLUE + '=' * bar_fill + Fore.RESET)
328 if bar_fill < self._bar_length:
329 write(Fore.GREEN + '>' + Fore.RESET)
330 write("-" * (self._bar_length - bar_fill - 1))
331 write("|")
332 write(suffix)
333
334 self._file.flush()
335
336 def _formatSuffix(self, value, frac):
337
338 if value >= self._total:
339 t = time.time() - self._start_time
340 time_str = ' '
341 elif value <= 0:
342 t = None
343 time_str = ''
344 else:
345 t = ((time.time() - self._start_time) * (1.0 - frac)) / frac
346 time_str = ' ETA '
347 if t is not None:
348 time_str += formatTime(t, short=True)
349
350 suffix = ' {0:>4s}/{1:>4s}'.format(formatSize(value, short=True),
351 formatSize(self._total, short=True))
352 suffix += ' ({0:>6s}%)'.format("{0:.2f}".format(frac * 100.0))
353 suffix += time_str
354
355 return suffix
356
357 def _silent_update(self, value=None):
358 pass
359
360 @classmethod
361 def map(cls, function, items, multiprocess=False, file=None):
362 """
363 Does a `map` operation while displaying a progress bar with
364 percentage complete.
365
366 ::
367
368 def work(i):
369 print(i)
370
371 ProgressBar.map(work, range(50))
372
373 Parameters:
374
375 function : function
376 Function to call for each step
377
378 items : sequence
379 Sequence where each element is a tuple of arguments to pass to
380 *function*.
381
382 multiprocess : bool, optional
383 If `True`, use the `multiprocessing` module to distribute each
384 task to a different processor core.
385
386 file : writeable file-like object, optional
387 The file to write the progress bar to. Defaults to
388 `sys.stdout`. If `file` is not a tty (as determined by
389 calling its `isatty` member, if any), the scrollbar will
390 be completely silent.
391 """
392 results = []
393
394 if file is None:
395 file = sys.stdout
396
397 with cls(len(items), file=file) as bar:
398 step_size = max(200, bar._bar_length)
399 steps = max(int(float(len(items)) / step_size), 1)
400 if not multiprocess:
401 for i, item in enumerate(items):
402 function(item)
403 if (i % steps) == 0:
404 bar.update(i)
405 else:
406 import multiprocessing
407 p = multiprocessing.Pool()
408 for i, result in enumerate(p.imap_unordered(function, items,
409 steps)):
410 bar.update(i)
411 results.append(result)
412
413 return results
414
415
416 def printMsg(s):
417 fp = sys.stdout
418 assert isinstance(s, str)
419 try:
420 fp.write("%s\n" % s)
421 except UnicodeEncodeError:
422 fp.write("%s\n" % str(s.encode("utf-8", "replace"), "utf-8"))
423 fp.flush()
424
425
426 def printError(s):
427 _printWithColor(s, ERROR_COLOR(), sys.stderr)
428
429
430 def printWarning(s):
431 _printWithColor(s, WARNING_COLOR(), sys.stdout)
432
433
434 def printHeader(s):
435 _printWithColor(s, HEADER_COLOR(), sys.stdout)
436
437
438 def boldText(s, c=None):
439 return formatText(s, b=True, c=c)
440
441
442 def formatText(s, b=False, c=None):
443 return ((Style.BRIGHT if b else '') +
444 (c or '') +
445 s +
446 (Fore.RESET if c else '') +
447 (Style.RESET_BRIGHT if b else ''))
448
449
450 def _printWithColor(s, color, file):
451 assert isinstance(s, str)
452 file.write(color + s + Fore.RESET + '\n')
453 file.flush()
454
455
456 def cformat(msg, fg, bg=None, styles=None):
457 """Format ``msg`` with foreground and optional background. Optional
458 ``styles`` lists will also be applied. The formatted string is returned."""
459 fg = fg or ""
460 bg = bg or ""
461 styles = "".join(styles or [])
462 reset = Fore.RESET + Back.RESET + Style.RESET_ALL if (fg or bg or styles) \
463 else ""
464
465 output = "%(fg)s%(bg)s%(styles)s%(msg)s%(reset)s" % locals()
466 return output
467
468
469 def getTtySize(fd=sys.stdout, check_tty=True):
470 hw = None
471 if check_tty:
472 try:
473 data = fcntl.ioctl(fd, termios.TIOCGWINSZ, '\0' * 4)
474 hw = struct.unpack("hh", data)
475 except (OSError, IOError, NameError):
476 pass
477 if not hw:
478 try:
479 hw = (int(os.environ.get('LINES')),
480 int(os.environ.get('COLUMNS')))
481 except (TypeError, ValueError):
482 hw = (78, 25)
483 return hw
484
485
486 def cprint(msg, fg, bg=None, styles=None, file=sys.stdout):
487 """Calls ``cformat`` and prints the result to output stream ``file``."""
488 print(cformat(msg, fg, bg=bg, styles=styles), file=file)
489
490
491 if __name__ == "__main__":
492 AnsiCodes.init(True)
493
494 def checkCode(c):
495 return (c[0] != '_' and
496 "RESET" not in c and
497 c[0] == c[0].upper()
498 )
499
500 for bg_name, bg_code in ((c, getattr(Back, c))
501 for c in dir(Back) if checkCode(c)):
502 sys.stdout.write('%s%-7s%s %s ' %
503 (bg_code, bg_name, Back.RESET, bg_code))
504 for fg_name, fg_code in ((c, getattr(Fore, c))
505 for c in dir(Fore) if checkCode(c)):
506 sys.stdout.write(fg_code)
507 for st_name, st_code in ((c, getattr(Style, c))
508 for c in dir(Style) if checkCode(c)):
509 sys.stdout.write('%s%s %s %s' %
510 (st_code, st_name,
511 getattr(Style, "RESET_%s" % st_name),
512 bg_code))
513 sys.stdout.write("%s\n" % Style.RESET_ALL)
514
515 sys.stdout.write("\n")
516
517 with Spinner(Fore.GREEN + "Phase #1") as spinner:
518 for i in range(50):
519 time.sleep(.05)
520 spinner.next()
521 with Spinner(Fore.RED + "Phase #2" + Fore.RESET,
522 print_done=False) as spinner:
523 for i in range(50):
524 time.sleep(.05)
525 spinner.next()
526 with Spinner("Phase #3", print_done=False, use_unicode=False) as spinner:
527 for i in range(50):
528 spinner.next()
529 time.sleep(.05)
530 with Spinner("Phase #4", print_done=False, chars='.oO°Oo.') as spinner:
531 for i in range(50):
532 spinner.next()
533 time.sleep(.05)
534
535 items = range(200)
536 with ProgressBar(len(items)) as bar:
537 for item in enumerate(items):
538 bar.update()
539 time.sleep(.05)
540
541 for item in ProgressBar(items):
542 time.sleep(.05)
543
544 progress = 0
545 max = 320000000
546 with ProgressBar(max) as bar:
547 while progress < max:
548 progress += 23400
549 bar.update(progress)
550 time.sleep(.001)
0 import logging
1 from ..__about__ import __version__ as VERSION
2
3 DEFAULT_FORMAT = '%(name)s:%(levelname)s: %(message)s'
4 MAIN_LOGGER = "eyed3"
5
6 # Add some levels
7 logging.VERBOSE = logging.DEBUG + 1
8 logging.addLevelName(logging.VERBOSE, "VERBOSE")
9
10
11 class Logger(logging.Logger):
12 """Base class for all loggers"""
13
14 def __init__(self, name):
15 logging.Logger.__init__(self, name)
16
17 # Using propagation of child to parent, by default
18 self.propagate = True
19 self.setLevel(logging.NOTSET)
20
21 def verbose(self, msg, *args, **kwargs):
22 """Log \a msg at 'verbose' level, debug < verbose < info"""
23 self.log(logging.VERBOSE, msg, *args, **kwargs)
24
25
26 def getLogger(name):
27 og_class = logging.getLoggerClass()
28 try:
29 logging.setLoggerClass(Logger)
30 return logging.getLogger(name)
31 finally:
32 logging.setLoggerClass(og_class)
33
34
35 # The main 'eyed3' logger
36 log = getLogger(MAIN_LOGGER)
37 log.debug("eyeD3 version " + VERSION)
38 del VERSION
39
40
41 def initLogging():
42 """initialize the default logger with console output"""
43 global log
44
45 logging.basicConfig()
46
47 # Don't propagate base 'eyed3'
48 log.propagate = False
49
50 console_handler = logging.StreamHandler()
51 console_handler.setFormatter(logging.Formatter(DEFAULT_FORMAT))
52 log.addHandler(console_handler)
53
54 log.setLevel(logging.ERROR)
55
56 return log
57
58
59 LEVELS = (logging.DEBUG, logging.VERBOSE, logging.INFO,
60 logging.WARNING, logging.ERROR, logging.CRITICAL)
0 import sys as _sys
1 from .console import Fore as fg
2
3 DISABLE_PROMPT = None
4 """Whenever a prompt occurs and this value is not ``None`` it can be ``exit``
5 to call sys.exit (see EXIT_STATUS) or ``raise`` to throw a RuntimeError,
6 which can be caught if desired."""
7
8 EXIT_STATUS = 2
9
10 BOOL_TRUE_RESPONSES = ("yes", "y", "true")
11
12
13 class PromptExit(RuntimeError):
14 """Raised when ``DISABLE_PROMPT`` is 'raise' and ``prompt`` is called."""
15 pass
16
17
18 def parseIntList(resp):
19 ints = set()
20 resp = resp.replace(',', ' ')
21 for c in resp.split():
22 i = int(c)
23 ints.add(i)
24 return list(ints)
25
26
27 def prompt(msg, default=None, required=True, type_=str,
28 validate=None, choices=None):
29 """Prompt user for imput, the prequest is in ``msg``. If ``default`` is
30 not ``None`` it will be displayed as the default and returned if not
31 input is entered. The value ``None`` is only returned if ``required`` is
32 ``False``. The response is passed to ``type_`` for conversion (default
33 is unicode) before being returned. An optional list of valid responses can
34 be provided in ``choices``."""
35 yes_no_prompt = default is True or default is False
36
37 if yes_no_prompt:
38 default_str = "Yn" if default is True else "yN"
39 else:
40 default_str = str(default) if default else None
41
42 if default is not None:
43 msg = "%s [%s]" % (msg, default_str)
44 msg += ": " if not yes_no_prompt else "? "
45
46 if DISABLE_PROMPT:
47 if DISABLE_PROMPT == "exit":
48 print(msg + "\nPrompting is disabled, exiting.")
49 _sys.exit(EXIT_STATUS)
50 else:
51 raise PromptExit(msg)
52
53 resp = None
54 while resp is None:
55
56 try:
57 resp = input(msg)
58 except EOFError:
59 # Converting this allows main functions to catch without
60 # catching other eofs
61 raise PromptExit()
62
63 if not resp and default not in (None, ""):
64 resp = str(default)
65
66 if resp:
67 if yes_no_prompt:
68 resp = True if resp.lower() in BOOL_TRUE_RESPONSES else False
69 else:
70 resp = resp.strip()
71 try:
72 resp = type_(resp)
73 except Exception as ex:
74 print(fg.red(str(ex)))
75 resp = None
76 elif not required:
77 return None
78 else:
79 resp = None
80
81 if ((choices and resp not in choices) or
82 (validate and not validate(resp))):
83 if choices:
84 print(fg.red("Invalid response, choose from: ") + str(choices))
85 else:
86 print(fg.red("Invalid response"))
87 resp = None
88
89 return resp
0 [[package]]
1 name = "alabaster"
2 version = "0.7.12"
3 description = "A configurable sidebar-enabled Sphinx theme"
4 category = "dev"
5 optional = false
6 python-versions = "*"
7
8 [[package]]
9 name = "appdirs"
10 version = "1.4.4"
11 description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
12 category = "main"
13 optional = true
14 python-versions = "*"
15
16 [[package]]
17 name = "arrow"
18 version = "0.17.0"
19 description = "Better dates & times for Python"
20 category = "dev"
21 optional = false
22 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
23
24 [package.dependencies]
25 python-dateutil = ">=2.7.0"
26
27 [[package]]
28 name = "atomicwrites"
29 version = "1.4.0"
30 description = "Atomic file writes."
31 category = "main"
32 optional = true
33 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
34 marker = "sys_platform == \"win32\""
35
36 [[package]]
37 name = "attrs"
38 version = "20.3.0"
39 description = "Classes Without Boilerplate"
40 category = "main"
41 optional = false
42 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
43
44 [package.extras]
45 dev = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"]
46 docs = ["furo", "sphinx", "zope.interface"]
47 tests = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
48 tests_no_zope = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"]
49
50 [[package]]
51 name = "babel"
52 version = "2.9.0"
53 description = "Internationalization utilities"
54 category = "dev"
55 optional = false
56 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
57
58 [package.dependencies]
59 pytz = ">=2015.7"
60
61 [[package]]
62 name = "binaryornot"
63 version = "0.4.4"
64 description = "Ultra-lightweight pure Python package to check if a file is binary or text."
65 category = "dev"
66 optional = false
67 python-versions = "*"
68
69 [package.dependencies]
70 chardet = ">=3.0.2"
71
72 [[package]]
73 name = "bleach"
74 version = "3.2.1"
75 description = "An easy safelist-based HTML-sanitizing tool."
76 category = "dev"
77 optional = false
78 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
79
80 [package.dependencies]
81 packaging = "*"
82 six = ">=1.9.0"
83 webencodings = "*"
84
85 [[package]]
86 name = "build"
87 version = "0.1.0"
88 description = "A simple, correct PEP517 package builder"
89 category = "main"
90 optional = true
91 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
92
93 [package.extras]
94 doc = ["furo", "sphinx", "sphinx-autodoc-typehints", "sphinxcontrib-autoprogram"]
95 test = ["filelock", "pytest", "pytest-cov", "pytest-mock", "pytest-xdist (>=1.34)"]
96 typing = ["mypy (0.790)", "typing-extensions (>=3.7.4.3)"]
97
98 [package.dependencies]
99 packaging = "*"
100 pep517 = ">=0.9"
101 toml = "*"
102
103 [package.dependencies.importlib-metadata]
104 version = "*"
105 python = "<3.8"
106
107 [[package]]
108 name = "certifi"
109 version = "2020.12.5"
110 description = "Python package for providing Mozilla's CA Bundle."
111 category = "main"
112 optional = false
113 python-versions = "*"
114
115 [[package]]
116 name = "cffi"
117 version = "1.14.4"
118 description = "Foreign Function Interface for Python calling C code."
119 category = "dev"
120 optional = false
121 python-versions = "*"
122 marker = "sys_platform == \"linux\""
123
124 [package.dependencies]
125 pycparser = "*"
126
127 [[package]]
128 name = "chardet"
129 version = "4.0.0"
130 description = "Universal encoding detector for Python 2 and 3"
131 category = "main"
132 optional = false
133 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
134
135 [[package]]
136 name = "check-manifest"
137 version = "0.45"
138 description = "Check MANIFEST.in in a Python source package for completeness"
139 category = "main"
140 optional = true
141 python-versions = ">=3.6"
142
143 [package.extras]
144 test = ["mock (>=3.0.0)"]
145
146 [package.dependencies]
147 build = ">=0.1"
148 setuptools = "*"
149 toml = "*"
150
151 [[package]]
152 name = "click"
153 version = "7.1.2"
154 description = "Composable command line interface toolkit"
155 category = "dev"
156 optional = false
157 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
158
159 [[package]]
160 name = "cogapp"
161 version = "3.0.0"
162 description = "Cog: A content generator for executing Python snippets in source files."
163 category = "dev"
164 optional = false
165 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
166
167 [[package]]
168 name = "colorama"
169 version = "0.4.4"
170 description = "Cross-platform colored terminal text."
171 category = "main"
172 optional = false
173 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
174
175 [[package]]
176 name = "cookiecutter"
177 version = "1.7.2"
178 description = "A command-line utility that creates projects from project templates, e.g. creating a Python package project from a Python package project template."
179 category = "dev"
180 optional = false
181 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
182
183 [package.dependencies]
184 binaryornot = ">=0.4.4"
185 click = ">=7.0"
186 Jinja2 = "<3.0.0"
187 jinja2-time = ">=0.2.0"
188 MarkupSafe = "<2.0.0"
189 poyo = ">=0.5.0"
190 python-slugify = ">=4.0.0"
191 requests = ">=2.23.0"
192 six = ">=1.10"
193
194 [[package]]
195 name = "coverage"
196 version = "5.3.1"
197 description = "Code coverage measurement for Python"
198 category = "main"
199 optional = true
200 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
201
202 [package.extras]
203 toml = ["toml"]
204
205 [package.dependencies]
206 [package.dependencies.toml]
207 version = "*"
208 optional = true
209
210 [[package]]
211 name = "cryptography"
212 version = "3.3.1"
213 description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
214 category = "dev"
215 optional = false
216 python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*"
217 marker = "sys_platform == \"linux\""
218
219 [package.extras]
220 docs = ["sphinx (>=1.6.5,<1.8.0 || >1.8.0,<3.1.0 || >3.1.0,<3.1.1 || >3.1.1)", "sphinx-rtd-theme"]
221 docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"]
222 pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"]
223 ssh = ["bcrypt (>=3.1.5)"]
224 test = ["pytest (>=3.6.0,<3.9.0 || >3.9.0,<3.9.1 || >3.9.1,<3.9.2 || >3.9.2)", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,<3.79.2 || >3.79.2)"]
225
226 [package.dependencies]
227 cffi = ">=1.12"
228 six = ">=1.4.1"
229
230 [[package]]
231 name = "dataclasses"
232 version = "0.8"
233 description = "A backport of the dataclasses module for Python 3.6"
234 category = "main"
235 optional = false
236 python-versions = ">=3.6, <3.7"
237 marker = "python_version >= \"3.6\" and python_version < \"3.7\""
238
239 [[package]]
240 name = "deprecation"
241 version = "2.1.0"
242 description = "A library to handle automated deprecations"
243 category = "main"
244 optional = false
245 python-versions = "*"
246
247 [package.dependencies]
248 packaging = "*"
249
250 [[package]]
251 name = "distlib"
252 version = "0.3.1"
253 description = "Distribution utilities"
254 category = "main"
255 optional = true
256 python-versions = "*"
257
258 [[package]]
259 name = "docutils"
260 version = "0.16"
261 description = "Docutils -- Python Documentation Utilities"
262 category = "dev"
263 optional = false
264 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
265
266 [[package]]
267 name = "factory-boy"
268 version = "3.1.0"
269 description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby."
270 category = "main"
271 optional = true
272 python-versions = ">=3.5"
273
274 [package.extras]
275 dev = ["coverage", "django", "flake8", "isort", "pillow", "sqlalchemy", "mongoengine", "wheel (>=0.32.0)", "tox", "zest.releaser"]
276 doc = ["sphinx", "sphinx-rtd-theme"]
277
278 [package.dependencies]
279 Faker = ">=0.7.0"
280
281 [[package]]
282 name = "faker"
283 version = "5.0.2"
284 description = "Faker is a Python package that generates fake data for you."
285 category = "main"
286 optional = true
287 python-versions = ">=3.6"
288
289 [package.dependencies]
290 python-dateutil = ">=2.4"
291 text-unidecode = "1.3"
292
293 [[package]]
294 name = "filelock"
295 version = "3.0.12"
296 description = "A platform independent file lock."
297 category = "main"
298 optional = true
299 python-versions = "*"
300
301 [[package]]
302 name = "filetype"
303 version = "1.0.7"
304 description = "Infer file type and MIME type of any file/buffer. No external dependencies."
305 category = "main"
306 optional = false
307 python-versions = "*"
308
309 [[package]]
310 name = "flake8"
311 version = "3.8.4"
312 description = "the modular source code checker: pep8 pyflakes and co"
313 category = "main"
314 optional = true
315 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
316
317 [package.dependencies]
318 mccabe = ">=0.6.0,<0.7.0"
319 pycodestyle = ">=2.6.0a1,<2.7.0"
320 pyflakes = ">=2.2.0,<2.3.0"
321
322 [package.dependencies.importlib-metadata]
323 version = "*"
324 python = "<3.8"
325
326 [[package]]
327 name = "gitchangelog"
328 version = "3.0.3"
329 description = "gitchangelog generates a changelog thanks to git log."
330 category = "dev"
331 optional = false
332 python-versions = "*"
333
334 [package.extras]
335 Mako = ["mako"]
336 Mustache = ["pystache"]
337 test = ["nose", "minimock", "mako", "pystache"]
338
339 [package.source]
340 url = "https://github.com/nicfit/gitchangelog.git"
341 reference = "b86c6e90f48daf100cd701e0708973af3bb138e6"
342 type = "git"
343
344 [[package]]
345 name = "grako"
346 version = "3.99.9"
347 description = "Grako takes a grammar in a variation of EBNF as input, and outputs a memoizing PEG/Packrat parser in Python."
348 category = "main"
349 optional = true
350 python-versions = "*"
351
352 [package.extras]
353 future-regex = ["regex"]
354
355 [[package]]
356 name = "idna"
357 version = "2.10"
358 description = "Internationalized Domain Names in Applications (IDNA)"
359 category = "main"
360 optional = false
361 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
362
363 [[package]]
364 name = "imagesize"
365 version = "1.2.0"
366 description = "Getting image size from png/jpeg/jpeg2000/gif file"
367 category = "dev"
368 optional = false
369 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
370
371 [[package]]
372 name = "importlib-metadata"
373 version = "2.1.1"
374 description = "Read metadata from Python packages"
375 category = "main"
376 optional = false
377 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
378 marker = "python_version < \"3.8\""
379
380 [package.extras]
381 docs = ["sphinx", "rst.linker"]
382 testing = ["packaging", "pep517", "unittest2", "importlib-resources (>=1.3)"]
383
384 [package.dependencies]
385 zipp = ">=0.5"
386
387 [[package]]
388 name = "importlib-resources"
389 version = "4.1.1"
390 description = "Read resources from Python packages"
391 category = "main"
392 optional = true
393 python-versions = ">=3.6"
394 marker = "python_version < \"3.7\""
395
396 [package.extras]
397 docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
398 testing = ["pytest (>=3.5,<3.7.3 || >3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "pytest-black (>=0.3.7)", "pytest-mypy"]
399
400 [package.dependencies]
401 [package.dependencies.zipp]
402 version = ">=0.4"
403 python = "<3.8"
404
405 [[package]]
406 name = "iniconfig"
407 version = "1.1.1"
408 description = "iniconfig: brain-dead simple config-ini parsing"
409 category = "main"
410 optional = true
411 python-versions = "*"
412
413 [[package]]
414 name = "jeepney"
415 version = "0.6.0"
416 description = "Low-level, pure Python DBus protocol wrapper."
417 category = "dev"
418 optional = false
419 python-versions = ">=3.6"
420 marker = "sys_platform == \"linux\""
421
422 [package.extras]
423 test = ["pytest", "pytest-trio", "pytest-asyncio", "testpath", "trio"]
424
425 [[package]]
426 name = "jinja2"
427 version = "2.11.2"
428 description = "A very fast and expressive template engine."
429 category = "dev"
430 optional = false
431 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
432
433 [package.extras]
434 i18n = ["Babel (>=0.8)"]
435
436 [package.dependencies]
437 MarkupSafe = ">=0.23"
438
439 [[package]]
440 name = "jinja2-time"
441 version = "0.2.0"
442 description = "Jinja2 Extension for Dates and Times"
443 category = "dev"
444 optional = false
445 python-versions = "*"
446
447 [package.dependencies]
448 arrow = "*"
449 jinja2 = "*"
450
451 [[package]]
452 name = "keyring"
453 version = "21.7.0"
454 description = "Store and access your passwords safely."
455 category = "dev"
456 optional = false
457 python-versions = ">=3.6"
458
459 [package.extras]
460 docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
461 testing = ["pytest (>=3.5,<3.7.3 || >3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "pytest-black (>=0.3.7)", "pytest-mypy"]
462
463 [package.dependencies]
464 jeepney = ">=0.4.2"
465 pywin32-ctypes = "<0.1.0 || >0.1.0,<0.1.1 || >0.1.1"
466 SecretStorage = ">=3.2"
467
468 [package.dependencies.importlib-metadata]
469 version = ">=1"
470 python = "<3.8"
471
472 [[package]]
473 name = "markupsafe"
474 version = "1.1.1"
475 description = "Safely add untrusted strings to HTML/XML markup."
476 category = "dev"
477 optional = false
478 python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
479
480 [[package]]
481 name = "mccabe"
482 version = "0.6.1"
483 description = "McCabe checker, plugin for flake8"
484 category = "main"
485 optional = true
486 python-versions = "*"
487
488 [[package]]
489 name = "nicfit.py"
490 version = "0.8.7"
491 description = "Common Python utils (App, logging, config, etc.)"
492 category = "dev"
493 optional = false
494 python-versions = "*"
495
496 [package.extras]
497 cookiecutter = ["cookiecutter"]
498 shell = ["prompt-toolkit", "pygments"]
499
500 [package.dependencies]
501 attrs = "*"
502 deprecation = "*"
503 PyYaml = "*"
504
505 [package.dependencies.cookiecutter]
506 version = "*"
507 optional = true
508
509 [[package]]
510 name = "packaging"
511 version = "20.8"
512 description = "Core utilities for Python packages"
513 category = "main"
514 optional = false
515 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
516
517 [package.dependencies]
518 pyparsing = ">=2.0.2"
519
520 [[package]]
521 name = "paver"
522 version = "1.3.4"
523 description = "Easy build, distribution and deployment scripting"
524 category = "dev"
525 optional = false
526 python-versions = "*"
527
528 [package.dependencies]
529 six = "*"
530
531 [[package]]
532 name = "pep517"
533 version = "0.9.1"
534 description = "Wrappers to build Python packages using PEP 517 hooks"
535 category = "main"
536 optional = true
537 python-versions = "*"
538
539 [package.dependencies]
540 toml = "*"
541
542 [package.dependencies.importlib_metadata]
543 version = "*"
544 python = "<3.8"
545
546 [package.dependencies.zipp]
547 version = "*"
548 python = "<3.8"
549
550 [[package]]
551 name = "pillow"
552 version = "8.0.1"
553 description = "Python Imaging Library (Fork)"
554 category = "main"
555 optional = true
556 python-versions = ">=3.6"
557
558 [[package]]
559 name = "pkginfo"
560 version = "1.6.1"
561 description = "Query metadatdata from sdists / bdists / installed packages."
562 category = "dev"
563 optional = false
564 python-versions = "*"
565
566 [package.extras]
567 testing = ["nose", "coverage"]
568
569 [[package]]
570 name = "pluggy"
571 version = "0.13.1"
572 description = "plugin and hook calling mechanisms for python"
573 category = "main"
574 optional = true
575 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
576
577 [package.extras]
578 dev = ["pre-commit", "tox"]
579
580 [package.dependencies]
581 [package.dependencies.importlib-metadata]
582 version = ">=0.12"
583 python = "<3.8"
584
585 [[package]]
586 name = "poyo"
587 version = "0.5.0"
588 description = "A lightweight YAML Parser for Python. 🐓"
589 category = "dev"
590 optional = false
591 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
592
593 [[package]]
594 name = "py"
595 version = "1.10.0"
596 description = "library with cross-python path, ini-parsing, io, code, log facilities"
597 category = "main"
598 optional = true
599 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
600
601 [[package]]
602 name = "pycodestyle"
603 version = "2.6.0"
604 description = "Python style guide checker"
605 category = "main"
606 optional = true
607 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
608
609 [[package]]
610 name = "pycparser"
611 version = "2.20"
612 description = "C parser in Python"
613 category = "dev"
614 optional = false
615 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
616 marker = "sys_platform == \"linux\""
617
618 [[package]]
619 name = "pyflakes"
620 version = "2.2.0"
621 description = "passive checker of Python programs"
622 category = "main"
623 optional = true
624 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
625
626 [[package]]
627 name = "pygments"
628 version = "2.7.3"
629 description = "Pygments is a syntax highlighting package written in Python."
630 category = "dev"
631 optional = false
632 python-versions = ">=3.5"
633
634 [[package]]
635 name = "pylast"
636 version = "4.0.0"
637 description = "A Python interface to Last.fm and Libre.fm"
638 category = "main"
639 optional = true
640 python-versions = ">=3.6"
641
642 [package.extras]
643 tests = ["flaky", "pytest", "pytest-cov", "pytest-random-order", "pyyaml"]
644
645 [[package]]
646 name = "pyparsing"
647 version = "2.4.7"
648 description = "Python parsing module"
649 category = "main"
650 optional = false
651 python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
652
653 [[package]]
654 name = "pytest"
655 version = "6.2.1"
656 description = "pytest: simple powerful testing with Python"
657 category = "main"
658 optional = true
659 python-versions = ">=3.6"
660
661 [package.extras]
662 testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
663
664 [package.dependencies]
665 atomicwrites = ">=1.0"
666 attrs = ">=19.2.0"
667 colorama = "*"
668 iniconfig = "*"
669 packaging = "*"
670 pluggy = ">=0.12,<1.0.0a1"
671 py = ">=1.8.2"
672 toml = "*"
673
674 [package.dependencies.importlib-metadata]
675 version = ">=0.12"
676 python = "<3.8"
677
678 [[package]]
679 name = "pytest-cov"
680 version = "2.10.1"
681 description = "Pytest plugin for measuring coverage."
682 category = "main"
683 optional = true
684 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
685
686 [package.extras]
687 testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "pytest-xdist", "virtualenv"]
688
689 [package.dependencies]
690 coverage = ">=4.4"
691 pytest = ">=4.6"
692
693 [[package]]
694 name = "python-dateutil"
695 version = "2.8.1"
696 description = "Extensions to the standard Python datetime module"
697 category = "main"
698 optional = false
699 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
700
701 [package.dependencies]
702 six = ">=1.5"
703
704 [[package]]
705 name = "python-slugify"
706 version = "4.0.1"
707 description = "A Python Slugify application that handles Unicode"
708 category = "dev"
709 optional = false
710 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
711
712 [package.extras]
713 unidecode = ["Unidecode (>=1.1.1)"]
714
715 [package.dependencies]
716 text-unidecode = ">=1.3"
717
718 [[package]]
719 name = "pytz"
720 version = "2020.5"
721 description = "World timezone definitions, modern and historical"
722 category = "dev"
723 optional = false
724 python-versions = "*"
725
726 [[package]]
727 name = "pywin32-ctypes"
728 version = "0.2.0"
729 description = ""
730 category = "dev"
731 optional = false
732 python-versions = "*"
733 marker = "sys_platform == \"win32\""
734
735 [[package]]
736 name = "pyyaml"
737 version = "5.3.1"
738 description = "YAML parser and emitter for Python"
739 category = "dev"
740 optional = false
741 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
742
743 [[package]]
744 name = "readme-renderer"
745 version = "28.0"
746 description = "readme_renderer is a library for rendering \"readme\" descriptions for Warehouse"
747 category = "dev"
748 optional = false
749 python-versions = "*"
750
751 [package.extras]
752 md = ["cmarkgfm (>=0.2.0)"]
753
754 [package.dependencies]
755 bleach = ">=2.1.0"
756 docutils = ">=0.13.1"
757 Pygments = ">=2.5.1"
758 six = "*"
759
760 [[package]]
761 name = "regarding"
762 version = "0.1.4"
763 description = "Create __about__.py files for Poetry and setup.py projects."
764 category = "dev"
765 optional = false
766 python-versions = ">=3.6,<4.0"
767
768 [package.dependencies]
769 setuptools = ">=50.3.2,<51.0.0"
770 toml = ">=0.10.2,<0.11.0"
771
772 [package.dependencies.dataclasses]
773 version = ">=0.8,<0.9"
774 python = ">=3.6,<3.7"
775
776 [[package]]
777 name = "requests"
778 version = "2.25.1"
779 description = "Python HTTP for Humans."
780 category = "main"
781 optional = false
782 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
783
784 [package.extras]
785 security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
786 socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"]
787
788 [package.dependencies]
789 certifi = ">=2017.4.17"
790 chardet = ">=3.0.2,<5"
791 idna = ">=2.5,<3"
792 urllib3 = ">=1.21.1,<1.27"
793
794 [[package]]
795 name = "requests-toolbelt"
796 version = "0.9.1"
797 description = "A utility belt for advanced users of python-requests"
798 category = "dev"
799 optional = false
800 python-versions = "*"
801
802 [package.dependencies]
803 requests = ">=2.0.1,<3.0.0"
804
805 [[package]]
806 name = "rfc3986"
807 version = "1.4.0"
808 description = "Validating URI References per RFC 3986"
809 category = "dev"
810 optional = false
811 python-versions = "*"
812
813 [package.extras]
814 idna2008 = ["idna"]
815
816 [[package]]
817 name = "ruamel.yaml"
818 version = "0.16.12"
819 description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order"
820 category = "main"
821 optional = true
822 python-versions = "*"
823
824 [package.extras]
825 docs = ["ryd"]
826 jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"]
827
828 [package.dependencies]
829 [package.dependencies."ruamel.yaml.clib"]
830 version = ">=0.1.2"
831 python = "<3.9"
832
833 [[package]]
834 name = "ruamel.yaml.clib"
835 version = "0.2.2"
836 description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml"
837 category = "main"
838 optional = true
839 python-versions = "*"
840 marker = "platform_python_implementation == \"CPython\" and python_version < \"3.9\""
841
842 [[package]]
843 name = "secretstorage"
844 version = "3.3.0"
845 description = "Python bindings to FreeDesktop.org Secret Service API"
846 category = "dev"
847 optional = false
848 python-versions = ">=3.6"
849 marker = "sys_platform == \"linux\""
850
851 [package.dependencies]
852 cryptography = ">=2.0"
853 jeepney = ">=0.6"
854
855 [[package]]
856 name = "six"
857 version = "1.15.0"
858 description = "Python 2 and 3 compatibility utilities"
859 category = "main"
860 optional = false
861 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
862
863 [[package]]
864 name = "snowballstemmer"
865 version = "2.0.0"
866 description = "This package provides 26 stemmers for 25 languages generated from Snowball algorithms."
867 category = "dev"
868 optional = false
869 python-versions = "*"
870
871 [[package]]
872 name = "sphinx"
873 version = "3.4.1"
874 description = "Python documentation generator"
875 category = "dev"
876 optional = false
877 python-versions = ">=3.5"
878
879 [package.extras]
880 docs = ["sphinxcontrib-websupport"]
881 lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.790)", "docutils-stubs"]
882 test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"]
883
884 [package.dependencies]
885 alabaster = ">=0.7,<0.8"
886 babel = ">=1.3"
887 colorama = ">=0.3.5"
888 docutils = ">=0.12"
889 imagesize = "*"
890 Jinja2 = ">=2.3"
891 packaging = "*"
892 Pygments = ">=2.0"
893 requests = ">=2.5.0"
894 setuptools = "*"
895 snowballstemmer = ">=1.1"
896 sphinxcontrib-applehelp = "*"
897 sphinxcontrib-devhelp = "*"
898 sphinxcontrib-htmlhelp = "*"
899 sphinxcontrib-jsmath = "*"
900 sphinxcontrib-qthelp = "*"
901 sphinxcontrib-serializinghtml = "*"
902
903 [[package]]
904 name = "sphinx-issues"
905 version = "1.2.0"
906 description = "A Sphinx extension for linking to your project's issue tracker"
907 category = "dev"
908 optional = false
909 python-versions = "*"
910
911 [package.extras]
912 dev = ["pytest", "flake8 (3.6.0)", "pre-commit (1.13.0)", "tox", "mock", "flake8-bugbear (18.8.0)"]
913 lint = ["flake8 (3.6.0)", "pre-commit (1.13.0)", "flake8-bugbear (18.8.0)"]
914 tests = ["pytest", "mock"]
915
916 [package.dependencies]
917 sphinx = "*"
918
919 [[package]]
920 name = "sphinx-rtd-theme"
921 version = "0.5.0"
922 description = "Read the Docs theme for Sphinx"
923 category = "dev"
924 optional = false
925 python-versions = "*"
926
927 [package.extras]
928 dev = ["transifex-client", "sphinxcontrib-httpdomain", "bump2version"]
929
930 [package.dependencies]
931 sphinx = "*"
932
933 [[package]]
934 name = "sphinxcontrib-applehelp"
935 version = "1.0.2"
936 description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books"
937 category = "dev"
938 optional = false
939 python-versions = ">=3.5"
940
941 [package.extras]
942 lint = ["flake8", "mypy", "docutils-stubs"]
943 test = ["pytest"]
944
945 [[package]]
946 name = "sphinxcontrib-devhelp"
947 version = "1.0.2"
948 description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document."
949 category = "dev"
950 optional = false
951 python-versions = ">=3.5"
952
953 [package.extras]
954 lint = ["flake8", "mypy", "docutils-stubs"]
955 test = ["pytest"]
956
957 [[package]]
958 name = "sphinxcontrib-htmlhelp"
959 version = "1.0.3"
960 description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files"
961 category = "dev"
962 optional = false
963 python-versions = ">=3.5"
964
965 [package.extras]
966 lint = ["flake8", "mypy", "docutils-stubs"]
967 test = ["pytest", "html5lib"]
968
969 [[package]]
970 name = "sphinxcontrib-jsmath"
971 version = "1.0.1"
972 description = "A sphinx extension which renders display math in HTML via JavaScript"
973 category = "dev"
974 optional = false
975 python-versions = ">=3.5"
976
977 [package.extras]
978 test = ["pytest", "flake8", "mypy"]
979
980 [[package]]
981 name = "sphinxcontrib-qthelp"
982 version = "1.0.3"
983 description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document."
984 category = "dev"
985 optional = false
986 python-versions = ">=3.5"
987
988 [package.extras]
989 lint = ["flake8", "mypy", "docutils-stubs"]
990 test = ["pytest"]
991
992 [[package]]
993 name = "sphinxcontrib-serializinghtml"
994 version = "1.1.4"
995 description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)."
996 category = "dev"
997 optional = false
998 python-versions = ">=3.5"
999
1000 [package.extras]
1001 lint = ["flake8", "mypy", "docutils-stubs"]
1002 test = ["pytest"]
1003
1004 [[package]]
1005 name = "text-unidecode"
1006 version = "1.3"
1007 description = "The most basic Text::Unidecode port"
1008 category = "main"
1009 optional = false
1010 python-versions = "*"
1011
1012 [[package]]
1013 name = "toml"
1014 version = "0.10.2"
1015 description = "Python Library for Tom's Obvious, Minimal Language"
1016 category = "main"
1017 optional = false
1018 python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
1019
1020 [[package]]
1021 name = "tox"
1022 version = "3.20.1"
1023 description = "tox is a generic virtualenv management and test command line tool"
1024 category = "main"
1025 optional = true
1026 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
1027
1028 [package.extras]
1029 docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"]
1030 testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pathlib2 (>=2.3.3)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "pytest-xdist (>=1.22.2)"]
1031
1032 [package.dependencies]
1033 colorama = ">=0.4.1"
1034 filelock = ">=3.0.0"
1035 packaging = ">=14"
1036 pluggy = ">=0.12.0"
1037 py = ">=1.4.17"
1038 six = ">=1.14.0"
1039 toml = ">=0.9.4"
1040 virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7"
1041
1042 [package.dependencies.importlib-metadata]
1043 version = ">=0.12,<3"
1044 python = "<3.8"
1045
1046 [[package]]
1047 name = "tqdm"
1048 version = "4.55.0"
1049 description = "Fast, Extensible Progress Meter"
1050 category = "dev"
1051 optional = false
1052 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
1053
1054 [package.extras]
1055 dev = ["py-make (>=0.1.0)", "twine", "wheel"]
1056 telegram = ["requests"]
1057
1058 [[package]]
1059 name = "twine"
1060 version = "3.3.0"
1061 description = "Collection of utilities for publishing packages on PyPI"
1062 category = "dev"
1063 optional = false
1064 python-versions = ">=3.6"
1065
1066 [package.dependencies]
1067 colorama = ">=0.4.3"
1068 keyring = ">=15.1"
1069 pkginfo = ">=1.4.2"
1070 readme-renderer = ">=21.0"
1071 requests = ">=2.20"
1072 requests-toolbelt = ">=0.8.0,<0.9.0 || >0.9.0"
1073 rfc3986 = ">=1.4.0"
1074 setuptools = ">=0.7.0"
1075 tqdm = ">=4.14"
1076
1077 [package.dependencies.importlib-metadata]
1078 version = "*"
1079 python = "<3.8"
1080
1081 [[package]]
1082 name = "urllib3"
1083 version = "1.26.2"
1084 description = "HTTP library with thread-safe connection pooling, file post, and more."
1085 category = "main"
1086 optional = false
1087 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
1088
1089 [package.extras]
1090 brotli = ["brotlipy (>=0.6.0)"]
1091 secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
1092 socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"]
1093
1094 [[package]]
1095 name = "virtualenv"
1096 version = "20.2.2"
1097 description = "Virtual Python Environment builder"
1098 category = "main"
1099 optional = true
1100 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
1101
1102 [package.extras]
1103 docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"]
1104 testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "pytest-xdist (>=1.31.0)", "packaging (>=20.0)", "xonsh (>=0.9.16)"]
1105
1106 [package.dependencies]
1107 appdirs = ">=1.4.3,<2"
1108 distlib = ">=0.3.1,<1"
1109 filelock = ">=3.0.0,<4"
1110 six = ">=1.9.0,<2"
1111
1112 [package.dependencies.importlib-metadata]
1113 version = ">=0.12"
1114 python = "<3.8"
1115
1116 [package.dependencies.importlib-resources]
1117 version = ">=1.0"
1118 python = "<3.7"
1119
1120 [[package]]
1121 name = "webencodings"
1122 version = "0.5.1"
1123 description = "Character encoding aliases for legacy web content"
1124 category = "dev"
1125 optional = false
1126 python-versions = "*"
1127
1128 [[package]]
1129 name = "wheel"
1130 version = "0.36.2"
1131 description = "A built-package format for Python"
1132 category = "dev"
1133 optional = false
1134 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
1135
1136 [package.extras]
1137 test = ["pytest (>=3.0.0)", "pytest-cov"]
1138
1139 [[package]]
1140 name = "zipp"
1141 version = "3.4.0"
1142 description = "Backport of pathlib-compatible object wrapper for zip files"
1143 category = "main"
1144 optional = false
1145 python-versions = ">=3.6"
1146 marker = "python_version < \"3.8\""
1147
1148 [package.extras]
1149 docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
1150 testing = ["pytest (>=3.5,<3.7.3 || >3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
1151
1152 [extras]
1153 test = ["pytest", "pytest-cov", "tox", "factory-boy", "flake8", "check-manifest"]
1154 yaml-plugin = ["ruamel.yaml"]
1155 display-plugin = ["grako"]
1156 art-plugin = ["Pillow", "pylast", "requests"]
1157
1158 [metadata]
1159 lock-version = "1.0"
1160 python-versions = "^3.6"
1161 content-hash = "36e58c94f32d6cf481a6d84b87d621d27f0a7523bf5c79dfce82ff69c01fe17c"
1162
1163 [metadata.files]
1164 alabaster = [
1165 {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"},
1166 {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"},
1167 ]
1168 appdirs = [
1169 {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
1170 {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
1171 ]
1172 arrow = [
1173 {file = "arrow-0.17.0-py2.py3-none-any.whl", hash = "sha256:e098abbd9af3665aea81bdd6c869e93af4feb078e98468dd351c383af187aac5"},
1174 {file = "arrow-0.17.0.tar.gz", hash = "sha256:ff08d10cda1d36c68657d6ad20d74fbea493d980f8b2d45344e00d6ed2bf6ed4"},
1175 ]
1176 atomicwrites = [
1177 {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
1178 {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
1179 ]
1180 attrs = [
1181 {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"},
1182 {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"},
1183 ]
1184 babel = [
1185 {file = "Babel-2.9.0-py2.py3-none-any.whl", hash = "sha256:9d35c22fcc79893c3ecc85ac4a56cde1ecf3f19c540bba0922308a6c06ca6fa5"},
1186 {file = "Babel-2.9.0.tar.gz", hash = "sha256:da031ab54472314f210b0adcff1588ee5d1d1d0ba4dbd07b94dba82bde791e05"},
1187 ]
1188 binaryornot = [
1189 {file = "binaryornot-0.4.4-py2.py3-none-any.whl", hash = "sha256:b8b71173c917bddcd2c16070412e369c3ed7f0528926f70cac18a6c97fd563e4"},
1190 {file = "binaryornot-0.4.4.tar.gz", hash = "sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061"},
1191 ]
1192 bleach = [
1193 {file = "bleach-3.2.1-py2.py3-none-any.whl", hash = "sha256:9f8ccbeb6183c6e6cddea37592dfb0167485c1e3b13b3363bc325aa8bda3adbd"},
1194 {file = "bleach-3.2.1.tar.gz", hash = "sha256:52b5919b81842b1854196eaae5ca29679a2f2e378905c346d3ca8227c2c66080"},
1195 ]
1196 build = [
1197 {file = "build-0.1.0-py2.py3-none-any.whl", hash = "sha256:2390c690a53bc22a09cbd35f70ece69d40cc8553e267ece046db4a5a1d32d856"},
1198 {file = "build-0.1.0.tar.gz", hash = "sha256:08b2b58098ff617d1154056c79f8a70beed18f7cfa710bca23a072196910d5b4"},
1199 ]
1200 certifi = [
1201 {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"},
1202 {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"},
1203 ]
1204 cffi = [
1205 {file = "cffi-1.14.4-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ebb253464a5d0482b191274f1c8bf00e33f7e0b9c66405fbffc61ed2c839c775"},
1206 {file = "cffi-1.14.4-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:2c24d61263f511551f740d1a065eb0212db1dbbbbd241db758f5244281590c06"},
1207 {file = "cffi-1.14.4-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9f7a31251289b2ab6d4012f6e83e58bc3b96bd151f5b5262467f4bb6b34a7c26"},
1208 {file = "cffi-1.14.4-cp27-cp27m-win32.whl", hash = "sha256:5cf4be6c304ad0b6602f5c4e90e2f59b47653ac1ed9c662ed379fe48a8f26b0c"},
1209 {file = "cffi-1.14.4-cp27-cp27m-win_amd64.whl", hash = "sha256:f60567825f791c6f8a592f3c6e3bd93dd2934e3f9dac189308426bd76b00ef3b"},
1210 {file = "cffi-1.14.4-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:c6332685306b6417a91b1ff9fae889b3ba65c2292d64bd9245c093b1b284809d"},
1211 {file = "cffi-1.14.4-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d9efd8b7a3ef378dd61a1e77367f1924375befc2eba06168b6ebfa903a5e59ca"},
1212 {file = "cffi-1.14.4-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:51a8b381b16ddd370178a65360ebe15fbc1c71cf6f584613a7ea08bfad946698"},
1213 {file = "cffi-1.14.4-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1d2c4994f515e5b485fd6d3a73d05526aa0fcf248eb135996b088d25dfa1865b"},
1214 {file = "cffi-1.14.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:af5c59122a011049aad5dd87424b8e65a80e4a6477419c0c1015f73fb5ea0293"},
1215 {file = "cffi-1.14.4-cp35-cp35m-win32.whl", hash = "sha256:594234691ac0e9b770aee9fcdb8fa02c22e43e5c619456efd0d6c2bf276f3eb2"},
1216 {file = "cffi-1.14.4-cp35-cp35m-win_amd64.whl", hash = "sha256:64081b3f8f6f3c3de6191ec89d7dc6c86a8a43911f7ecb422c60e90c70be41c7"},
1217 {file = "cffi-1.14.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f803eaa94c2fcda012c047e62bc7a51b0bdabda1cad7a92a522694ea2d76e49f"},
1218 {file = "cffi-1.14.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:105abaf8a6075dc96c1fe5ae7aae073f4696f2905fde6aeada4c9d2926752362"},
1219 {file = "cffi-1.14.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0638c3ae1a0edfb77c6765d487fee624d2b1ee1bdfeffc1f0b58c64d149e7eec"},
1220 {file = "cffi-1.14.4-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b"},
1221 {file = "cffi-1.14.4-cp36-cp36m-win32.whl", hash = "sha256:155136b51fd733fa94e1c2ea5211dcd4c8879869008fc811648f16541bf99668"},
1222 {file = "cffi-1.14.4-cp36-cp36m-win_amd64.whl", hash = "sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009"},
1223 {file = "cffi-1.14.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a7711edca4dcef1a75257b50a2fbfe92a65187c47dab5a0f1b9b332c5919a3fb"},
1224 {file = "cffi-1.14.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:00e28066507bfc3fe865a31f325c8391a1ac2916219340f87dfad602c3e48e5d"},
1225 {file = "cffi-1.14.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03"},
1226 {file = "cffi-1.14.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:a5ed8c05548b54b998b9498753fb9cadbfd92ee88e884641377d8a8b291bcc01"},
1227 {file = "cffi-1.14.4-cp37-cp37m-win32.whl", hash = "sha256:00a1ba5e2e95684448de9b89888ccd02c98d512064b4cb987d48f4b40aa0421e"},
1228 {file = "cffi-1.14.4-cp37-cp37m-win_amd64.whl", hash = "sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35"},
1229 {file = "cffi-1.14.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:df5169c4396adc04f9b0a05f13c074df878b6052430e03f50e68adf3a57aa28d"},
1230 {file = "cffi-1.14.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:9ffb888f19d54a4d4dfd4b3f29bc2c16aa4972f1c2ab9c4ab09b8ab8685b9c2b"},
1231 {file = "cffi-1.14.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53"},
1232 {file = "cffi-1.14.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:d5ff0621c88ce83a28a10d2ce719b2ee85635e85c515f12bac99a95306da4b2e"},
1233 {file = "cffi-1.14.4-cp38-cp38-win32.whl", hash = "sha256:b4e248d1087abf9f4c10f3c398896c87ce82a9856494a7155823eb45a892395d"},
1234 {file = "cffi-1.14.4-cp38-cp38-win_amd64.whl", hash = "sha256:ec80dc47f54e6e9a78181ce05feb71a0353854cc26999db963695f950b5fb375"},
1235 {file = "cffi-1.14.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909"},
1236 {file = "cffi-1.14.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:b18e0a9ef57d2b41f5c68beefa32317d286c3d6ac0484efd10d6e07491bb95dd"},
1237 {file = "cffi-1.14.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:045d792900a75e8b1e1b0ab6787dd733a8190ffcf80e8c8ceb2fb10a29ff238a"},
1238 {file = "cffi-1.14.4-cp39-cp39-win32.whl", hash = "sha256:ba4e9e0ae13fc41c6b23299545e5ef73055213e466bd107953e4a013a5ddd7e3"},
1239 {file = "cffi-1.14.4-cp39-cp39-win_amd64.whl", hash = "sha256:f032b34669220030f905152045dfa27741ce1a6db3324a5bc0b96b6c7420c87b"},
1240 {file = "cffi-1.14.4.tar.gz", hash = "sha256:1a465cbe98a7fd391d47dce4b8f7e5b921e6cd805ef421d04f5f66ba8f06086c"},
1241 ]
1242 chardet = [
1243 {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
1244 {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
1245 ]
1246 check-manifest = [
1247 {file = "check-manifest-0.45.tar.gz", hash = "sha256:636b65a3b685374ad429ff22fe213966765b145f08bc560c8d033b604c7bee4c"},
1248 {file = "check_manifest-0.45-py2.py3-none-any.whl", hash = "sha256:79dfd287348504a6f5195507dd15d0a6f66574feb34d3dbe1b33c80e24d2ceb9"},
1249 ]
1250 click = [
1251 {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
1252 {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
1253 ]
1254 cogapp = [
1255 {file = "cogapp-3.0.0-py2.py3-none-any.whl", hash = "sha256:61e9a66bbf1e6755df47330a441479c5be363bab30ca58c5598d9b641cdc9115"},
1256 {file = "cogapp-3.0.0.tar.gz", hash = "sha256:5e5da2bcfc4e4750c66cecb80ea4eaed1ef4fddd3787c989d4f5bfffb1152d6a"},
1257 ]
1258 colorama = [
1259 {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
1260 {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
1261 ]
1262 cookiecutter = [
1263 {file = "cookiecutter-1.7.2-py2.py3-none-any.whl", hash = "sha256:430eb882d028afb6102c084bab6cf41f6559a77ce9b18dc6802e3bc0cc5f4a30"},
1264 {file = "cookiecutter-1.7.2.tar.gz", hash = "sha256:efb6b2d4780feda8908a873e38f0e61778c23f6a2ea58215723bcceb5b515dac"},
1265 ]
1266 coverage = [
1267 {file = "coverage-5.3.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:fabeeb121735d47d8eab8671b6b031ce08514c86b7ad8f7d5490a7b6dcd6267d"},
1268 {file = "coverage-5.3.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:7e4d159021c2029b958b2363abec4a11db0ce8cd43abb0d9ce44284cb97217e7"},
1269 {file = "coverage-5.3.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:378ac77af41350a8c6b8801a66021b52da8a05fd77e578b7380e876c0ce4f528"},
1270 {file = "coverage-5.3.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:e448f56cfeae7b1b3b5bcd99bb377cde7c4eb1970a525c770720a352bc4c8044"},
1271 {file = "coverage-5.3.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:cc44e3545d908ecf3e5773266c487ad1877be718d9dc65fc7eb6e7d14960985b"},
1272 {file = "coverage-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:08b3ba72bd981531fd557f67beee376d6700fba183b167857038997ba30dd297"},
1273 {file = "coverage-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:8dacc4073c359f40fcf73aede8428c35f84639baad7e1b46fce5ab7a8a7be4bb"},
1274 {file = "coverage-5.3.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ee2f1d1c223c3d2c24e3afbb2dd38be3f03b1a8d6a83ee3d9eb8c36a52bee899"},
1275 {file = "coverage-5.3.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:9a9d4ff06804920388aab69c5ea8a77525cf165356db70131616acd269e19b36"},
1276 {file = "coverage-5.3.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:782a5c7df9f91979a7a21792e09b34a658058896628217ae6362088b123c8500"},
1277 {file = "coverage-5.3.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:fda29412a66099af6d6de0baa6bd7c52674de177ec2ad2630ca264142d69c6c7"},
1278 {file = "coverage-5.3.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:f2c6888eada180814b8583c3e793f3f343a692fc802546eed45f40a001b1169f"},
1279 {file = "coverage-5.3.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:8f33d1156241c43755137288dea619105477961cfa7e47f48dbf96bc2c30720b"},
1280 {file = "coverage-5.3.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b239711e774c8eb910e9b1ac719f02f5ae4bf35fa0420f438cdc3a7e4e7dd6ec"},
1281 {file = "coverage-5.3.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:f54de00baf200b4539a5a092a759f000b5f45fd226d6d25a76b0dff71177a714"},
1282 {file = "coverage-5.3.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:be0416074d7f253865bb67630cf7210cbc14eb05f4099cc0f82430135aaa7a3b"},
1283 {file = "coverage-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:c46643970dff9f5c976c6512fd35768c4a3819f01f61169d8cdac3f9290903b7"},
1284 {file = "coverage-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9a4f66259bdd6964d8cf26142733c81fb562252db74ea367d9beb4f815478e72"},
1285 {file = "coverage-5.3.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c6e5174f8ca585755988bc278c8bb5d02d9dc2e971591ef4a1baabdf2d99589b"},
1286 {file = "coverage-5.3.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:3911c2ef96e5ddc748a3c8b4702c61986628bb719b8378bf1e4a6184bbd48fe4"},
1287 {file = "coverage-5.3.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:c5ec71fd4a43b6d84ddb88c1df94572479d9a26ef3f150cef3dacefecf888105"},
1288 {file = "coverage-5.3.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f51dbba78d68a44e99d484ca8c8f604f17e957c1ca09c3ebc2c7e3bbd9ba0448"},
1289 {file = "coverage-5.3.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:a2070c5affdb3a5e751f24208c5c4f3d5f008fa04d28731416e023c93b275277"},
1290 {file = "coverage-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:535dc1e6e68fad5355f9984d5637c33badbdc987b0c0d303ee95a6c979c9516f"},
1291 {file = "coverage-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:a4857f7e2bc6921dbd487c5c88b84f5633de3e7d416c4dc0bb70256775551a6c"},
1292 {file = "coverage-5.3.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fac3c432851038b3e6afe086f777732bcf7f6ebbfd90951fa04ee53db6d0bcdd"},
1293 {file = "coverage-5.3.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:cd556c79ad665faeae28020a0ab3bda6cd47d94bec48e36970719b0b86e4dcf4"},
1294 {file = "coverage-5.3.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:a66ca3bdf21c653e47f726ca57f46ba7fc1f260ad99ba783acc3e58e3ebdb9ff"},
1295 {file = "coverage-5.3.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:ab110c48bc3d97b4d19af41865e14531f300b482da21783fdaacd159251890e8"},
1296 {file = "coverage-5.3.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:e52d3d95df81c8f6b2a1685aabffadf2d2d9ad97203a40f8d61e51b70f191e4e"},
1297 {file = "coverage-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:fa10fee7e32213f5c7b0d6428ea92e3a3fdd6d725590238a3f92c0de1c78b9d2"},
1298 {file = "coverage-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ce6f3a147b4b1a8b09aae48517ae91139b1b010c5f36423fa2b866a8b23df879"},
1299 {file = "coverage-5.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:93a280c9eb736a0dcca19296f3c30c720cb41a71b1f9e617f341f0a8e791a69b"},
1300 {file = "coverage-5.3.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:3102bb2c206700a7d28181dbe04d66b30780cde1d1c02c5f3c165cf3d2489497"},
1301 {file = "coverage-5.3.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8ffd4b204d7de77b5dd558cdff986a8274796a1e57813ed005b33fd97e29f059"},
1302 {file = "coverage-5.3.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:a607ae05b6c96057ba86c811d9c43423f35e03874ffb03fbdcd45e0637e8b631"},
1303 {file = "coverage-5.3.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:3a3c3f8863255f3c31db3889f8055989527173ef6192a283eb6f4db3c579d830"},
1304 {file = "coverage-5.3.1-cp38-cp38-win32.whl", hash = "sha256:ff1330e8bc996570221b450e2d539134baa9465f5cb98aff0e0f73f34172e0ae"},
1305 {file = "coverage-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:3498b27d8236057def41de3585f317abae235dd3a11d33e01736ffedb2ef8606"},
1306 {file = "coverage-5.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ceb499d2b3d1d7b7ba23abe8bf26df5f06ba8c71127f188333dddcf356b4b63f"},
1307 {file = "coverage-5.3.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:3b14b1da110ea50c8bcbadc3b82c3933974dbeea1832e814aab93ca1163cd4c1"},
1308 {file = "coverage-5.3.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:76b2775dda7e78680d688daabcb485dc87cf5e3184a0b3e012e1d40e38527cc8"},
1309 {file = "coverage-5.3.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:cef06fb382557f66d81d804230c11ab292d94b840b3cb7bf4450778377b592f4"},
1310 {file = "coverage-5.3.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:6f61319e33222591f885c598e3e24f6a4be3533c1d70c19e0dc59e83a71ce27d"},
1311 {file = "coverage-5.3.1-cp39-cp39-win32.whl", hash = "sha256:cc6f8246e74dd210d7e2b56c76ceaba1cc52b025cd75dbe96eb48791e0250e98"},
1312 {file = "coverage-5.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:2757fa64e11ec12220968f65d086b7a29b6583d16e9a544c889b22ba98555ef1"},
1313 {file = "coverage-5.3.1-pp36-none-any.whl", hash = "sha256:723d22d324e7997a651478e9c5a3120a0ecbc9a7e94071f7e1954562a8806cf3"},
1314 {file = "coverage-5.3.1-pp37-none-any.whl", hash = "sha256:c89b558f8a9a5a6f2cfc923c304d49f0ce629c3bd85cb442ca258ec20366394c"},
1315 {file = "coverage-5.3.1.tar.gz", hash = "sha256:38f16b1317b8dd82df67ed5daa5f5e7c959e46579840d77a67a4ceb9cef0a50b"},
1316 ]
1317 cryptography = [
1318 {file = "cryptography-3.3.1-cp27-cp27m-macosx_10_10_x86_64.whl", hash = "sha256:c366df0401d1ec4e548bebe8f91d55ebcc0ec3137900d214dd7aac8427ef3030"},
1319 {file = "cryptography-3.3.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9f6b0492d111b43de5f70052e24c1f0951cb9e6022188ebcb1cc3a3d301469b0"},
1320 {file = "cryptography-3.3.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a69bd3c68b98298f490e84519b954335154917eaab52cf582fa2c5c7efc6e812"},
1321 {file = "cryptography-3.3.1-cp27-cp27m-win32.whl", hash = "sha256:84ef7a0c10c24a7773163f917f1cb6b4444597efd505a8aed0a22e8c4780f27e"},
1322 {file = "cryptography-3.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:594a1db4511bc4d960571536abe21b4e5c3003e8750ab8365fafce71c5d86901"},
1323 {file = "cryptography-3.3.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:0003a52a123602e1acee177dc90dd201f9bb1e73f24a070db7d36c588e8f5c7d"},
1324 {file = "cryptography-3.3.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:83d9d2dfec70364a74f4e7c70ad04d3ca2e6a08b703606993407bf46b97868c5"},
1325 {file = "cryptography-3.3.1-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:dc42f645f8f3a489c3dd416730a514e7a91a59510ddaadc09d04224c098d3302"},
1326 {file = "cryptography-3.3.1-cp36-abi3-manylinux1_x86_64.whl", hash = "sha256:788a3c9942df5e4371c199d10383f44a105d67d401fb4304178020142f020244"},
1327 {file = "cryptography-3.3.1-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:69e836c9e5ff4373ce6d3ab311c1a2eed274793083858d3cd4c7d12ce20d5f9c"},
1328 {file = "cryptography-3.3.1-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:9e21301f7a1e7c03dbea73e8602905a4ebba641547a462b26dd03451e5769e7c"},
1329 {file = "cryptography-3.3.1-cp36-abi3-win32.whl", hash = "sha256:b4890d5fb9b7a23e3bf8abf5a8a7da8e228f1e97dc96b30b95685df840b6914a"},
1330 {file = "cryptography-3.3.1-cp36-abi3-win_amd64.whl", hash = "sha256:0e85aaae861d0485eb5a79d33226dd6248d2a9f133b81532c8f5aae37de10ff7"},
1331 {file = "cryptography-3.3.1.tar.gz", hash = "sha256:7e177e4bea2de937a584b13645cab32f25e3d96fc0bc4a4cf99c27dc77682be6"},
1332 ]
1333 dataclasses = [
1334 {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"},
1335 {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"},
1336 ]
1337 deprecation = [
1338 {file = "deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a"},
1339 {file = "deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff"},
1340 ]
1341 distlib = [
1342 {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"},
1343 {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"},
1344 ]
1345 docutils = [
1346 {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"},
1347 {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"},
1348 ]
1349 factory-boy = [
1350 {file = "factory_boy-3.1.0-py2.py3-none-any.whl", hash = "sha256:d8626622550c8ba31392f9e19fdbcef9f139cf1ad643c5923f20490a7b3e2e3d"},
1351 {file = "factory_boy-3.1.0.tar.gz", hash = "sha256:ded73e49135c24bd4d3f45bf1eb168f8d290090f5cf4566b8df3698317dc9c08"},
1352 ]
1353 faker = [
1354 {file = "Faker-5.0.2-py3-none-any.whl", hash = "sha256:5b17c95cfb013a22b062b8df18286f08ce4ea880f9948ec74295e5a42dbb2e44"},
1355 {file = "Faker-5.0.2.tar.gz", hash = "sha256:00ce4342c221b1931b2f35d46f5027d35bc62a4ca3a34628b2c5b514b4ca958a"},
1356 ]
1357 filelock = [
1358 {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"},
1359 {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"},
1360 ]
1361 filetype = [
1362 {file = "filetype-1.0.7-py2.py3-none-any.whl", hash = "sha256:353369948bb1c09b8b3ea3d78390b5586e9399bff9aab894a1dff954e31a66f6"},
1363 {file = "filetype-1.0.7.tar.gz", hash = "sha256:da393ece8d98b47edf2dd5a85a2c8733e44b769e32c71af4cd96ed8d38d96aa7"},
1364 ]
1365 flake8 = [
1366 {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"},
1367 {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"},
1368 ]
1369 gitchangelog = []
1370 grako = [
1371 {file = "grako-3.99.9-py2.py3-none-any.whl", hash = "sha256:28813fd09e31edf26761e2f814ef65adc073d432f0f1e4311ae5e5b1519978c2"},
1372 {file = "grako-3.99.9.zip", hash = "sha256:fcc37309eab7cd0cbbb26cfd6a54303fbb80a00a58ab295d1e665bc69189c364"},
1373 ]
1374 idna = [
1375 {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
1376 {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
1377 ]
1378 imagesize = [
1379 {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"},
1380 {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"},
1381 ]
1382 importlib-metadata = [
1383 {file = "importlib_metadata-2.1.1-py2.py3-none-any.whl", hash = "sha256:c2d6341ff566f609e89a2acb2db190e5e1d23d5409d6cc8d2fe34d72443876d4"},
1384 {file = "importlib_metadata-2.1.1.tar.gz", hash = "sha256:b8de9eff2b35fb037368f28a7df1df4e6436f578fa74423505b6c6a778d5b5dd"},
1385 ]
1386 importlib-resources = [
1387 {file = "importlib_resources-4.1.1-py3-none-any.whl", hash = "sha256:0a948d0c8c3f9344de62997e3f73444dbba233b1eaf24352933c2d264b9e4182"},
1388 {file = "importlib_resources-4.1.1.tar.gz", hash = "sha256:6b45007a479c4ec21165ae3ffbe37faf35404e2041fac6ae1da684f38530ca73"},
1389 ]
1390 iniconfig = [
1391 {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
1392 {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
1393 ]
1394 jeepney = [
1395 {file = "jeepney-0.6.0-py3-none-any.whl", hash = "sha256:aec56c0eb1691a841795111e184e13cad504f7703b9a64f63020816afa79a8ae"},
1396 {file = "jeepney-0.6.0.tar.gz", hash = "sha256:7d59b6622675ca9e993a6bd38de845051d315f8b0c72cca3aef733a20b648657"},
1397 ]
1398 jinja2 = [
1399 {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"},
1400 {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"},
1401 ]
1402 jinja2-time = [
1403 {file = "jinja2-time-0.2.0.tar.gz", hash = "sha256:d14eaa4d315e7688daa4969f616f226614350c48730bfa1692d2caebd8c90d40"},
1404 {file = "jinja2_time-0.2.0-py2.py3-none-any.whl", hash = "sha256:d3eab6605e3ec8b7a0863df09cc1d23714908fa61aa6986a845c20ba488b4efa"},
1405 ]
1406 keyring = [
1407 {file = "keyring-21.7.0-py3-none-any.whl", hash = "sha256:4c41ce4f6d1ee91d589a346699ef5a94ba3429603ac8f700cc0097644cdd6748"},
1408 {file = "keyring-21.7.0.tar.gz", hash = "sha256:a144f7e1044c897c3976202af868cb0ac860f4d433d5d0f8e750fa1a2f0f0b50"},
1409 ]
1410 markupsafe = [
1411 {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"},
1412 {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"},
1413 {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"},
1414 {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"},
1415 {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"},
1416 {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"},
1417 {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"},
1418 {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"},
1419 {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"},
1420 {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"},
1421 {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"},
1422 {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"},
1423 {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"},
1424 {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"},
1425 {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"},
1426 {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"},
1427 {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"},
1428 {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"},
1429 {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"},
1430 {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"},
1431 {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"},
1432 {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"},
1433 {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"},
1434 {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"},
1435 {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"},
1436 {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"},
1437 {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"},
1438 {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"},
1439 {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"},
1440 {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"},
1441 {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"},
1442 {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"},
1443 {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"},
1444 ]
1445 mccabe = [
1446 {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
1447 {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
1448 ]
1449 "nicfit.py" = [
1450 {file = "nicfit.py-0.8.7-py3-none-any.whl", hash = "sha256:331b2288b57e2125ee814f044c3145687dcb5c1968008267c3460dc0e632972b"},
1451 {file = "nicfit.py-0.8.7-py3.8.egg", hash = "sha256:246e452cad175aff1e790ee9166c17e3cc084d3470d4bcd9f181596982150b8f"},
1452 {file = "nicfit.py-0.8.7.tar.gz", hash = "sha256:9b82ef588e6ec1fd6f24655058578a5943b23b540cfbda4973e5300c92ddf8f3"},
1453 ]
1454 packaging = [
1455 {file = "packaging-20.8-py2.py3-none-any.whl", hash = "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858"},
1456 {file = "packaging-20.8.tar.gz", hash = "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"},
1457 ]
1458 paver = [
1459 {file = "Paver-1.3.4-py2.py3-none-any.whl", hash = "sha256:aeca608dc680abf58675e12b78d02817beb6d7ea5ae58ff2f917776d22d176fd"},
1460 {file = "Paver-1.3.4.tar.gz", hash = "sha256:d3e6498881485ab750efe40c5278982a9343bc627e137b11adced627719308c7"},
1461 ]
1462 pep517 = [
1463 {file = "pep517-0.9.1-py2.py3-none-any.whl", hash = "sha256:3985b91ebf576883efe5fa501f42a16de2607684f3797ddba7202b71b7d0da51"},
1464 {file = "pep517-0.9.1.tar.gz", hash = "sha256:aeb78601f2d1aa461960b43add204cc7955667687fbcf9cdb5170f00556f117f"},
1465 ]
1466 pillow = [
1467 {file = "Pillow-8.0.1-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:b63d4ff734263ae4ce6593798bcfee6dbfb00523c82753a3a03cbc05555a9cc3"},
1468 {file = "Pillow-8.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5f9403af9c790cc18411ea398a6950ee2def2a830ad0cfe6dc9122e6d528b302"},
1469 {file = "Pillow-8.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:6b4a8fd632b4ebee28282a9fef4c341835a1aa8671e2770b6f89adc8e8c2703c"},
1470 {file = "Pillow-8.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:cc3ea6b23954da84dbee8025c616040d9aa5eaf34ea6895a0a762ee9d3e12e11"},
1471 {file = "Pillow-8.0.1-cp36-cp36m-win32.whl", hash = "sha256:d8a96747df78cda35980905bf26e72960cba6d355ace4780d4bdde3b217cdf1e"},
1472 {file = "Pillow-8.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:7ba0ba61252ab23052e642abdb17fd08fdcfdbbf3b74c969a30c58ac1ade7cd3"},
1473 {file = "Pillow-8.0.1-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:795e91a60f291e75de2e20e6bdd67770f793c8605b553cb6e4387ce0cb302e09"},
1474 {file = "Pillow-8.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0a2e8d03787ec7ad71dc18aec9367c946ef8ef50e1e78c71f743bc3a770f9fae"},
1475 {file = "Pillow-8.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:006de60d7580d81f4a1a7e9f0173dc90a932e3905cc4d47ea909bc946302311a"},
1476 {file = "Pillow-8.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:bd7bf289e05470b1bc74889d1466d9ad4a56d201f24397557b6f65c24a6844b8"},
1477 {file = "Pillow-8.0.1-cp37-cp37m-win32.whl", hash = "sha256:95edb1ed513e68bddc2aee3de66ceaf743590bf16c023fb9977adc4be15bd3f0"},
1478 {file = "Pillow-8.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:e38d58d9138ef972fceb7aeec4be02e3f01d383723965bfcef14d174c8ccd039"},
1479 {file = "Pillow-8.0.1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:d3d07c86d4efa1facdf32aa878bd508c0dc4f87c48125cc16b937baa4e5b5e11"},
1480 {file = "Pillow-8.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:fbd922f702582cb0d71ef94442bfca57624352622d75e3be7a1e7e9360b07e72"},
1481 {file = "Pillow-8.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:92c882b70a40c79de9f5294dc99390671e07fc0b0113d472cbea3fde15db1792"},
1482 {file = "Pillow-8.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7c9401e68730d6c4245b8e361d3d13e1035cbc94db86b49dc7da8bec235d0015"},
1483 {file = "Pillow-8.0.1-cp38-cp38-win32.whl", hash = "sha256:6c1aca8231625115104a06e4389fcd9ec88f0c9befbabd80dc206c35561be271"},
1484 {file = "Pillow-8.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:cc9ec588c6ef3a1325fa032ec14d97b7309db493782ea8c304666fb10c3bd9a7"},
1485 {file = "Pillow-8.0.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:eb472586374dc66b31e36e14720747595c2b265ae962987261f044e5cce644b5"},
1486 {file = "Pillow-8.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:0eeeae397e5a79dc088d8297a4c2c6f901f8fb30db47795113a4a605d0f1e5ce"},
1487 {file = "Pillow-8.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:81f812d8f5e8a09b246515fac141e9d10113229bc33ea073fec11403b016bcf3"},
1488 {file = "Pillow-8.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:895d54c0ddc78a478c80f9c438579ac15f3e27bf442c2a9aa74d41d0e4d12544"},
1489 {file = "Pillow-8.0.1-cp39-cp39-win32.whl", hash = "sha256:2fb113757a369a6cdb189f8df3226e995acfed0a8919a72416626af1a0a71140"},
1490 {file = "Pillow-8.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:59e903ca800c8cfd1ebe482349ec7c35687b95e98cefae213e271c8c7fffa021"},
1491 {file = "Pillow-8.0.1-pp36-pypy36_pp73-macosx_10_10_x86_64.whl", hash = "sha256:5abd653a23c35d980b332bc0431d39663b1709d64142e3652890df4c9b6970f6"},
1492 {file = "Pillow-8.0.1-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:4b0ef2470c4979e345e4e0cc1bbac65fda11d0d7b789dbac035e4c6ce3f98adb"},
1493 {file = "Pillow-8.0.1-pp37-pypy37_pp73-win32.whl", hash = "sha256:8de332053707c80963b589b22f8e0229f1be1f3ca862a932c1bcd48dafb18dd8"},
1494 {file = "Pillow-8.0.1.tar.gz", hash = "sha256:11c5c6e9b02c9dac08af04f093eb5a2f84857df70a7d4a6a6ad461aca803fb9e"},
1495 ]
1496 pkginfo = [
1497 {file = "pkginfo-1.6.1-py2.py3-none-any.whl", hash = "sha256:ce14d7296c673dc4c61c759a0b6c14bae34e34eb819c0017bb6ca5b7292c56e9"},
1498 {file = "pkginfo-1.6.1.tar.gz", hash = "sha256:a6a4ac943b496745cec21f14f021bbd869d5e9b4f6ec06918cffea5a2f4b9193"},
1499 ]
1500 pluggy = [
1501 {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
1502 {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
1503 ]
1504 poyo = [
1505 {file = "poyo-0.5.0-py2.py3-none-any.whl", hash = "sha256:3e2ca8e33fdc3c411cd101ca395668395dd5dc7ac775b8e809e3def9f9fe041a"},
1506 {file = "poyo-0.5.0.tar.gz", hash = "sha256:e26956aa780c45f011ca9886f044590e2d8fd8b61db7b1c1cf4e0869f48ed4dd"},
1507 ]
1508 py = [
1509 {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"},
1510 {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"},
1511 ]
1512 pycodestyle = [
1513 {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"},
1514 {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"},
1515 ]
1516 pycparser = [
1517 {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"},
1518 {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
1519 ]
1520 pyflakes = [
1521 {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"},
1522 {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"},
1523 ]
1524 pygments = [
1525 {file = "Pygments-2.7.3-py3-none-any.whl", hash = "sha256:f275b6c0909e5dafd2d6269a656aa90fa58ebf4a74f8fcf9053195d226b24a08"},
1526 {file = "Pygments-2.7.3.tar.gz", hash = "sha256:ccf3acacf3782cbed4a989426012f1c535c9a90d3a7fc3f16d231b9372d2b716"},
1527 ]
1528 pylast = [
1529 {file = "pylast-4.0.0-py3-none-any.whl", hash = "sha256:745eab1be2bf70599f61abb4f11d48edcc2073f0dd3b3003c82f1ec72dec457d"},
1530 {file = "pylast-4.0.0.tar.gz", hash = "sha256:8ec555d6c4c1b474e9b3c96c3786abd38303a1a5716d928b0f3cfdcb4499b093"},
1531 ]
1532 pyparsing = [
1533 {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
1534 {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
1535 ]
1536 pytest = [
1537 {file = "pytest-6.2.1-py3-none-any.whl", hash = "sha256:1969f797a1a0dbd8ccf0fecc80262312729afea9c17f1d70ebf85c5e76c6f7c8"},
1538 {file = "pytest-6.2.1.tar.gz", hash = "sha256:66e419b1899bc27346cb2c993e12c5e5e8daba9073c1fbce33b9807abc95c306"},
1539 ]
1540 pytest-cov = [
1541 {file = "pytest-cov-2.10.1.tar.gz", hash = "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"},
1542 {file = "pytest_cov-2.10.1-py2.py3-none-any.whl", hash = "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191"},
1543 ]
1544 python-dateutil = [
1545 {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"},
1546 {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"},
1547 ]
1548 python-slugify = [
1549 {file = "python-slugify-4.0.1.tar.gz", hash = "sha256:69a517766e00c1268e5bbfc0d010a0a8508de0b18d30ad5a1ff357f8ae724270"},
1550 ]
1551 pytz = [
1552 {file = "pytz-2020.5-py2.py3-none-any.whl", hash = "sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4"},
1553 {file = "pytz-2020.5.tar.gz", hash = "sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5"},
1554 ]
1555 pywin32-ctypes = [
1556 {file = "pywin32-ctypes-0.2.0.tar.gz", hash = "sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942"},
1557 {file = "pywin32_ctypes-0.2.0-py2.py3-none-any.whl", hash = "sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98"},
1558 ]
1559 pyyaml = [
1560 {file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"},
1561 {file = "PyYAML-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76"},
1562 {file = "PyYAML-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2"},
1563 {file = "PyYAML-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c"},
1564 {file = "PyYAML-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2"},
1565 {file = "PyYAML-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648"},
1566 {file = "PyYAML-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"},
1567 {file = "PyYAML-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf"},
1568 {file = "PyYAML-5.3.1-cp38-cp38-win32.whl", hash = "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97"},
1569 {file = "PyYAML-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee"},
1570 {file = "PyYAML-5.3.1-cp39-cp39-win32.whl", hash = "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a"},
1571 {file = "PyYAML-5.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e"},
1572 {file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"},
1573 ]
1574 readme-renderer = [
1575 {file = "readme_renderer-28.0-py2.py3-none-any.whl", hash = "sha256:267854ac3b1530633c2394ead828afcd060fc273217c42ac36b6be9c42cd9a9d"},
1576 {file = "readme_renderer-28.0.tar.gz", hash = "sha256:6b7e5aa59210a40de72eb79931491eaf46fefca2952b9181268bd7c7c65c260a"},
1577 ]
1578 regarding = [
1579 {file = "regarding-0.1.4-py3-none-any.whl", hash = "sha256:c128194beae914e2c50edb260dbbb5d72205fefad0ca5c399be993d8a6d965bd"},
1580 {file = "regarding-0.1.4.tar.gz", hash = "sha256:c9c76b6135d4d267f596089434530db2b7194ec9ab84460230330a3f968716c9"},
1581 ]
1582 requests = [
1583 {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"},
1584 {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"},
1585 ]
1586 requests-toolbelt = [
1587 {file = "requests-toolbelt-0.9.1.tar.gz", hash = "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"},
1588 {file = "requests_toolbelt-0.9.1-py2.py3-none-any.whl", hash = "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f"},
1589 ]
1590 rfc3986 = [
1591 {file = "rfc3986-1.4.0-py2.py3-none-any.whl", hash = "sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50"},
1592 {file = "rfc3986-1.4.0.tar.gz", hash = "sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d"},
1593 ]
1594 "ruamel.yaml" = [
1595 {file = "ruamel.yaml-0.16.12-py2.py3-none-any.whl", hash = "sha256:012b9470a0ea06e4e44e99e7920277edf6b46eee0232a04487ea73a7386340a5"},
1596 {file = "ruamel.yaml-0.16.12.tar.gz", hash = "sha256:076cc0bc34f1966d920a49f18b52b6ad559fbe656a0748e3535cf7b3f29ebf9e"},
1597 ]
1598 "ruamel.yaml.clib" = [
1599 {file = "ruamel.yaml.clib-0.2.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:28116f204103cb3a108dfd37668f20abe6e3cafd0d3fd40dba126c732457b3cc"},
1600 {file = "ruamel.yaml.clib-0.2.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:daf21aa33ee9b351f66deed30a3d450ab55c14242cfdfcd377798e2c0d25c9f1"},
1601 {file = "ruamel.yaml.clib-0.2.2-cp27-cp27m-win32.whl", hash = "sha256:30dca9bbcbb1cc858717438218d11eafb78666759e5094dd767468c0d577a7e7"},
1602 {file = "ruamel.yaml.clib-0.2.2-cp27-cp27m-win_amd64.whl", hash = "sha256:f6061a31880c1ed6b6ce341215336e2f3d0c1deccd84957b6fa8ca474b41e89f"},
1603 {file = "ruamel.yaml.clib-0.2.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:73b3d43e04cc4b228fa6fa5d796409ece6fcb53a6c270eb2048109cbcbc3b9c2"},
1604 {file = "ruamel.yaml.clib-0.2.2-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:53b9dd1abd70e257a6e32f934ebc482dac5edb8c93e23deb663eac724c30b026"},
1605 {file = "ruamel.yaml.clib-0.2.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:839dd72545ef7ba78fd2aa1a5dd07b33696adf3e68fae7f31327161c1093001b"},
1606 {file = "ruamel.yaml.clib-0.2.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1236df55e0f73cd138c0eca074ee086136c3f16a97c2ac719032c050f7e0622f"},
1607 {file = "ruamel.yaml.clib-0.2.2-cp35-cp35m-win32.whl", hash = "sha256:b1e981fe1aff1fd11627f531524826a4dcc1f26c726235a52fcb62ded27d150f"},
1608 {file = "ruamel.yaml.clib-0.2.2-cp35-cp35m-win_amd64.whl", hash = "sha256:4e52c96ca66de04be42ea2278012a2342d89f5e82b4512fb6fb7134e377e2e62"},
1609 {file = "ruamel.yaml.clib-0.2.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a873e4d4954f865dcb60bdc4914af7eaae48fb56b60ed6daa1d6251c72f5337c"},
1610 {file = "ruamel.yaml.clib-0.2.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ab845f1f51f7eb750a78937be9f79baea4a42c7960f5a94dde34e69f3cce1988"},
1611 {file = "ruamel.yaml.clib-0.2.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:2fd336a5c6415c82e2deb40d08c222087febe0aebe520f4d21910629018ab0f3"},
1612 {file = "ruamel.yaml.clib-0.2.2-cp36-cp36m-win32.whl", hash = "sha256:e9f7d1d8c26a6a12c23421061f9022bb62704e38211fe375c645485f38df34a2"},
1613 {file = "ruamel.yaml.clib-0.2.2-cp36-cp36m-win_amd64.whl", hash = "sha256:2602e91bd5c1b874d6f93d3086f9830f3e907c543c7672cf293a97c3fabdcd91"},
1614 {file = "ruamel.yaml.clib-0.2.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:44c7b0498c39f27795224438f1a6be6c5352f82cb887bc33d962c3a3acc00df6"},
1615 {file = "ruamel.yaml.clib-0.2.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:8e8fd0a22c9d92af3a34f91e8a2594eeb35cba90ab643c5e0e643567dc8be43e"},
1616 {file = "ruamel.yaml.clib-0.2.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:75f0ee6839532e52a3a53f80ce64925ed4aed697dd3fa890c4c918f3304bd4f4"},
1617 {file = "ruamel.yaml.clib-0.2.2-cp37-cp37m-win32.whl", hash = "sha256:464e66a04e740d754170be5e740657a3b3b6d2bcc567f0c3437879a6e6087ff6"},
1618 {file = "ruamel.yaml.clib-0.2.2-cp37-cp37m-win_amd64.whl", hash = "sha256:52ae5739e4b5d6317b52f5b040b1b6639e8af68a5b8fd606a8b08658fbd0cab5"},
1619 {file = "ruamel.yaml.clib-0.2.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4df5019e7783d14b79217ad9c56edf1ba7485d614ad5a385d1b3c768635c81c0"},
1620 {file = "ruamel.yaml.clib-0.2.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:5254af7d8bdf4d5484c089f929cb7f5bafa59b4f01d4f48adda4be41e6d29f99"},
1621 {file = "ruamel.yaml.clib-0.2.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8be05be57dc5c7b4a0b24edcaa2f7275866d9c907725226cdde46da09367d923"},
1622 {file = "ruamel.yaml.clib-0.2.2-cp38-cp38-win32.whl", hash = "sha256:74161d827407f4db9072011adcfb825b5258a5ccb3d2cd518dd6c9edea9e30f1"},
1623 {file = "ruamel.yaml.clib-0.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:058a1cc3df2a8aecc12f983a48bda99315cebf55a3b3a5463e37bb599b05727b"},
1624 {file = "ruamel.yaml.clib-0.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c6ac7e45367b1317e56f1461719c853fd6825226f45b835df7436bb04031fd8a"},
1625 {file = "ruamel.yaml.clib-0.2.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:b4b0d31f2052b3f9f9b5327024dc629a253a83d8649d4734ca7f35b60ec3e9e5"},
1626 {file = "ruamel.yaml.clib-0.2.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:1f8c0a4577c0e6c99d208de5c4d3fd8aceed9574bb154d7a2b21c16bb924154c"},
1627 {file = "ruamel.yaml.clib-0.2.2-cp39-cp39-win32.whl", hash = "sha256:46d6d20815064e8bb023ea8628cfb7402c0f0e83de2c2227a88097e239a7dffd"},
1628 {file = "ruamel.yaml.clib-0.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:6c0a5dc52fc74eb87c67374a4e554d4761fd42a4d01390b7e868b30d21f4b8bb"},
1629 {file = "ruamel.yaml.clib-0.2.2.tar.gz", hash = "sha256:2d24bd98af676f4990c4d715bcdc2a60b19c56a3fb3a763164d2d8ca0e806ba7"},
1630 ]
1631 secretstorage = [
1632 {file = "SecretStorage-3.3.0-py3-none-any.whl", hash = "sha256:5c36f6537a523ec5f969ef9fad61c98eb9e017bc601d811e53aa25bece64892f"},
1633 {file = "SecretStorage-3.3.0.tar.gz", hash = "sha256:30cfdef28829dad64d6ea1ed08f8eff6aa115a77068926bcc9f5225d5a3246aa"},
1634 ]
1635 six = [
1636 {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
1637 {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
1638 ]
1639 snowballstemmer = [
1640 {file = "snowballstemmer-2.0.0-py2.py3-none-any.whl", hash = "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0"},
1641 {file = "snowballstemmer-2.0.0.tar.gz", hash = "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"},
1642 ]
1643 sphinx = [
1644 {file = "Sphinx-3.4.1-py3-none-any.whl", hash = "sha256:aeef652b14629431c82d3fe994ce39ead65b3fe87cf41b9a3714168ff8b83376"},
1645 {file = "Sphinx-3.4.1.tar.gz", hash = "sha256:e450cb205ff8924611085183bf1353da26802ae73d9251a8fcdf220a8f8712ef"},
1646 ]
1647 sphinx-issues = [
1648 {file = "sphinx-issues-1.2.0.tar.gz", hash = "sha256:845294736c7ac4c09c706f13431f709e1164037cbb00f6bf623ae16eccf509f3"},
1649 {file = "sphinx_issues-1.2.0-py2.py3-none-any.whl", hash = "sha256:1208e1869742b7800a45b3c47ab987b87b2ad2024cbc36e0106e8bba3549dd22"},
1650 ]
1651 sphinx-rtd-theme = [
1652 {file = "sphinx_rtd_theme-0.5.0-py2.py3-none-any.whl", hash = "sha256:373413d0f82425aaa28fb288009bf0d0964711d347763af2f1b65cafcb028c82"},
1653 {file = "sphinx_rtd_theme-0.5.0.tar.gz", hash = "sha256:22c795ba2832a169ca301cd0a083f7a434e09c538c70beb42782c073651b707d"},
1654 ]
1655 sphinxcontrib-applehelp = [
1656 {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"},
1657 {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"},
1658 ]
1659 sphinxcontrib-devhelp = [
1660 {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"},
1661 {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"},
1662 ]
1663 sphinxcontrib-htmlhelp = [
1664 {file = "sphinxcontrib-htmlhelp-1.0.3.tar.gz", hash = "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"},
1665 {file = "sphinxcontrib_htmlhelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f"},
1666 ]
1667 sphinxcontrib-jsmath = [
1668 {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"},
1669 {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"},
1670 ]
1671 sphinxcontrib-qthelp = [
1672 {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"},
1673 {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"},
1674 ]
1675 sphinxcontrib-serializinghtml = [
1676 {file = "sphinxcontrib-serializinghtml-1.1.4.tar.gz", hash = "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc"},
1677 {file = "sphinxcontrib_serializinghtml-1.1.4-py2.py3-none-any.whl", hash = "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"},
1678 ]
1679 text-unidecode = [
1680 {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"},
1681 {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"},
1682 ]
1683 toml = [
1684 {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
1685 {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
1686 ]
1687 tox = [
1688 {file = "tox-3.20.1-py2.py3-none-any.whl", hash = "sha256:42ce19ce5dc2f6d6b1fdc5666c476e1f1e2897359b47e0aa3a5b774f335d57c2"},
1689 {file = "tox-3.20.1.tar.gz", hash = "sha256:4321052bfe28f9d85082341ca8e233e3ea901fdd14dab8a5d3fbd810269fbaf6"},
1690 ]
1691 tqdm = [
1692 {file = "tqdm-4.55.0-py2.py3-none-any.whl", hash = "sha256:0cd81710de29754bf17b6fee07bdb86f956b4fa20d3078f02040f83e64309416"},
1693 {file = "tqdm-4.55.0.tar.gz", hash = "sha256:f4f80b96e2ceafea69add7bf971b8403b9cba8fb4451c1220f91c79be4ebd208"},
1694 ]
1695 twine = [
1696 {file = "twine-3.3.0-py3-none-any.whl", hash = "sha256:2f6942ec2a17417e19d2dd372fc4faa424c87ee9ce49b4e20c427eb00a0f3f41"},
1697 {file = "twine-3.3.0.tar.gz", hash = "sha256:fcffa8fc37e8083a5be0728371f299598870ee1eccc94e9a25cef7b1dcfa8297"},
1698 ]
1699 urllib3 = [
1700 {file = "urllib3-1.26.2-py2.py3-none-any.whl", hash = "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"},
1701 {file = "urllib3-1.26.2.tar.gz", hash = "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08"},
1702 ]
1703 virtualenv = [
1704 {file = "virtualenv-20.2.2-py2.py3-none-any.whl", hash = "sha256:54b05fc737ea9c9ee9f8340f579e5da5b09fb64fd010ab5757eb90268616907c"},
1705 {file = "virtualenv-20.2.2.tar.gz", hash = "sha256:b7a8ec323ee02fb2312f098b6b4c9de99559b462775bc8fe3627a73706603c1b"},
1706 ]
1707 webencodings = [
1708 {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"},
1709 {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"},
1710 ]
1711 wheel = [
1712 {file = "wheel-0.36.2-py2.py3-none-any.whl", hash = "sha256:78b5b185f0e5763c26ca1e324373aadd49182ca90e825f7853f4b2509215dc0e"},
1713 {file = "wheel-0.36.2.tar.gz", hash = "sha256:e11eefd162658ea59a60a0f6c7d493a7190ea4b9a85e335b33489d9f17e0245e"},
1714 ]
1715 zipp = [
1716 {file = "zipp-3.4.0-py3-none-any.whl", hash = "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108"},
1717 {file = "zipp-3.4.0.tar.gz", hash = "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"},
1718 ]
0 [build-system]
1 requires = ["poetry>=0.12"]
2 build-backend = "poetry.masonry.api"
3
4
5 [tool.regarding]
6 release_name = "True Blue"
7 years = "2002-2020"
8
9 [tool.poetry]
10 name = "eyeD3"
11 version = "0.9.6"
12 description = "Python audio data toolkit (ID3 and MP3)"
13 authors = ["Travis Shirk <travis@pobox.com>"]
14 license = "GPL-3.0-or-later"
15 classifiers = [
16 "Environment :: Console",
17 "Intended Audience :: End Users/Desktop",
18 "Topic :: Multimedia :: Sound/Audio :: Editors",
19 "Topic :: Software Development :: Libraries :: Python Modules",
20 "Intended Audience :: Developers",
21 "Operating System :: POSIX",
22 "Natural Language :: English",
23 "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
24 "Programming Language :: Python",
25 "Programming Language :: Python :: 3.6",
26 "Programming Language :: Python :: 3.7",
27 "Programming Language :: Python :: 3.8",
28 "Programming Language :: Python :: 3.9",
29 ]
30 keywords = ["id3", "mp3", "python"]
31 readme = "README.rst"
32 packages = [
33 {include = "eyed3"},
34 ]
35 include = [
36 "poetry.lock",
37 "README.rst", "LICENSE", "Makefile", "requirements.txt", "MANIFEST.in",
38 "AUTHORS.rst", "CONTRIBUTING.rst", "HISTORY.rst",
39 "tox.ini",
40 "tests/**/*.py",
41 "docs/",
42 "requirements/*.txt",
43 "examples/*",
44 "eyed3/plugins/DisplayPattern.ebnf",
45 ]
46 exclude = [
47 "docs/_build",
48 ]
49
50 [tool.poetry.scripts]
51 eyeD3 = "eyed3.main:_main"
52
53 [tool.poetry.dependencies]
54 python = "^3.6"
55 dataclasses = {version = "^0.8", python = "~3.6"}
56 filetype = "^1.0.7"
57 deprecation = "^2.1.0"
58 # yaml-plugin extra
59 "ruamel.yaml" = {version = "^0.16.12", optional = true}
60 # display-plugin extra
61 grako = {version = "^3.99.9", optional = true}
62 # art-plugin extra
63 Pillow = {version = "^8.0.1", optional = true}
64 pylast = {version = "^4.0.0", optional = true}
65 requests = {version = "^2.25.0", optional = true}
66 # Test extra
67 pytest = {version = "^6.2.1", optional = true}
68 coverage = {version = "^5.3.1", optional = true, extras = ["toml"]}
69 pytest-cov = {version = "^2.10.1", optional = true}
70 tox = {version = "^3.20.1", optional = true}
71 factory-boy = {version = "^3.1.0", optional = true}
72 flake8 = {version = "^3.8.4", optional = true}
73 check-manifest = {version = "^0.45", optional = true}
74
75 [tool.poetry.extras]
76 test = ["pytest", "pytest-cov", "tox", "factory-boy", "flake8",
77 "check-manifest"]
78 yaml-plugin = ["ruamel.yaml"]
79 display-plugin = ["grako"]
80 art-plugin = ["Pillow", "pylast", "requests"]
81
82 [tool.poetry.dev-dependencies]
83 gitchangelog = {git = "https://github.com/nicfit/gitchangelog.git", rev = "nicfit.py"}
84 regarding = "^0.1.4"
85 wheel = "^0.36.2"
86 twine = "^3.3.0"
87 Sphinx = "^3.4.1"
88 sphinx_rtd_theme = "^0.5.0"
89 sphinx-issues = "^1.2.0"
90 cogapp = "^3.0.0"
91 paver = "^1.3.4"
92 "nicfit.py" = {version = "^0.8.7", extras = ["cookiecutter"]}
93
94 [tool.dephell]
95 [tool.dephell.main]
96 from = {format = "poetry", path = "pyproject.toml"}
97 to = {format = "setuppy", path = "setup.py"}
98
99
100 [tool.coverage.html]
101 directory = "build/tests/coverage"
0 certifi==2020.12.5
1 chardet==4.0.0
2 colorama==0.4.4
3 dataclasses==0.8; python_version >= "3.6" and python_version < "3.7"
4 deprecation==2.1.0
5 filetype==1.0.7
6 idna==2.10
7 importlib-metadata==2.1.1; python_version < "3.8"
8 packaging==20.8
9 pyparsing==2.4.7
10 requests==2.25.1
11 six==1.15.0
12 toml==0.10.2
13 urllib3==1.26.2
14 zipp==3.4.0; python_version < "3.8"
+0
-16
requirements/dev.txt less more
0 Sphinx==1.8.3
1 check-manifest==0.37
2 cogapp==2.5.1
3 flake8==3.6.0
4 hg+https://nicfit@bitbucket.org/nicfit/sphinxcontrib-bitbucket
5 ipdb==0.11
6 nicfit.py[cookiecutter]==0.8.3
7 pip-tools==3.2.0
8 pss==1.42
9 pyaml==18.11.0
10 sphinx-issues==1.2.0
11 sphinx_rtd_theme==0.4.2
12 sphinxcontrib-paverutils==1.17.0
13 tox==3.7.0
14 twine==1.12.1
15 wheel==0.32.3
0 certifi==2020.12.5
1 chardet==4.0.0
2 colorama==0.4.4
3 dataclasses==0.8; python_version >= "3.6" and python_version < "3.7"
4 deprecation==2.1.0
5 filetype==1.0.7
6 grako==3.99.9
7 idna==2.10
8 importlib-metadata==2.1.1; python_version < "3.8"
9 packaging==20.8
10 pillow==8.0.1
11 pylast==4.0.0
12 pyparsing==2.4.7
13 requests==2.25.1
14 ruamel.yaml==0.16.12
15 ruamel.yaml.clib==0.2.2; platform_python_implementation == "CPython" and python_version < "3.9"
16 six==1.15.0
17 toml==0.10.2
18 urllib3==1.26.2
19 zipp==3.4.0; python_version < "3.8"
+0
-3
requirements/extra_art-plugin.txt less more
0 pillow==5.4.1
1 pylast ~= 2.0
2 requests==2.21.0
+0
-1
requirements/extra_display-plugin.txt less more
0 grako==3.99.9
+0
-3
requirements/main.txt less more
0 pathlib; python_version < "3.4"
1 python-magic==0.4.15
2 six==1.12.0
0 certifi==2020.12.5
1 chardet==4.0.0
2 colorama==0.4.4
3 dataclasses==0.8; python_version >= "3.6" and python_version < "3.7"
4 deprecation==2.1.0
5 filetype==1.0.7
6 idna==2.10
7 importlib-metadata==2.1.1; python_version < "3.8"
8 packaging==20.8
9 pyparsing==2.4.7
10 requests==2.25.1
11 six==1.15.0
12 toml==0.10.2
13 urllib3==1.26.2
14 zipp==3.4.0; python_version < "3.8"
+0
-41
requirements/requirements.yml less more
0 main:
1 - six
2 - pathlib; python_version < "3.4"
3 - python-magic
4
5 extra_display-plugin:
6 - grako
7
8 extra_art-plugin:
9 - pylast ~= 2.0
10 - requests
11 - pillow
12
13 test:
14 # 4.x series required for python <= 3.4
15 - pytest>=4.1.1
16 - pytest-cov
17 - pytest-runner
18 - factory-boy
19 - mock; python_version < "3.4"
20
21 dev:
22 - nicfit.py[cookiecutter]
23 - Sphinx
24 - sphinx_rtd_theme
25 - check-manifest
26 - flake8
27 - ipdb
28 - pip-tools
29 - tox
30 - twine
31 - wheel
32 - cogapp
33 - sphinxcontrib-paverutils
34 - pyaml
35 - pss
36 - sphinx-issues
37 # Upstream PR: https://bitbucket.org/dhellmann/sphinxcontrib-bitbucket/pull-requests/1/use-setuptools-over-distribute-python3/diff
38 #- sphinxcontrib-bitbucket
39 - hg+https://nicfit@bitbucket.org/nicfit/sphinxcontrib-bitbucket
40 #- git+https://github.com/nicfit/gitchangelog.git
0 appdirs==1.4.4
1 atomicwrites==1.4.0; sys_platform == "win32"
2 attrs==20.3.0
3 build==0.1.0
4 certifi==2020.12.5
5 chardet==4.0.0
6 check-manifest==0.45
7 colorama==0.4.4
8 coverage==5.3.1
9 dataclasses==0.8; python_version >= "3.6" and python_version < "3.7"
10 deprecation==2.1.0
11 distlib==0.3.1
12 factory-boy==3.1.0
13 faker==5.0.2
14 filelock==3.0.12
15 filetype==1.0.7
16 flake8==3.8.4
17 idna==2.10
18 importlib-metadata==2.1.1; python_version < "3.8"
19 importlib-resources==4.1.1; python_version < "3.7"
20 iniconfig==1.1.1
21 mccabe==0.6.1
22 packaging==20.8
23 pep517==0.9.1
24 pluggy==0.13.1
25 py==1.10.0
26 pycodestyle==2.6.0
27 pyflakes==2.2.0
28 pyparsing==2.4.7
29 pytest==6.2.1
30 pytest-cov==2.10.1
31 python-dateutil==2.8.1
32 requests==2.25.1
33 six==1.15.0
34 text-unidecode==1.3
35 toml==0.10.2
36 tox==3.20.1
37 urllib3==1.26.2
38 virtualenv==20.2.2
39 zipp==3.4.0; python_version < "3.8"
+0
-5
requirements/test.txt less more
0 factory-boy==2.11.1
1 mock; python_version < "3.4"
2 pytest-cov==2.6.1
3 pytest-runner==4.2
4 pytest>=4.1.1
+0
-7
requirements.txt less more
0 grako==3.99.9
1 pathlib; python_version < "3.4"
2 pillow==5.4.1
3 pylast ~= 2.0
4 python-magic==0.4.15
5 requests==2.21.0
6 six==1.12.0
0 [wheel]
1 universal = 1
2
30 [flake8]
1 statistics = 1
2 max-complexity = 62
43 max-line-length = 100
5 statistics = 1
6 ignore = E121,E124,E126,E127,E128,E131,E266,W504
7
8 [aliases]
9 test = pytest
10
11 # coverage+pytest setting (coverage: prefix not working.)
12 [html]
13 directory = build/tests/coverage
14
15 # coverage+pytest setting (coverage: prefix not working.)
16 [run]
17 omit = /tmp/*
18
19 [tool:pytest]
20 addopts = --verbose
21
22 [metadata]
23 license_file = LICENSE
4 ignore = E121,E124,E126,E127,E128,E131,E252,E266,E741,F405,W503,W504
0 #!/usr/bin/env python
0
11 # -*- coding: utf-8 -*-
2 import io
3 import os
4 import re
5 import sys
6 import warnings
7 from setuptools import setup, find_packages
8 from setuptools.command.install import install
92
10 classifiers = [
11 "Environment :: Console",
12 "Intended Audience :: End Users/Desktop",
13 "Topic :: Multimedia :: Sound/Audio :: Editors",
14 "Topic :: Software Development :: Libraries :: Python Modules",
15 "Intended Audience :: Developers",
16 "Operating System :: POSIX",
17 "Natural Language :: English",
18 "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
19 "Programming Language :: Python",
20 "Programming Language :: Python :: 2.7",
21 "Programming Language :: Python :: 3.4",
22 "Programming Language :: Python :: 3.5",
23 "Programming Language :: Python :: 3.6",
24 "Programming Language :: Python :: 3.7",
25 ]
3 # DO NOT EDIT THIS FILE!
4 # This file has been autogenerated by dephell <3
5 # https://github.com/dephell/dephell
6
7 try:
8 from setuptools import setup
9 except ImportError:
10 from distutils.core import setup
2611
2712
28 def getPackageInfo():
29 info_dict = {}
30 info_keys = ["version", "name", "author", "author_email", "url", "license",
31 "description", "release_name", "github_url"]
32 key_remap = {"name": "pypi_name"}
13 import os.path
3314
34 # __about__
35 info_fpath = os.path.join(os.path.abspath(os.path.dirname(__file__)),
36 "./src",
37 "eyed3",
38 "__about__.py")
39 with io.open(info_fpath, encoding='utf-8') as infof:
40 for line in infof:
41 for what in info_keys:
42 rex = re.compile(r"__{what}__\s*=\s*['\"](.*?)['\"]"
43 .format(what=what if what not in key_remap
44 else key_remap[what]))
45
46 m = rex.match(line.strip())
47 if not m:
48 continue
49 info_dict[what] = m.groups()[0]
50
51 if sys.version_info[:2] >= (3, 4):
52 vparts = info_dict["version"].split("-", maxsplit=1)
53 else:
54 vparts = info_dict["version"].split("-", 1)
55 info_dict["release"] = vparts[1] if len(vparts) > 1 else "final"
56
57 # Requirements
58 requirements, extras = requirements_yaml()
59 info_dict["install_requires"] = requirements["main"] \
60 if "main" in requirements else []
61 info_dict["tests_require"] = requirements["test"] \
62 if "test" in requirements else []
63 info_dict["extras_require"] = extras
64
65 # Info
66 readme = ""
67 if os.path.exists("README.rst"):
68 with io.open("README.rst", encoding='utf-8') as readme_file:
69 readme = readme_file.read()
70 hist = "`changelog <https://github.com/nicfit/eyeD3/blob/master/HISTORY.rst>`_"
71 info_dict["long_description"] =\
72 readme + "\n\n" +\
73 "See the {} file for release history and changes.".format(hist)
74
75 return info_dict, requirements
15 readme = ''
16 here = os.path.abspath(os.path.dirname(__file__))
17 readme_path = os.path.join(here, 'README.rst')
18 if os.path.exists(readme_path):
19 with open(readme_path, 'rb') as stream:
20 readme = stream.read().decode('utf8')
7621
7722
78 def requirements_yaml():
79 prefix = "extra_"
80 reqs = {}
81 reqfile = os.path.join("requirements", "requirements.yml")
82 if os.path.exists(reqfile):
83 with io.open(reqfile, encoding='utf-8') as fp:
84 curr = None
85 for line in [l for l in [l.strip() for l in fp.readlines()]
86 if l and not l.startswith("#")]:
87 if curr is None or line[0] != "-":
88 curr = line.split(":")[0]
89 reqs[curr] = []
90 else:
91 assert line[0] == "-"
92 r = line[1:].strip()
93 if r:
94 reqs[curr].append(r.strip())
95
96 return (reqs, {x[len(prefix):]: vals
97 for x, vals in reqs.items() if x.startswith(prefix)})
98
99
100 class PipInstallCommand(install, object):
101 def run(self):
102 reqs = " ".join(["'%s'" % r for r in PKG_INFO["install_requires"]])
103 os.system("pip install " + reqs)
104 # XXX: py27 compatible
105 return super(PipInstallCommand, self).run()
106
107
108 PKG_INFO, REQUIREMENTS = getPackageInfo()
109 if PKG_INFO["release"].startswith("a"):
110 #classifiers.append("Development Status :: 1 - Planning")
111 #classifiers.append("Development Status :: 2 - Pre-Alpha")
112 classifiers.append("Development Status :: 3 - Alpha")
113 elif PKG_INFO["release"].startswith("b"):
114 classifiers.append("Development Status :: 4 - Beta")
115 else:
116 classifiers.append("Development Status :: 5 - Production/Stable")
117 #classifiers.append("Development Status :: 6 - Mature")
118 #classifiers.append("Development Status :: 7 - Inactive")
119
120 gz = "{name}-{version}.tar.gz".format(**PKG_INFO)
121 PKG_INFO["download_url"] = (
122 "{github_url}/releases/downloads/v{version}/{gz}"
123 .format(gz=gz, **PKG_INFO)
23 setup(
24 long_description=readme,
25 name='eyeD3',
26 version='0.9.6',
27 description='Python audio data toolkit (ID3 and MP3)',
28 python_requires='==3.*,>=3.6.0',
29 author='Travis Shirk',
30 author_email='travis@pobox.com',
31 license='GPL-3.0-or-later',
32 keywords='id3 mp3 python',
33 classifiers=['Environment :: Console', 'Intended Audience :: End Users/Desktop', 'Topic :: Multimedia :: Sound/Audio :: Editors', 'Topic :: Software Development :: Libraries :: Python Modules', 'Intended Audience :: Developers', 'Operating System :: POSIX', 'Natural Language :: English', 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 'Programming Language :: Python', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9'],
34 entry_points={"console_scripts": ["eyeD3 = eyed3.main:_main"]},
35 packages=['eyed3', 'eyed3.id3', 'eyed3.mp3', 'eyed3.plugins', 'eyed3.utils'],
36 package_dir={"": "."},
37 package_data={"eyed3.plugins": ["*.ebnf"]},
38 install_requires=['coverage[toml]==5.*,>=5.3.1', 'dataclasses==0.*,>=0.8.0; python_version == "3.6.*" and python_version >= "3.6.0"', 'deprecation==2.*,>=2.1.0', 'filetype==1.*,>=1.0.7'],
39 dependency_links=['git+https://github.com/nicfit/gitchangelog.git@nicfit.py#egg=gitchangelog'],
40 extras_require={"art-plugin": ["pillow==8.*,>=8.0.1", "pylast==4.*,>=4.0.0", "requests==2.*,>=2.25.0"], "dev": ["cogapp==3.*,>=3.0.0", "gitchangelog", "nicfit.py[cookiecutter]==0.*,>=0.8.7", "paver==1.*,>=1.3.4", "regarding==0.*,>=0.1.4", "sphinx==3.*,>=3.4.1", "sphinx-issues==1.*,>=1.2.0", "sphinx-rtd-theme==0.*,>=0.5.0", "twine==3.*,>=3.3.0", "wheel==0.*,>=0.36.2"], "display-plugin": ["grako==3.*,>=3.99.9"], "test": ["check-manifest==0.*,>=0.45.0", "factory-boy==3.*,>=3.1.0", "flake8==3.*,>=3.8.4", "pytest==6.*,>=6.2.1", "pytest-cov==2.*,>=2.10.1", "tox==3.*,>=3.20.1"], "yaml-plugin": ["ruamel.yaml==0.*,>=0.16.12"]},
12441 )
125
126
127 def package_files(directory, prefix=".."):
128 paths = []
129 for (path, _, filenames) in os.walk(directory):
130 if "__pycache__" in path:
131 continue
132 for filename in filenames:
133 if filename.endswith(".pyc"):
134 continue
135 paths.append(os.path.join(prefix, path, filename))
136 return paths
137
138
139 if sys.argv[1:] and sys.argv[1] == "--release-name":
140 print(PKG_INFO["release_name"])
141 sys.exit(0)
142 else:
143 test_requirements = REQUIREMENTS["test"]
144 # The extra command line options we added cause warnings, quell that.
145 with warnings.catch_warnings():
146 warnings.filterwarnings("ignore", message="Unknown distribution option")
147 warnings.filterwarnings("ignore", message="Normalizing")
148 setup(classifiers=classifiers,
149 package_dir={"eyed3": "./src/eyed3"},
150 packages=find_packages("./src",
151 exclude=["test", "test.*"]),
152 zip_safe=False,
153 platforms=["Any"],
154 keywords=["id3", "mp3", "python"],
155 test_suite="./src/tests",
156 include_package_data=True,
157 package_data={},
158 entry_points={
159 "console_scripts": [
160 "eyeD3 = eyed3.main:_main",
161 ]
162 },
163 cmdclass={
164 "install": PipInstallCommand,
165 },
166 **PKG_INFO
167 )
+0
-44
src/eyed3/__about__.py less more
0 # -*- coding: utf-8 -*-
1 from collections import namedtuple
2
3
4 def __parse_version(v): # pragma: nocover
5 ver, rel = v, "final"
6 for c in ("a", "b", "c"):
7 parsed = v.split(c)
8 if len(parsed) == 2:
9 ver, rel = (parsed[0], c + parsed[1])
10
11 v = tuple((int(v) for v in ver.split(".")))
12 ver_info = namedtuple("Version", "major, minor, maint, release")(
13 *(v + (tuple((0,)) * (3 - len(v))) + tuple((rel,))))
14 return ver, rel, ver_info
15
16
17 __version__ = "0.8.11"
18 __release_name__ = ""
19 __years__ = "2002-2019"
20
21 _, __release__, __version_info__ = __parse_version(__version__)
22 __project_name__ = "eyeD3"
23 __project_slug__ = "eyed3"
24 __pypi_name__ = "eyeD3"
25 __author__ = "Travis Shirk"
26 __author_email__ = "travis@pobox.com"
27 __url__ = "http://eyed3.nicfit.net/"
28 __description__ = "Python audio data toolkit (ID3 and MP3)"
29 # FIXME: __long_description__ not being used anywhere.
30 __long_description__ = """
31 eyeD3 is a Python module and command line program for processing ID3 tags.
32 Information about mp3 files (i.e bit rate, sample frequency,
33 play time, etc.) is also provided. The formats supported are ID3
34 v1.0/v1.1 and v2.3/v2.4.
35 """
36 __license__ = "GNU GPL v3.0"
37 __github_url__ = "https://github.com/nicfit/eyeD3",
38 __version_txt__ = """
39 %(__name__)s %(__version__)s (C) Copyright %(__years__)s %(__author__)s
40 This program comes with ABSOLUTELY NO WARRANTY! See LICENSE for details.
41 Run with --help/-h for usage information or read the docs at
42 %(__url__)s
43 """ % (locals())
+0
-39
src/eyed3/__init__.py less more
0 # -*- coding: utf-8 -*-
1 import sys
2 import locale
3 from .__about__ import __version__ as version
4
5 _DEFAULT_ENCODING = "latin1"
6 LOCAL_ENCODING = locale.getpreferredencoding(do_setlocale=True)
7 """The local encoding, used when parsing command line options, console output,
8 etc. The default is always ``latin1`` if it cannot be determined, it is NOT
9 the value shown."""
10 if not LOCAL_ENCODING or LOCAL_ENCODING == "ANSI_X3.4-1968": # pragma: no cover
11 LOCAL_ENCODING = _DEFAULT_ENCODING
12
13 LOCAL_FS_ENCODING = sys.getfilesystemencoding()
14 """The local file system encoding, the default is ``latin1`` if it cannot be
15 determined."""
16 if not LOCAL_FS_ENCODING: # pragma: no cover
17 LOCAL_FS_ENCODING = _DEFAULT_ENCODING
18
19
20 class Error(Exception):
21 """Base exception type for all eyed3 errors."""
22 def __init__(self, *args):
23 super(Error, self).__init__(*args)
24 if args:
25 # The base class will do exactly this if len(args) == 1,
26 # but not when > 1. Note, the 2.7 base class will, 3 will not.
27 # Make it so.
28 self.message = args[0]
29
30
31 from .utils.log import log # noqa: E402
32 from .core import load # noqa: E402
33
34 del sys
35 del locale
36
37 __all__ = ["log", "load", "version", "LOCAL_ENCODING", "LOCAL_FS_ENCODING",
38 "Error"]
+0
-145
src/eyed3/compat.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2013 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 '''
19 Compatibility for various versions of Python (e.g. 2.6, 2.7, and 3.3)
20 '''
21 import os
22 import sys
23 import types
24
25
26 PY2 = sys.version_info[0] == 2
27
28 if PY2:
29 # Python2
30 StringTypes = types.StringTypes
31 UnicodeType = unicode # noqa
32 BytesType = str
33 unicode = unicode # noqa
34 _og_chr = chr
35
36 from ConfigParser import SafeConfigParser as ConfigParser
37 from ConfigParser import Error as ConfigParserError
38
39 from StringIO import StringIO
40
41 def chr(i):
42 '''byte strings units are single byte strings'''
43 return _og_chr(i)
44
45 input = raw_input # noqa
46 cmp = cmp # noqa
47 else:
48 # Python3
49 StringTypes = (str,)
50 UnicodeType = str
51 BytesType = bytes
52 unicode = str
53
54 from configparser import ConfigParser # noqa
55 from configparser import Error as ConfigParserError # noqa
56 from io import StringIO # noqa
57
58 def chr(i):
59 '''byte strings units are ints'''
60 return intToByteString(i)
61
62 input = input
63
64 def cmp(a, b):
65 return (a > b) - (a < b)
66
67
68 if sys.version_info[0:2] < (3, 4):
69 # py3.4 has two maps, nice nicer. Make it so for other versions.
70 import logging
71 logging._nameToLevel = {_k: _v
72 for _k, _v in logging._levelNames.items()
73 if isinstance(_k, str)}
74
75
76 def b(x, encoder=None):
77 if isinstance(x, BytesType):
78 return x
79 else:
80 import codecs
81 encoder = encoder or codecs.latin_1_encode
82 return encoder(x)[0]
83
84
85 def intToByteString(n):
86 '''Convert the integer ``n`` to a single character byte string.'''
87 if PY2:
88 return chr(n)
89 else:
90 return bytes((n,))
91
92
93 def byteiter(bites):
94 assert(isinstance(bites, BytesType))
95 for b in bites:
96 yield b if PY2 else intToByteString(b)
97
98
99 def byteOrd(bite):
100 '''The utility handles the following difference with byte strings in
101 Python 2 and 3:
102
103 b"123"[1] == b"2" (Python2)
104 b"123"[1] == 50 (Python3)
105
106 As this function name implies, the oridinal value is returned given either
107 a byte string of length 1 (python2) or a integer value (python3). With
108 Python3 the value is simply return.
109 '''
110
111 if PY2:
112 assert(isinstance(bite, BytesType))
113 return ord(bite)
114 else:
115 assert(isinstance(bite, int))
116 return bite
117
118
119 def importmod(mod_file):
120 '''Imports a Ptyhon module referenced by absolute or relative path
121 ``mod_file``. The module is retured.'''
122 mod_name = os.path.splitext(os.path.basename(mod_file))[0]
123
124 if PY2:
125 import imp
126 mod = imp.load_source(mod_name, mod_file)
127 else:
128 import importlib.machinery
129 loader = importlib.machinery.SourceFileLoader(mod_name, mod_file)
130 mod = loader.load_module()
131
132 return mod
133
134
135 class UnicodeMixin(object):
136 '''A shim to handlke __unicode__ missing from Python3.
137 Inspired by: http://lucumr.pocoo.org/2011/1/22/forwards-compatible-python/
138 '''
139 if PY2:
140 def __str__(self):
141 return unicode(self).encode('utf-8')
142 else:
143 def __str__(self):
144 return self.__unicode__()
+0
-438
src/eyed3/core.py less more
0 # -*- coding: utf-8 -*-
1 """Basic core types and utilities."""
2 import os
3 import sys
4 import time
5 import functools
6 import pathlib
7 from . import LOCAL_FS_ENCODING
8 from .utils import guessMimetype
9 from . import compat
10
11 from .utils.log import getLogger
12 log = getLogger(__name__)
13
14 AUDIO_NONE = 0
15 """Audio type selecter for no audio."""
16 AUDIO_MP3 = 1
17 """Audio type selecter for mpeg (mp3) audio."""
18
19 AUDIO_TYPES = (AUDIO_NONE, AUDIO_MP3)
20
21 LP_TYPE = u"lp"
22 EP_TYPE = u"ep"
23 EP_MAX_SIZE_HINT = 6
24 COMP_TYPE = u"compilation"
25 LIVE_TYPE = u"live"
26 VARIOUS_TYPE = u"various"
27 DEMO_TYPE = u"demo"
28 SINGLE_TYPE = u"single"
29 ALBUM_TYPE_IDS = [LP_TYPE, EP_TYPE, COMP_TYPE, LIVE_TYPE, VARIOUS_TYPE,
30 DEMO_TYPE, SINGLE_TYPE]
31
32 VARIOUS_ARTISTS = u"Various Artists"
33
34 TXXX_ALBUM_TYPE = u"eyeD3#album_type"
35 """A key that can be used in a TXXX frame to specify the type of collection
36 (or album) a file belongs. See :class:`eyed3.core.ALBUM_TYPE_IDS`."""
37
38 TXXX_ARTIST_ORIGIN = u"eyeD3#artist_origin"
39 """A key that can be used in a TXXX frame to specify the origin of an
40 artist/band. i.e. where they are from.
41 The format is: city<tab>state<tab>country"""
42
43
44 def load(path, tag_version=None):
45 """Loads the file identified by ``path`` and returns a concrete type of
46 :class:`eyed3.core.AudioFile`. If ``path`` is not a file an ``IOError`` is
47 raised. ``None`` is returned when the file type (i.e. mime-type) is not
48 recognized.
49 The following AudioFile types are supported:
50
51 * :class:`eyed3.mp3.Mp3AudioFile` - For mp3 audio files.
52 * :class:`eyed3.id3.TagFile` - For raw ID3 data files.
53
54 If ``tag_version`` is not None (the default) only a specific version of
55 metadata is loaded. This value must be a version constant specific to the
56 eventual format of the metadata.
57 """
58 from . import mp3, id3
59 if not isinstance(path, pathlib.Path):
60 if compat.PY2:
61 path = pathlib.Path(path.encode(sys.getfilesystemencoding()))
62 else:
63 path = pathlib.Path(path)
64 log.debug("Loading file: %s" % path)
65
66 if path.exists():
67 if not path.is_file():
68 raise IOError("not a file: %s" % path)
69 else:
70 raise IOError("file not found: %s" % path)
71
72 mtypes = guessMimetype(path, all_types=True)
73 log.debug("File mime-type: %s" % mtypes)
74
75 if set(mtypes).intersection(set(mp3.MIME_TYPES)):
76 return mp3.Mp3AudioFile(path, tag_version)
77 elif (set(mtypes).intersection(set(mp3.OTHER_MIME_TYPES)) and
78 path.suffix.lower() in mp3.EXTENSIONS):
79 # Same as above, but mp3 was not typed detected; making this odd/special
80 return mp3.Mp3AudioFile(path, tag_version)
81 elif "application/x-id3" in mtypes:
82 return id3.TagFile(path, tag_version)
83 else:
84 return None
85
86
87 class AudioInfo(object):
88 """A base container for common audio details."""
89 time_secs = 0.0
90 """The number of seconds of audio data (i.e., the playtime)"""
91 size_bytes = 0
92 """The number of bytes of audio data."""
93
94
95 class Tag(object):
96 """An abstract interface for audio tag (meta) data (e.g. artist, title,
97 etc.)
98 """
99
100 read_only = False
101
102 def _setArtist(self, val):
103 raise NotImplementedError
104
105 def _getArtist(self):
106 raise NotImplementedError
107
108 def _getAlbumArtist(self):
109 raise NotImplementedError
110
111 def _setAlbumArtist(self, val):
112 raise NotImplementedError
113
114 def _setAlbum(self, val):
115 raise NotImplementedError
116
117 def _getAlbum(self):
118 raise NotImplementedError
119
120 def _setTitle(self, val):
121 raise NotImplementedError
122
123 def _getTitle(self):
124 raise NotImplementedError
125
126 def _setTrackNum(self, val):
127 raise NotImplementedError
128
129 def _getTrackNum(self):
130 raise NotImplementedError
131
132 @property
133 def artist(self):
134 return self._getArtist()
135
136 @artist.setter
137 def artist(self, v):
138 self._setArtist(v)
139
140 @property
141 def album_artist(self):
142 return self._getAlbumArtist()
143
144 @album_artist.setter
145 def album_artist(self, v):
146 self._setAlbumArtist(v)
147
148 @property
149 def album(self):
150 return self._getAlbum()
151
152 @album.setter
153 def album(self, v):
154 self._setAlbum(v)
155
156 @property
157 def title(self):
158 return self._getTitle()
159
160 @title.setter
161 def title(self, v):
162 self._setTitle(v)
163
164 @property
165 def track_num(self):
166 """Track number property.
167 Must return a 2-tuple of (track-number, total-number-of-tracks).
168 Either tuple value may be ``None``.
169 """
170 return self._getTrackNum()
171
172 @track_num.setter
173 def track_num(self, v):
174 self._setTrackNum(v)
175
176 def __init__(self, title=None, artist=None, album=None, album_artist=None,
177 track_num=None):
178 self.title = title
179 self.artist = artist
180 self.album = album
181 self.album_artist = album_artist
182 self.track_num = track_num
183
184
185 class AudioFile(object):
186 """Abstract base class for audio file types (AudioInfo + Tag)"""
187
188 def _read(self):
189 """Subclasses MUST override this method and set ``self._info``,
190 ``self._tag`` and ``self.type``.
191 """
192 raise NotImplementedError()
193
194 def rename(self, name, fsencoding=LOCAL_FS_ENCODING,
195 preserve_file_time=False):
196 """Rename the file to ``name``.
197 The encoding used for the file name is :attr:`eyed3.LOCAL_FS_ENCODING`
198 unless overridden by ``fsencoding``. Note, if the target file already
199 exists, or the full path contains non-existent directories the
200 operation will fail with :class:`IOError`.
201 File times are not modified when ``preserve_file_time`` is ``True``,
202 ``False`` is the default.
203 """
204 curr_path = pathlib.Path(self.path)
205 ext = curr_path.suffix
206
207 new_path = curr_path.parent / "{name}{ext}".format(**locals())
208 if new_path.exists():
209 raise IOError(u"File '%s' exists, will not overwrite" % new_path)
210 elif not new_path.parent.exists():
211 raise IOError(u"Target directory '%s' does not exists, will not "
212 "create" % new_path.parent)
213
214 os.rename(self.path, str(new_path))
215 if self.tag:
216 self.tag.file_info.name = str(new_path)
217 if preserve_file_time:
218 self.tag.file_info.touch((self.tag.file_info.atime,
219 self.tag.file_info.mtime))
220
221 self.path = str(new_path)
222
223 @property
224 def path(self):
225 """The absolute path of this file."""
226 return self._path
227
228 @path.setter
229 def path(self, t):
230 """Set the path"""
231 from os.path import abspath, realpath, normpath
232 self._path = normpath(realpath(abspath(t)))
233
234 @property
235 def info(self):
236 """Returns a concrete implemenation of :class:`eyed3.core.AudioInfo`"""
237 return self._info
238
239 @property
240 def tag(self):
241 """Returns a concrete implemenation of :class:`eyed3.core.Tag`"""
242 return self._tag
243
244 @tag.setter
245 def tag(self, t):
246 self._tag = t
247
248 def __init__(self, path):
249 """Construct with a path and invoke ``_read``.
250 All other members are set to None."""
251 if isinstance(path, pathlib.Path):
252 path = str(path)
253 self.path = path
254
255 self.type = None
256 self._info = None
257 self._tag = None
258 self._read()
259
260
261 @functools.total_ordering
262 class Date(object):
263 """
264 A class for representing a date and time (optional). This class differs
265 from ``datetime.datetime`` in that the default values for month, day,
266 hour, minute, and second is ``None`` and not 'January 1, 00:00:00'.
267 This allows for an object that is simply 1987, and not January 1 12AM,
268 for example. But when more resolution is required those vales can be set
269 as well.
270 """
271
272 TIME_STAMP_FORMATS = ["%Y",
273 "%Y-%m",
274 "%Y-%m-%d",
275 "%Y-%m-%dT%H",
276 "%Y-%m-%dT%H:%M",
277 "%Y-%m-%dT%H:%M:%S",
278 # The following end with 'Z' signally time is UTC
279 "%Y-%m-%dT%HZ",
280 "%Y-%m-%dT%H:%MZ",
281 "%Y-%m-%dT%H:%M:%SZ",
282 # The following are wrong per the specs, but ...
283 "%Y-%m-%d %H:%M:%S",
284 "%Y-00-00",
285 ]
286 """Valid time stamp formats per ISO 8601 and used by `strptime`."""
287
288 def __init__(self, year, month=None, day=None,
289 hour=None, minute=None, second=None):
290 # Validate with datetime
291 from datetime import datetime
292 _ = datetime(year, month if month is not None else 1,
293 day if day is not None else 1,
294 hour if hour is not None else 0,
295 minute if minute is not None else 0,
296 second if second is not None else 0)
297
298 self._year = year
299 self._month = month
300 self._day = day
301 self._hour = hour
302 self._minute = minute
303 self._second = second
304
305 # Python's date classes do a lot more date validation than does not
306 # need to be duplicated here. Validate it
307 _ = Date._validateFormat(str(self)) # noqa
308
309 @property
310 def year(self):
311 return self._year
312
313 @property
314 def month(self):
315 return self._month
316
317 @property
318 def day(self):
319 return self._day
320
321 @property
322 def hour(self):
323 return self._hour
324
325 @property
326 def minute(self):
327 return self._minute
328
329 @property
330 def second(self):
331 return self._second
332
333 def __eq__(self, rhs):
334 if not rhs:
335 return False
336
337 return (self.year == rhs.year and
338 self.month == rhs.month and
339 self.day == rhs.day and
340 self.hour == rhs.hour and
341 self.minute == rhs.minute and
342 self.second == rhs.second)
343
344 def __ne__(self, rhs):
345 return not(self == rhs)
346
347 def __lt__(self, rhs):
348 if not rhs:
349 return False
350
351 for left, right in ((self.year, rhs.year),
352 (self.month, rhs.month),
353 (self.day, rhs.day),
354 (self.hour, rhs.hour),
355 (self.minute, rhs.minute),
356 (self.second, rhs.second)):
357
358 left = left if left is not None else -1
359 right = right if right is not None else -1
360
361 if left < right:
362 return True
363 elif left > right:
364 return False
365
366 return False
367
368 def __hash__(self):
369 return hash(str(self))
370
371 @staticmethod
372 def _validateFormat(s):
373 pdate = None
374 for fmt in Date.TIME_STAMP_FORMATS:
375 try:
376 pdate = time.strptime(s, fmt)
377 break
378 except ValueError:
379 # date string did not match format.
380 continue
381
382 if pdate is None:
383 raise ValueError("Invalid date string: %s" % s)
384
385 assert(pdate)
386 return pdate, fmt
387
388 @staticmethod
389 def parse(s):
390 """Parses date strings that conform to ISO-8601."""
391 if not isinstance(s, compat.UnicodeType):
392 s = s.decode("ascii")
393 s = s.strip('\x00')
394
395 pdate, fmt = Date._validateFormat(s)
396
397 # Here is the difference with Python date/datetime objects, some
398 # of the members can be None
399 kwargs = {}
400 if "%m" in fmt:
401 kwargs["month"] = pdate.tm_mon
402 if "%d" in fmt:
403 kwargs["day"] = pdate.tm_mday
404 if "%H" in fmt:
405 kwargs["hour"] = pdate.tm_hour
406 if "%M" in fmt:
407 kwargs["minute"] = pdate.tm_min
408 if "%S" in fmt:
409 kwargs["second"] = pdate.tm_sec
410
411 return Date(pdate.tm_year, **kwargs)
412
413 def __str__(self):
414 """Returns date strings that conform to ISO-8601.
415 The returned string will be no larger than 17 characters."""
416 s = "%d" % self.year
417 if self.month:
418 s += "-%s" % str(self.month).rjust(2, '0')
419 if self.day:
420 s += "-%s" % str(self.day).rjust(2, '0')
421 if self.hour is not None:
422 s += "T%s" % str(self.hour).rjust(2, '0')
423 if self.minute is not None:
424 s += ":%s" % str(self.minute).rjust(2, '0')
425 if self.second is not None:
426 s += ":%s" % str(self.second).rjust(2, '0')
427 return s
428
429 def __unicode__(self):
430 return compat.unicode(str(self), "latin1")
431
432
433 def parseError(ex):
434 """A function that is invoked when non-fatal parse, format, etc. errors
435 occur. In most cases the invalid values will be ignored or possibly fixed.
436 This function simply logs the error."""
437 log.warning(ex)
+0
-543
src/eyed3/id3/__init__.py less more
0 ################################################################################
1 # Copyright (C) 2002-2014 Travis Shirk <travis@pobox.com>
2 #
3 # This program is free software; you can redistribute it and/or modify
4 # it under the terms of the GNU General Public License as published by
5 # the Free Software Foundation; either version 2 of the License, or
6 # (at your option) any later version.
7 #
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
12 #
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, see <http://www.gnu.org/licenses/>.
15 #
16 ################################################################################
17 import re
18
19 from .. import core
20 from .. import Error
21 from .. import compat
22 from ..utils import requireUnicode
23 from ..utils.log import getLogger
24
25 log = getLogger(__name__)
26
27 # Version constants and helpers
28 ID3_V1 = (1, None, None)
29 """Version 1, 1.0 or 1.1"""
30 ID3_V1_0 = (1, 0, 0)
31 """Version 1.0, specifically"""
32 ID3_V1_1 = (1, 1, 0)
33 """Version 1.1, specifically"""
34 ID3_V2 = (2, None, None)
35 """Version 2, 2.2, 2.3 or 2.4"""
36 ID3_V2_2 = (2, 2, 0)
37 """Version 2.2, specifically"""
38 ID3_V2_3 = (2, 3, 0)
39 """Version 2.3, specifically"""
40 ID3_V2_4 = (2, 4, 0)
41 """Version 2.4, specifically"""
42 ID3_DEFAULT_VERSION = ID3_V2_4
43 """The default version for eyeD3 tags and save operations."""
44 ID3_ANY_VERSION = (ID3_V1[0] | ID3_V2[0], None, None)
45 """Useful for operations where any version will suffice."""
46
47 LATIN1_ENCODING = b"\x00"
48 """Byte code for latin1"""
49 UTF_16_ENCODING = b"\x01"
50 """Byte code for UTF-16"""
51 UTF_16BE_ENCODING = b"\x02"
52 """Byte code for UTF-16 (big endian)"""
53 UTF_8_ENCODING = b"\x03"
54 """Byte code for UTF-8 (Not supported in ID3 versions < 2.4)"""
55
56 DEFAULT_LANG = b"eng"
57 """Default language code for frames that contain a language portion."""
58
59
60 def isValidVersion(v, fully_qualified=False):
61 """Check the tuple ``v`` against the list of valid ID3 version constants.
62 If ``fully_qualified`` is ``True`` it is enforced that there are 3
63 components to the version in ``v``. Returns ``True`` when valid and
64 ``False`` otherwise."""
65 valid = v in [ID3_V1, ID3_V1_0, ID3_V1_1,
66 ID3_V2, ID3_V2_2, ID3_V2_3, ID3_V2_4,
67 ID3_ANY_VERSION]
68 if not valid:
69 return False
70
71 if fully_qualified:
72 return (None not in (v[0], v[1], v[2]))
73 else:
74 return True
75
76
77 def normalizeVersion(v):
78 """If version tuple ``v`` is of the non-specific type (v1 or v2, any, etc.)
79 a fully qualified version is returned."""
80 if v == ID3_V1:
81 v = ID3_V1_1
82 elif v == ID3_V2:
83 assert(ID3_DEFAULT_VERSION[0] & ID3_V2[0])
84 v = ID3_DEFAULT_VERSION
85 elif v == ID3_ANY_VERSION:
86 v = ID3_DEFAULT_VERSION
87
88 # Now, correct bogus version as seen in the wild
89 if v[:2] == (2, 2) and v[2] != 0:
90 v = (2, 2, 0)
91
92 return v
93
94
95 # Convert an ID3 version constant to a display string
96 def versionToString(v):
97 """Conversion version tuple ``v`` to a string description."""
98 if v == ID3_ANY_VERSION:
99 return "v1.x/v2.x"
100 elif v[0] == 1:
101 if v == ID3_V1_0:
102 return "v1.0"
103 elif v == ID3_V1_1:
104 return "v1.1"
105 elif v == ID3_V1:
106 return "v1.x"
107 elif v[0] == 2:
108 if v == ID3_V2_2:
109 return "v2.2"
110 elif v == ID3_V2_3:
111 return "v2.3"
112 elif v == ID3_V2_4:
113 return "v2.4"
114 elif v == ID3_V2:
115 return "v2.x"
116 raise ValueError("Invalid ID3 version constant: %s" % str(v))
117
118
119 class GenreException(Error):
120 """Excpetion type for exceptions related to genres."""
121
122
123 class Genre(compat.UnicodeMixin):
124 """A genre in terms of a ``name`` and and ``id``. Only when ``name`` is
125 a "standard" genre (as defined by ID3 v1) will ``id`` be a value other
126 than ``None``."""
127
128 @requireUnicode("name")
129 def __init__(self, name=None, id=None):
130 """Constructor takes an optional ``name`` and ``id``. If ``id`` is
131 provided the ``name``, regardless of value, is set to the string the
132 id maps to. Likewise, if ``name`` is passed and is a standard genre the
133 ``id`` is set to the correct value. Any invalid id values cause a
134 ``ValueError`` to be raised. Genre names that are not in the standard
135 list are still accepted but the ``id`` value is set to ``None``."""
136 self.id, self.name = None, None
137 if not name and id is None:
138 return
139
140 # An ID always takes precedence
141 if id is not None:
142 try:
143 self.id = id
144 # valid id will set name
145 if name and name != self.name:
146 log.warning("Genre ID takes precedence and remapped "
147 "'%s' to '%s'" % (name, self.name))
148 except ValueError:
149 log.warning("Invalid numeric genre ID: %d" % id)
150 if not name:
151 # Gave an invalid ID and no name to fallback on
152 raise
153 self.name = name
154 self.id = None
155 else:
156 # All we have is a name
157 self.name = name
158
159 assert(self.id or self.name)
160
161 @property
162 def id(self):
163 """The Genre's id property.
164 When setting the value is strictly enforced and if the value is not
165 a valid genre code a ``ValueError`` is raised. Otherwise the id is
166 set **and** the ``name`` property is updated to the code's string
167 name.
168 """
169 return self._id
170
171 @id.setter
172 def id(self, val):
173 global genres
174
175 if val is None:
176 self._id = None
177 return
178
179 val = int(val)
180 if val not in list(genres.keys()) or not genres[val]:
181 raise ValueError("Invalid numeric genre ID: %d" % val)
182
183 name = genres[val]
184 self._id = val
185 self._name = name
186
187 @property
188 def name(self):
189 """The Genre's name property.
190 When setting the value the name is looked up in the standard genre
191 map and if found the ``id`` ppropery is set to the numeric valud **and**
192 the name is normalized to the sting found in the map. Non standard
193 genres are set (with a warning log) and the ``id`` is set to ``None``.
194 It is valid to set the value to ``None``.
195 """
196 return self._name
197
198 @name.setter
199 @requireUnicode(1)
200 def name(self, val):
201 global genres
202 if val is None:
203 self._name = None
204 return
205
206 if val.lower() in list(genres.keys()):
207 self._id = genres[val]
208 # normalize the name
209 self._name = genres[self._id]
210 else:
211 log.warning("Non standard genre name: %s" % val)
212 self._id = None
213 self._name = val
214
215 @staticmethod
216 @requireUnicode(1)
217 def parse(g_str, id3_std=True):
218 """Parses genre information from `genre_str`.
219 The following formats are supported:
220 01, 2, 23, 125 - ID3 v1.x style.
221 (01), (2), (129)Hardcore, (9)Metal, Indie - ID3v2 style with and without
222 refinement.
223 Raises GenreException when an invalid string is passed.
224 """
225 g_str = g_str.strip()
226 if not g_str:
227 return None
228
229 def strip0Padding(s):
230 if len(s) > 1:
231 return s.lstrip(u"0")
232 else:
233 return s
234
235 if id3_std:
236 # ID3 v1 style.
237 # Match 03, 34, 129.
238 regex = re.compile("[0-9][0-9]*$")
239 if regex.match(g_str):
240 return Genre(id=int(strip0Padding(g_str)))
241
242 # ID3 v2 style.
243 # Match (03), (0)Blues, (15) Rap
244 regex = re.compile(r"\(([0-9][0-9]*)\)(.*)$")
245 m = regex.match(g_str)
246 if m:
247 (id, name) = m.groups()
248
249 id = int(strip0Padding(id))
250 if id and name:
251 id = id
252 name = name.strip()
253 else:
254 id = id
255 name = None
256
257 return Genre(id=id, name=name)
258
259 # Let everything else slide, genres suck anyway
260 return Genre(id=None, name=g_str)
261
262 def __unicode__(self):
263 """When Python2 support is dropped this method must be renamed __str__
264 and the UnicodeMixin base class is dropped."""
265 s = u""
266 if self.id is not None:
267 s += u"(%d)" % self.id
268 if self.name:
269 s += self.name
270 return s
271
272 def __eq__(self, rhs):
273 return self.id == rhs.id and self.name == rhs.name
274
275 def __ne__(self, rhs):
276 return not self.__eq__(rhs)
277
278
279 class GenreMap(dict):
280 """Classic genres defined around ID3 v1 but suitable anywhere. This class
281 is used primarily as a way to map numeric genre values to a string name.
282 Genre strings on the other hand are not required to exist in this list.
283 """
284 GENRE_MIN = 0
285 GENRE_MAX = None
286 ID3_GENRE_MIN = 0
287 ID3_GENRE_MAX = 79
288 WINAMP_GENRE_MIN = 80
289 WINAMP_GENRE_MAX = 191
290
291 def __init__(self, *args):
292 """The optional ``*args`` are passed directly to the ``dict``
293 constructor."""
294 global ID3_GENRES
295 super(GenreMap, self).__init__(*args)
296
297 # ID3 genres as defined by the v1.1 spec with WinAmp extensions.
298 for i, g in enumerate(ID3_GENRES):
299 self[i] = g
300 self[g.lower() if g else None] = i
301
302 GenreMap.GENRE_MAX = len(ID3_GENRES) - 1
303 # Pad up to 255
304 for i in range(GenreMap.GENRE_MAX + 1, 255 + 1):
305 self[i] = None
306 self[None] = 255
307
308 def __getitem__(self, key):
309 if key and type(key) is not int:
310 key = key.lower()
311 return super(GenreMap, self).__getitem__(key)
312
313
314 class TagFile(core.AudioFile):
315 """
316 A shim class for dealing with files that contain only ID3 data, no audio.
317 """
318 def __init__(self, path, version=ID3_ANY_VERSION):
319 self._tag_version = version
320 core.AudioFile.__init__(self, path)
321 assert(self.type == core.AUDIO_NONE)
322
323 def _read(self):
324
325 with open(self.path, 'rb') as file_obj:
326 tag = Tag()
327 tag_found = tag.parse(file_obj, self._tag_version)
328 self._tag = tag if tag_found else None
329
330 self.type = core.AUDIO_NONE
331
332 def initTag(self, version=ID3_DEFAULT_VERSION):
333 """Add a id3.Tag to the file (removing any existing tag if one exists).
334 """
335 self.tag = Tag()
336 self.tag.version = version
337 self.tag.file_info = FileInfo(self.path)
338
339
340 ID3_GENRES = [
341 u'Blues',
342 u'Classic Rock',
343 u'Country',
344 u'Dance',
345 u'Disco',
346 u'Funk',
347 u'Grunge',
348 u'Hip-Hop',
349 u'Jazz',
350 u'Metal',
351 u'New Age',
352 u'Oldies',
353 u'Other',
354 u'Pop',
355 u'R&B',
356 u'Rap',
357 u'Reggae',
358 u'Rock',
359 u'Techno',
360 u'Industrial',
361 u'Alternative',
362 u'Ska',
363 u'Death Metal',
364 u'Pranks',
365 u'Soundtrack',
366 u'Euro-Techno',
367 u'Ambient',
368 u'Trip-Hop',
369 u'Vocal',
370 u'Jazz+Funk',
371 u'Fusion',
372 u'Trance',
373 u'Classical',
374 u'Instrumental',
375 u'Acid',
376 u'House',
377 u'Game',
378 u'Sound Clip',
379 u'Gospel',
380 u'Noise',
381 u'AlternRock',
382 u'Bass',
383 u'Soul',
384 u'Punk',
385 u'Space',
386 u'Meditative',
387 u'Instrumental Pop',
388 u'Instrumental Rock',
389 u'Ethnic',
390 u'Gothic',
391 u'Darkwave',
392 u'Techno-Industrial',
393 u'Electronic',
394 u'Pop-Folk',
395 u'Eurodance',
396 u'Dream',
397 u'Southern Rock',
398 u'Comedy',
399 u'Cult',
400 u'Gangsta Rap',
401 u'Top 40',
402 u'Christian Rap',
403 u'Pop / Funk',
404 u'Jungle',
405 u'Native American',
406 u'Cabaret',
407 u'New Wave',
408 u'Psychedelic',
409 u'Rave',
410 u'Showtunes',
411 u'Trailer',
412 u'Lo-Fi',
413 u'Tribal',
414 u'Acid Punk',
415 u'Acid Jazz',
416 u'Polka',
417 u'Retro',
418 u'Musical',
419 u'Rock & Roll',
420 u'Hard Rock',
421 u'Folk',
422 u'Folk-Rock',
423 u'National Folk',
424 u'Swing',
425 u'Fast Fusion',
426 u'Bebob',
427 u'Latin',
428 u'Revival',
429 u'Celtic',
430 u'Bluegrass',
431 u'Avantgarde',
432 u'Gothic Rock',
433 u'Progressive Rock',
434 u'Psychedelic Rock',
435 u'Symphonic Rock',
436 u'Slow Rock',
437 u'Big Band',
438 u'Chorus',
439 u'Easy Listening',
440 u'Acoustic',
441 u'Humour',
442 u'Speech',
443 u'Chanson',
444 u'Opera',
445 u'Chamber Music',
446 u'Sonata',
447 u'Symphony',
448 u'Booty Bass',
449 u'Primus',
450 u'Porn Groove',
451 u'Satire',
452 u'Slow Jam',
453 u'Club',
454 u'Tango',
455 u'Samba',
456 u'Folklore',
457 u'Ballad',
458 u'Power Ballad',
459 u'Rhythmic Soul',
460 u'Freestyle',
461 u'Duet',
462 u'Punk Rock',
463 u'Drum Solo',
464 u'A Cappella',
465 u'Euro-House',
466 u'Dance Hall',
467 u'Goa',
468 u'Drum & Bass',
469 u'Club-House',
470 u'Hardcore',
471 u'Terror',
472 u'Indie',
473 u'BritPop',
474 u'Negerpunk',
475 u'Polsk Punk',
476 u'Beat',
477 u'Christian Gangsta Rap',
478 u'Heavy Metal',
479 u'Black Metal',
480 u'Crossover',
481 u'Contemporary Christian',
482 u'Christian Rock',
483 u'Merengue',
484 u'Salsa',
485 u'Thrash Metal',
486 u'Anime',
487 u'JPop',
488 u'Synthpop',
489 # https://de.wikipedia.org/wiki/Liste_der_ID3v1-Genres
490 u'Abstract',
491 u'Art Rock',
492 u'Baroque',
493 u'Bhangra',
494 u'Big Beat',
495 u'Breakbeat',
496 u'Chillout',
497 u'Downtempo',
498 u'Dub',
499 u'EBM',
500 u'Eclectic',
501 u'Electro',
502 u'Electroclash',
503 u'Emo',
504 u'Experimental',
505 u'Garage',
506 u'Global',
507 u'IDM',
508 u'Illbient',
509 u'Industro-Goth',
510 u'Jam Band',
511 u'Krautrock',
512 u'Leftfield',
513 u'Lounge',
514 u'Math Rock',
515 u'New Romantic',
516 u'Nu-Breakz',
517 u'Post-Punk',
518 u'Post-Rock',
519 u'Psytrance',
520 u'Shoegaze',
521 u'Space Rock',
522 u'Trop Rock',
523 u'World Music',
524 u'Neoclassical',
525 u'Audiobook',
526 u'Audio Theatre',
527 u'Neue Deutsche Welle',
528 u'Podcast',
529 u'Indie Rock',
530 u'G-Funk',
531 u'Dubstep',
532 u'Garage Rock',
533 u'Psybient',
534 ]
535 """ID3 genres, as defined in ID3 v1. The position in the list is the genre's
536 numeric byte value."""
537
538 genres = GenreMap()
539 """A map of standard genre names and IDs per the ID3 v1 genre definition."""
540
541 from . import frames # noqa
542 from .tag import Tag, TagException, TagTemplate, FileInfo # noqa
+0
-69
src/eyed3/id3/apple.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2012 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 '''
19 Here lies Apple frames, all of which are non-standard. All of these would have
20 been standard user text frames by anyone not being a bastard, on purpose.
21 '''
22 from .frames import Frame, TextFrame
23
24 PCST_FID = b"PCST"
25 WFED_FID = b"WFED"
26 TKWD_FID = b"TKWD"
27 TDES_FID = b"TDES"
28 TGID_FID = b"TGID"
29
30
31 class PCST(Frame):
32 '''Indicates a podcast. The 4 bytes of data is undefined, and is typically
33 all 0.'''
34
35 def __init__(self, id=PCST_FID):
36 super(PCST, self).__init__(PCST_FID)
37
38 def render(self):
39 self.data = b"\x00" * 4
40 return super(PCST, self).render()
41
42
43 class TKWD(TextFrame):
44 '''Podcast keywords.'''
45
46 def __init__(self, id=TKWD_FID):
47 super(TKWD, self).__init__(TKWD_FID)
48
49
50 class TDES(TextFrame):
51 '''Podcast description. One encoding byte followed by text per encoding.'''
52
53 def __init__(self, id=TDES_FID):
54 super(TDES, self).__init__(TDES_FID)
55
56
57 class TGID(TextFrame):
58 '''Podcast URL of the audio file. This should be a W frame!'''
59
60 def __init__(self, id=TGID_FID):
61 super(TGID, self).__init__(TGID_FID)
62
63
64 class WFED(TextFrame):
65 '''Another podcast URL, the feed URL it is said.'''
66
67 def __init__(self, id=WFED_FID, url=""):
68 super(WFED, self).__init__(WFED_FID, url)
+0
-1848
src/eyed3/id3/frames.py less more
0 # -*- coding: utf-8 -*-
1 from io import BytesIO
2 from codecs import ascii_encode
3 from collections import namedtuple
4
5 from .. import core
6 from ..utils import requireUnicode, requireBytes
7 from ..utils.binfuncs import (bin2bytes, bin2dec, bytes2bin, dec2bin,
8 bytes2dec, dec2bytes)
9 from ..compat import unicode, UnicodeType, BytesType, byteiter
10 from .. import Error
11 from . import ID3_V2, ID3_V2_3, ID3_V2_4
12 from . import (LATIN1_ENCODING, UTF_8_ENCODING, UTF_16BE_ENCODING,
13 UTF_16_ENCODING, DEFAULT_LANG)
14 from .headers import FrameHeader
15
16
17 from ..utils.log import getLogger
18 log = getLogger(__name__)
19
20
21 class FrameException(Error):
22 pass
23
24
25 TITLE_FID = b"TIT2" # noqa
26 SUBTITLE_FID = b"TIT3" # noqa
27 ARTIST_FID = b"TPE1" # noqa
28 ALBUM_ARTIST_FID = b"TPE2" # noqa
29 COMPOSER_FID = b"TCOM" # noqa
30 ALBUM_FID = b"TALB" # noqa
31 TRACKNUM_FID = b"TRCK" # noqa
32 GENRE_FID = b"TCON" # noqa
33 COMMENT_FID = b"COMM" # noqa
34 USERTEXT_FID = b"TXXX" # noqa
35 OBJECT_FID = b"GEOB" # noqa
36 UNIQUE_FILE_ID_FID = b"UFID" # noqa
37 LYRICS_FID = b"USLT" # noqa
38 DISCNUM_FID = b"TPOS" # noqa
39 IMAGE_FID = b"APIC" # noqa
40 USERURL_FID = b"WXXX" # noqa
41 PLAYCOUNT_FID = b"PCNT" # noqa
42 BPM_FID = b"TBPM" # noqa
43 PUBLISHER_FID = b"TPUB" # noqa
44 CDID_FID = b"MCDI" # noqa
45 PRIVATE_FID = b"PRIV" # noqa
46 TOS_FID = b"USER" # noqa
47 POPULARITY_FID = b"POPM" # noqa
48
49 URL_COMMERCIAL_FID = b"WCOM" # noqa
50 URL_COPYRIGHT_FID = b"WCOP" # noqa
51 URL_AUDIOFILE_FID = b"WOAF" # noqa
52 URL_ARTIST_FID = b"WOAR" # noqa
53 URL_AUDIOSRC_FID = b"WOAS" # noqa
54 URL_INET_RADIO_FID = b"WORS" # noqa
55 URL_PAYMENT_FID = b"WPAY" # noqa
56 URL_PUBLISHER_FID = b"WPUB" # noqa
57 URL_FIDS = [URL_COMMERCIAL_FID, URL_COPYRIGHT_FID, # noqa
58 URL_AUDIOFILE_FID, URL_ARTIST_FID, URL_AUDIOSRC_FID,
59 URL_INET_RADIO_FID, URL_PAYMENT_FID,
60 URL_PUBLISHER_FID]
61
62 TOC_FID = b"CTOC" # noqa
63 CHAPTER_FID = b"CHAP" # noqa
64
65 DEPRECATED_DATE_FIDS = [b"TDAT", b"TYER", b"TIME", b"TORY", b"TRDA",
66 # Nonstandard v2.3 only
67 b"XDOR",
68 ]
69 DATE_FIDS = [b"TDEN", b"TDOR", b"TDRC", b"TDRL", b"TDTG"]
70
71
72 class Frame(object):
73 @requireBytes(1)
74 def __init__(self, id):
75 self.id = id
76 self.header = None
77
78 self.decompressed_size = 0
79 self.group_id = None
80 self.encrypt_method = None
81 self.data = None
82 self.data_len = 0
83 self._encoding = None
84
85 @property
86 def header(self):
87 return self._header
88
89 @header.setter
90 def header(self, h):
91 self._header = h
92
93 @requireBytes(1)
94 def parse(self, data, frame_header):
95 self.id = frame_header.id
96 self.header = frame_header
97 self.data = self._disassembleFrame(data)
98
99 def render(self):
100 return self._assembleFrame(self.data)
101
102 def __lt__(self, other):
103 return self.id < other.id
104
105 @staticmethod
106 def decompress(data):
107 import zlib
108 log.debug("before decompression: %d bytes" % len(data))
109 data = zlib.decompress(data, 15)
110 log.debug("after decompression: %d bytes" % len(data))
111 return data
112
113 @staticmethod
114 def compress(data):
115 import zlib
116 log.debug("before compression: %d bytes" % len(data))
117 data = zlib.compress(data)
118 log.debug("after compression: %d bytes" % len(data))
119 return data
120
121 @staticmethod
122 def decrypt(data):
123 raise NotImplementedError("Frame decryption not yet supported")
124
125 @staticmethod
126 def encrypt(data):
127 raise NotImplementedError("Frame encryption not yet supported")
128
129 @requireBytes(1)
130 def _disassembleFrame(self, data):
131 assert(self.header)
132 header = self.header
133 # Format flags in the frame header may add extra data to the
134 # beginning of this data.
135 if header.minor_version <= 3:
136 # 2.3: compression(4), encryption(1), group(1)
137 if header.compressed:
138 self.decompressed_size = bin2dec(bytes2bin(data[:4]))
139 data = data[4:]
140 log.debug("Decompressed Size: %d" % self.decompressed_size)
141 if header.encrypted:
142 self.encrypt_method = bin2dec(bytes2bin(data[0:1]))
143 data = data[1:]
144 log.debug("Encryption Method: %d" % self.encrypt_method)
145 if header.grouped:
146 self.group_id = bin2dec(bytes2bin(data[0:1]))
147 data = data[1:]
148 log.debug("Group ID: %d" % self.group_id)
149 else:
150 # 2.4: group(1), encrypted(1), data_length_indicator (4,7)
151 if header.grouped:
152 self.group_id = bin2dec(bytes2bin(data[0:1]))
153 log.debug("Group ID: %d" % self.group_id)
154 data = data[1:]
155 if header.encrypted:
156 self.encrypt_method = bin2dec(bytes2bin(data[0:1]))
157 data = data[1:]
158 log.debug("Encryption Method: %d" % self.encrypt_method)
159 if header.data_length_indicator:
160 self.data_len = bin2dec(bytes2bin(data[:4], 7))
161 data = data[4:]
162 log.debug("Data Length: %d" % self.data_len)
163 if header.compressed:
164 self.decompressed_size = self.data_len
165 log.debug("Decompressed Size: %d" % self.decompressed_size)
166
167 if header.minor_version == 4 and header.unsync:
168 data = deunsyncData(data)
169 if header.encrypted:
170 data = self.decrypt(data)
171 if header.compressed:
172 data = self.decompress(data)
173
174 return data
175
176 @requireBytes(1)
177 def _assembleFrame(self, data):
178 assert(self.header)
179 header = self.header
180
181 # eyeD3 never writes unsync'd frames
182 header.unsync = False
183
184 format_data = b""
185 if header.minor_version == 3:
186 if header.compressed:
187 format_data += bin2bytes(dec2bin(len(data), 32))
188 if header.encrypted:
189 format_data += bin2bytes(dec2bin(self.encrypt_method, 8))
190 if header.grouped:
191 format_data += bin2bytes(dec2bin(self.group_id, 8))
192 else:
193 if header.grouped:
194 format_data += bin2bytes(dec2bin(self.group_id, 8))
195 if header.encrypted:
196 format_data += bin2bytes(dec2bin(self.encrypt_method, 8))
197 if header.compressed or header.data_length_indicator:
198 header.data_length_indicator = 1
199 format_data += bin2bytes(dec2bin(len(data), 32))
200
201 if header.compressed:
202 data = self.compress(data)
203
204 if header.encrypted:
205 data = self.encrypt(data)
206
207 self.data = format_data + data
208 return header.render(len(self.data)) + self.data
209
210 @property
211 def text_delim(self):
212 assert(self.encoding is not None)
213 return b"\x00\x00" if self.encoding in (UTF_16_ENCODING,
214 UTF_16BE_ENCODING) else b"\x00"
215
216 def _initEncoding(self):
217 assert(self.header.version and len(self.header.version) == 3)
218 curr_enc = self.encoding
219
220 if self.encoding is not None:
221 # Make sure the encoding is valid for this version
222 if self.header.version[:2] < (2, 4):
223 if self.header.version[0] == 1:
224 self.encoding = LATIN1_ENCODING
225 else:
226 if self.encoding > UTF_16_ENCODING:
227 # v2.3 cannot do utf16 BE or utf8
228 self.encoding = UTF_16_ENCODING
229 else:
230 if self.header.version[:2] < (2, 4):
231 if self.header.version[0] == 2:
232 self.encoding = UTF_16_ENCODING
233 else:
234 self.encoding = LATIN1_ENCODING
235 else:
236 self.encoding = UTF_8_ENCODING
237
238 log.debug("_initEncoding: was={} now={}".format(curr_enc,
239 self.encoding))
240
241 @property
242 def encoding(self):
243 return self._encoding
244
245 @encoding.setter
246 def encoding(self, enc):
247 if not isinstance(enc, bytes):
248 raise TypeError("encoding argument must be a byte string.")
249 elif not (LATIN1_ENCODING <= enc <= UTF_8_ENCODING):
250 raise ValueError("Unknown encoding value {}".format(enc))
251 self._encoding = enc
252
253
254 class TextFrame(Frame):
255 """Text frames.
256 Data string format: encoding (one byte) + text
257 """
258 @requireUnicode("text")
259 def __init__(self, id, text=None):
260 super(TextFrame, self).__init__(id)
261 assert(self.id[0:1] == b'T' or self.id in [b"XSOA", b"XSOP", b"XSOT",
262 b"XDOR", b"WFED"])
263 self.text = text or u""
264
265 @property
266 def text(self):
267 return self._text
268
269 @text.setter
270 @requireUnicode(1)
271 def text(self, txt):
272 self._text = txt
273
274 def parse(self, data, frame_header):
275 super(TextFrame, self).parse(data, frame_header)
276
277 try:
278 self.encoding = self.data[0:1]
279 text_data = self.data[1:]
280 except ValueError as err:
281 log.warning("TextFrame[{fid}] - {err}; using latin1"
282 .format(err=err, fid=self.id))
283 self.encoding = LATIN1_ENCODING
284 text_data = self.data[:]
285
286 try:
287 self.text = decodeUnicode(text_data, self.encoding)
288 except UnicodeDecodeError as err:
289 log.warning("Error decoding text frame {fid}: {err}"
290 .format(fid=self.id, err=err))
291 self.test = u""
292 log.debug("TextFrame text: %s" % self.text)
293
294 def render(self):
295 self._initEncoding()
296 self.data = (self.encoding +
297 self.text.encode(id3EncodingToString(self.encoding)))
298 assert(type(self.data) == BytesType)
299 return super(TextFrame, self).render()
300
301
302 class UserTextFrame(TextFrame):
303 @requireUnicode("description", "text")
304 def __init__(self, id=USERTEXT_FID, description=u"", text=u""):
305 super(UserTextFrame, self).__init__(id, text=text)
306 self.description = description
307
308 @property
309 def description(self):
310 return self._description
311
312 @description.setter
313 @requireUnicode(1)
314 def description(self, txt):
315 self._description = txt
316
317 def parse(self, data, frame_header):
318 """Data string format:
319 encoding (one byte) + description + b"\x00" + text """
320 # Calling Frame, not TextFrame implementation here since TextFrame
321 # does not know about description
322 Frame.parse(self, data, frame_header)
323
324 try:
325 self.encoding = self.data[0:1]
326 (d, t) = splitUnicode(self.data[1:], self.encoding)
327 except ValueError as err:
328 log.warning("UserTextFrame[{fid}] - {err}; using latin1"
329 .format(err=err, fid=self.id))
330 self.encoding = LATIN1_ENCODING
331 (d, t) = splitUnicode(self.data[:], self.encoding)
332
333 self.description = decodeUnicode(d, self.encoding)
334 log.debug("UserTextFrame description: %s" % self.description)
335 self.text = decodeUnicode(t, self.encoding)
336 log.debug("UserTextFrame text: %s" % self.text)
337
338 def render(self):
339 self._initEncoding()
340 data = (self.encoding +
341 self.description.encode(id3EncodingToString(self.encoding)) +
342 self.text_delim +
343 self.text.encode(id3EncodingToString(self.encoding)))
344 self.data = data
345 # Calling Frame, not the base
346 return Frame.render(self)
347
348
349 class DateFrame(TextFrame):
350 def __init__(self, id, date=u""):
351 assert(id in DATE_FIDS or id in DEPRECATED_DATE_FIDS)
352 super(DateFrame, self).__init__(id, text=unicode(date))
353 self.date = self.text
354 self.encoding = LATIN1_ENCODING
355
356 def parse(self, data, frame_header):
357 super(DateFrame, self).parse(data, frame_header)
358 try:
359 if self.text:
360 _ = core.Date.parse(self.text) # noqa
361 except ValueError:
362 # Date is invalid, log it and reset.
363 core.parseError(FrameException(u"Invalid date: " + self.text))
364 self.text = u''
365
366 @property
367 def date(self):
368 return core.Date.parse(self.text.encode("latin1")) if self.text \
369 else None
370
371 # \a date Either an ISO 8601 date string or a eyed3.core.Date object.
372 @date.setter
373 def date(self, date):
374 if not date:
375 self.text = u""
376 return
377
378 try:
379 if type(date) is str:
380 date = core.Date.parse(date)
381 elif type(date) is unicode:
382 date = core.Date.parse(date.encode("latin1"))
383 elif not isinstance(date, core.Date):
384 raise TypeError("str, unicode, and eyed3.core.Date type "
385 "expected")
386 except ValueError:
387 log.warning("Invalid date text: %s" % date)
388 self.text = u""
389 return
390
391 self.text = unicode(str(date))
392
393 def _initEncoding(self):
394 # Dates are always latin1 since they are always represented in ISO 8601
395 self.encoding = LATIN1_ENCODING
396
397
398 class UrlFrame(Frame):
399 @requireBytes("url")
400 def __init__(self, id, url=b""):
401 assert(id in URL_FIDS or id == USERURL_FID)
402 super(UrlFrame, self).__init__(id)
403 self.encoding = LATIN1_ENCODING
404 self.url = url
405
406 @property
407 def url(self):
408 return self._url
409
410 @requireBytes(1)
411 @url.setter
412 def url(self, url):
413 self._url = url
414
415 def parse(self, data, frame_header):
416 super(UrlFrame, self).parse(data, frame_header)
417 # The URL is ascii, ensure
418 try:
419 self.url = unicode(self.data, "ascii").encode("ascii")
420 except UnicodeDecodeError:
421 log.warning("Non ascii url, clearing.")
422 self.url = ""
423
424 def render(self):
425 self.data = self.url
426 return super(UrlFrame, self).render()
427
428
429 class UserUrlFrame(UrlFrame):
430 """
431 Data string format:
432 encoding (one byte) + description + b"\x00" + url (ascii)
433 """
434 @requireUnicode("description")
435 def __init__(self, id=USERURL_FID, description=u"", url=b""):
436 UrlFrame.__init__(self, id, url=url)
437 assert(self.id == USERURL_FID)
438
439 self.description = description
440
441 @property
442 def description(self):
443 return self._description
444
445 @description.setter
446 @requireUnicode(1)
447 def description(self, desc):
448 self._description = desc
449
450 def parse(self, data, frame_header):
451 # Calling Frame and NOT UrlFrame to get the basic disassemble behavior
452 # UrlFrame would be confused by the encoding, desc, etc.
453 super(UserUrlFrame, self).parse(data, frame_header)
454 self.encoding = encoding = self.data[0:1]
455
456 (d, u) = splitUnicode(self.data[1:], encoding)
457 self.description = decodeUnicode(d, encoding)
458 log.debug("UserUrlFrame description: %s" % self.description)
459 # The URL is ascii, ensure
460 try:
461 self.url = unicode(u, "ascii").encode("ascii")
462 except UnicodeDecodeError:
463 log.warning("Non ascii url, clearing.")
464 self.url = ""
465 log.debug("UserUrlFrame text: %s" % self.url)
466
467 def render(self):
468 self._initEncoding()
469 data = (self.encoding +
470 self.description.encode(id3EncodingToString(self.encoding)) +
471 self.text_delim + self.url)
472 self.data = data
473 # Calling Frame, not the base.
474 return Frame.render(self)
475
476
477 ##
478 # Data string format:
479 # <Header for 'Attached picture', ID: "APIC">
480 # Text encoding $xx
481 # MIME type <text string> $00
482 # Picture type $xx
483 # Description <text string according to encoding> $00 (00)
484 # Picture data <binary data>
485 class ImageFrame(Frame):
486 OTHER = 0x00 # noqa
487 ICON = 0x01 # 32x32 png only. # noqa
488 OTHER_ICON = 0x02 # noqa
489 FRONT_COVER = 0x03 # noqa
490 BACK_COVER = 0x04 # noqa
491 LEAFLET = 0x05 # noqa
492 MEDIA = 0x06 # label side of cd, vinyl, etc. # noqa
493 LEAD_ARTIST = 0x07 # noqa
494 ARTIST = 0x08 # noqa
495 CONDUCTOR = 0x09 # noqa
496 BAND = 0x0A # noqa
497 COMPOSER = 0x0B # noqa
498 LYRICIST = 0x0C # noqa
499 RECORDING_LOCATION = 0x0D # noqa
500 DURING_RECORDING = 0x0E # noqa
501 DURING_PERFORMANCE = 0x0F # noqa
502 VIDEO = 0x10 # noqa
503 BRIGHT_COLORED_FISH = 0x11 # There's always room for porno. # noqa
504 ILLUSTRATION = 0x12 # noqa
505 BAND_LOGO = 0x13 # noqa
506 PUBLISHER_LOGO = 0x14 # noqa
507 MIN_TYPE = OTHER # noqa
508 MAX_TYPE = PUBLISHER_LOGO # noqa
509
510 URL_MIME_TYPE = b"-->" # noqa
511 URL_MIME_TYPE_STR = u"-->" # noqa
512 URL_MIME_TYPE_VALUES = (URL_MIME_TYPE, URL_MIME_TYPE_STR)
513
514 @requireUnicode("description")
515 def __init__(self, id=IMAGE_FID, description=u"",
516 image_data=None, image_url=None,
517 picture_type=None, mime_type=None):
518 assert(id == IMAGE_FID)
519 super(ImageFrame, self).__init__(id)
520 self.description = description
521 self.image_data = image_data
522 self.image_url = image_url
523
524 self.picture_type = picture_type
525 self.mime_type = mime_type
526
527 @property
528 def description(self):
529 return self._description
530
531 @description.setter
532 @requireUnicode(1)
533 def description(self, d):
534 self._description = d
535
536 @property
537 def mime_type(self):
538 return unicode(self._mime_type, "ascii")
539
540 @mime_type.setter
541 def mime_type(self, m):
542 m = m or b''
543 self._mime_type = m if isinstance(m, BytesType) else m.encode('ascii')
544
545 @property
546 def picture_type(self):
547 return self._pic_type
548
549 @picture_type.setter
550 def picture_type(self, t):
551 if t is not None and (t < ImageFrame.MIN_TYPE or
552 t > ImageFrame.MAX_TYPE):
553 raise ValueError("Invalid picture_type: %d" % t)
554 self._pic_type = t
555
556 def parse(self, data, frame_header):
557 super(ImageFrame, self).parse(data, frame_header)
558
559 input = BytesIO(self.data)
560 log.debug("APIC frame data size: %d" % len(self.data))
561 self.encoding = encoding = input.read(1)
562
563 # Mime type
564 self._mime_type = b""
565 if frame_header.minor_version != 2:
566 ch = input.read(1)
567 while ch and ch != b"\x00":
568 self._mime_type += ch
569 ch = input.read(1)
570 else:
571 # v2.2 (OBSOLETE) special case
572 self._mime_type = input.read(3)
573 log.debug("APIC mime type: %s" % self._mime_type)
574 if not self._mime_type:
575 core.parseError(FrameException("APIC frame does not contain a mime "
576 "type"))
577 if (self._mime_type != self.URL_MIME_TYPE and
578 self._mime_type.find(b"/") == -1):
579 self._mime_type = b"image/" + self._mime_type
580
581 pt = ord(input.read(1))
582 log.debug("Initial APIC picture type: %d" % pt)
583 if pt < self.MIN_TYPE or pt > self.MAX_TYPE:
584 core.parseError(FrameException("Invalid APIC picture type: %d" %
585 pt))
586 self.picture_type = self.OTHER
587 else:
588 self.picture_type = pt
589 log.debug("APIC picture type: %d" % self.picture_type)
590
591 self.description = u""
592
593 # Remaining data is a NULL separated description and image data
594 buffer = input.read()
595 input.close()
596
597 (desc, img) = splitUnicode(buffer, encoding)
598 log.debug("description len: %d" % len(desc))
599 log.debug("image len: %d" % len(img))
600 self.description = decodeUnicode(desc, encoding)
601 log.debug("APIC description: %s" % self.description)
602
603 if self._mime_type.find(self.URL_MIME_TYPE) != -1:
604 self.image_data = None
605 self.image_url = img
606 log.debug("APIC image URL: %s" %
607 len(self.image_url.decode("ascii")))
608 else:
609 self.image_data = img
610 self.image_url = None
611 log.debug("APIC image data: %d bytes" % len(self.image_data))
612 if not self.image_data and not self.image_url:
613 core.parseError(FrameException("APIC frame does not contain image "
614 "data/url"))
615
616 def render(self):
617 # some code has problems with image descriptions encoded <> latin1
618 # namely mp3diags: work around the problem by forcing latin1 encoding
619 # for empty descriptions, which is by far the most common case anyway
620 self._initEncoding()
621
622 if not self.image_data and self.image_url:
623 self._mime_type = self.URL_MIME_TYPE
624
625 data = (self.encoding + self._mime_type + b"\x00" +
626 bin2bytes(dec2bin(self.picture_type, 8)) +
627 self.description.encode(id3EncodingToString(self.encoding)) +
628 self.text_delim)
629
630 if self.image_data:
631 data += self.image_data
632 elif self.image_url:
633 data += self.image_url
634
635 self.data = data
636 return super(ImageFrame, self).render()
637
638 @staticmethod
639 def picTypeToString(t):
640 if t == ImageFrame.OTHER:
641 return "OTHER"
642 elif t == ImageFrame.ICON:
643 return "ICON"
644 elif t == ImageFrame.OTHER_ICON:
645 return "OTHER_ICON"
646 elif t == ImageFrame.FRONT_COVER:
647 return "FRONT_COVER"
648 elif t == ImageFrame.BACK_COVER:
649 return "BACK_COVER"
650 elif t == ImageFrame.LEAFLET:
651 return "LEAFLET"
652 elif t == ImageFrame.MEDIA:
653 return "MEDIA"
654 elif t == ImageFrame.LEAD_ARTIST:
655 return "LEAD_ARTIST"
656 elif t == ImageFrame.ARTIST:
657 return "ARTIST"
658 elif t == ImageFrame.CONDUCTOR:
659 return "CONDUCTOR"
660 elif t == ImageFrame.BAND:
661 return "BAND"
662 elif t == ImageFrame.COMPOSER:
663 return "COMPOSER"
664 elif t == ImageFrame.LYRICIST:
665 return "LYRICIST"
666 elif t == ImageFrame.RECORDING_LOCATION:
667 return "RECORDING_LOCATION"
668 elif t == ImageFrame.DURING_RECORDING:
669 return "DURING_RECORDING"
670 elif t == ImageFrame.DURING_PERFORMANCE:
671 return "DURING_PERFORMANCE"
672 elif t == ImageFrame.VIDEO:
673 return "VIDEO"
674 elif t == ImageFrame.BRIGHT_COLORED_FISH:
675 return "BRIGHT_COLORED_FISH"
676 elif t == ImageFrame.ILLUSTRATION:
677 return "ILLUSTRATION"
678 elif t == ImageFrame.BAND_LOGO:
679 return "BAND_LOGO"
680 elif t == ImageFrame.PUBLISHER_LOGO:
681 return "PUBLISHER_LOGO"
682 else:
683 raise ValueError("Invalid APIC picture type: %d" % t)
684
685 @staticmethod
686 def stringToPicType(s):
687 if s == "OTHER":
688 return ImageFrame.OTHER
689 elif s == "ICON":
690 return ImageFrame.ICON
691 elif s == "OTHER_ICON":
692 return ImageFrame.OTHER_ICON
693 elif s == "FRONT_COVER":
694 return ImageFrame.FRONT_COVER
695 elif s == "BACK_COVER":
696 return ImageFrame.BACK_COVER
697 elif s == "LEAFLET":
698 return ImageFrame.LEAFLET
699 elif s == "MEDIA":
700 return ImageFrame.MEDIA
701 elif s == "LEAD_ARTIST":
702 return ImageFrame.LEAD_ARTIST
703 elif s == "ARTIST":
704 return ImageFrame.ARTIST
705 elif s == "CONDUCTOR":
706 return ImageFrame.CONDUCTOR
707 elif s == "BAND":
708 return ImageFrame.BAND
709 elif s == "COMPOSER":
710 return ImageFrame.COMPOSER
711 elif s == "LYRICIST":
712 return ImageFrame.LYRICIST
713 elif s == "RECORDING_LOCATION":
714 return ImageFrame.RECORDING_LOCATION
715 elif s == "DURING_RECORDING":
716 return ImageFrame.DURING_RECORDING
717 elif s == "DURING_PERFORMANCE":
718 return ImageFrame.DURING_PERFORMANCE
719 elif s == "VIDEO":
720 return ImageFrame.VIDEO
721 elif s == "BRIGHT_COLORED_FISH":
722 return ImageFrame.BRIGHT_COLORED_FISH
723 elif s == "ILLUSTRATION":
724 return ImageFrame.ILLUSTRATION
725 elif s == "BAND_LOGO":
726 return ImageFrame.BAND_LOGO
727 elif s == "PUBLISHER_LOGO":
728 return ImageFrame.PUBLISHER_LOGO
729 else:
730 raise ValueError("Invalid APIC picture type: %s" % s)
731
732 def makeFileName(self, name=None):
733 name = ImageFrame.picTypeToString(self.picture_type) if not name \
734 else name
735 ext = self.mime_type.split("/")[1]
736 if ext == "jpeg":
737 ext = "jpg"
738 return ".".join([name, ext])
739
740
741 class ObjectFrame(Frame):
742 @requireUnicode("description", "filename")
743 def __init__(self, id=OBJECT_FID, description=u"", filename=u"",
744 object_data=None, mime_type=None):
745 super(ObjectFrame, self).__init__(OBJECT_FID)
746 self.description = description
747 self.filename = filename
748 self.mime_type = mime_type
749 self.object_data = object_data
750
751 @property
752 def description(self):
753 return self._description
754
755 @description.setter
756 @requireUnicode(1)
757 def description(self, txt):
758 self._description = txt
759
760 @property
761 def mime_type(self):
762 return unicode(self._mime_type, "ascii")
763
764 @mime_type.setter
765 def mime_type(self, m):
766 m = m or b''
767 self._mime_type = m if isinstance(m, BytesType) else m.encode('ascii')
768
769 @property
770 def filename(self):
771 return self._filename
772
773 @filename.setter
774 @requireUnicode(1)
775 def filename(self, txt):
776 self._filename = txt
777
778 def parse(self, data, frame_header):
779 """Parse the frame from ``data`` bytes using details from
780 ``frame_header``.
781
782 Data string format:
783 <Header for 'General encapsulated object', ID: "GEOB">
784 Text encoding $xx
785 MIME type <text string> $00
786 Filename <text string according to encoding> $00 (00)
787 Content description <text string according to encoding> $00 (00)
788 Encapsulated object <binary data>
789 """
790 super(ObjectFrame, self).parse(data, frame_header)
791
792 input = BytesIO(self.data)
793 log.debug("GEOB frame data size: " + str(len(self.data)))
794 self.encoding = encoding = input.read(1)
795
796 # Mime type
797 self._mime_type = b""
798 if self.header.minor_version != 2:
799 ch = input.read(1)
800 while ch != b"\x00":
801 self._mime_type += ch
802 ch = input.read(1)
803 else:
804 # v2.2 (OBSOLETE) special case
805 self._mime_type = input.read(3)
806 log.debug("GEOB mime type: %s" % self._mime_type)
807 if not self._mime_type:
808 core.parseError(FrameException("GEOB frame does not contain a "
809 "mime type"))
810 if self._mime_type.find(b"/") == -1:
811 core.parseError(FrameException("GEOB frame does not contain a "
812 "valid mime type"))
813
814 self.filename = u""
815 self.description = u""
816
817 # Remaining data is a NULL separated filename, description and object
818 # data
819 buffer = input.read()
820 input.close()
821
822 (filename, buffer) = splitUnicode(buffer, encoding)
823 (desc, obj) = splitUnicode(buffer, encoding)
824 self.filename = decodeUnicode(filename, encoding)
825 log.debug("GEOB filename: " + self.filename)
826 self.description = decodeUnicode(desc, encoding)
827 log.debug("GEOB description: " + self.description)
828
829 self.object_data = obj
830 log.debug("GEOB data: %d bytes " % len(self.object_data))
831 if not self.object_data:
832 core.parseError(FrameException("GEOB frame does not contain any "
833 "data"))
834
835 def render(self):
836 self._initEncoding()
837 data = (self.encoding + self._mime_type + b"\x00" +
838 self.filename.encode(id3EncodingToString(self.encoding)) +
839 self.text_delim +
840 self.description.encode(id3EncodingToString(self.encoding)) +
841 self.text_delim +
842 (self.object_data or b""))
843 self.data = data
844 return super(ObjectFrame, self).render()
845
846
847 class PrivateFrame(Frame):
848 """PRIV"""
849
850 def __init__(self, id=PRIVATE_FID, owner_id=b"", owner_data=b""):
851 super(PrivateFrame, self).__init__(id)
852 assert(id == PRIVATE_FID)
853 self.owner_id = owner_id
854 self.owner_data = owner_data
855
856 def parse(self, data, frame_header):
857 super(PrivateFrame, self).parse(data, frame_header)
858 try:
859 self.owner_id, self.owner_data = self.data.split(b'\x00', 1)
860 except ValueError:
861 # If data doesn't contain required \x00
862 # all data is taken to be owner_id
863 self.owner_id = self.data
864
865 def render(self):
866 self.data = self.owner_id + b"\x00" + self.owner_data
867 return super(PrivateFrame, self).render()
868
869
870 class MusicCDIdFrame(Frame):
871
872 def __init__(self, id=CDID_FID, toc=b""):
873 super(MusicCDIdFrame, self).__init__(id)
874 assert(id == CDID_FID)
875 self.toc = toc
876
877 @property
878 def toc(self):
879 return self.data
880
881 @toc.setter
882 def toc(self, toc):
883 self.data = toc
884
885 def parse(self, data, frame_header):
886 super(MusicCDIdFrame, self).parse(data, frame_header)
887 self.toc = self.data
888
889
890 class PlayCountFrame(Frame):
891 def __init__(self, id=PLAYCOUNT_FID, count=0):
892 super(PlayCountFrame, self).__init__(id)
893 assert(self.id == PLAYCOUNT_FID)
894
895 if count is None or count < 0:
896 raise ValueError("Invalid count value: %s" % str(count))
897 self.count = count
898
899 def parse(self, data, frame_header):
900 super(PlayCountFrame, self).parse(data, frame_header)
901 # data of less then 4 bytes is handled with with 'sz' arg
902 if len(self.data) < 4:
903 log.warning("Fixing invalid PCNT frame: less than 32 bits")
904
905 self.count = bytes2dec(self.data)
906
907 def render(self):
908 self.data = dec2bytes(self.count, 32)
909 return super(PlayCountFrame, self).render()
910
911
912 class PopularityFrame(Frame):
913 """Frame type for 'POPM' frames; popularity.
914 Frame format:
915 <Header for 'Popularimeter', ID: "POPM">
916 Email to user <text string> $00
917 Rating $xx
918 Counter $xx xx xx xx (xx ...)
919 """
920 def __init__(self, id=POPULARITY_FID, email=b"", rating=0, count=0):
921 super(PopularityFrame, self).__init__(id)
922 assert(self.id == POPULARITY_FID)
923
924 self.email = email
925 self.rating = rating
926 if count is None or count < 0:
927 raise ValueError("Invalid count value: %s" % str(count))
928 self.count = count
929
930 @property
931 def rating(self):
932 return self._rating
933
934 @rating.setter
935 def rating(self, rating):
936 if rating < 0 or rating > 255:
937 raise ValueError("Popularity rating must be >= 0 and <=255")
938 self._rating = rating
939
940 @property
941 def email(self):
942 return self._email
943
944 @email.setter
945 def email(self, email):
946 # XXX: becoming a pattern?
947 if isinstance(email, UnicodeType):
948 self._email = email.encode(ascii_encode)
949 elif isinstance(email, BytesType):
950 _ = email.decode("ascii") # noqa
951 self._email = email
952 else:
953 raise TypeError("bytes, str, unicode email required")
954
955 @property
956 def count(self):
957 return self._count
958
959 @count.setter
960 def count(self, count):
961 if count < 0:
962 raise ValueError("Popularity count must be > 0")
963 self._count = count
964
965 def parse(self, data, frame_header):
966 super(PopularityFrame, self).parse(data, frame_header)
967 data = self.data
968
969 null_byte = data.find(b'\x00')
970 try:
971 self.email = data[:null_byte]
972 except UnicodeDecodeError:
973 core.parseError(FrameException("Invalid (non-ascii) POPM email "
974 "address. Setting to 'BOGUS'"))
975 self.email = b"BOGUS"
976 data = data[null_byte + 1:]
977
978 self.rating = bytes2dec(data[0:1])
979
980 data = data[1:]
981 if len(self.data) < 4:
982 core.parseError(FrameException(
983 "Invalid POPM play count: less than 32 bits."))
984 self.count = bytes2dec(data)
985
986 def render(self):
987 data = (self.email or b"") + b'\x00'
988 data += dec2bytes(self.rating)
989 data += dec2bytes(self.count, 32)
990
991 self.data = data
992 return super(PopularityFrame, self).render()
993
994
995 class UniqueFileIDFrame(Frame):
996 def __init__(self, id=UNIQUE_FILE_ID_FID, owner_id=None, uniq_id=None):
997 super(UniqueFileIDFrame, self).__init__(id)
998 assert(self.id == UNIQUE_FILE_ID_FID)
999
1000 self.owner_id = owner_id
1001 self.uniq_id = uniq_id
1002
1003 def parse(self, data, frame_header):
1004 """
1005 Data format
1006 Owner identifier <text string> $00
1007 Identifier up to 64 bytes binary data>
1008 """
1009 super(UniqueFileIDFrame, self).parse(data, frame_header)
1010 split_data = self.data.split(b'\x00', 1)
1011 if len(split_data) == 2:
1012 (self.owner_id, self.uniq_id) = split_data
1013 else:
1014 self.owner_id, self.uniq_id = b"", split_data[0:1]
1015 log.debug("UFID owner_id: %s" % self.owner_id)
1016 log.debug("UFID id: %s" % self.uniq_id)
1017 if len(self.owner_id) == 0:
1018 dummy_owner_id = b"http://www.id3.org/dummy/ufid.html"
1019 self.owner_id = dummy_owner_id
1020 core.parseError(FrameException("Invalid UFID, owner_id is empty. "
1021 "Setting to '%s'" % dummy_owner_id))
1022 elif 0 <= len(self.uniq_id) > 64:
1023 core.parseError(FrameException("Invalid UFID, ID is empty or too "
1024 "long: %s" % self.uniq_id))
1025
1026 def render(self):
1027 self.data = self.owner_id + b"\x00" + self.uniq_id
1028 return super(UniqueFileIDFrame, self).render()
1029
1030
1031 class LanguageCodeMixin(object):
1032 @property
1033 def lang(self):
1034 assert self._lang is not None
1035 return self._lang
1036
1037 @lang.setter
1038 @requireBytes(1)
1039 def lang(self, lang):
1040 if not lang:
1041 self._lang = b""
1042 return
1043
1044 lang = lang.strip(b"\00")
1045 lang = lang[:3] if lang else DEFAULT_LANG
1046 try:
1047 if lang != DEFAULT_LANG:
1048 lang.decode("ascii")
1049 except UnicodeDecodeError:
1050 lang = DEFAULT_LANG
1051 assert len(lang) <= 3
1052 self._lang = lang
1053
1054 def _renderLang(self):
1055 lang = self.lang
1056 if len(lang) < 3:
1057 lang = lang + (b"\x00" * (3 - len(lang)))
1058 return lang
1059
1060
1061 class DescriptionLangTextFrame(Frame, LanguageCodeMixin):
1062 @requireBytes(1, 3)
1063 @requireUnicode(2, 4)
1064 def __init__(self, id, description, lang, text):
1065 super(DescriptionLangTextFrame,
1066 self).__init__(id)
1067 self.lang = lang
1068 self.description = description
1069 self.text = text
1070
1071 @property
1072 def description(self):
1073 return self._description
1074
1075 @description.setter
1076 @requireUnicode(1)
1077 def description(self, description):
1078 self._description = description
1079
1080 @property
1081 def text(self):
1082 return self._text
1083
1084 @text.setter
1085 @requireUnicode(1)
1086 def text(self, text):
1087 self._text = text
1088
1089 def parse(self, data, frame_header):
1090 super(DescriptionLangTextFrame, self).parse(data, frame_header)
1091
1092 self.encoding = encoding = self.data[0:1]
1093 self.lang = self.data[1:4]
1094 log.debug("%s lang: %s" % (self.id, self.lang))
1095
1096 try:
1097 (d, t) = splitUnicode(self.data[4:], encoding)
1098 self.description = decodeUnicode(d, encoding)
1099 log.debug("%s description: %s" % (self.id, self.description))
1100 self.text = decodeUnicode(t, encoding)
1101 log.debug("%s text: %s" % (self.id, self.text))
1102 except ValueError:
1103 log.warning("Invalid %s frame; no description/text" % self.id)
1104 self.description = u""
1105 self.text = u""
1106
1107 def render(self):
1108 lang = self._renderLang()
1109
1110 self._initEncoding()
1111 data = (self.encoding + lang +
1112 self.description.encode(id3EncodingToString(self.encoding)) +
1113 self.text_delim +
1114 self.text.encode(id3EncodingToString(self.encoding)))
1115 self.data = data
1116 return super(DescriptionLangTextFrame, self).render()
1117
1118
1119 class CommentFrame(DescriptionLangTextFrame):
1120 def __init__(self, id=COMMENT_FID, description=u"", lang=DEFAULT_LANG,
1121 text=u""):
1122 super(CommentFrame, self).__init__(id, description, lang, text)
1123 assert(self.id == COMMENT_FID)
1124
1125
1126 class LyricsFrame(DescriptionLangTextFrame):
1127 def __init__(self, id=LYRICS_FID, description=u"", lang=DEFAULT_LANG,
1128 text=u""):
1129 super(LyricsFrame, self).__init__(id, description, lang, text)
1130 assert(self.id == LYRICS_FID)
1131
1132
1133 class TermsOfUseFrame(Frame, LanguageCodeMixin):
1134 @requireUnicode("text")
1135 def __init__(self, id=b"USER", text=u"", lang=DEFAULT_LANG):
1136 super(TermsOfUseFrame, self).__init__(id)
1137 self.lang = lang
1138 self.text = text
1139
1140 @property
1141 def text(self):
1142 return self._text
1143
1144 @text.setter
1145 @requireUnicode(1)
1146 def text(self, text):
1147 self._text = text
1148
1149 def parse(self, data, frame_header):
1150 super(TermsOfUseFrame, self).parse(data, frame_header)
1151
1152 self.encoding = encoding = self.data[0:1]
1153 self.lang = self.data[1:4]
1154 log.debug("%s lang: %s" % (self.id, self.lang))
1155 self.text = decodeUnicode(self.data[4:], encoding)
1156 log.debug("%s text: %s" % (self.id, self.text))
1157
1158 def render(self):
1159 lang = self._renderLang()
1160 self._initEncoding()
1161 self.data = (self.encoding + lang +
1162 self.text.encode(id3EncodingToString(self.encoding)))
1163 return super(TermsOfUseFrame, self).render()
1164
1165
1166 class TocFrame(Frame):
1167 """Table of content frame. There may be more than one, but only one may
1168 have the top-level flag set.
1169
1170 Data format:
1171 Element ID: <string>\x00
1172 TOC flags: %000000ab
1173 Entry count: %xx
1174 Child elem IDs: <string>\x00 (... num entry count)
1175 Description: TIT2 frame (optional)
1176 """
1177 TOP_LEVEL_FLAG_BIT = 6
1178 ORDERED_FLAG_BIT = 7
1179
1180 @requireBytes(1, 2)
1181 def __init__(self, id=TOC_FID, element_id=None, toplevel=True, ordered=True,
1182 child_ids=None, description=None):
1183 assert(id == TOC_FID)
1184 super(TocFrame, self).__init__(id)
1185
1186 self.element_id = element_id
1187 self.toplevel = toplevel
1188 self.ordered = ordered
1189 self.child_ids = child_ids or []
1190 self.description = description
1191
1192 def parse(self, data, frame_header):
1193 super(TocFrame, self).parse(data, frame_header)
1194
1195 data = self.data
1196 log.debug("CTOC frame data size: %d" % len(data))
1197
1198 null_byte = data.find(b'\x00')
1199 self.element_id = data[0:null_byte]
1200 data = data[null_byte + 1:]
1201
1202 flag_bits = bytes2bin(data[0:1])
1203 self.toplevel = bool(flag_bits[self.TOP_LEVEL_FLAG_BIT])
1204 self.ordered = bool(flag_bits[self.ORDERED_FLAG_BIT])
1205 entry_count = bytes2dec(data[1:2])
1206 data = data[2:]
1207
1208 self.child_ids = []
1209 for i in range(entry_count):
1210 null_byte = data.find(b'\x00')
1211 self.child_ids.append(data[:null_byte])
1212 data = data[null_byte + 1:]
1213
1214 # Any data remaining must be a TIT2 frame
1215 self.description = None
1216 if data and data[:4] != b"TIT2":
1217 log.warning("Invalid toc data, TIT2 frame expected")
1218 return
1219 elif data:
1220 data = BytesIO(data)
1221 frame_header = FrameHeader.parse(data, self.header.version)
1222 data = data.read()
1223 description_frame = TextFrame(TITLE_FID)
1224 description_frame.parse(data, frame_header)
1225
1226 self.description = description_frame.text
1227
1228 def render(self):
1229 flags = [0] * 8
1230 if self.toplevel:
1231 flags[self.TOP_LEVEL_FLAG_BIT] = 1
1232 if self.ordered:
1233 flags[self.ORDERED_FLAG_BIT] = 1
1234
1235 data = (self.element_id + b'\x00' +
1236 bin2bytes(flags) + dec2bytes(len(self.child_ids)))
1237
1238 for cid in self.child_ids:
1239 data += cid + b'\x00'
1240
1241 if self.description is not None:
1242 desc_frame = TextFrame(TITLE_FID, self.description)
1243 desc_frame.header = FrameHeader(TITLE_FID, self.header.version)
1244 data += desc_frame.render()
1245
1246 self.data = data
1247 return super(TocFrame, self).render()
1248
1249
1250 StartEndTuple = namedtuple("StartEndTuple", ["start", "end"])
1251 """A 2-tuple, with names 'start' and 'end'."""
1252
1253
1254 class ChapterFrame(Frame):
1255 """Frame type for chapter/section of the audio file.
1256 <ID3v2.3 or ID3v2.4 frame header, ID: "CHAP"> (10 bytes)
1257 Element ID <text string> $00
1258 Start time $xx xx xx xx
1259 End time $xx xx xx xx
1260 Start offset $xx xx xx xx
1261 End offset $xx xx xx xx
1262 <Optional embedded sub-frames>
1263 """
1264
1265 NO_OFFSET = 4294967295
1266 """No offset value, aka '0xff0xff0xff0xff'"""
1267
1268 def __init__(self, id=CHAPTER_FID, element_id=None, times=None,
1269 offsets=None, sub_frames=None):
1270 assert(id == CHAPTER_FID)
1271 super(ChapterFrame, self).__init__(id)
1272 self.element_id = element_id
1273 self.times = times or StartEndTuple(None, None)
1274 self.offsets = offsets or StartEndTuple(None, None)
1275 self.sub_frames = sub_frames or FrameSet()
1276
1277 def parse(self, data, frame_header):
1278 from .headers import TagHeader, ExtendedTagHeader
1279
1280 super(ChapterFrame, self).parse(data, frame_header)
1281
1282 data = self.data
1283 log.debug("CTOC frame data size: %d" % len(data))
1284
1285 null_byte = data.find(b'\x00')
1286 self.element_id = data[0:null_byte]
1287 data = data[null_byte + 1:]
1288
1289 start = bytes2dec(data[:4])
1290 data = data[4:]
1291 end = bytes2dec(data[:4])
1292 data = data[4:]
1293 self.times = StartEndTuple(start, end)
1294
1295 start = bytes2dec(data[:4])
1296 data = data[4:]
1297 end = bytes2dec(data[:4])
1298 data = data[4:]
1299 self.offsets = StartEndTuple(start if start != self.NO_OFFSET else None,
1300 end if end != self.NO_OFFSET else None)
1301
1302 if data:
1303 dummy_tag_header = TagHeader(self.header.version)
1304 dummy_tag_header.tag_size = len(data)
1305 _ = self.sub_frames.parse(BytesIO(data), dummy_tag_header, # noqa
1306 ExtendedTagHeader())
1307 else:
1308 self.sub_frames = FrameSet()
1309
1310 def render(self):
1311 data = self.element_id + b'\x00'
1312
1313 for n in self.times + self.offsets:
1314 if n is not None:
1315 data += dec2bytes(n, 32)
1316 else:
1317 data += b'\xff\xff\xff\xff'
1318
1319 for f in self.sub_frames.getAllFrames():
1320 f.header = FrameHeader(f.id, self.header.version)
1321 data += f.render()
1322
1323 self.data = data
1324 return super(ChapterFrame, self).render()
1325
1326 @property
1327 def title(self):
1328 if TITLE_FID in self.sub_frames:
1329 return self.sub_frames[TITLE_FID][0].text
1330 return None
1331
1332 @title.setter
1333 def title(self, title):
1334 self.sub_frames.setTextFrame(TITLE_FID, title)
1335
1336 @property
1337 def subtitle(self):
1338 if SUBTITLE_FID in self.sub_frames:
1339 return self.sub_frames[SUBTITLE_FID][0].text
1340 return None
1341
1342 @subtitle.setter
1343 def subtitle(self, subtitle):
1344 self.sub_frames.setTextFrame(SUBTITLE_FID, subtitle)
1345
1346 @property
1347 def user_url(self):
1348 if USERURL_FID in self.sub_frames:
1349 frame = self.sub_frames[USERURL_FID][0]
1350 # Not returning frame description, it is always the same since it
1351 # allows only 1 URL.
1352 return frame.url
1353 return None
1354
1355 @user_url.setter
1356 def user_url(self, url):
1357 DESCRIPTION = u"chapter url"
1358
1359 if url is None:
1360 del self.sub_frames[USERURL_FID]
1361 else:
1362 if USERURL_FID in self.sub_frames:
1363 for frame in self.sub_frames[USERURL_FID]:
1364 if frame.description == DESCRIPTION:
1365 frame.url = url
1366 return
1367
1368 self.sub_frames[USERURL_FID] = UserUrlFrame(USERURL_FID,
1369 DESCRIPTION, url)
1370
1371
1372 # XXX: This data structure pretty sucks, or it is beautiful anarchy
1373 class FrameSet(dict):
1374 def __init__(self):
1375 dict.__init__(self)
1376
1377 def parse(self, f, tag_header, extended_header):
1378 """Read frames starting from the current read position of the file
1379 object. Returns the amount of padding which occurs after the tag, but
1380 before the audio content. A return valule of 0 does not mean error."""
1381 self.clear()
1382
1383 padding_size = 0
1384 size_left = tag_header.tag_size - extended_header.size
1385 consumed_size = 0
1386
1387 # Handle a tag-level unsync. Some frames may have their own unsync bit
1388 # set instead.
1389 tag_data = f.read(size_left)
1390
1391 # If the tag is 2.3 and the tag header unsync bit is set then all the
1392 # frame data is deunsync'd at once, otherwise it will happen on a per
1393 # frame basis.
1394 if tag_header.unsync and tag_header.version <= ID3_V2_3:
1395 log.debug("De-unsynching %d bytes at once (<= 2.3 tag)" %
1396 len(tag_data))
1397 og_size = len(tag_data)
1398 tag_data = deunsyncData(tag_data)
1399 size_left = len(tag_data)
1400 log.debug("De-unsynch'd %d bytes at once (<= 2.3 tag) to %d bytes" %
1401 (og_size, size_left))
1402
1403 # Adding bytes to simulate the tag header(s) in the buffer. This keeps
1404 # f.tell() values matching the file offsets for logging.
1405 prepadding = b'\x00' * 10 # Tag header
1406 prepadding += b'\x00' * extended_header.size
1407 tag_buffer = BytesIO(prepadding + tag_data)
1408 tag_buffer.seek(len(prepadding))
1409
1410 frame_count = 0
1411 while size_left > 0:
1412 log.debug("size_left: " + str(size_left))
1413 if size_left < (10 + 1): # The size of the smallest frame.
1414 log.debug("FrameSet: Implied padding (size_left<minFrameSize)")
1415 padding_size = size_left
1416 break
1417
1418 log.debug("+++++++++++++++++++++++++++++++++++++++++++++++++")
1419 log.debug("FrameSet: Reading Frame #" + str(frame_count + 1))
1420 frame_header = FrameHeader.parse(tag_buffer, tag_header.version)
1421 if not frame_header:
1422 log.debug("No frame found, implied padding of %d bytes" %
1423 size_left)
1424 padding_size = size_left
1425 break
1426
1427 # Frame data.
1428 if frame_header.data_size:
1429 log.debug("FrameSet: Reading %d (0x%X) bytes of data from byte "
1430 "pos %d (0x%X)" % (frame_header.data_size,
1431 frame_header.data_size,
1432 tag_buffer.tell(),
1433 tag_buffer.tell()))
1434 data = tag_buffer.read(frame_header.data_size)
1435
1436 log.debug("FrameSet: %d bytes of data read" % len(data))
1437 consumed_size += (frame_header.size +
1438 frame_header.data_size)
1439 frame = createFrame(tag_header, frame_header, data)
1440 self[frame.id] = frame
1441 frame_count += 1
1442
1443 # Each frame contains data_size + headerSize bytes.
1444 size_left -= (frame_header.size +
1445 frame_header.data_size)
1446
1447 return padding_size
1448
1449 @requireBytes(1)
1450 def __getitem__(self, fid):
1451 if fid in self:
1452 return dict.__getitem__(self, fid)
1453 else:
1454 return None
1455
1456 @requireBytes(1)
1457 def __setitem__(self, fid, frame):
1458 assert(fid == frame.id)
1459
1460 if fid in self:
1461 self[fid].append(frame)
1462 else:
1463 dict.__setitem__(self, fid, [frame])
1464
1465 def getAllFrames(self):
1466 """Return all the frames in the set as a list. The list is sorted
1467 in an arbitrary but consistent order."""
1468 frames = []
1469 for flist in list(self.values()):
1470 frames += flist
1471 frames.sort()
1472 return frames
1473
1474 @requireBytes(1)
1475 @requireUnicode(2)
1476 def setTextFrame(self, fid, text):
1477 """Set a text frame value.
1478 Text frame IDs must be unique. If a frame with
1479 the same Id is already in the list it's value is changed, otherwise
1480 the frame is added.
1481 """
1482 assert(fid[0:1] == b"T" and (fid in ID3_FRAMES or
1483 fid in NONSTANDARD_ID3_FRAMES))
1484
1485 if fid in self:
1486 self[fid][0].text = text
1487 else:
1488 if fid in (DATE_FIDS + DEPRECATED_DATE_FIDS):
1489 self[fid] = DateFrame(fid, date=text)
1490 else:
1491 self[fid] = TextFrame(fid, text=text)
1492
1493 @requireBytes(1)
1494 def __contains__(self, fid):
1495 return dict.__contains__(self, fid)
1496
1497
1498 def deunsyncData(data):
1499 output = []
1500 safe = True
1501 for val in byteiter(data):
1502 if safe:
1503 output.append(val)
1504 safe = (val != b'\xff')
1505 else:
1506 if val != b'\x00':
1507 output.append(val)
1508 safe = True
1509 return b''.join(output)
1510
1511
1512 # Create and return the appropriate frame.
1513 def createFrame(tag_header, frame_header, data):
1514 fid = frame_header.id
1515 FrameClass = None
1516
1517 if fid in ID3_FRAMES:
1518 (desc, ver, FrameClass) = ID3_FRAMES[fid]
1519 elif fid in NONSTANDARD_ID3_FRAMES:
1520 log.verbose("Non standard frame '%s' encountered" % fid)
1521 (desc, ver, FrameClass) = NONSTANDARD_ID3_FRAMES[fid]
1522 else:
1523 log.warning("Unknown ID3 frame ID: %s" % fid)
1524 (desc, ver, FrameClass) = ("Unknown", None, Frame)
1525 log.debug("createFrame (desc:{}) - {} - {}".format(desc, ver, FrameClass))
1526
1527 # FrameClass may still be None if the frame is standard but does not
1528 # yet have a concrete type.
1529 if not FrameClass:
1530 log.warning("Frame '%s' is not yet supported, using raw Frame to parse"
1531 % fid.decode("ascii"))
1532 FrameClass = Frame
1533
1534 log.debug("createFrame '%s' with class '%s'" % (fid, FrameClass))
1535 if tag_header.version[:2] == (2, 4) and tag_header.unsync:
1536 frame_header.unsync = True
1537
1538 frame = FrameClass(fid)
1539 frame.parse(data, frame_header)
1540 return frame
1541
1542
1543 def decodeUnicode(bites, encoding):
1544 for obj, obj_name in ((bites, "bites"), (encoding, "encoding")):
1545 if not isinstance(obj, bytes):
1546 raise TypeError("%s argument must be a byte string." % obj_name)
1547
1548 codec = id3EncodingToString(encoding)
1549 log.debug("Unicode encoding: %s" % codec)
1550 if (codec.startswith("utf_16") and
1551 len(bites) % 2 != 0 and bites[-1:] == b"\x00"):
1552 # Catch and fix bad utf16 data, it is everywhere.
1553 log.warning("Fixing utf16 data with extra zero bytes")
1554 bites = bites[:-1]
1555 return unicode(bites, codec).rstrip("\x00")
1556
1557
1558 def splitUnicode(data, encoding):
1559 try:
1560 if encoding == LATIN1_ENCODING or encoding == UTF_8_ENCODING:
1561 (d, t) = data.split(b"\x00", 1)
1562 elif encoding == UTF_16_ENCODING or encoding == UTF_16BE_ENCODING:
1563 # Two null bytes split, but since each utf16 char is also two
1564 # bytes we need to ensure we found a proper boundary.
1565 (d, t) = data.split(b"\x00\x00", 1)
1566 if (len(d) % 2) != 0:
1567 (d, t) = data.split(b"\x00\x00\x00", 1)
1568 d += b"\x00"
1569 except ValueError as ex:
1570 log.warning("Invalid 2-tuple ID3 frame data: %s", ex)
1571 d, t = data, b""
1572 return (d, t)
1573
1574
1575 def id3EncodingToString(encoding):
1576 if not isinstance(encoding, bytes):
1577 raise TypeError("encoding argument must be a byte string.")
1578
1579 if encoding == LATIN1_ENCODING:
1580 return "latin_1"
1581 elif encoding == UTF_8_ENCODING:
1582 return "utf_8"
1583 elif encoding == UTF_16_ENCODING:
1584 return "utf_16"
1585 elif encoding == UTF_16BE_ENCODING:
1586 return "utf_16_be"
1587 else:
1588 raise ValueError("Encoding unknown: %s" % encoding)
1589
1590
1591 def stringToEncoding(s):
1592 s = s.replace('-', '_')
1593 if s in ("latin_1", "latin1"):
1594 return LATIN1_ENCODING
1595 elif s in ("utf_8", "utf8"):
1596 return UTF_8_ENCODING
1597 elif s in ("utf_16", "utf16"):
1598 return UTF_16_ENCODING
1599 elif s in ("utf_16_be", "utf16_be"):
1600 return UTF_16BE_ENCODING
1601 else:
1602 raise ValueError("Encoding unknown: %s" % s)
1603
1604
1605 # { frame-id : (frame-description, valid-id3-version, frame-class) }
1606 ID3_FRAMES = {b"AENC": ("Audio encryption",
1607 ID3_V2,
1608 None),
1609 b"APIC": ("Attached picture",
1610 ID3_V2,
1611 ImageFrame),
1612 b"ASPI": ("Audio seek point index",
1613 ID3_V2_4,
1614 None),
1615
1616 b"COMM": ("Comments", ID3_V2, CommentFrame),
1617 b"COMR": ("Commercial frame", ID3_V2, None),
1618
1619 b"CTOC": ("Table of contents", ID3_V2, TocFrame),
1620 b"CHAP": ("Chapter", ID3_V2, ChapterFrame),
1621
1622 b"ENCR": ("Encryption method registration", ID3_V2, None),
1623 b"EQUA": ("Equalisation", ID3_V2_3, None),
1624 b"EQU2": ("Equalisation (2)", ID3_V2_4, None),
1625 b"ETCO": ("Event timing codes", ID3_V2, None),
1626
1627 b"GEOB": ("General encapsulated object", ID3_V2, ObjectFrame),
1628 b"GRID": ("Group identification registration", ID3_V2, None),
1629
1630 b"IPLS": ("Involved people list", ID3_V2_3, None),
1631
1632 b"LINK": ("Linked information", ID3_V2, None),
1633
1634 b"MCDI": ("Music CD identifier", ID3_V2, MusicCDIdFrame),
1635 b"MLLT": ("MPEG location lookup table", ID3_V2, None),
1636
1637 b"OWNE": ("Ownership frame", ID3_V2, None),
1638
1639 b"PRIV": ("Private frame", ID3_V2, PrivateFrame),
1640 b"PCNT": ("Play counter", ID3_V2, PlayCountFrame),
1641 b"POPM": ("Popularimeter", ID3_V2, PopularityFrame),
1642 b"POSS": ("Position synchronisation frame", ID3_V2, None),
1643
1644 b"RBUF": ("Recommended buffer size", ID3_V2, None),
1645 b"RVAD": ("Relative volume adjustment", ID3_V2_3, None),
1646 b"RVA2": ("Relative volume adjustment (2)", ID3_V2_4, None),
1647 b"RVRB": ("Reverb", ID3_V2, None),
1648
1649 b"SEEK": ("Seek frame", ID3_V2_4, None),
1650 b"SIGN": ("Signature frame", ID3_V2_4, None),
1651 b"SYLT": ("Synchronised lyric/text", ID3_V2, None),
1652 b"SYTC": ("Synchronised tempo codes", ID3_V2, None),
1653
1654 b"TALB": ("Album/Movie/Show title", ID3_V2, TextFrame),
1655 b"TBPM": ("BPM (beats per minute)", ID3_V2, TextFrame),
1656 b"TCOM": ("Composer", ID3_V2, TextFrame),
1657 b"TCON": ("Content type", ID3_V2, TextFrame),
1658 b"TCOP": ("Copyright message", ID3_V2, TextFrame),
1659 b"TDAT": ("Date", ID3_V2_3, DateFrame),
1660 b"TDEN": ("Encoding time", ID3_V2_4, DateFrame),
1661 b"TDLY": ("Playlist delay", ID3_V2, TextFrame),
1662 b"TDOR": ("Original release time", ID3_V2_4, DateFrame),
1663 b"TDRC": ("Recording time", ID3_V2_4, DateFrame),
1664 b"TDRL": ("Release time", ID3_V2_4, DateFrame),
1665 b"TDTG": ("Tagging time", ID3_V2_4, DateFrame),
1666 b"TENC": ("Encoded by", ID3_V2, TextFrame),
1667 b"TEXT": ("Lyricist/Text writer", ID3_V2, TextFrame),
1668 b"TFLT": ("File type", ID3_V2, TextFrame),
1669 b"TIME": ("Time", ID3_V2_3, DateFrame),
1670 b"TIPL": ("Involved people list", ID3_V2_4, TextFrame),
1671 b"TIT1": ("Content group description", ID3_V2, TextFrame),
1672 b"TIT2": ("Title/songname/content description", ID3_V2,
1673 TextFrame),
1674 b"TIT3": ("Subtitle/Description refinement", ID3_V2, TextFrame),
1675 b"TKEY": ("Initial key", ID3_V2, TextFrame),
1676 b"TLAN": ("Language(s)", ID3_V2, TextFrame),
1677 b"TLEN": ("Length", ID3_V2, TextFrame),
1678 b"TMCL": ("Musician credits list", ID3_V2_4, TextFrame),
1679 b"TMED": ("Media type", ID3_V2, TextFrame),
1680 b"TMOO": ("Mood", ID3_V2_4, TextFrame),
1681 b"TOAL": ("Original album/movie/show title", ID3_V2, TextFrame),
1682 b"TOFN": ("Original filename", ID3_V2, TextFrame),
1683 b"TOLY": ("Original lyricist(s)/text writer(s)", ID3_V2,
1684 TextFrame),
1685 b"TOPE": ("Original artist(s)/performer(s)", ID3_V2, TextFrame),
1686 b"TORY": ("Original release year", ID3_V2_3, DateFrame),
1687 b"TOWN": ("File owner/licensee", ID3_V2, TextFrame),
1688 b"TPE1": ("Lead performer(s)/Soloist(s)", ID3_V2, TextFrame),
1689 b"TPE2": ("Band/orchestra/accompaniment", ID3_V2, TextFrame),
1690 b"TPE3": ("Conductor/performer refinement", ID3_V2, TextFrame),
1691 b"TPE4": ("Interpreted, remixed, or otherwise modified by",
1692 ID3_V2, TextFrame),
1693 b"TPOS": ("Part of a set", ID3_V2, TextFrame),
1694 b"TPRO": ("Produced notice", ID3_V2_4, TextFrame),
1695 b"TPUB": ("Publisher", ID3_V2, TextFrame),
1696 b"TRCK": ("Track number/Position in set", ID3_V2, TextFrame),
1697 b"TRDA": ("Recording dates", ID3_V2_3, DateFrame),
1698 b"TRSN": ("Internet radio station name", ID3_V2, TextFrame),
1699 b"TRSO": ("Internet radio station owner", ID3_V2, TextFrame),
1700 b"TSOA": ("Album sort order", ID3_V2_4, TextFrame),
1701 b"TSOP": ("Performer sort order", ID3_V2_4, TextFrame),
1702 b"TSOT": ("Title sort order", ID3_V2_4, TextFrame),
1703 b"TSIZ": ("Size", ID3_V2_3, TextFrame),
1704 b"TSRC": ("ISRC (international standard recording code)", ID3_V2,
1705 TextFrame),
1706 b"TSSE": ("Software/Hardware and settings used for encoding",
1707 ID3_V2, TextFrame),
1708 b"TSST": ("Set subtitle", ID3_V2_4, TextFrame),
1709 b"TYER": ("Year", ID3_V2_3, DateFrame),
1710 b"TXXX": ("User defined text information frame", ID3_V2,
1711 UserTextFrame),
1712
1713 b"UFID": ("Unique file identifier", ID3_V2, UniqueFileIDFrame),
1714 b"USER": ("Terms of use", ID3_V2, TermsOfUseFrame),
1715 b"USLT": ("Unsynchronised lyric/text transcription", ID3_V2,
1716 LyricsFrame),
1717
1718 b"WCOM": ("Commercial information", ID3_V2, UrlFrame),
1719 b"WCOP": ("Copyright/Legal information", ID3_V2, UrlFrame),
1720 b"WOAF": ("Official audio file webpage", ID3_V2, UrlFrame),
1721 b"WOAR": ("Official artist/performer webpage", ID3_V2, UrlFrame),
1722 b"WOAS": ("Official audio source webpage", ID3_V2, UrlFrame),
1723 b"WORS": ("Official Internet radio station homepage", ID3_V2,
1724 UrlFrame),
1725 b"WPAY": ("Payment", ID3_V2, UrlFrame),
1726 b"WPUB": ("Publishers official webpage", ID3_V2, UrlFrame),
1727 b"WXXX": ("User defined URL link frame", ID3_V2, UserUrlFrame),
1728 }
1729
1730
1731 def map2_2FrameId(orig_id):
1732 if orig_id not in TAGS2_2_TO_TAGS_2_3_AND_4:
1733 return orig_id
1734 return TAGS2_2_TO_TAGS_2_3_AND_4[orig_id]
1735
1736
1737 # mapping of 2.2 frames to 2.3/2.4
1738 TAGS2_2_TO_TAGS_2_3_AND_4 = {
1739 b"TT1": b"TIT1", # CONTENTGROUP content group description
1740 b"TT2": b"TIT2", # TITLE title/songname/content description
1741 b"TT3": b"TIT3", # SUBTITLE subtitle/description refinement
1742 b"TP1": b"TPE1", # ARTIST lead performer(s)/soloist(s)
1743 b"TP2": b"TPE2", # BAND band/orchestra/accompaniment
1744 b"TP3": b"TPE3", # CONDUCTOR conductor/performer refinement
1745 b"TP4": b"TPE4", # MIXARTIST interpreted, remixed, modified by
1746 b"TCM": b"TCOM", # COMPOSER composer
1747 b"TXT": b"TEXT", # LYRICIST lyricist/text writer
1748 b"TLA": b"TLAN", # LANGUAGE language(s)
1749 b"TCO": b"TCON", # CONTENTTYPE content type
1750 b"TAL": b"TALB", # ALBUM album/movie/show title
1751 b"TRK": b"TRCK", # TRACKNUM track number/position in set
1752 b"TPA": b"TPOS", # PARTINSET part of set
1753 b"TRC": b"TSRC", # ISRC international standard recording code
1754 b"TDA": b"TDAT", # DATE date
1755 b"TYE": b"TYER", # YEAR year
1756 b"TIM": b"TIME", # TIME time
1757 b"TRD": b"TRDA", # RECORDINGDATES recording dates
1758 b"TOR": b"TORY", # ORIGYEAR original release year
1759 b"TBP": b"TBPM", # BPM beats per minute
1760 b"TMT": b"TMED", # MEDIATYPE media type
1761 b"TFT": b"TFLT", # FILETYPE file type
1762 b"TCR": b"TCOP", # COPYRIGHT copyright message
1763 b"TPB": b"TPUB", # PUBLISHER publisher
1764 b"TEN": b"TENC", # ENCODEDBY encoded by
1765 b"TSS": b"TSSE", # ENCODERSETTINGS software/hardware+settings for encoding
1766 b"TLE": b"TLEN", # SONGLEN length (ms)
1767 b"TSI": b"TSIZ", # SIZE size (bytes)
1768 b"TDY": b"TDLY", # PLAYLISTDELAY playlist delay
1769 b"TKE": b"TKEY", # INITIALKEY initial key
1770 b"TOT": b"TOAL", # ORIGALBUM original album/movie/show title
1771 b"TOF": b"TOFN", # ORIGFILENAME original filename
1772 b"TOA": b"TOPE", # ORIGARTIST original artist(s)/performer(s)
1773 b"TOL": b"TOLY", # ORIGLYRICIST original lyricist(s)/text writer(s)
1774 b"TXX": b"TXXX", # USERTEXT user defined text information frame
1775 b"WAF": b"WOAF", # WWWAUDIOFILE official audio file webpage
1776 b"WAR": b"WOAR", # WWWARTIST official artist/performer webpage
1777 b"WAS": b"WOAS", # WWWAUDIOSOURCE official audion source webpage
1778 b"WCM": b"WCOM", # WWWCOMMERCIALINFO commercial information
1779 b"WCP": b"WCOP", # WWWCOPYRIGHT copyright/legal information
1780 b"WPB": b"WPUB", # WWWPUBLISHER publishers official webpage
1781 b"WXX": b"WXXX", # WWWUSER user defined URL link frame
1782 b"IPL": b"IPLS", # INVOLVEDPEOPLE involved people list
1783 b"ULT": b"USLT", # UNSYNCEDLYRICS unsynchronised lyrics/text transcription
1784 b"COM": b"COMM", # COMMENT comments
1785 b"UFI": b"UFID", # UNIQUEFILEID unique file identifier
1786 b"MCI": b"MCDI", # CDID music CD identifier
1787 b"ETC": b"ETCO", # EVENTTIMING event timing codes
1788 b"MLL": b"MLLT", # MPEGLOOKUP MPEG location lookup table
1789 b"STC": b"SYTC", # SYNCEDTEMPO synchronised tempo codes
1790 b"SLT": b"SYLT", # SYNCEDLYRICS synchronised lyrics/text
1791 b"RVA": b"RVAD", # VOLUMEADJ relative volume adjustment
1792 b"EQU": b"EQUA", # EQUALIZATION equalization
1793 b"REV": b"RVRB", # REVERB reverb
1794 b"PIC": b"APIC", # PICTURE attached picture
1795 b"GEO": b"GEOB", # GENERALOBJECT general encapsulated object
1796 b"CNT": b"PCNT", # PLAYCOUNTER play counter
1797 b"POP": b"POPM", # POPULARIMETER popularimeter
1798 b"BUF": b"RBUF", # BUFFERSIZE recommended buffer size
1799 b"CRA": b"AENC", # AUDIOCRYPTO audio encryption
1800 b"LNK": b"LINK", # LINKEDINFO linked information
1801 # Extension workarounds i.e., ignore them
1802 b"TCP": b"TCMP", # iTunes "extension" for compilation marking
1803 b"TST": b"TSOT", # iTunes "extension" for title sort
1804 b"TSP": b"TSOP", # iTunes "extension" for artist sort
1805 b"TSA": b"TSOA", # iTunes "extension" for album sort
1806 b"TS2": b"TSO2", # iTunes "extension" for album artist sort
1807 b"TSC": b"TSOC", # iTunes "extension" for composer sort
1808 b"TDR": b"TDRL", # iTunes "extension" for release date
1809 b"TDS": b"TDES", # iTunes "extension" for podcast description
1810 b"TID": b"TGID", # iTunes "extension" for podcast identifier
1811 b"WFD": b"WFED", # iTunes "extension" for podcast feed URL
1812 b"CM1": b"CM1 ", # Seems to be some script kiddie tagging the tag.
1813 # For example, [rH] join #rH on efnet [rH]
1814 b"PCS": b"PCST", # iTunes extension for podcast marking.
1815 }
1816
1817 from . import apple # noqa
1818 NONSTANDARD_ID3_FRAMES = {
1819 b"NCON": ("Undefined MusicMatch extension", ID3_V2, Frame),
1820 b"TCMP": ("iTunes complilation flag extension", ID3_V2, TextFrame),
1821 b"XSOA": ("Album sort-order string extension for v2.3",
1822 ID3_V2_3, TextFrame),
1823 b"XSOP": ("Performer sort-order string extension for v2.3",
1824 ID3_V2_3, TextFrame),
1825 b"XSOT": ("Title sort-order string extension for v2.3",
1826 ID3_V2_3, TextFrame),
1827 b"XDOR": ("MusicBrainz release date (full) extension for v2.3",
1828 ID3_V2_3, DateFrame),
1829
1830 b"TSO2": ("Album artist sort-order used in iTunes and Picard",
1831 ID3_V2, TextFrame),
1832 b"TSOC": ("Composer sort-order used in iTunes and Picard",
1833 ID3_V2, TextFrame),
1834
1835 b"PCST": ("iTunes extension; marks the file as a podcast",
1836 ID3_V2, apple.PCST),
1837 b"TKWD": ("iTunes extension; podcast keywords?",
1838 ID3_V2, apple.TKWD),
1839 b"TDES": ("iTunes extension; podcast description?",
1840 ID3_V2, apple.TDES),
1841 b"TGID": ("iTunes extension; podcast ?????",
1842 ID3_V2, apple.TGID),
1843 b"WFED": ("iTunes extension; podcast feed URL?",
1844 ID3_V2, apple.WFED),
1845 b"TCAT": ("iTunes extension; podcast category.",
1846 ID3_V2, TextFrame),
1847 }
+0
-718
src/eyed3/id3/headers.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2002-2015 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 import math
19 import logging
20 import binascii
21 from ..utils import requireBytes
22 from ..utils.binfuncs import (bin2dec, bytes2bin, bin2bytes,
23 bin2synchsafe, dec2bin)
24 from .. import core
25 from .. import compat
26 from ..compat import byteOrd
27
28 from . import ID3_DEFAULT_VERSION, isValidVersion, normalizeVersion
29
30 from ..utils.log import getLogger
31 log = getLogger(__name__)
32
33 NULL_FRAME_FLAGS = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
34
35
36 class TagHeader(object):
37 SIZE = 10
38
39 def __init__(self, version=ID3_DEFAULT_VERSION):
40 self.clear()
41 self.version = version
42
43 def clear(self):
44 self.tag_size = 0
45 # Flag bits
46 self.unsync = False
47 self.extended = False
48 self.experimental = False
49 # v2.4 addition
50 self.footer = False
51
52 @property
53 def version(self):
54 return tuple([v for v in self._version])
55
56 @version.setter
57 def version(self, v):
58 v = normalizeVersion(v)
59 if not isValidVersion(v, fully_qualified=True):
60 raise ValueError("Invalid version: %s" % str(v))
61 self._version = v
62
63 @property
64 def major_version(self):
65 return self._version[0]
66
67 @property
68 def minor_version(self):
69 return self._version[1]
70
71 @property
72 def rev_version(self):
73 return self._version[2]
74
75 def parse(self, f):
76 '''Parse an ID3 v2 header starting at the current position of ``f``.
77 If a header is parsed ``True`` is returned, otherwise ``False``. If
78 a header is found but malformed an ``eyed3.id3.tag.TagException`` is
79 thrown.
80 '''
81 from .tag import TagException
82
83 self.clear()
84
85 # 3 bytes: v2 header is "ID3".
86 if f.read(3) != b"ID3":
87 return False
88 log.debug("Located ID3 v2 tag")
89
90 # 2 bytes: the minor and revision versions.
91 version = f.read(2)
92 if len(version) != 2:
93 return False
94 major = 2
95 minor = byteOrd(version[0])
96 rev = byteOrd(version[1])
97 log.debug("TagHeader [major]: %d " % major)
98 log.debug("TagHeader [minor]: %d " % minor)
99 log.debug("TagHeader [rev]: %d " % rev)
100 if not (major == 2 and (minor >= 2 and minor <= 4)):
101 raise TagException("ID3 v%d.%d is not supported" % (major, minor))
102 self.version = (major, minor, rev)
103
104 # 1 byte (first 4 bits): flags
105 data = f.read(1)
106 if not data:
107 return False
108 (self.unsync,
109 self.extended,
110 self.experimental,
111 self.footer) = (bool(b) for b in bytes2bin(data)[0:4])
112 log.debug("TagHeader [flags]: unsync(%d) extended(%d) "
113 "experimental(%d) footer(%d)" % (self.unsync, self.extended,
114 self.experimental,
115 self.footer))
116
117 # 4 bytes: The size of the extended header (if any), frames, and padding
118 # afer unsynchronization. This is a sync safe integer, so only the
119 # bottom 7 bits of each byte are used.
120 tag_size_bytes = f.read(4)
121 if len(tag_size_bytes) != 4:
122 return False
123 log.debug("TagHeader [size string]: 0x%02x%02x%02x%02x" %
124 (byteOrd(tag_size_bytes[0]), byteOrd(tag_size_bytes[1]),
125 byteOrd(tag_size_bytes[2]), byteOrd(tag_size_bytes[3])))
126 self.tag_size = bin2dec(bytes2bin(tag_size_bytes, 7))
127 log.debug("TagHeader [size]: %d (0x%x)" % (self.tag_size,
128 self.tag_size))
129
130 return True
131
132 def render(self, tag_len=None):
133 if tag_len is not None:
134 self.tag_size = tag_len
135
136 if self.unsync:
137 raise NotImplementedError("eyeD3 does not write (only reads) "
138 "unsync'd data")
139
140 data = b"ID3"
141 data += compat.chr(self.minor_version) + compat.chr(self.rev_version)
142 data += bin2bytes([int(self.unsync),
143 int(self.extended),
144 int(self.experimental),
145 int(self.footer),
146 0, 0, 0, 0])
147 log.debug("Setting tag size to %d" % self.tag_size)
148 data += bin2bytes(bin2synchsafe(dec2bin(self.tag_size, 32)))
149 log.debug("TagHeader rendered %d bytes" % len(data))
150 return data
151
152
153 class ExtendedTagHeader(object):
154 RESTRICT_TAG_SZ_LARGE = 0x00
155 RESTRICT_TAG_SZ_MED = 0x01
156 RESTRICT_TAG_SZ_SMALL = 0x02
157 RESTRICT_TAG_SZ_TINY = 0x03
158
159 RESTRICT_TEXT_ENC_NONE = 0x00
160 RESTRICT_TEXT_ENC_UTF8 = 0x01
161
162 RESTRICT_TEXT_LEN_NONE = 0x00
163 RESTRICT_TEXT_LEN_1024 = 0x01
164 RESTRICT_TEXT_LEN_128 = 0x02
165 RESTRICT_TEXT_LEN_30 = 0x03
166
167 RESTRICT_IMG_ENC_NONE = 0x00
168 RESTRICT_IMG_ENC_PNG_JPG = 0x01
169
170 RESTRICT_IMG_SZ_NONE = 0x00
171 RESTRICT_IMG_SZ_256 = 0x01
172 RESTRICT_IMG_SZ_64 = 0x02
173 RESTRICT_IMG_SZ_64_EXACT = 0x03
174
175 def __init__(self):
176 self.size = 0
177 self._flags = 0
178 self.crc = None
179 self._restrictions = 0
180
181 @property
182 def update_bit(self):
183 return bool(self._flags & 0x40)
184
185 @update_bit.setter
186 def update_bit(self, v):
187 if v:
188 self._flags |= 0x40
189 else:
190 self._flags &= ~0x40
191
192 @property
193 def crc_bit(self):
194 return bool(self._flags & 0x20)
195
196 @crc_bit.setter
197 def crc_bit(self, v):
198 if v:
199 self._flags |= 0x20
200 else:
201 self._flags &= ~0x20
202
203 @property
204 def crc(self):
205 return self._crc
206
207 @crc.setter
208 def crc(self, v):
209 self.crc_bit = 1 if v else 0
210 self._crc = v
211
212 @property
213 def restrictions_bit(self):
214 return bool(self._flags & 0x10)
215
216 @restrictions_bit.setter
217 def restrictions_bit(self, v):
218 if v:
219 self._flags |= 0x10
220 else:
221 self._flags &= ~0x10
222
223 @property
224 def tag_size_restriction(self):
225 return self._restrictions >> 6
226
227 @tag_size_restriction.setter
228 def tag_size_restriction(self, v):
229 assert(v >= 0 and v <= 3)
230 self.restrictions_bit = 1
231 self._restrictions = (v << 6) | (self._restrictions & 0x3f)
232
233 @property
234 def tag_size_restriction_description(self):
235 val = self.tag_size_restriction
236 if val == ExtendedTagHeader.RESTRICT_TAG_SZ_LARGE:
237 return "No more than 128 frames and 1 MB total tag size"
238 elif val == ExtendedTagHeader.RESTRICT_TAG_SZ_MED:
239 return "No more than 64 frames and 128 KB total tag size"
240 elif val == ExtendedTagHeader.RESTRICT_TAG_SZ_SMALL:
241 return "No more than 32 frames and 40 KB total tag size"
242 elif val == ExtendedTagHeader.RESTRICT_TAG_SZ_TINY:
243 return "No more than 32 frames and 4 KB total tag size"
244
245 @property
246 def text_enc_restriction(self):
247 return (self._restrictions & 0x20) >> 5
248
249 @text_enc_restriction.setter
250 def text_enc_restriction(self, v):
251 assert(v == 0 or v == 1)
252 self.restrictions_bit = 1
253 self._restrictions ^= 0x20
254
255 @property
256 def text_enc_restriction_description(self):
257 if self.text_enc_restriction:
258 return "Strings are only encoded with ISO-8859-1 or UTF-8"
259 else:
260 return "None"
261
262 @property
263 def text_length_restriction(self):
264 return (self._restrictions >> 3) & 0x03
265
266 @text_length_restriction.setter
267 def text_length_restriction(self, v):
268 assert(v >= 0 and v <= 3)
269 self.restrictions_bit = 1
270 self._restrictions = (v << 3) | (self._restrictions & 0xe7)
271
272 @property
273 def text_length_restriction_description(self):
274 val = self.text_length_restriction
275 if val == ExtendedTagHeader.RESTRICT_TEXT_LEN_NONE:
276 return "None"
277 elif val == ExtendedTagHeader.RESTRICT_TEXT_LEN_1024:
278 return "No string is longer than 1024 characters."
279 elif val == ExtendedTagHeader.RESTRICT_TEXT_LEN_128:
280 return "No string is longer than 128 characters."
281 elif val == ExtendedTagHeader.RESTRICT_TEXT_LEN_30:
282 return "No string is longer than 30 characters."
283
284 @property
285 def image_enc_restriction(self):
286 return (self._restrictions & 0x04) >> 2
287
288 @image_enc_restriction.setter
289 def image_enc_restriction(self, v):
290 assert(v == 0 or v == 1)
291 self.restrictions_bit = 1
292 self._restrictions ^= 0x04
293
294 @property
295 def image_enc_restriction_description(self):
296 if self.image_enc_restriction:
297 return "Images are encoded only with PNG [PNG] or JPEG [JFIF]."
298 else:
299 return "None"
300
301 @property
302 def image_size_restriction(self):
303 return self._restrictions & 0x03
304
305 @image_size_restriction.setter
306 def image_size_restriction(self, v):
307 assert(v >= 0 and v <= 3)
308 self.restrictions_bit = 1
309 self._restrictions = v | (self._restrictions & 0xfc)
310
311 @property
312 def image_size_restriction_description(self):
313 val = self.image_size_restriction
314 if val == ExtendedTagHeader.RESTRICT_IMG_SZ_NONE:
315 return "None"
316 elif val == ExtendedTagHeader.RESTRICT_IMG_SZ_256:
317 return "All images are 256x256 pixels or smaller."
318 elif val == ExtendedTagHeader.RESTRICT_IMG_SZ_64:
319 return "All images are 64x64 pixels or smaller."
320 elif val == ExtendedTagHeader.RESTRICT_IMG_SZ_64_EXACT:
321 return "All images are exactly 64x64 pixels, unless required "\
322 "otherwise."
323
324 def _syncsafeCRC(self):
325 bites = b""
326 bites += compat.chr((self.crc >> 28) & 0x7f)
327 bites += compat.chr((self.crc >> 21) & 0x7f)
328 bites += compat.chr((self.crc >> 14) & 0x7f)
329 bites += compat.chr((self.crc >> 7) & 0x7f)
330 bites += compat.chr((self.crc >> 0) & 0x7f)
331 return bites
332
333 def render(self, version, frame_data, padding=0):
334 assert(version[0] == 2)
335
336 data = b""
337 if version[1] == 4:
338 # Version 2.4
339 size = 6
340 # Extended flags.
341 if self.update_bit:
342 data += b"\x00"
343 if self.crc_bit:
344 data += b"\x05"
345 # XXX: Using the absolute value of the CRC. The spec is unclear
346 # about the type of this data.
347 self.crc = int(math.fabs(binascii.crc32(frame_data +
348 (b"\x00" * padding))))
349 crc_data = self._syncsafeCRC()
350 if len(crc_data) < 5:
351 # pad if necessary
352 crc_data = (b"\x00" * (5 - len(crc_data))) + crc_data
353 assert(len(crc_data) == 5)
354 data += crc_data
355 if self.restrictions_bit:
356 data += b"\x01"
357 data += compat.chr(self._restrictions)
358 log.debug("Rendered extended header data (%d bytes)" % len(data))
359
360 # Extended header size.
361 size = bin2bytes(bin2synchsafe(dec2bin(len(data) + 6, 32)))
362 assert(len(size) == 4)
363
364 data = size + b"\x01" + bin2bytes(dec2bin(self._flags)) + data
365 log.debug("Rendered extended header of size %d" % len(data))
366 else:
367 # Version 2.3
368 size = 6 # Note, the 4 size bytes are not included in the size
369 # Extended flags.
370 f = [0] * 16
371 crc = None
372 if self.crc_bit:
373 f[0] = 1
374 # XXX: Using the absolute value of the CRC. The spec is unclear
375 # about the type of this value.
376 self.crc = int(math.fabs(binascii.crc32(frame_data +
377 (b"\x00" * padding))))
378 crc = bin2bytes(dec2bin(self.crc))
379 assert(len(crc) == 4)
380 size += 4
381 flags = bin2bytes(f)
382 assert(len(flags) == 2)
383 # Extended header size.
384 size = bin2bytes(dec2bin(size, 32))
385 assert(len(size) == 4)
386 # Padding size
387 padding_size = bin2bytes(dec2bin(padding, 32))
388
389 data = size + flags + padding_size
390 if crc:
391 data += crc
392
393 return data
394
395 # Only call this when you *know* there is an extened header.
396 def parse(self, fp, version):
397 '''Parse an ID3 v2 extended header starting at the current position
398 of ``fp`` and per the format defined by ``version``. This method
399 should only be called when the presence of an extended header is known
400 since it moves the file position. If a header is found but malformed
401 an ``eyed3.id3.tag.TagException`` is thrown. The return value is
402 ``None``.
403 '''
404 from .tag import TagException
405 assert(version[0] == 2)
406
407 log.debug("Parsing extended header @ 0x%x" % fp.tell())
408 # First 4 bytes is the size of the extended header.
409 data = fp.read(4)
410 if version[1] == 4:
411 # sync-safe
412 sz = bin2dec(bytes2bin(data, 7))
413 self.size = sz
414 log.debug("Extended header size (includes the 4 size bytes): %d" %
415 sz)
416 data = fp.read(sz - 4)
417
418 # Number of flag bytes
419 if byteOrd(data[0]) != 1 or (byteOrd(data[1]) & 0x8f):
420 # As of 2.4 the first byte is 1 and the second can only have
421 # bits 6, 5, and 4 set.
422 raise TagException("Invalid Extended Header")
423
424 self._flags = byteOrd(data[1])
425 log.debug("Extended header flags: %x" % self._flags)
426
427 offset = 2
428 if self.update_bit:
429 log.debug("Extended header has update bit set")
430 assert(byteOrd(data[offset]) == 0)
431 offset += 1
432 if self.crc_bit:
433 log.debug("Extended header has CRC bit set")
434 assert(byteOrd(data[offset]) == 5)
435 offset += 1
436 crc_data = data[offset:offset + 5]
437 # This is sync-safe.
438 self.crc = bin2dec(bytes2bin(crc_data, 7))
439 log.debug("Extended header CRC: %d" % self.crc)
440 offset += 5
441 if self.restrictions_bit:
442 log.debug("Extended header has restrictions bit set")
443 assert(byteOrd(data[offset]) == 1)
444 offset += 1
445 self._restrictions = byteOrd(data[offset])
446 offset += 1
447 else:
448 # v2.3 is totally different... *sigh*
449 sz = bin2dec(bytes2bin(data))
450 self.size = sz
451 log.debug("Extended header size (not including 4 size bytes): %d" %
452 sz)
453 tmpFlags = fp.read(2)
454 # Read the padding size, but it'll be computed during the parse.
455 ps = fp.read(4)
456 log.debug("Extended header says there is %d bytes of padding" %
457 bin2dec(bytes2bin(ps)))
458 # Make this look like a v2.4 mask.
459 self._flags = byteOrd(tmpFlags[0]) >> 2
460 if self.crc_bit:
461 log.debug("Extended header has CRC bit set")
462 crc_data = fp.read(4)
463 self.crc = bin2dec(bytes2bin(crc_data))
464 log.debug("Extended header CRC: %d" % self.crc)
465
466
467 class FrameHeader(object):
468 '''A header for each and every ID3 frame in a tag.'''
469
470 # 2.4 not only added flag bits, but also reordered the previously defined
471 # flags. So these are mapped once the ID3 version is known. Access through
472 # 'self', always
473 TAG_ALTER = None
474 FILE_ALTER = None
475 READ_ONLY = None
476 COMPRESSED = None
477 ENCRYPTED = None
478 GROUPED = None
479 UNSYNC = None
480 DATA_LEN = None
481
482 # Constructor.
483 @requireBytes(1)
484 def __init__(self, fid, version):
485 self._version = version
486 self._setBitMask()
487 # _setBitMask will throw if the version is no good
488
489 # Correctly set size of header (v2.2 is smaller)
490 self.size = 10 if self.minor_version != 2 else 6
491
492 # The frame header itself...
493 self.id = fid # First 4 bytes, frame ID
494 self._flags = [0] * 16 # 16 bits, represented here as a list
495 self.data_size = 0 # 4 bytes, size of frame data
496
497 def copyFlags(self, rhs):
498 self.tag_alter = rhs._flags[rhs.TAG_ALTER]
499 self.file_alter = rhs._flags[rhs.FILE_ALTER]
500 self.read_only = rhs._flags[rhs.READ_ONLY]
501 self.compressed = rhs._flags[rhs.COMPRESSED]
502 self.encrypted = rhs._flags[rhs.ENCRYPTED]
503 self.grouped = rhs._flags[rhs.GROUPED]
504 self.unsync = rhs._flags[rhs.UNSYNC]
505 self.data_length_indicator = rhs._flags[rhs.DATA_LEN]
506
507 @property
508 def major_version(self):
509 return self._version[0]
510
511 @property
512 def minor_version(self):
513 return self._version[1]
514
515 @property
516 def version(self):
517 return self._version
518
519 @property
520 def tag_alter(self):
521 return self._flags[self.TAG_ALTER]
522
523 @tag_alter.setter
524 def tag_alter(self, b):
525 self._flags[self.TAG_ALTER] = int(bool(b))
526
527 @property
528 def file_alter(self):
529 return self._flags[self.FILE_ALTER]
530
531 @file_alter.setter
532 def file_alter(self, b):
533 self._flags[self.FILE_ALTER] = int(bool(b))
534
535 @property
536 def read_only(self):
537 return self._flags[self.READ_ONLY]
538
539 @read_only.setter
540 def read_only(self, b):
541 self._flags[self.READ_ONLY] = int(bool(b))
542
543 @property
544 def compressed(self):
545 return self._flags[self.COMPRESSED]
546
547 @compressed.setter
548 def compressed(self, b):
549 self._flags[self.COMPRESSED] = int(bool(b))
550
551 @property
552 def encrypted(self):
553 return self._flags[self.ENCRYPTED]
554
555 @encrypted.setter
556 def encrypted(self, b):
557 self._flags[self.ENCRYPTED] = int(bool(b))
558
559 @property
560 def grouped(self):
561 return self._flags[self.GROUPED]
562
563 @grouped.setter
564 def grouped(self, b):
565 self._flags[self.GROUPED] = int(bool(b))
566
567 @property
568 def unsync(self):
569 return self._flags[self.UNSYNC]
570
571 @unsync.setter
572 def unsync(self, b):
573 self._flags[self.UNSYNC] = int(bool(b))
574
575 @property
576 def data_length_indicator(self):
577 return self._flags[self.DATA_LEN]
578
579 @data_length_indicator.setter
580 def data_length_indicator(self, b):
581 self._flags[self.DATA_LEN] = int(bool(b))
582
583 def _setBitMask(self):
584 major = self.major_version
585 minor = self.minor_version
586
587 # 1.x tags are converted to 2.4 frames internally. These frames are
588 # created with frame flags \x00.
589
590 if (major == 2 and minor in (3, 2)):
591 # v2.2 does not contain flags, but set anyway, as long as the
592 # values remain 0 all is good
593 self.TAG_ALTER = 0
594 self.FILE_ALTER = 1
595 self.READ_ONLY = 2
596 self.COMPRESSED = 8
597 self.ENCRYPTED = 9
598 self.GROUPED = 10
599 # This is not in 2.3 frame header flags, map to unused
600 self.UNSYNC = 14
601 # This is not in 2.3 frame header flags, map to unused
602 self.DATA_LEN = 4
603 elif ((major == 2 and minor == 4) or (major == 1 and minor in (0, 1))):
604 self.TAG_ALTER = 1
605 self.FILE_ALTER = 2
606 self.READ_ONLY = 3
607 self.COMPRESSED = 12
608 self.ENCRYPTED = 13
609 self.GROUPED = 9
610 self.UNSYNC = 14
611 self.DATA_LEN = 15
612 else:
613 raise ValueError("ID3 v" + str(major) + "." + str(minor) +
614 " is not supported.")
615
616 def render(self, data_size):
617 data = b''
618
619 assert(type(self.id) is compat.BytesType)
620 data += self.id
621
622 self.data_size = data_size
623
624 if self.minor_version == 3:
625 data += bin2bytes(dec2bin(data_size, 32))
626 else:
627 data += bin2bytes(bin2synchsafe(dec2bin(data_size, 32)))
628
629 if self.unsync:
630 raise NotImplementedError("eyeD3 does not write (only reads) "
631 "unsync'd data")
632 data += bin2bytes(self._flags)
633
634 return data
635
636 @staticmethod
637 def _parse2_2(f, version):
638 from .frames import map2_2FrameId
639 from .frames import FrameException
640 frame_id_22 = f.read(3)
641 frame_id = map2_2FrameId(frame_id_22)
642 if FrameHeader._isValidFrameId(frame_id):
643 log.debug("FrameHeader [id]: %s (0x%x%x%x)" %
644 (frame_id_22, byteOrd(frame_id_22[0]),
645 byteOrd(frame_id_22[1]), byteOrd(frame_id_22[2])))
646 frame_header = FrameHeader(frame_id, version)
647 # data_size corresponds to the size of the data segment after
648 # encryption, compression, and unsynchronization.
649 sz = f.read(3)
650 frame_header.data_size = bin2dec(bytes2bin(sz, 8))
651 log.debug("FrameHeader [data size]: %d (0x%X)" %
652 (frame_header.data_size, frame_header.data_size))
653 return frame_header
654 elif frame_id == b'\x00\x00\x00':
655 log.debug("FrameHeader: Null frame id found at byte %d" % f.tell())
656 else:
657 core.parseError(FrameException("FrameHeader: Illegal Frame ID: %s" %
658 frame_id))
659
660 return None
661
662 @staticmethod
663 def parse(f, version):
664 from .frames import FrameException
665 log.debug("FrameHeader [start byte]: %d (0x%X)" % (f.tell(),
666 f.tell()))
667 major_version, minor_version = version[:2]
668 if minor_version == 2:
669 return FrameHeader._parse2_2(f, version)
670
671 frame_id = f.read(4)
672 if FrameHeader._isValidFrameId(frame_id):
673 log.debug("FrameHeader [id]: %s (0x%x%x%x%x)" %
674 (frame_id, byteOrd(frame_id[0]), byteOrd(frame_id[1]),
675 byteOrd(frame_id[2]), byteOrd(frame_id[3])))
676 frame_header = FrameHeader(frame_id, version)
677 # data_size corresponds to the size of the data segment after
678 # encryption, compression, and unsynchronization.
679 sz = f.read(4)
680 # In ID3 v2.4 this value became a synch-safe integer, meaning only
681 # the low 7 bits are used per byte.
682 if minor_version == 3:
683 frame_header.data_size = bin2dec(bytes2bin(sz, 8))
684 else:
685 frame_header.data_size = bin2dec(bytes2bin(sz, 7))
686 log.debug("FrameHeader [data size]: %d (0x%X)" %
687 (frame_header.data_size, frame_header.data_size))
688
689 # Frame flags.
690 flags = f.read(2)
691 frame_header._flags = bytes2bin(flags)
692 if log.getEffectiveLevel() <= logging.DEBUG:
693 log.debug("FrameHeader [flags]: ta(%d) fa(%d) ro(%d) co(%d) "
694 "en(%d) gr(%d) un(%d) dl(%d)" %
695 (frame_header.tag_alter,
696 frame_header.file_alter, frame_header.read_only,
697 frame_header.compressed, frame_header.encrypted,
698 frame_header.grouped, frame_header.unsync,
699 frame_header.data_length_indicator))
700 if (frame_header.minor_version >= 4 and frame_header.compressed and
701 not frame_header.data_length_indicator):
702 core.parseError(FrameException("Invalid frame; compressed with "
703 "no data length indicator"))
704
705 return frame_header
706 elif frame_id == b'\x00' * 4:
707 log.debug("FrameHeader: Null frame id found at byte %d" % f.tell())
708 else:
709 core.parseError(FrameException("FrameHeader: Illegal Frame ID: %s" %
710 frame_id))
711
712 return None
713
714 @staticmethod
715 def _isValidFrameId(id):
716 import re
717 return re.compile(b"^[A-Z0-9][A-Z0-9][A-Z0-9][A-Z0-9]$").match(id)
+0
-1892
src/eyed3/id3/tag.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2007-2012 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 import os
19 import string
20 import shutil
21 import tempfile
22 from functools import partial
23 from codecs import ascii_encode
24
25 from ..utils import requireUnicode, chunkCopy, datePicker
26 from .. import core
27 from ..core import TXXX_ALBUM_TYPE, TXXX_ARTIST_ORIGIN, ALBUM_TYPE_IDS
28 from .. import Error
29 from . import (ID3_ANY_VERSION, ID3_V1, ID3_V1_0, ID3_V1_1,
30 ID3_V2, ID3_V2_2, ID3_V2_3, ID3_V2_4, versionToString)
31 from . import DEFAULT_LANG
32 from . import Genre
33 from . import frames
34 from .headers import TagHeader, ExtendedTagHeader
35 from .. import compat
36 from ..compat import StringTypes, BytesType, unicode, UnicodeType, b
37
38 from ..utils.log import getLogger
39 log = getLogger(__name__)
40
41
42 class TagException(Error):
43 pass
44
45
46 ID3_V1_COMMENT_DESC = u"ID3v1.x Comment"
47 ID3_V1_MAX_TEXTLEN = 30
48 DEFAULT_PADDING = 256
49
50
51 class Tag(core.Tag):
52 def __init__(self, **kwargs):
53 self.clear()
54 core.Tag.__init__(self, **kwargs)
55
56 def clear(self):
57 """Reset all tag data."""
58 # ID3 tag header
59 self.header = TagHeader()
60 # Optional extended header in v2 tags.
61 self.extended_header = ExtendedTagHeader()
62 # Contains the tag's frames. ID3v1 fields are read and converted
63 # the the corresponding v2 frame.
64 self.frame_set = frames.FrameSet()
65 self._comments = CommentsAccessor(self.frame_set)
66 self._images = ImagesAccessor(self.frame_set)
67 self._lyrics = LyricsAccessor(self.frame_set)
68 self._objects = ObjectsAccessor(self.frame_set)
69 self._privates = PrivatesAccessor(self.frame_set)
70 self._user_texts = UserTextsAccessor(self.frame_set)
71 self._unique_file_ids = UniqueFileIdAccessor(self.frame_set)
72 self._user_urls = UserUrlsAccessor(self.frame_set)
73 self._chapters = ChaptersAccessor(self.frame_set)
74 self._tocs = TocAccessor(self.frame_set)
75 self._popularities = PopularitiesAccessor(self.frame_set)
76 self.file_info = None
77
78 def parse(self, fileobj, version=ID3_ANY_VERSION):
79 assert(fileobj)
80 self.clear()
81 version = version or ID3_ANY_VERSION
82
83 close_file = False
84 try:
85 filename = fileobj.name
86 except AttributeError:
87 if type(fileobj) in StringTypes:
88 filename = fileobj
89 fileobj = open(filename, "rb")
90 close_file = True
91 else:
92 raise ValueError("Invalid type: %s" % str(type(fileobj)))
93
94 self.file_info = FileInfo(filename)
95
96 try:
97 tag_found = False
98 padding = 0
99 # The & is for supporting the "meta" versions, any, etc.
100 if version[0] & 2:
101 tag_found, padding = self._loadV2Tag(fileobj)
102
103 if not tag_found and version[0] & 1:
104 tag_found, padding = self._loadV1Tag(fileobj)
105 if tag_found:
106 self.extended_header = None
107
108 if tag_found and self.isV2:
109 self.file_info.tag_size = (TagHeader.SIZE +
110 self.header.tag_size)
111 if tag_found:
112 self.file_info.tag_padding_size = padding
113
114 finally:
115 if close_file:
116 fileobj.close()
117
118 return tag_found
119
120 def _loadV2Tag(self, fp):
121 """Returns (tag_found, padding_len)"""
122 padding = 0
123 # Look for a tag and if found load it.
124 if not self.header.parse(fp):
125 return (False, 0)
126
127 # Read the extended header if present.
128 if self.header.extended:
129 self.extended_header.parse(fp, self.header.version)
130
131 # Header is definitely there so at least one frame *must* follow.
132 padding = self.frame_set.parse(fp, self.header,
133 self.extended_header)
134
135 log.debug("Tag contains %d bytes of padding." % padding)
136 return (True, padding)
137
138 def _loadV1Tag(self, fp):
139 v1_enc = "latin1"
140
141 # Seek to the end of the file where all v1x tags are written.
142 # v1.x tags are 128 bytes min and max
143 fp.seek(0, 2)
144 if fp.tell() < 128:
145 return (False, 0)
146 fp.seek(-128, 2)
147 tag_data = fp.read(128)
148
149 if tag_data[0:3] != b"TAG":
150 return (False, 0)
151
152 log.debug("Located ID3 v1 tag")
153 # v1.0 is implied until a v1.1 feature is recognized.
154 self.version = ID3_V1_0
155
156 STRIP_CHARS = compat.b(string.whitespace) + b"\x00"
157 title = tag_data[3:33].strip(STRIP_CHARS)
158 log.debug("Tite: %s" % title)
159 if title:
160 self.title = unicode(title, v1_enc)
161
162 artist = tag_data[33:63].strip(STRIP_CHARS)
163 log.debug("Artist: %s" % artist)
164 if artist:
165 self.artist = unicode(artist, v1_enc)
166
167 album = tag_data[63:93].strip(STRIP_CHARS)
168 log.debug("Album: %s" % album)
169 if album:
170 self.album = unicode(album, v1_enc)
171
172 year = tag_data[93:97].strip(STRIP_CHARS)
173 log.debug("Year: %s" % year)
174 try:
175 if year and int(year):
176 # Values here typically mean the year of release
177 self.release_date = int(year)
178 except ValueError:
179 # Bogus year strings.
180 log.warn("ID3v1.x tag contains invalid year: %s" % year)
181 pass
182
183 # Can't use STRIP_CHARS here, since the final byte is numeric
184 comment = tag_data[97:127].rstrip(b"\x00")
185 # Track numbers stuffed in the comment field is what makes v1.1
186 if comment:
187 if (len(comment) >= 2 and
188 # Python the slices (the chars), so this is really
189 # comment[2] and comment[-1]
190 comment[-2:-1] == b"\x00" and comment[-1:] != b"\x00"):
191 log.debug("Track Num found, setting version to v1.1")
192 self.version = ID3_V1_1
193
194 track = compat.byteOrd(comment[-1])
195 self.track_num = (track, None)
196 log.debug("Track: " + str(track))
197 comment = comment[:-2].strip(STRIP_CHARS)
198
199 # There may only have been a track #
200 if comment:
201 log.debug("Comment: %s" % comment)
202 self.comments.set(unicode(comment, v1_enc), ID3_V1_COMMENT_DESC)
203
204 genre = ord(tag_data[127:128])
205 log.debug("Genre ID: %d" % genre)
206 try:
207 self.genre = genre
208 except ValueError as ex:
209 log.warning(ex)
210 self.genre = None
211
212 return (True, 0)
213
214 @property
215 def version(self):
216 return self.header.version
217
218 @version.setter
219 def version(self, v):
220 # Tag version changes required possible frame conversion
221 std, non = self._checkForConversions(v)
222 converted = []
223 if non:
224 converted = self._convertFrames(std, non, v)
225 if converted:
226 self.frame_set.clear()
227 for frame in (std + converted):
228 self.frame_set[frame.id] = frame
229
230 self.header.version = v
231
232 def isV1(self):
233 """Test ID3 major version for v1.x"""
234 return self.header.major_version == 1
235
236 def isV2(self):
237 """Test ID3 major version for v2.x"""
238 return self.header.major_version == 2
239
240 @requireUnicode(2)
241 def setTextFrame(self, fid, txt):
242 fid = b(fid, ascii_encode)
243 if not fid.startswith(b"T") or fid.startswith(b"TX"):
244 raise ValueError("Invalid frame-id for text frame")
245
246 if not txt and self.frame_set[fid]:
247 del self.frame_set[fid]
248 elif txt:
249 self.frame_set.setTextFrame(fid, txt)
250
251 def getTextFrame(self, fid):
252 fid = b(fid, ascii_encode)
253 if not fid.startswith(b"T") or fid.startswith(b"TX"):
254 raise ValueError("Invalid frame-id for text frame")
255 f = self.frame_set[fid]
256 return f[0].text if f else None
257
258 @requireUnicode(1)
259 def _setArtist(self, val):
260 self.setTextFrame(frames.ARTIST_FID, val)
261
262 def _getArtist(self):
263 return self.getTextFrame(frames.ARTIST_FID)
264
265 @requireUnicode(1)
266 def _setAlbumArtist(self, val):
267 self.setTextFrame(frames.ALBUM_ARTIST_FID, val)
268
269 def _getAlbumArtist(self):
270 return self.getTextFrame(frames.ALBUM_ARTIST_FID)
271
272 @requireUnicode(1)
273 def _setComposer(self, val):
274 self.setTextFrame(frames.COMPOSER_FID, val)
275
276 def _getComposer(self):
277 return self.getTextFrame(frames.COMPOSER_FID)
278
279 @property
280 def composer(self):
281 return self._getComposer()
282
283 @composer.setter
284 def composer(self, v):
285 self._setComposer(v)
286
287 @requireUnicode(1)
288 def _setAlbum(self, val):
289 self.setTextFrame(frames.ALBUM_FID, val)
290
291 def _getAlbum(self):
292 return self.getTextFrame(frames.ALBUM_FID)
293
294 @requireUnicode(1)
295 def _setTitle(self, val):
296 self.setTextFrame(frames.TITLE_FID, val)
297
298 def _getTitle(self):
299 return self.getTextFrame(frames.TITLE_FID)
300
301 def _setTrackNum(self, val):
302 self._setNum(frames.TRACKNUM_FID, val)
303
304 def _getTrackNum(self):
305 return self._splitNum(frames.TRACKNUM_FID)
306
307 def _splitNum(self, fid):
308 f = self.frame_set[fid]
309 first, second = None, None
310 if f and f[0].text:
311 n = f[0].text.split('/')
312 try:
313 first = int(n[0])
314 second = int(n[1]) if len(n) == 2 else None
315 except ValueError as ex:
316 log.warning(str(ex))
317 return (first, second)
318
319 def _setNum(self, fid, val):
320 tn, tt = None, None
321 if type(val) is tuple:
322 tn, tt = val
323 elif type(val) is int:
324 tn, tt = val, None
325 elif val is None:
326 tn, tt = None, None
327
328 n = (tn, tt)
329
330 if n[0] is None and n[1] is None:
331 if self.frame_set[fid]:
332 del self.frame_set[fid]
333 return
334
335 total_str = ""
336 if n[1] is not None:
337 if n[1] >= 0 and n[1] <= 9:
338 total_str = "0" + str(n[1])
339 else:
340 total_str = str(n[1])
341
342 t = n[0] if n[0] else 0
343 track_str = str(t)
344
345 # Pad with zeros according to how large the total count is.
346 if len(track_str) == 1:
347 track_str = "0" + track_str
348 if len(track_str) < len(total_str):
349 track_str = ("0" * (len(total_str) - len(track_str))) + track_str
350
351 final_str = ""
352 if track_str and total_str:
353 final_str = "%s/%s" % (track_str, total_str)
354 elif track_str and not total_str:
355 final_str = track_str
356
357 self.frame_set.setTextFrame(fid, unicode(final_str))
358
359 @property
360 def comments(self):
361 return self._comments
362
363 def _getBpm(self):
364 from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
365
366 bpm = None
367 if frames.BPM_FID in self.frame_set:
368 bpm_str = self.frame_set[frames.BPM_FID][0].text or u"0"
369 try:
370 # Round floats since the spec says this is an integer. Python3
371 # changed how 'round' works, hence the using of decimal
372 bpm = int(Decimal(bpm_str).quantize(1, ROUND_HALF_UP))
373 except (InvalidOperation, ValueError) as ex:
374 log.warning(ex)
375 return bpm
376
377 def _setBpm(self, bpm):
378 assert(bpm >= 0)
379 self.setTextFrame(frames.BPM_FID, unicode(str(bpm)))
380
381 bpm = property(_getBpm, _setBpm)
382
383 @property
384 def play_count(self):
385 if frames.PLAYCOUNT_FID in self.frame_set:
386 pc = self.frame_set[frames.PLAYCOUNT_FID][0]
387 return pc.count
388 else:
389 return None
390
391 @play_count.setter
392 def play_count(self, count):
393 if count is None:
394 del self.frame_set[frames.PLAYCOUNT_FID]
395 return
396
397 if count < 0:
398 raise ValueError("Invalid play count value: %d" % count)
399
400 if self.frame_set[frames.PLAYCOUNT_FID]:
401 pc = self.frame_set[frames.PLAYCOUNT_FID][0]
402 pc.count = count
403 else:
404 self.frame_set[frames.PLAYCOUNT_FID] = \
405 frames.PlayCountFrame(count=count)
406
407 def _getPublisher(self):
408 if frames.PUBLISHER_FID in self.frame_set:
409 pub = self.frame_set[frames.PUBLISHER_FID]
410 return pub[0].text
411 else:
412 return None
413
414 @requireUnicode(1)
415 def _setPublisher(self, p):
416 self.setTextFrame(frames.PUBLISHER_FID, p)
417
418 publisher = property(_getPublisher, _setPublisher)
419
420 @property
421 def cd_id(self):
422 if frames.CDID_FID in self.frame_set:
423 return self.frame_set[frames.CDID_FID][0].toc
424 else:
425 return None
426
427 @cd_id.setter
428 def cd_id(self, toc):
429 if len(toc) > 804:
430 raise ValueError("CD identifier table of contents can be no "
431 "greater than 804 bytes")
432
433 if self.frame_set[frames.CDID_FID]:
434 cdid = self.frame_set[frames.CDID_FID][0]
435 cdid.toc = BytesType(toc)
436 else:
437 self.frame_set[frames.CDID_FID] = \
438 frames.MusicCDIdFrame(toc=toc)
439
440 @property
441 def images(self):
442 return self._images
443
444 def _getEncodingDate(self):
445 return self._getDate(b"TDEN")
446
447 def _setEncodingDate(self, date):
448 self._setDate(b"TDEN", date)
449 encoding_date = property(_getEncodingDate, _setEncodingDate)
450
451 @property
452 def best_release_date(self):
453 """This method tries its best to return a date of some sort, amongst
454 alll the possible date frames. The order of preference for a release
455 date is 1) date of original release 2) date of this versions release
456 3) the recording date. Or None is returned."""
457 import warnings
458 warnings.warn("Use Tag.getBestDate() instead", DeprecationWarning,
459 stacklevel=2)
460 return (self.original_release_date or
461 self.release_date or
462 self.recording_date)
463
464 def getBestDate(self, prefer_recording_date=False):
465 """This method returns a date of some sort, amongst all the possible
466 date frames. The order of preference is:
467
468 1) date of original release
469 2) date of this versions release
470 3) the recording date.
471
472 Unless ``prefer_recording_date`` is ``True`` in which case the order is
473 3, 1, 2.
474
475 ``None`` will be returned if no dates are available."""
476 return datePicker(self, prefer_recording_date)
477
478 def _getReleaseDate(self):
479 return self._getDate(b"TDRL") if self.version == ID3_V2_4 \
480 else self._getV23OrignalReleaseDate()
481
482 def _setReleaseDate(self, date):
483 self._setDate(b"TDRL" if self.version == ID3_V2_4 else b"TORY", date)
484
485 release_date = property(_getReleaseDate, _setReleaseDate)
486 """The date the audio was released. This is NOT the original date the
487 work was released, instead it is more like the pressing or version of the
488 release. Original release date is usually what is intended but many programs
489 use this frame and/or don't distinguish between the two."""
490
491 def _getOrigReleaseDate(self):
492 return self._getDate(b"TDOR") or self._getV23OrignalReleaseDate()
493
494 def _setOrigReleaseDate(self, date):
495 self._setDate(b"TDOR", date)
496
497 original_release_date = property(_getOrigReleaseDate, _setOrigReleaseDate)
498 """The date the work was originally released."""
499
500 def _getRecordingDate(self):
501 return self._getDate(b"TDRC") or self._getV23RecordingDate()
502
503 def _setRecordingDate(self, date):
504 if date in (None, ""):
505 for fid in (b"TDRC", b"TYER", b"TDAT", b"TIME"):
506 self._setDate(fid, None)
507 elif self.version == ID3_V2_4:
508 self._setDate(b"TDRC", date)
509 else:
510 self._setDate(b"TYER", unicode(date.year))
511 if None not in (date.month, date.day):
512 date_str = u"%s%s" % (str(date.day).rjust(2, "0"),
513 str(date.month).rjust(2, "0"))
514 self._setDate(b"TDAT", date_str)
515 if None not in (date.hour, date.minute):
516 date_str = u"%s%s" % (str(date.hour).rjust(2, "0"),
517 str(date.minute).rjust(2, "0"))
518 self._setDate(b"TIME", date_str)
519
520 recording_date = property(_getRecordingDate, _setRecordingDate)
521 """The date of the recording. Many applications use this for release date
522 regardless of the fact that this value is rarely known, and release dates
523 are more correct."""
524
525 def _getV23RecordingDate(self):
526 # v2.3 TYER (yyyy), TDAT (DDMM), TIME (HHmm)
527 date = None
528 try:
529 date_str = b""
530 if b"TYER" in self.frame_set:
531 date_str = self.frame_set[b"TYER"][0].text.encode("latin1")
532 date = core.Date.parse(date_str)
533 if b"TDAT" in self.frame_set:
534 text = self.frame_set[b"TDAT"][0].text.encode("latin1")
535 date_str += b"-%s-%s" % (text[2:], text[:2])
536 date = core.Date.parse(date_str)
537 if b"TIME" in self.frame_set:
538 text = self.frame_set[b"TIME"][0].text.encode("latin1")
539 date_str += b"T%s:%s" % (text[:2], text[2:])
540 date = core.Date.parse(date_str)
541 except ValueError as ex:
542 log.warning("Invalid v2.3 TYER, TDAT, or TIME frame: %s" % ex)
543
544 return date
545
546 def _getV23OrignalReleaseDate(self):
547 date, date_str = None, None
548 try:
549 for fid in (b"XDOR", b"TORY"):
550 # Prefering XDOR over TORY since it can contain full date.
551 if fid in self.frame_set:
552 date_str = self.frame_set[fid][0].text.encode("latin1")
553 break
554 if date_str:
555 date = core.Date.parse(date_str)
556 except ValueError as ex:
557 log.warning("Invalid v2.3 TORY/XDOR frame: %s" % ex)
558
559 return date
560
561 def _getTaggingDate(self):
562 return self._getDate(b"TDTG")
563
564 def _setTaggingDate(self, date):
565 self._setDate(b"TDTG", date)
566 tagging_date = property(_getTaggingDate, _setTaggingDate)
567
568 def _setDate(self, fid, date):
569 assert(fid in frames.DATE_FIDS or
570 fid in frames.DEPRECATED_DATE_FIDS)
571
572 if date in (None, ""):
573 try:
574 del self.frame_set[fid]
575 except KeyError:
576 pass
577 return
578
579 # Special casing the conversion to DATE objects cuz TDAT and TIME won't
580 if fid not in (b"TDAT", b"TIME"):
581 # Convert to ISO format which is what FrameSet wants.
582 date_type = type(date)
583 if date_type is int:
584 # The integer year
585 date = core.Date(date)
586 elif date_type in StringTypes:
587 date = core.Date.parse(date)
588 elif not isinstance(date, core.Date):
589 raise TypeError("Invalid type: %s" % str(type(date)))
590
591 date_text = unicode(str(date))
592 if fid in self.frame_set:
593 self.frame_set[fid][0].date = date
594 else:
595 self.frame_set[fid] = frames.DateFrame(fid, date_text)
596
597 def _getDate(self, fid):
598 if fid in (b"TORY", b"XDOR"):
599 return self._getV23OrignalReleaseDate()
600
601 if fid in self.frame_set:
602 if fid in (b"TYER", b"TDAT", b"TIME"):
603 if fid == b"TYER":
604 # Contain years only, date conversion can happen
605 return core.Date(int(self.frame_set[fid][0].text))
606 else:
607 return self.frame_set[fid][0].text
608 else:
609 return self.frame_set[fid][0].date
610 else:
611 return None
612
613 @property
614 def lyrics(self):
615 return self._lyrics
616
617 @property
618 def disc_num(self):
619 return self._splitNum(frames.DISCNUM_FID)
620
621 @disc_num.setter
622 def disc_num(self, val):
623 self._setNum(frames.DISCNUM_FID, val)
624
625 @property
626 def objects(self):
627 return self._objects
628
629 @property
630 def privates(self):
631 return self._privates
632
633 @property
634 def popularities(self):
635 return self._popularities
636
637 def _getGenre(self, id3_std=True):
638 f = self.frame_set[frames.GENRE_FID]
639 if f and f[0].text:
640 try:
641 return Genre.parse(f[0].text, id3_std=id3_std)
642 except ValueError:
643 return None
644 else:
645 return None
646
647 def _setGenre(self, g, id3_std=True):
648 """Set the genre.
649 Four types are accepted for the ``g`` argument.
650 A Genre object, an acceptable (see Genre.parse) genre string,
651 or an integer genre ID all will set the value. A value of None will
652 remove the genre."""
653 if g is None:
654 if self.frame_set[frames.GENRE_FID]:
655 del self.frame_set[frames.GENRE_FID]
656 return
657
658 if isinstance(g, unicode):
659 g = Genre.parse(g, id3_std=id3_std)
660 elif isinstance(g, int):
661 g = Genre(id=g)
662 elif not isinstance(g, Genre):
663 raise TypeError("Invalid genre data type: %s" % str(type(g)))
664 self.frame_set.setTextFrame(frames.GENRE_FID, unicode(g))
665 genre = property(_getGenre, _setGenre)
666 """genre property."""
667 non_std_genre = property(partial(_getGenre, id3_std=False),
668 partial(_setGenre, id3_std=False))
669 """Non-standard genres."""
670
671 @property
672 def user_text_frames(self):
673 return self._user_texts
674
675 def _setUrlFrame(self, fid, url):
676 if fid not in frames.URL_FIDS:
677 raise ValueError("Invalid URL frame-id")
678
679 if self.frame_set[fid]:
680 if not url:
681 del self.frame_set[fid]
682 else:
683 self.frame_set[fid][0].url = url
684 else:
685 self.frame_set[fid] = frames.UrlFrame(fid, url)
686
687 def _getUrlFrame(self, fid):
688 if fid not in frames.URL_FIDS:
689 raise ValueError("Invalid URL frame-id")
690 f = self.frame_set[fid]
691 return f[0].url if f else None
692
693 @property
694 def commercial_url(self):
695 return self._getUrlFrame(frames.URL_COMMERCIAL_FID)
696
697 @commercial_url.setter
698 def commercial_url(self, url):
699 self._setUrlFrame(frames.URL_COMMERCIAL_FID, url)
700
701 @property
702 def copyright_url(self):
703 return self._getUrlFrame(frames.URL_COPYRIGHT_FID)
704
705 @copyright_url.setter
706 def copyright_url(self, url):
707 self._setUrlFrame(frames.URL_COPYRIGHT_FID, url)
708
709 @property
710 def audio_file_url(self):
711 return self._getUrlFrame(frames.URL_AUDIOFILE_FID)
712
713 @audio_file_url.setter
714 def audio_file_url(self, url):
715 self._setUrlFrame(frames.URL_AUDIOFILE_FID, url)
716
717 @property
718 def audio_source_url(self):
719 return self._getUrlFrame(frames.URL_AUDIOSRC_FID)
720
721 @audio_source_url.setter
722 def audio_source_url(self, url):
723 self._setUrlFrame(frames.URL_AUDIOSRC_FID, url)
724
725 @property
726 def artist_url(self):
727 return self._getUrlFrame(frames.URL_ARTIST_FID)
728
729 @artist_url.setter
730 def artist_url(self, url):
731 self._setUrlFrame(frames.URL_ARTIST_FID, url)
732
733 @property
734 def internet_radio_url(self):
735 return self._getUrlFrame(frames.URL_INET_RADIO_FID)
736
737 @internet_radio_url.setter
738 def internet_radio_url(self, url):
739 self._setUrlFrame(frames.URL_INET_RADIO_FID, url)
740
741 @property
742 def payment_url(self):
743 return self._getUrlFrame(frames.URL_PAYMENT_FID)
744
745 @payment_url.setter
746 def payment_url(self, url):
747 self._setUrlFrame(frames.URL_PAYMENT_FID, url)
748
749 @property
750 def publisher_url(self):
751 return self._getUrlFrame(frames.URL_PUBLISHER_FID)
752
753 @publisher_url.setter
754 def publisher_url(self, url):
755 self._setUrlFrame(frames.URL_PUBLISHER_FID, url)
756
757 @property
758 def user_url_frames(self):
759 return self._user_urls
760
761 @property
762 def unique_file_ids(self):
763 return self._unique_file_ids
764
765 @property
766 def terms_of_use(self):
767 if self.frame_set[frames.TOS_FID]:
768 return self.frame_set[frames.TOS_FID][0].text
769
770 @terms_of_use.setter
771 def terms_of_use(self, tos):
772 """Set the terms of use text.
773 To specify a language (other than DEFAULT_LANG) code with the text pass
774 a tuple:
775 (text, lang)
776 Language codes are 3 *bytes* of ascii data.
777 """
778 if isinstance(tos, tuple):
779 tos, lang = tos
780 else:
781 lang = DEFAULT_LANG
782 if self.frame_set[frames.TOS_FID]:
783 self.frame_set[frames.TOS_FID][0].text = tos
784 self.frame_set[frames.TOS_FID][0].lang = lang
785 else:
786 self.frame_set[frames.TOS_FID] = frames.TermsOfUseFrame(text=tos,
787 lang=lang)
788
789 def _raiseIfReadonly(self):
790 if self.read_only:
791 raise RuntimeError("Tag is set read only.")
792
793 def save(self, filename=None, version=None, encoding=None, backup=False,
794 preserve_file_time=False, max_padding=None):
795 """Save the tag. If ``filename`` is not give the value from the
796 ``file_info`` member is used, or a ``TagException`` is raised. The
797 ``version`` argument can be used to select an ID3 version other than
798 the version read. ``Select text encoding with ``encoding`` or use
799 the existing (or default) encoding. If ``backup`` is True the orignal
800 file is preserved; likewise if ``preserve_file_time`` is True the
801 file´s modification/access times are not updated.
802 """
803 self._raiseIfReadonly()
804
805 if not (filename or self.file_info):
806 raise TagException("No file")
807 elif filename:
808 self.file_info = FileInfo(filename)
809
810 version = version if version else self.version
811 if version == ID3_V2_2:
812 raise NotImplementedError("Unable to write ID3 v2.2")
813 self.version = version
814
815 if backup and os.path.isfile(self.file_info.name):
816 backup_name = "%s.%s" % (self.file_info.name, "orig")
817 i = 1
818 while os.path.isfile(backup_name):
819 backup_name = "%s.%s.%d" % (self.file_info.name, "orig", i)
820 i += 1
821 shutil.copyfile(self.file_info.name, backup_name)
822
823 if version[0] == 1:
824 self._saveV1Tag(version)
825 elif version[0] == 2:
826 self._saveV2Tag(version, encoding, max_padding)
827 else:
828 assert(not "Version bug: %s" % str(version))
829
830 if preserve_file_time and None not in (self.file_info.atime,
831 self.file_info.mtime):
832 self.file_info.touch((self.file_info.atime, self.file_info.mtime))
833 else:
834 self.file_info.initStatTimes()
835
836 def _saveV1Tag(self, version):
837 self._raiseIfReadonly()
838
839 assert(version[0] == 1)
840
841 def pack(s, n):
842 assert(type(s) is BytesType)
843 if len(s) > n:
844 log.warning("ID3 v1.x text value truncated to length {n}".format(n=n))
845 return s.ljust(n, b'\x00')[:n]
846
847 def encode(s):
848 return s.encode("latin_1", "replace")
849
850 # Build tag buffer.
851 tag = b"TAG"
852 tag += pack(encode(self.title) if self.title else b"", ID3_V1_MAX_TEXTLEN)
853 tag += pack(encode(self.artist) if self.artist else b"", ID3_V1_MAX_TEXTLEN)
854 tag += pack(encode(self.album) if self.album else b"", ID3_V1_MAX_TEXTLEN)
855
856 release_date = self.getBestDate()
857 year = unicode(release_date.year).encode("ascii") if release_date \
858 else b""
859 tag += pack(year, 4)
860
861 cmt = ""
862 for c in self.comments:
863 if c.description == ID3_V1_COMMENT_DESC:
864 cmt = c.text
865 # We prefer this one over ""
866 break
867 elif c.description == u"":
868 cmt = c.text
869 # Keep searching in case we find the description eyeD3 uses.
870 cmt = pack(encode(cmt), ID3_V1_MAX_TEXTLEN)
871
872 if version != ID3_V1_0:
873 track = self.track_num[0]
874 if track is not None:
875 cmt = cmt[0:28] + b"\x00" + compat.chr(int(track) & 0xff)
876 tag += cmt
877
878 if not self.genre or self.genre.id is None:
879 genre = 12 # Other
880 else:
881 genre = self.genre.id
882 tag += compat.chr(genre & 0xff)
883
884 assert(len(tag) == 128)
885
886 mode = "rb+" if os.path.isfile(self.file_info.name) else "w+b"
887 with open(self.file_info.name, mode) as tag_file:
888 # Write the tag over top an original or append it.
889 try:
890 tag_file.seek(-128, 2)
891 if tag_file.read(3) == b"TAG":
892 tag_file.seek(-128, 2)
893 else:
894 tag_file.seek(0, 2)
895 except IOError:
896 # File is smaller than 128 bytes.
897 tag_file.seek(0, 2)
898
899 tag_file.write(tag)
900 tag_file.flush()
901
902 def _checkForConversions(self, target_version):
903 """Check the current frame set against `target_version` for frames
904 requiring conversion.
905 :param: The version the frames need to map to.
906 :returns: A 2-tuple where the first element is a list of frames that
907 are accepted for `target_version`, and the second a list of frames
908 requiring conversion.
909 """
910 std_frames = []
911 non_std_frames = []
912 for f in self.frame_set.getAllFrames():
913 try:
914 _, fversion, _ = frames.ID3_FRAMES[f.id]
915 if fversion in (target_version, ID3_V2):
916 std_frames.append(f)
917 else:
918 non_std_frames.append(f)
919 except KeyError:
920 # Not a standard frame (ID3_FRAMES)
921 try:
922 _, fversion, _ = frames.NONSTANDARD_ID3_FRAMES[f.id]
923 # but is it one we can handle.
924 if fversion in (target_version, ID3_V2):
925 std_frames.append(f)
926 else:
927 non_std_frames.append(f)
928 except KeyError:
929 # Don't know anything about this pass it on for the error
930 # check there.
931 non_std_frames.append(f)
932
933 return std_frames, non_std_frames
934
935 def _render(self, version, curr_tag_size, max_padding_size):
936 converted_frames = []
937 std_frames, non_std_frames = self._checkForConversions(version)
938 if non_std_frames:
939 converted_frames = self._convertFrames(std_frames, non_std_frames,
940 version)
941
942 # Render all frames first so the data size is known for the tag header.
943 frame_data = b""
944 for f in std_frames + converted_frames:
945 frame_header = frames.FrameHeader(f.id, version)
946 if f.header:
947 frame_header.copyFlags(f.header)
948 f.header = frame_header
949
950 log.debug("Rendering frame: %s" % frame_header.id)
951 raw_frame = f.render()
952 log.debug("Rendered %d bytes" % len(raw_frame))
953 frame_data += raw_frame
954
955 log.debug("Rendered %d total frame bytes" % len(frame_data))
956
957 # eyeD3 never writes unsync'd data
958 self.header.unsync = False
959
960 pending_size = TagHeader.SIZE + len(frame_data)
961 if self.header.extended:
962 # Using dummy data and padding, the actual size of this header
963 # will be the same regardless, it's more about the flag bits
964 tmp_ext_header_data = self.extended_header.render(version,
965 b"\x00", 0)
966 pending_size += len(tmp_ext_header_data)
967
968 padding_size = 0
969 if pending_size > curr_tag_size:
970 # current tag (minus padding) larger than the current (plus padding)
971 padding_size = DEFAULT_PADDING
972 rewrite_required = True
973 else:
974 padding_size = curr_tag_size - pending_size
975 if max_padding_size is not None and padding_size > max_padding_size:
976 padding_size = min(DEFAULT_PADDING, max_padding_size)
977 rewrite_required = True
978 else:
979 rewrite_required = False
980
981 assert(padding_size >= 0)
982 log.debug("Using %d bytes of padding" % padding_size)
983
984 # Extended header
985 ext_header_data = b""
986 if self.header.extended:
987 log.debug("Rendering extended header")
988 ext_header_data += self.extended_header.render(self.header.version,
989 frame_data,
990 padding_size)
991
992 # Render the tag header.
993 total_size = pending_size + padding_size
994 log.debug("Rendering %s tag header with size %d" %
995 (versionToString(version),
996 total_size - TagHeader.SIZE))
997 header_data = self.header.render(total_size - TagHeader.SIZE)
998
999 # Assemble the entire tag.
1000 tag_data = (header_data +
1001 ext_header_data +
1002 frame_data)
1003 assert(len(tag_data) == (total_size - padding_size))
1004 return (rewrite_required, tag_data, b"\x00" * padding_size)
1005
1006 def _saveV2Tag(self, version, encoding, max_padding):
1007 self._raiseIfReadonly()
1008
1009 assert(version[0] == 2 and version[1] != 2)
1010
1011 log.debug("Rendering tag version: %s" % versionToString(version))
1012
1013 file_exists = os.path.exists(self.file_info.name)
1014
1015 if encoding:
1016 # Any invalid encoding is going to get coersed to a valid value
1017 # when the frame is rendered.
1018 for f in self.frame_set.getAllFrames():
1019 f.encoding = frames.stringToEncoding(encoding)
1020
1021 curr_tag_size = 0
1022
1023 if file_exists:
1024 # We may be converting from 1.x to 2.x so we need to find any
1025 # current v2.x tag otherwise we're gonna hork the file.
1026 # This also resets all offsets, state, etc. and makes me feel safe.
1027 tmp_tag = Tag()
1028 if tmp_tag.parse(self.file_info.name, ID3_V2):
1029 log.debug("Found current v2.x tag:")
1030 curr_tag_size = tmp_tag.file_info.tag_size
1031 log.debug("Current tag size: %d" % curr_tag_size)
1032
1033 rewrite_required, tag_data, padding = self._render(version,
1034 curr_tag_size,
1035 max_padding)
1036 log.debug("Writing %d bytes of tag data and %d bytes of "
1037 "padding" % (len(tag_data), len(padding)))
1038 if rewrite_required:
1039 # Open tmp file
1040 with tempfile.NamedTemporaryFile("wb", delete=False) \
1041 as tmp_file:
1042 tmp_file.write(tag_data + padding)
1043
1044 # Copy audio data in chunks
1045 with open(self.file_info.name, "rb") as tag_file:
1046 if curr_tag_size != 0:
1047 seek_point = curr_tag_size
1048 else:
1049 seek_point = 0
1050 log.debug("Seeking to beginning of audio data, "
1051 "byte %d (%x)" % (seek_point, seek_point))
1052 tag_file.seek(seek_point)
1053 chunkCopy(tag_file, tmp_file)
1054
1055 tmp_file.flush()
1056
1057 # Move tmp to orig.
1058 shutil.copyfile(tmp_file.name, self.file_info.name)
1059 os.unlink(tmp_file.name)
1060
1061 else:
1062 with open(self.file_info.name, "r+b") as tag_file:
1063 tag_file.write(tag_data + padding)
1064
1065 else:
1066 _, tag_data, padding = self._render(version, 0, None)
1067 with open(self.file_info.name, "wb") as tag_file:
1068 tag_file.write(tag_data + padding)
1069
1070 log.debug("Tag write complete. Updating FileInfo state.")
1071 self.file_info.tag_size = len(tag_data) + len(padding)
1072
1073 def _convertFrames(self, std_frames, convert_list, version):
1074 """Maps frame incompatibilities between ID3 v2.3 and v2.4.
1075 The items in ``std_frames`` need no conversion, but the list/frames
1076 may be edited if necessary (e.g. a converted frame replaces a frame
1077 in the list). The items in ``convert_list`` are the frames to convert
1078 and return. The ``version`` is the target ID3 version."""
1079 from . import versionToString
1080 from .frames import (DATE_FIDS, DEPRECATED_DATE_FIDS,
1081 DateFrame, TextFrame)
1082 converted_frames = []
1083 flist = list(convert_list)
1084
1085 # Date frame conversions.
1086 date_frames = {}
1087 for f in flist:
1088 if version == ID3_V2_4:
1089 if f.id in DEPRECATED_DATE_FIDS:
1090 date_frames[f.id] = f
1091 else:
1092 if f.id in DATE_FIDS:
1093 date_frames[f.id] = f
1094
1095 if date_frames:
1096 def fidHandled(fid):
1097 # A duplicate text frame (illegal ID3 but oft seen) may exist. The date_frames dict
1098 # will have one, but the flist has multiple, hence the loop.
1099 for frame in list(flist):
1100 if frame.id == fid:
1101 flist.remove(frame)
1102 del date_frames[fid]
1103
1104 if version == ID3_V2_4:
1105 if b"TORY" in date_frames or b"XDOR" in date_frames:
1106 # XDOR -> TDOR (full date)
1107 # TORY -> TDOR (year only)
1108 date = self._getV23OrignalReleaseDate()
1109 if date:
1110 converted_frames.append(DateFrame(b"TDOR", date))
1111 for fid in (b"TORY", b"XDOR"):
1112 if fid in flist:
1113 fidHandled(fid)
1114
1115 # TYER, TDAT, TIME -> TDRC
1116 if (b"TYER" in date_frames or b"TDAT" in date_frames or
1117 b"TIME" in date_frames):
1118 date = self._getV23RecordingDate()
1119 if date:
1120 converted_frames.append(DateFrame(b"TDRC", date))
1121 for fid in [b"TYER", b"TDAT", b"TIME"]:
1122 if fid in date_frames:
1123 fidHandled(fid)
1124
1125 elif version == ID3_V2_3:
1126 if b"TDOR" in date_frames:
1127 date = date_frames[b"TDOR"].date
1128 if date:
1129 converted_frames.append(DateFrame(b"TORY",
1130 unicode(date.year)))
1131 fidHandled(b"TDOR")
1132
1133 if b"TDRC" in date_frames:
1134 date = date_frames[b"TDRC"].date
1135
1136 if date:
1137 converted_frames.append(DateFrame(b"TYER",
1138 unicode(date.year)))
1139 if None not in (date.month, date.day):
1140 date_str = u"%s%s" %\
1141 (str(date.day).rjust(2, "0"),
1142 str(date.month).rjust(2, "0"))
1143 converted_frames.append(TextFrame(b"TDAT",
1144 date_str))
1145 if None not in (date.hour, date.minute):
1146 date_str = u"%s%s" %\
1147 (str(date.hour).rjust(2, "0"),
1148 str(date.minute).rjust(2, "0"))
1149 converted_frames.append(TextFrame(b"TIME",
1150 date_str))
1151
1152 fidHandled(b"TDRC")
1153
1154 if b"TDRL" in date_frames:
1155 # TDRL -> XDOR
1156 date = date_frames[b"TDRL"].date
1157 if date:
1158 converted_frames.append(DateFrame(b"XDOR", str(date)))
1159 fidHandled(b"TDRL")
1160
1161 # All other date frames have no conversion
1162 for fid in date_frames:
1163 log.warning("%s frame being dropped due to conversion to %s" %
1164 (fid, versionToString(version)))
1165 flist.remove(date_frames[fid])
1166
1167 # Convert sort order frames 2.3 (XSO*) <-> 2.4 (TSO*)
1168 prefix = b"X" if version == ID3_V2_4 else b"T"
1169 fids = [prefix + suffix
1170 for suffix in [b"SOA", b"SOP", b"SOT"]]
1171 soframes = [f for f in flist if f.id in fids]
1172
1173 for frame in soframes:
1174 frame.id = (b"X" if prefix == b"T" else b"T") + frame.id[1:]
1175 flist.remove(frame)
1176 converted_frames.append(frame)
1177
1178 # TSIZ (v2.3) are completely deprecated, remove them
1179 if version == ID3_V2_4:
1180 flist = [f for f in flist if f.id != b"TSIZ"]
1181
1182 # TSST (v2.4) --> TIT3 (2.3)
1183 if version == ID3_V2_3 and b"TSST" in [f.id for f in flist]:
1184 tsst_frame = [f for f in flist if f.id == b"TSST"][0]
1185 flist.remove(tsst_frame)
1186 tsst_frame = frames.UserTextFrame(
1187 description=u"Subtitle (converted)", text=tsst_frame.text)
1188 converted_frames.append(tsst_frame)
1189
1190 # Raise an error for frames that could not be converted.
1191 if len(flist) != 0:
1192 unconverted = u", ".join([f.id.decode("ascii") for f in flist])
1193 if version[0] != 1:
1194 raise TagException("Unable to convert the following frames to "
1195 "version %s: %s" % (versionToString(version),
1196 unconverted))
1197
1198 # Some frames in converted_frames may replace/edit frames in std_frames.
1199 for cframe in converted_frames:
1200 for sframe in std_frames:
1201 if cframe.id == sframe.id:
1202 std_frames.remove(sframe)
1203
1204 return converted_frames
1205
1206 @staticmethod
1207 def remove(filename, version=ID3_ANY_VERSION, preserve_file_time=False):
1208 retval = False
1209
1210 if version[0] & ID3_V1[0]:
1211 # ID3 v1.x
1212 tag = Tag()
1213 with open(filename, "r+b") as tag_file:
1214 found = tag.parse(tag_file, ID3_V1)
1215 if found:
1216 tag_file.seek(-128, 2)
1217 log.debug("Removing ID3 v1.x Tag")
1218 tag_file.truncate()
1219 retval |= True
1220
1221 if version[0] & ID3_V2[0]:
1222 tag = Tag()
1223 with open(filename, "rb") as tag_file:
1224 found = tag.parse(tag_file, ID3_V2)
1225 if found:
1226 log.debug("Removing ID3 %s tag" %
1227 versionToString(tag.version))
1228 tag_file.seek(tag.file_info.tag_size)
1229
1230 # Open tmp file
1231 with tempfile.NamedTemporaryFile("wb", delete=False) \
1232 as tmp_file:
1233 chunkCopy(tag_file, tmp_file)
1234
1235 # Move tmp to orig
1236 shutil.copyfile(tmp_file.name, filename)
1237 os.unlink(tmp_file.name)
1238
1239 retval |= True
1240
1241 if preserve_file_time and retval and None not in (tag.file_info.atime,
1242 tag.file_info.mtime):
1243 tag.file_info.touch((tag.file_info.atime, tag.file_info.mtime))
1244
1245 return retval
1246
1247 @property
1248 def chapters(self):
1249 return self._chapters
1250
1251 @property
1252 def table_of_contents(self):
1253 return self._tocs
1254
1255 @property
1256 def album_type(self):
1257 if TXXX_ALBUM_TYPE in self.user_text_frames:
1258 return self.user_text_frames.get(TXXX_ALBUM_TYPE).text
1259 else:
1260 return None
1261
1262 @album_type.setter
1263 def album_type(self, t):
1264 if not t:
1265 self.user_text_frames.remove(TXXX_ALBUM_TYPE)
1266 elif t in ALBUM_TYPE_IDS:
1267 self.user_text_frames.set(t, TXXX_ALBUM_TYPE)
1268 else:
1269 raise ValueError("Invalid album_type: %s" % t)
1270
1271 @property
1272 def artist_origin(self):
1273 """Returns a 3-tuple: (city, state, country) Any may be ``None``."""
1274 if TXXX_ARTIST_ORIGIN in self.user_text_frames:
1275 origin = self.user_text_frames.get(TXXX_ARTIST_ORIGIN).text
1276 vals = origin.split('\t')
1277 else:
1278 vals = [None] * 3
1279
1280 vals.extend([None] * (3 - len(vals)))
1281 vals = [None if not v else v for v in vals]
1282 assert(len(vals) == 3)
1283 return vals
1284
1285 @artist_origin.setter
1286 def artist_origin(self, city, state, country):
1287 vals = (city, state, country)
1288 vals = [None if not v else v for v in vals]
1289 if vals == (None, None, None):
1290 self.user_text_frames.remove(TXXX_ARTIST_ORIGIN)
1291 else:
1292 assert(len(vals) == 3)
1293 self.user_text_frames.set('\t'.join(vals), TXXX_ARTIST_ORIGIN)
1294
1295 def frameiter(self, fids=None):
1296 """A iterator for tag frames. If ``fids`` is passed it must be a list
1297 of frame IDs to filter and return."""
1298 fids = fids or []
1299 fids = [(b(f, ascii_encode)
1300 if isinstance(f, UnicodeType) else f) for f in fids]
1301 for f in self.frame_set.getAllFrames():
1302 if not fids or f.id in fids:
1303 yield f
1304
1305
1306 class FileInfo:
1307 """
1308 This class is for storing information about a parsed file. It containts info
1309 such as the filename, original tag size, and amount of padding; all of which
1310 can make rewriting faster.
1311 """
1312 def __init__(self, file_name, tagsz=0, tpadd=0):
1313 from .. import LOCAL_FS_ENCODING
1314
1315 if type(file_name) is unicode:
1316 self.name = file_name
1317 else:
1318 try:
1319 self.name = unicode(file_name, LOCAL_FS_ENCODING)
1320 except UnicodeDecodeError:
1321 # Work around the local encoding not matching that of a mounted
1322 # filesystem
1323 log.warning(u"Mismatched file system encoding for file '%s'" %
1324 repr(file_name))
1325 self.name = file_name
1326
1327 self.tag_size = tagsz or 0 # This includes the padding byte count.
1328 self.tag_padding_size = tpadd or 0
1329
1330 self.initStatTimes()
1331
1332 def initStatTimes(self):
1333 try:
1334 s = os.stat(self.name)
1335 except OSError:
1336 self.atime, self.mtime = None, None
1337 else:
1338 self.atime, self.mtime = s.st_atime, s.st_mtime
1339
1340 def touch(self, times):
1341 """times is a 2-tuple of (atime, mtime)."""
1342 os.utime(self.name, times)
1343 self.initStatTimes()
1344
1345
1346 class AccessorBase(object):
1347 def __init__(self, fid, fs, match_func=None):
1348 self._fid = fid
1349 self._fs = fs
1350 self._match_func = match_func
1351
1352 def __iter__(self):
1353 for f in self._fs[self._fid] or []:
1354 yield f
1355
1356 def __len__(self):
1357 return len(self._fs[self._fid] or [])
1358
1359 def __getitem__(self, i):
1360 frames = self._fs[self._fid]
1361 if not frames:
1362 raise IndexError("list index out of range")
1363 return frames[i]
1364
1365 def get(self, *args, **kwargs):
1366 for frame in self._fs[self._fid] or []:
1367 if self._match_func(frame, *args, **kwargs):
1368 return frame
1369 return None
1370
1371 def remove(self, *args, **kwargs):
1372 """Returns the removed item or ``None`` if not found."""
1373 fid_frames = self._fs[self._fid] or []
1374 for frame in fid_frames:
1375 if self._match_func(frame, *args, **kwargs):
1376 fid_frames.remove(frame)
1377 return frame
1378 return None
1379
1380
1381 class DltAccessor(AccessorBase):
1382 def __init__(self, FrameClass, fid, fs):
1383 def match_func(frame, description, lang=DEFAULT_LANG):
1384 return (frame.description == description and
1385 frame.lang == (lang if isinstance(lang, BytesType)
1386 else lang.encode("ascii")))
1387
1388 super(DltAccessor, self).__init__(fid, fs, match_func)
1389 self.FrameClass = FrameClass
1390
1391 @requireUnicode(1, 2)
1392 def set(self, text, description=u"", lang=DEFAULT_LANG):
1393 lang = lang or DEFAULT_LANG
1394 for f in self._fs[self._fid] or []:
1395 if f.description == description and f.lang == lang:
1396 # Exists, update text
1397 f.text = text
1398 return f
1399
1400 new_frame = self.FrameClass(description=description, lang=lang,
1401 text=text)
1402 self._fs[self._fid] = new_frame
1403 return new_frame
1404
1405 @requireUnicode(1)
1406 def remove(self, description, lang=DEFAULT_LANG):
1407 return super(DltAccessor, self).remove(description,
1408 lang=lang or DEFAULT_LANG)
1409
1410 @requireUnicode(1)
1411 def get(self, description, lang=DEFAULT_LANG):
1412 return super(DltAccessor, self).get(description,
1413 lang=lang or DEFAULT_LANG)
1414
1415
1416 class CommentsAccessor(DltAccessor):
1417 def __init__(self, fs):
1418 super(CommentsAccessor, self).__init__(frames.CommentFrame,
1419 frames.COMMENT_FID, fs)
1420
1421
1422 class LyricsAccessor(DltAccessor):
1423 def __init__(self, fs):
1424 super(LyricsAccessor, self).__init__(frames.LyricsFrame,
1425 frames.LYRICS_FID, fs)
1426
1427
1428 class ImagesAccessor(AccessorBase):
1429 def __init__(self, fs):
1430 def match_func(frame, description):
1431 return frame.description == description
1432 super(ImagesAccessor, self).__init__(frames.IMAGE_FID, fs, match_func)
1433
1434 @requireUnicode("description")
1435 def set(self, type_, img_data, mime_type, description=u"", img_url=None):
1436 """Add an image of ``type_`` (a type constant from ImageFrame).
1437 The ``img_data`` is either bytes or ``None``. In the latter case
1438 ``img_url`` MUST be the URL to the image. In this case ``mime_type``
1439 is ignored and "-->" is used to signal this as a link and not data
1440 (per the ID3 spec)."""
1441 img_url = b(img_url) if img_url else None
1442
1443 if not img_data and not img_url:
1444 raise ValueError("img_url MUST not be none when no image data")
1445
1446 mime_type = mime_type if img_data else frames.ImageFrame.URL_MIME_TYPE
1447 mime_type = b(mime_type)
1448
1449 images = self._fs[frames.IMAGE_FID] or []
1450 for img in images:
1451 if img.description == description:
1452 # update
1453 if not img_data:
1454 img.image_url = img_url
1455 img.image_data = None
1456 img.mime_type = frames.ImageFrame.URL_MIME_TYPE
1457 else:
1458 img.image_url = None
1459 img.image_data = img_data
1460 img.mime_type = mime_type
1461 img.picture_type = type_
1462 return img
1463
1464 img_frame = frames.ImageFrame(description=description,
1465 image_data=img_data,
1466 image_url=img_url,
1467 mime_type=mime_type,
1468 picture_type=type_)
1469 self._fs[frames.IMAGE_FID] = img_frame
1470 return img_frame
1471
1472 @requireUnicode(1)
1473 def remove(self, description):
1474 return super(ImagesAccessor, self).remove(description)
1475
1476 @requireUnicode(1)
1477 def get(self, description):
1478 return super(ImagesAccessor, self).get(description)
1479
1480
1481 class ObjectsAccessor(AccessorBase):
1482 def __init__(self, fs):
1483 def match_func(frame, description):
1484 return frame.description == description
1485 super(ObjectsAccessor, self).__init__(frames.OBJECT_FID, fs, match_func)
1486
1487 @requireUnicode("description", "filename")
1488 def set(self, data, mime_type, description=u"", filename=u""):
1489 objects = self._fs[frames.OBJECT_FID] or []
1490 for obj in objects:
1491 if obj.description == description:
1492 # update
1493 obj.object_data = data
1494 obj.mime_type = mime_type
1495 obj.filename = filename
1496 return obj
1497
1498 obj_frame = frames.ObjectFrame(description=description,
1499 filename=filename,
1500 object_data=data,
1501 mime_type=mime_type)
1502 self._fs[frames.OBJECT_FID] = obj_frame
1503 return obj_frame
1504
1505 @requireUnicode(1)
1506 def remove(self, description):
1507 return super(ObjectsAccessor, self).remove(description)
1508
1509 @requireUnicode(1)
1510 def get(self, description):
1511 return super(ObjectsAccessor, self).get(description)
1512
1513
1514 class PrivatesAccessor(AccessorBase):
1515 def __init__(self, fs):
1516 def match_func(frame, owner_id):
1517 return frame.owner_id == owner_id
1518 super(PrivatesAccessor, self).__init__(frames.PRIVATE_FID, fs,
1519 match_func)
1520
1521 def set(self, data, owner_id):
1522 priv_frames = self._fs[frames.PRIVATE_FID] or []
1523 for f in priv_frames:
1524 if f.owner_id == owner_id:
1525 # update
1526 f.owner_data = data
1527 return f
1528
1529 priv_frame = frames.PrivateFrame(owner_id=owner_id,
1530 owner_data=data)
1531 self._fs[frames.PRIVATE_FID] = priv_frame
1532 return priv_frame
1533
1534 def remove(self, owner_id):
1535 return super(PrivatesAccessor, self).remove(owner_id)
1536
1537 def get(self, owner_id):
1538 return super(PrivatesAccessor, self).get(owner_id)
1539
1540
1541 class UserTextsAccessor(AccessorBase):
1542 def __init__(self, fs):
1543 def match_func(frame, description):
1544 return frame.description == description
1545 super(UserTextsAccessor, self).__init__(frames.USERTEXT_FID, fs,
1546 match_func)
1547
1548 @requireUnicode(1, "description")
1549 def set(self, text, description=u""):
1550 flist = self._fs[frames.USERTEXT_FID] or []
1551 for utf in flist:
1552 if utf.description == description:
1553 # update
1554 utf.text = text
1555 return utf
1556
1557 utf = frames.UserTextFrame(description=description,
1558 text=text)
1559 self._fs[frames.USERTEXT_FID] = utf
1560 return utf
1561
1562 @requireUnicode(1)
1563 def remove(self, description):
1564 return super(UserTextsAccessor, self).remove(description)
1565
1566 @requireUnicode(1)
1567 def get(self, description):
1568 return super(UserTextsAccessor, self).get(description)
1569
1570 @requireUnicode(1)
1571 def __contains__(self, description):
1572 return bool(self.get(description))
1573
1574
1575 class UniqueFileIdAccessor(AccessorBase):
1576 def __init__(self, fs):
1577 def match_func(frame, owner_id):
1578 return frame.owner_id == owner_id
1579 super(UniqueFileIdAccessor, self).__init__(frames.UNIQUE_FILE_ID_FID,
1580 fs, match_func)
1581
1582 def set(self, data, owner_id):
1583 data, owner_id = b(data), b(owner_id)
1584 if len(data) > 64:
1585 raise TagException("UFID data must be 64 bytes or less")
1586
1587 flist = self._fs[frames.UNIQUE_FILE_ID_FID] or []
1588 for f in flist:
1589 if f.owner_id == owner_id:
1590 # update
1591 f.uniq_id = data
1592 return f
1593
1594 uniq_id_frame = frames.UniqueFileIDFrame(owner_id=owner_id,
1595 uniq_id=data)
1596 self._fs[frames.UNIQUE_FILE_ID_FID] = uniq_id_frame
1597 return uniq_id_frame
1598
1599 def remove(self, owner_id):
1600 owner_id = b(owner_id)
1601 return super(UniqueFileIdAccessor, self).remove(owner_id)
1602
1603 def get(self, owner_id):
1604 owner_id = b(owner_id)
1605 return super(UniqueFileIdAccessor, self).get(owner_id)
1606
1607
1608 class UserUrlsAccessor(AccessorBase):
1609 def __init__(self, fs):
1610 def match_func(frame, description):
1611 return frame.description == description
1612 super(UserUrlsAccessor, self).__init__(frames.USERURL_FID, fs,
1613 match_func)
1614
1615 @requireUnicode("description")
1616 def set(self, url, description=u""):
1617 flist = self._fs[frames.USERURL_FID] or []
1618 for uuf in flist:
1619 if uuf.description == description:
1620 # update
1621 uuf.url = url
1622 return uuf
1623
1624 uuf = frames.UserUrlFrame(description=description, url=url)
1625 self._fs[frames.USERURL_FID] = uuf
1626 return uuf
1627
1628 @requireUnicode(1)
1629 def remove(self, description):
1630 return super(UserUrlsAccessor, self).remove(description)
1631
1632 @requireUnicode(1)
1633 def get(self, description):
1634 return super(UserUrlsAccessor, self).get(description)
1635
1636
1637 class PopularitiesAccessor(AccessorBase):
1638 def __init__(self, fs):
1639 def match_func(frame, email):
1640 return frame.email == email
1641 super(PopularitiesAccessor, self).__init__(frames.POPULARITY_FID, fs,
1642 match_func)
1643
1644 def set(self, email, rating, play_count):
1645 flist = self._fs[frames.POPULARITY_FID] or []
1646 for popm in flist:
1647 if popm.email == email:
1648 # update
1649 popm.rating = rating
1650 popm.count = play_count
1651 return popm
1652
1653 popm = frames.PopularityFrame(email=email, rating=rating,
1654 count=play_count)
1655 self._fs[frames.POPULARITY_FID] = popm
1656 return popm
1657
1658 def remove(self, email):
1659 return super(PopularitiesAccessor, self).remove(email)
1660
1661 def get(self, email):
1662 return super(PopularitiesAccessor, self).get(email)
1663
1664
1665 class ChaptersAccessor(AccessorBase):
1666 def __init__(self, fs):
1667 def match_func(frame, element_id):
1668 return frame.element_id == element_id
1669 super(ChaptersAccessor, self).__init__(frames.CHAPTER_FID, fs,
1670 match_func)
1671
1672 def set(self, element_id, times, offsets=(None, None), sub_frames=None):
1673 flist = self._fs[frames.CHAPTER_FID] or []
1674 for chap in flist:
1675 if chap.element_id == element_id:
1676 # update
1677 chap.times, chap.offsets = times, offsets
1678 if sub_frames:
1679 chap.sub_frames = sub_frames
1680 return chap
1681
1682 chap = frames.ChapterFrame(element_id=element_id,
1683 times=times, offsets=offsets,
1684 sub_frames=sub_frames)
1685 self._fs[frames.CHAPTER_FID] = chap
1686 return chap
1687
1688 def remove(self, element_id):
1689 return super(ChaptersAccessor, self).remove(element_id)
1690
1691 def get(self, element_id):
1692 return super(ChaptersAccessor, self).get(element_id)
1693
1694 def __getitem__(self, elem_id):
1695 """Overiding the index based __getitem__ for one indexed with chapter
1696 element IDs. These are stored in the tag's table of contents frames."""
1697 for chapter in (self._fs[frames.CHAPTER_FID] or []):
1698 if chapter.element_id == elem_id:
1699 return chapter
1700 raise IndexError("chapter '%s' not found" % elem_id)
1701
1702
1703 class TocAccessor(AccessorBase):
1704 def __init__(self, fs):
1705 def match_func(frame, element_id):
1706 return frame.element_id == element_id
1707 super(TocAccessor, self).__init__(frames.TOC_FID, fs, match_func)
1708
1709 def __iter__(self):
1710 tocs = list(self._fs[self._fid] or [])
1711 for toc_frame in tocs:
1712 # Find and put top level at the front of the list
1713 if toc_frame.toplevel:
1714 tocs.remove(toc_frame)
1715 tocs.insert(0, toc_frame)
1716 break
1717
1718 for toc in tocs:
1719 yield toc
1720
1721 @requireUnicode("description")
1722 def set(self, element_id, toplevel=False, ordered=True, child_ids=None,
1723 description=u""):
1724 flist = self._fs[frames.TOC_FID] or []
1725
1726 # Enforce one top-level
1727 if toplevel:
1728 for toc in flist:
1729 if toc.toplevel:
1730 raise ValueError("There may only be one top-level "
1731 "table of contents. Toc '%s' is current "
1732 "top-level." % toc.element_id)
1733 for toc in flist:
1734 if toc.element_id == element_id:
1735 # update
1736 toc.toplevel = toplevel
1737 toc.ordered = ordered
1738 toc.child_ids = child_ids
1739 toc.description = description
1740 return toc
1741
1742 toc = frames.TocFrame(element_id=element_id, toplevel=toplevel,
1743 ordered=ordered, child_ids=child_ids,
1744 description=description)
1745 self._fs[frames.TOC_FID] = toc
1746 return toc
1747
1748 def remove(self, element_id):
1749 return super(TocAccessor, self).remove(element_id)
1750
1751 def get(self, element_id):
1752 return super(TocAccessor, self).get(element_id)
1753
1754 def __getitem__(self, elem_id):
1755 """Overiding the index based __getitem__ for one indexed with table
1756 of contents element IDs."""
1757 for toc in (self._fs[frames.TOC_FID] or []):
1758 if toc.element_id == elem_id:
1759 return toc
1760 raise IndexError("toc '%s' not found" % elem_id)
1761
1762
1763 class TagTemplate(string.Template):
1764 idpattern = r'[_a-z][_a-z0-9:]*'
1765
1766 def __init__(self, pattern, path_friendly="-", dotted_dates=False):
1767 super(TagTemplate, self).__init__(pattern)
1768
1769 if type(path_friendly) is bool and path_friendly:
1770 # Previous versions used boolean values, convert old default to new
1771 path_friendly = "-"
1772 self._path_friendly = path_friendly
1773
1774 self._dotted_dates = dotted_dates
1775
1776 def substitute(self, tag, zeropad=True):
1777 mapping = self._makeMapping(tag, zeropad)
1778
1779 # Helper function for .sub()
1780 def convert(mo):
1781 named = mo.group('named')
1782 if named is not None:
1783 try:
1784 if type(mapping[named]) is tuple:
1785 func, args = mapping[named][0], mapping[named][1:]
1786 return u'%s' % func(tag, named, *args)
1787 # We use this idiom instead of str() because the latter
1788 # will fail if val is a Unicode containing non-ASCII
1789 return u'%s' % (mapping[named],)
1790 except KeyError:
1791 return self.delimiter + named
1792 braced = mo.group('braced')
1793 if braced is not None:
1794 try:
1795 if type(mapping[braced]) is tuple:
1796 func, args = mapping[braced][0], mapping[braced][1:]
1797 return u'%s' % func(tag, braced, *args)
1798 return u'%s' % (mapping[braced],)
1799 except KeyError:
1800 return self.delimiter + '{' + braced + '}'
1801 if mo.group('escaped') is not None:
1802 return self.delimiter
1803 if mo.group('invalid') is not None:
1804 return self.delimiter
1805 raise ValueError('Unrecognized named group in pattern',
1806 self.pattern)
1807
1808 name = self.pattern.sub(convert, self.template)
1809 if self._path_friendly:
1810 name = name.replace("/", self._path_friendly)
1811 return name
1812
1813 safe_substitute = substitute
1814
1815 def _dates(self, tag, param):
1816 if param.startswith("release_"):
1817 date = tag.release_date
1818 elif param.startswith("recording_"):
1819 date = tag.recording_date
1820 elif param.startswith("original_release_"):
1821 date = tag.original_release_date
1822 else:
1823 date = tag.getBestDate(
1824 prefer_recording_date=":prefer_recording" in param)
1825
1826 if date and param.endswith(":year"):
1827 dstr = unicode(date.year)
1828 elif date:
1829 dstr = unicode(date)
1830 else:
1831 dstr = u""
1832
1833 if self._dotted_dates:
1834 dstr = dstr.replace('-', '.')
1835
1836 return dstr
1837
1838 def _nums(self, num_tuple, param, zeropad):
1839 nn, nt = ((unicode(n) if n else None) for n in num_tuple)
1840 if zeropad:
1841 if nt:
1842 nt = nt.rjust(2, "0")
1843 nn = nn.rjust(len(nt) if nt else 2, "0")
1844
1845 if param.endswith(":num"):
1846 return nn
1847 elif param.endswith(":total"):
1848 return nt
1849 else:
1850 raise ValueError("Unknown template param: %s" % param)
1851
1852 def _track(self, tag, param, zeropad):
1853 return self._nums(tag.track_num, param, zeropad)
1854
1855 def _disc(self, tag, param, zeropad):
1856 return self._nums(tag.disc_num, param, zeropad)
1857
1858 def _file(self, tag, param):
1859 assert(param.startswith("file"))
1860
1861 if param.endswith(":ext"):
1862 return os.path.splitext(tag.file_info.name)[1][1:]
1863 else:
1864 return tag.file_info.name
1865
1866 def _makeMapping(self, tag, zeropad):
1867 return {"artist": tag.artist if tag else None,
1868 "album_artist": tag.album_artist if tag else None,
1869 "album": tag.album if tag else None,
1870 "title": tag.title if tag else None,
1871 "track:num": (self._track, zeropad) if tag else None,
1872 "track:total": (self._track, zeropad) if tag else None,
1873 "release_date": (self._dates,) if tag else None,
1874 "release_date:year": (self._dates,) if tag else None,
1875 "recording_date": (self._dates,) if tag else None,
1876 "recording_date:year": (self._dates,) if tag else None,
1877 "original_release_date": (self._dates,) if tag else None,
1878 "original_release_date:year": (self._dates,) if tag else None,
1879 "best_date": (self._dates,) if tag else None,
1880 "best_date:year": (self._dates,) if tag else None,
1881 "best_date:prefer_recording": (self._dates,) if tag else None,
1882 "best_date:prefer_release": (self._dates,) if tag else None,
1883 "best_date:prefer_recording:year": (self._dates,) if tag
1884 else None,
1885 "best_date:prefer_release:year": (self._dates,) if tag
1886 else None,
1887 "file": (self._file,) if tag else None,
1888 "file:ext": (self._file,) if tag else None,
1889 "disc:num": (self._disc, zeropad) if tag else None,
1890 "disc:total": (self._disc, zeropad) if tag else None,
1891 }
+0
-305
src/eyed3/main.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2009-2012 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 from __future__ import print_function
19 import os
20 import sys
21 import textwrap
22 import warnings
23
24 import eyed3
25 import eyed3.utils
26 import eyed3.utils.console
27 import eyed3.plugins
28 import eyed3.__about__
29 from eyed3.compat import ConfigParser, ConfigParserError, StringIO, UnicodeType
30
31 from eyed3.utils.log import initLogging
32
33 DEFAULT_PLUGIN = "classic"
34 DEFAULT_CONFIG = os.path.expandvars("${HOME}/.eyeD3/config.ini")
35 USER_PLUGINS_DIR = os.path.expandvars("${HOME}/.eyeD3/plugins")
36
37
38 def main(args, config):
39 if "list_plugins" in args and args.list_plugins:
40 _listPlugins(config)
41 return 0
42
43 args.plugin.start(args, config)
44
45 # Process paths (files/directories)
46 for p in args.paths:
47 eyed3.utils.walk(args.plugin, p, excludes=args.excludes,
48 fs_encoding=args.fs_encoding)
49
50 retval = args.plugin.handleDone()
51
52 return retval or 0
53
54
55 def _listPlugins(config):
56 from eyed3.utils.console import Fore, Style
57
58 print("")
59
60 def header(name):
61 is_default = name == DEFAULT_PLUGIN
62 return (Style.BRIGHT + (Fore.GREEN if is_default else '') + "* " +
63 name + Style.RESET_ALL)
64
65 all_plugins = eyed3.plugins.load(reload=True, paths=_getPluginPath(config))
66 # Create a new dict for sorted display
67 plugin_names = []
68 for plugin in set(all_plugins.values()):
69 plugin_names.append(plugin.NAMES[0])
70
71 print("Type 'eyeD3 --plugin=<name> --help' for more help")
72 print("")
73
74 plugin_names.sort()
75 for name in plugin_names:
76 plugin = all_plugins[name]
77
78 alt_names = plugin.NAMES[1:]
79 alt_names = " (%s)" % ", ".join(alt_names) if alt_names else ""
80
81 print("%s %s:" % (header(name), alt_names))
82 for l in textwrap.wrap(plugin.SUMMARY,
83 initial_indent=' ' * 2,
84 subsequent_indent=' ' * 2):
85 print(Style.BRIGHT + Fore.GREY + l + Style.RESET_ALL)
86 print("")
87
88
89 def _loadConfig(args):
90 import os
91
92 config = None
93 config_file = None
94
95 if args.config:
96 config_file = os.path.abspath(config_file)
97 elif args.no_config is False:
98 config_file = DEFAULT_CONFIG
99
100 if not config_file:
101 return None
102
103 if os.path.isfile(config_file):
104 try:
105 config = ConfigParser()
106 config.read(config_file)
107 except ConfigParserError as ex:
108 eyed3.log.warning("User config error: " + str(ex))
109 return None
110 elif config_file != DEFAULT_CONFIG:
111 raise IOError("User config not found: %s" % config_file)
112
113 return config
114
115
116 def _getPluginPath(config):
117 plugin_path = [USER_PLUGINS_DIR]
118
119 if config and config.has_option("default", "plugin_path"):
120 val = config.get("default", "plugin_path")
121 plugin_path += [os.path.expanduser(os.path.expandvars(d)) for d
122 in val.split(':') if val]
123 return plugin_path
124
125
126 def profileMain(args, config): # pragma: no cover
127 '''This is the main function for profiling
128 http://code.google.com/appengine/kb/commontasks.html#profiling
129 '''
130 import cProfile
131 import pstats
132
133 eyed3.log.debug("driver profileMain")
134 prof = cProfile.Profile()
135 prof = prof.runctx("main(args)", globals(), locals())
136
137 stream = StringIO()
138 stats = pstats.Stats(prof, stream=stream)
139 stats.sort_stats("time") # Or cumulative
140 stats.print_stats(100) # 80 = how many to print
141
142 # The rest is optional.
143 stats.print_callees()
144 stats.print_callers()
145 sys.stderr.write("Profile data:\n%s\n" % stream.getvalue())
146
147 return 0
148
149
150 def setFileScannerOpts(arg_parser, paths_metavar="PATH",
151 paths_help="Files or directory paths"):
152 arg_parser.add_argument("--exclude",
153 action="append", metavar="PATTERN", dest="excludes",
154 help="A regular expression for path exclusion. May be specified "
155 "multiple times.")
156 arg_parser.add_argument("--fs-encoding",
157 action="store", dest="fs_encoding",
158 default=eyed3.LOCAL_FS_ENCODING, metavar="ENCODING",
159 help="Use the specified file system encoding for filenames. "
160 "Default as it was detected is '%s' but this option is still "
161 "useful when reading from mounted file systems." %
162 eyed3.LOCAL_FS_ENCODING)
163 arg_parser.add_argument("paths", metavar=paths_metavar, nargs="*",
164 help=paths_help)
165
166
167 def makeCmdLineParser(subparser=None):
168 from eyed3.utils import ArgumentParser
169
170 p = (ArgumentParser(prog=eyed3.__about__.__project_name__, add_help=True)
171 if not subparser else subparser)
172
173 setFileScannerOpts(p)
174
175 p.add_argument("-L", "--plugins", action="store_true", default=False,
176 dest="list_plugins", help="List all available plugins")
177 p.add_argument("-P", "--plugin", action="store", dest="plugin",
178 default=None, metavar="NAME",
179 help="Specify which plugin to use. The default is '%s'" %
180 DEFAULT_PLUGIN)
181 p.add_argument("-C", "--config", action="store", dest="config",
182 default=None, metavar="FILE",
183 help="Supply a configuration file. The default is "
184 "'%s', although even that is optional." %
185 DEFAULT_CONFIG)
186 p.add_argument("--backup", action="store_true", dest="backup",
187 help="Plugins should honor this option such that "
188 "a backup is made of any file modified. The backup "
189 "is made in same directory with a '.orig' "
190 "extension added.")
191 p.add_argument("-Q", "--quiet", action="store_true", dest="quiet",
192 default=False, help="A hint to plugins to output less.")
193 p.add_argument("--no-color", action="store_true", dest="no_color",
194 help="Suppress color codes in console output. "
195 "This will happen automatically if the output is "
196 "not a TTY (e.g. when redirecting to a file)")
197 p.add_argument("--no-config",
198 action="store_true", dest="no_config",
199 help="Do not load the default user config '%s'. "
200 "The -c/--config options are still honored if "
201 "present." % DEFAULT_CONFIG)
202
203 return p
204
205
206 def parseCommandLine(cmd_line_args=None):
207
208 cmd_line_args = list(cmd_line_args) if cmd_line_args else list(sys.argv[1:])
209
210 # Remove any options not related to plugin/config for first parse. These
211 # determine the parser for the next stage.
212 stage_one_args = []
213 idx, auto_append = 0, False
214 while idx < len(cmd_line_args):
215 opt = cmd_line_args[idx]
216 if auto_append:
217 stage_one_args.append(opt)
218 auto_append = False
219
220 if opt in ("-C", "--config", "-P", "--plugin", "--no-config"):
221 stage_one_args.append(opt)
222 if opt != "--no-config":
223 auto_append = True
224 elif (opt.startswith("-C=") or opt.startswith("--config=") or
225 opt.startswith("-P=") or opt.startswith("--plugin=")):
226 stage_one_args.append(opt)
227 idx += 1
228
229 parser = makeCmdLineParser()
230 args = parser.parse_args(stage_one_args)
231
232 config = _loadConfig(args)
233
234 if args.plugin:
235 # Plugin on the command line takes precedence over config.
236 plugin_name = args.plugin
237 elif config and config.has_option("default", "plugin"):
238 # Get default plugin from config or use DEFAULT_CONFIG
239 plugin_name = config.get("default", "plugin")
240 if not plugin_name:
241 plugin_name = DEFAULT_PLUGIN
242 else:
243 plugin_name = DEFAULT_PLUGIN
244 assert(plugin_name)
245
246 PluginClass = eyed3.plugins.load(plugin_name, paths=_getPluginPath(config))
247 if PluginClass is None:
248 eyed3.utils.console.printError("Plugin not found: %s" % plugin_name)
249 parser.exit(1)
250 plugin = PluginClass(parser)
251
252 if config and config.has_option("default", "options"):
253 cmd_line_args.extend(config.get("default", "options").split())
254 if config and config.has_option(plugin_name, "options"):
255 cmd_line_args.extend(config.get(plugin_name, "options").split())
256
257 # Reparse the command line including options from the config.
258 args = parser.parse_args(args=cmd_line_args)
259
260 args.plugin = plugin
261 eyed3.log.debug("command line args: %s", args)
262 eyed3.log.debug("plugin is: %s", plugin)
263
264 return args, parser, config
265
266
267 def _main():
268 """Entry point"""
269 initLogging()
270 try:
271 args, _, config = parseCommandLine()
272
273 eyed3.utils.console.AnsiCodes.init(not args.no_color)
274
275 mainFunc = main if args.debug_profile is False else profileMain
276 retval = mainFunc(args, config)
277 except KeyboardInterrupt:
278 retval = 0
279 except (StopIteration, IOError) as ex:
280 eyed3.utils.console.printError(UnicodeType(ex))
281 retval = 1
282 except Exception as ex:
283 eyed3.utils.console.printError("Uncaught exception: %s\n" % str(ex))
284 eyed3.log.exception(ex)
285 retval = 1
286
287 if args.debug_pdb:
288 try:
289 with warnings.catch_warnings():
290 warnings.simplefilter("ignore", PendingDeprecationWarning)
291 # Must delay the import of ipdb as say as possible because
292 # of https://github.com/gotcha/ipdb/issues/48
293 import ipdb as pdb
294 except ImportError:
295 import pdb
296
297 e, m, tb = sys.exc_info()
298 pdb.post_mortem(tb)
299
300 sys.exit(retval)
301
302
303 if __name__ == "__main__": # pragma: no cover
304 _main()
+0
-197
src/eyed3/mp3/__init__.py less more
0 import os
1 import re
2
3 from .. import Error
4 from .. import id3
5 from .. import core, utils
6
7 from ..utils.log import getLogger
8 log = getLogger(__name__)
9
10
11 class Mp3Exception(Error):
12 """Used to signal mp3-related errors."""
13 pass
14
15
16 NAME = "mpeg"
17 MIME_TYPES = ["audio/mpeg", "audio/mp3", "audio/x-mp3", "audio/x-mpeg",
18 "audio/mpeg3", "audio/x-mpeg3", "audio/mpg", "audio/x-mpg",
19 "audio/x-mpegaudio",
20 ]
21 '''Mime-types that are recognized at MP3'''
22
23 OTHER_MIME_TYPES = ['application/octet-stream', # ???
24 'audio/x-hx-aac-adts', # ???
25 'audio/x-wav', # RIFF wrapped mp3s
26 ]
27 '''Mime-types that have been seen to contain mp3 data.'''
28
29 EXTENSIONS = [".mp3"]
30 '''Valid file extensions.'''
31
32
33 def isMp3File(file_name):
34 '''Does a mime-type check on ``file_name`` and returns ``True`` it the
35 file is mp3, and ``False`` otherwise.'''
36 return utils.guessMimetype(file_name) in MIME_TYPES
37
38
39 class Mp3AudioInfo(core.AudioInfo):
40 def __init__(self, file_obj, start_offset, tag):
41 from . import headers
42 from .headers import timePerFrame
43
44 log.debug("mp3 header search starting @ %x" % start_offset)
45 core.AudioInfo.__init__(self)
46
47 self.mp3_header = None
48 self.xing_header = None
49 self.vbri_header = None
50 # If not ``None``, the Lame header.
51 # See :class:`eyed3.mp3.headers.LameHeader`
52 self.lame_tag = None
53 # 2-tuple, (vrb?:boolean, bitrate:int)
54 self.bit_rate = (None, None)
55
56 header_pos = 0
57 while self.mp3_header is None:
58 # Find first mp3 header
59 (header_pos,
60 header_int,
61 header_bytes) = headers.findHeader(file_obj, start_offset)
62 if not header_int:
63 try:
64 fname = file_obj.name
65 except AttributeError:
66 fname = 'unknown'
67 raise headers.Mp3Exception(
68 "Unable to find a valid mp3 frame in '%s'" % fname)
69
70 try:
71 self.mp3_header = headers.Mp3Header(header_int)
72 log.debug("mp3 header %x found at position: 0x%x" %
73 (header_int, header_pos))
74 except headers.Mp3Exception as ex:
75 log.debug("Invalid mp3 header: %s" % str(ex))
76 # keep looking...
77 start_offset += 4
78
79 file_obj.seek(header_pos)
80 mp3_frame = file_obj.read(self.mp3_header.frame_length)
81 if re.compile(b'Xing|Info').search(mp3_frame):
82 # Check for Xing/Info header information.
83 self.xing_header = headers.XingHeader()
84 if not self.xing_header.decode(mp3_frame):
85 log.debug("Ignoring corrupt Xing header")
86 self.xing_header = None
87 elif mp3_frame.find(b'VBRI') >= 0:
88 # Check for VBRI header information.
89 self.vbri_header = headers.VbriHeader()
90 if not self.vbri_header.decode(mp3_frame):
91 log.debug("Ignoring corrupt VBRI header")
92 self.vbri_header = None
93
94 # Check for LAME Tag
95 self.lame_tag = headers.LameHeader(mp3_frame)
96
97 # Set file size
98 import stat
99 self.size_bytes = os.stat(file_obj.name)[stat.ST_SIZE]
100
101 # Compute track play time.
102 if self.xing_header and self.xing_header.vbr:
103 tpf = timePerFrame(self.mp3_header, True)
104 self.time_secs = tpf * self.xing_header.numFrames
105 elif self.vbri_header and self.vbri_header.version == 1:
106 tpf = timePerFrame(self.mp3_header, True)
107 self.time_secs = tpf * self.vbri_header.num_frames
108 else:
109 tpf = timePerFrame(self.mp3_header, False)
110 length = self.size_bytes
111 if tag and tag.isV2():
112 length -= tag.header.SIZE + tag.header.tag_size
113 # Handle the case where there is a v2 tag and a v1 tag.
114 file_obj.seek(-128, 2)
115 if file_obj.read(3) == "TAG":
116 length -= 128
117 elif tag and tag.isV1():
118 length -= 128
119 self.time_secs = (length / self.mp3_header.frame_length) * tpf
120
121 # Compute bitate
122 if (self.xing_header and self.xing_header.vbr and
123 self.xing_header.numFrames): # if xing_header.numFrames == 0, ZeroDivisionError
124 br = int((self.xing_header.numBytes * 8) /
125 (tpf * self.xing_header.numFrames * 1000))
126 vbr = True
127 else:
128 br = self.mp3_header.bit_rate
129 vbr = False
130 self.bit_rate = (vbr, br)
131
132 self.sample_freq = self.mp3_header.sample_freq
133 self.mode = self.mp3_header.mode
134
135 ##
136 # Helper to get the bitrate as a string. The prefix '~' is used to denote
137 # variable bit rates.
138 @property
139 def bit_rate_str(self):
140 (vbr, bit_rate) = self.bit_rate
141 brs = "%d kb/s" % bit_rate
142 if vbr:
143 brs = "~" + brs
144 return brs
145
146
147 class Mp3AudioFile(core.AudioFile):
148 """Audio file container for mp3 files."""
149
150 def __init__(self, path, version=id3.ID3_ANY_VERSION):
151 self._tag_version = version
152
153 core.AudioFile.__init__(self, path)
154 assert(self.type == core.AUDIO_MP3)
155
156 def _read(self):
157 with open(self.path, 'rb') as file_obj:
158 self._tag = id3.Tag()
159 tag_found = self._tag.parse(file_obj, self._tag_version)
160
161 # Compute offset for starting mp3 data search
162 if tag_found and self._tag.isV1():
163 mp3_offset = 0
164 elif tag_found and self._tag.isV2():
165 mp3_offset = self._tag.header.SIZE + self._tag.header.tag_size
166 else:
167 mp3_offset = 0
168 self._tag = None
169
170 try:
171 self._info = Mp3AudioInfo(file_obj, mp3_offset, self._tag)
172 except Mp3Exception as ex:
173 # Only logging a warning here since we can still operate on
174 # the tag.
175 log.warning(ex)
176 self._info = None
177
178 self.type = core.AUDIO_MP3
179
180 def initTag(self, version=id3.ID3_DEFAULT_VERSION):
181 """Add a id3.Tag to the file (removing any existing tag if one exists).
182 """
183 self.tag = id3.Tag()
184 self.tag.version = version
185 self.tag.file_info = id3.FileInfo(self.path)
186 return self.tag
187
188 @core.AudioFile.tag.setter
189 def tag(self, t):
190 if t:
191 t.file_info = id3.FileInfo(self.path)
192 if self._tag and self._tag.file_info:
193 t.file_info.tag_size = self._tag.file_info.tag_size
194 t.file_info.tag_padding_size = \
195 self._tag.file_info.tag_padding_size
196 self._tag = t
+0
-868
src/eyed3/mp3/headers.py less more
0 ################################################################################
1 # Copyright (C) 2002-2015 Travis Shirk <travis@pobox.com>
2 #
3 # This program is free software; you can redistribute it and/or modify
4 # it under the terms of the GNU General Public License as published by
5 # the Free Software Foundation; either version 2 of the License, or
6 # (at your option) any later version.
7 #
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
12 #
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, see <http://www.gnu.org/licenses/>.
15 #
16 ################################################################################
17 from math import log10
18
19 from . import Mp3Exception
20
21 from ..utils.binfuncs import bytes2bin, bytes2dec, bin2dec
22 from .. import compat
23
24 from ..utils.log import getLogger
25 log = getLogger(__name__)
26
27
28 def isValidHeader(header):
29 """Determine if ``header`` (an integer, 4 bytes compared) is a valid mp3
30 frame header."""
31 # Test for the mp3 frame sync: 11 set bits.
32 sync = (header >> 16)
33 if sync & 0xffe0 != 0xffe0:
34 # ffe0 is 11 sync bits, 12 are not used in order to support identifying
35 # mpeg v2.5 (bits 20,19)
36 return False
37
38 # All the remaining tests are not entireley required, but do help in
39 # finding false syncs
40
41 version = (header >> 19) & 0x3
42 if version == 1:
43 # This is a "reserved" version
44 log.debug("invalid mpeg version")
45 return False
46
47 layer = (header >> 17) & 0x3
48 if layer == 0:
49 # This is a "reserved" layer
50 log.debug("invalid mpeg layer")
51 return False
52
53 bitrate = (header >> 12) & 0xf
54 if bitrate in (0, 0xf):
55 # free and bad bitrate values
56 log.debug("invalid mpeg bitrate")
57 return False
58
59 sample_rate = (header >> 10) & 0x3
60 if sample_rate == 0x3:
61 # this is a "reserved" sample rate
62 log.debug("invalid mpeg sample rate")
63 return False
64
65 return True
66
67
68 def findHeader(fp, start_pos=0):
69 """Locate the first mp3 header in file stream ``fp`` starting a offset
70 ``start_pos`` (defaults to 0). Returned is a 3-tuple containing the offset
71 where the header was found, the header as an integer, and the header as 4
72 bytes. If no header is found header_int will equal 0.
73 """
74
75 def isBOM(buffer, pos):
76 try:
77 return 254 in (buffer[pos + 1], buffer[pos - 1])
78 except IndexError:
79 return False
80
81 def find_sync(fp, start_pos=0):
82 CHUNK_SIZE = 8192 # Measured as optimal
83
84 fp.seek(start_pos)
85 data = fp.read(CHUNK_SIZE)
86
87 while data:
88 pos = 0
89 while pos >= 0 and pos < CHUNK_SIZE:
90 pos = data.find(b"\xff", pos)
91 if pos == -1:
92 break
93
94 if not isBOM(data, pos):
95 header = data[pos:pos + 4]
96 if len(header) == 4:
97 return (start_pos + pos, header)
98
99 pos += 1
100
101 start_pos += len(data)
102 data = fp.read(CHUNK_SIZE)
103
104 return (None, None)
105
106 sync_pos, header_bytes = find_sync(fp, start_pos)
107 while sync_pos is not None:
108 header = bytes2dec(header_bytes)
109 if isValidHeader(header):
110 return (sync_pos, header, header_bytes)
111 sync_pos, header_bytes = find_sync(fp, start_pos + sync_pos + 2)
112 return (None, None, None)
113
114
115 def timePerFrame(mp3_header, vbr):
116 """Computes the number of seconds per mp3 frame. It can be used to
117 compute overall playtime and bitrate. The mp3 layer and sample
118 rate from ``mp3_header`` are used to compute the number of seconds
119 (fractional float point value) per mp3 frame. Be sure to set ``vbr`` True
120 when dealing with VBR, otherwise playtimes may be incorrect."""
121 # https://bitbucket.org/nicfit/eyed3/issue/32/mp3audioinfotime_secs-incorrect-for-mpeg2
122 if mp3_header.version >= 2.0 and vbr:
123 row = _mp3VersionKey(mp3_header.version)
124 else:
125 row = 0
126 return (float(SAMPLES_PER_FRAME_TABLE[row][mp3_header.layer]) /
127 float(mp3_header.sample_freq))
128
129
130 def compute_time_per_frame(mp3_header):
131 """Deprecated, use timePerFrame instead."""
132 import warnings
133 warnings.warn("Use timePerFrame instead", DeprecationWarning, stacklevel=2)
134 return timePerFrame(mp3_header, False)
135
136
137 class Mp3Header:
138 """Header container for MP3 frames."""
139 def __init__(self, header_data=None):
140 self.version = None
141 self.layer = None
142 self.error_protection = None
143 self.bit_rate = None
144 self.sample_freq = None
145 self.padding = None
146 self.private_bit = None
147 self.copyright = None
148 self.original = None
149 self.emphasis = None
150 self.mode = None
151 # This value is left as is: 0<=mode_extension<=3.
152 # See http://www.dv.co.yu/mpgscript/mpeghdr.htm for how to interpret
153 self.mode_extension = None
154 self.frame_length = None
155
156 if header_data:
157 self.decode(header_data)
158
159 # This may throw an Mp3Exception if the header is malformed.
160 def decode(self, header):
161 if not isValidHeader(header):
162 raise Mp3Exception("Invalid MPEG header")
163
164 # MPEG audio version from bits 19 and 20.
165 version = (header >> 19) & 0x3
166 self.version = [2.5, None, 2.0, 1.0][version]
167 if self.version is None:
168 raise Mp3Exception("Illegal MPEG version")
169
170 # MPEG layer
171 self.layer = 4 - ((header >> 17) & 0x3)
172 if self.layer == 4:
173 raise Mp3Exception("Illegal MPEG layer")
174
175 # Decode some simple values.
176 self.error_protection = not (header >> 16) & 0x1
177 self.padding = (header >> 9) & 0x1
178 self.private_bit = (header >> 8) & 0x1
179 self.copyright = (header >> 3) & 0x1
180 self.original = (header >> 2) & 0x1
181
182 # Obtain sampling frequency.
183 sample_bits = (header >> 10) & 0x3
184 self.sample_freq = \
185 SAMPLE_FREQ_TABLE[sample_bits][_mp3VersionKey(self.version)]
186 if not self.sample_freq:
187 raise Mp3Exception("Illegal MPEG sampling frequency")
188
189 # Compute bitrate.
190 bit_rate_row = (header >> 12) & 0xf
191 if int(self.version) == 1 and self.layer == 1:
192 bit_rate_col = 0
193 elif int(self.version) == 1 and self.layer == 2:
194 bit_rate_col = 1
195 elif int(self.version) == 1 and self.layer == 3:
196 bit_rate_col = 2
197 elif int(self.version) == 2 and self.layer == 1:
198 bit_rate_col = 3
199 elif int(self.version) == 2 and (self.layer == 2 or
200 self.layer == 3):
201 bit_rate_col = 4
202 else:
203 raise Mp3Exception("Mp3 version %f and layer %d is an invalid "
204 "combination" % (self.version, self.layer))
205 self.bit_rate = BIT_RATE_TABLE[bit_rate_row][bit_rate_col]
206 if self.bit_rate is None:
207 raise Mp3Exception("Invalid bit rate")
208 # We know know the bit rate specified in this frame, but if the file
209 # is VBR we need to obtain the average from the Xing header.
210 # This is done by the caller since right now all we have is the frame
211 # header.
212
213 # Emphasis; whatever that means??
214 emph = header & 0x3
215 if emph == 0:
216 self.emphasis = EMPHASIS_NONE
217 elif emph == 1:
218 self.emphasis = EMPHASIS_5015
219 elif emph == 2:
220 self.emphasis = EMPHASIS_CCIT
221 else:
222 raise Mp3Exception("Illegal mp3 emphasis value: %d" % emph)
223
224 # Channel mode.
225 mode_bits = (header >> 6) & 0x3
226 if mode_bits == 0:
227 self.mode = MODE_STEREO
228 elif mode_bits == 1:
229 self.mode = MODE_JOINT_STEREO
230 elif mode_bits == 2:
231 self.mode = MODE_DUAL_CHANNEL_STEREO
232 else:
233 self.mode = MODE_MONO
234 self.mode_extension = (header >> 4) & 0x3
235
236 # Layer II has restrictions wrt to mode and bit rate. This code
237 # enforces them.
238 if self.layer == 2:
239 m = self.mode
240 br = self.bit_rate
241 if (br in [32, 48, 56, 80] and (m != MODE_MONO)):
242 raise Mp3Exception("Invalid mode/bitrate combination for layer "
243 "II")
244 if (br in [224, 256, 320, 384] and (m == MODE_MONO)):
245 raise Mp3Exception("Invalid mode/bitrate combination for layer "
246 "II")
247
248 br = self.bit_rate * 1000
249 sf = self.sample_freq
250 p = self.padding
251 if self.layer == 1:
252 # Layer 1 uses 32 bit slots for padding.
253 p = self.padding * 4
254 self.frame_length = int((((12 * br) / sf) + p) * 4)
255 else:
256 # Layer 2 and 3 uses 8 bit slots for padding.
257 p = self.padding * 1
258 self.frame_length = int(((144 * br) / sf) + p)
259
260 # Dump the state.
261 log.debug("MPEG audio version: " + str(self.version))
262 log.debug("MPEG audio layer: " + ("I" * self.layer))
263 log.debug("MPEG sampling frequency: " + str(self.sample_freq))
264 log.debug("MPEG bit rate: " + str(self.bit_rate))
265 log.debug("MPEG channel mode: " + self.mode)
266 log.debug("MPEG channel mode extension: " + str(self.mode_extension))
267 log.debug("MPEG CRC error protection: " + str(self.error_protection))
268 log.debug("MPEG original: " + str(self.original))
269 log.debug("MPEG copyright: " + str(self.copyright))
270 log.debug("MPEG private bit: " + str(self.private_bit))
271 log.debug("MPEG padding: " + str(self.padding))
272 log.debug("MPEG emphasis: " + str(self.emphasis))
273 log.debug("MPEG frame length: " + str(self.frame_length))
274
275
276 class VbriHeader(object):
277 def __init__(self):
278 self.vbr = True
279 self.version = None
280
281 ##
282 # \brief Decode the VBRI info from \a frame.
283 # http://www.codeproject.com/audio/MPEGAudioInfo.asp#VBRIHeader
284 def decode(self, frame):
285
286 # The header is 32 bytes after the end of the first MPEG audio header,
287 # therefore 4 + 32 = 36
288 offset = 36
289 head = frame[offset:offset + 4]
290 if head != 'VBRI':
291 return False
292 log.debug("VBRI header detected @ %x" % (offset))
293 offset += 4
294
295 self.version = bin2dec(bytes2bin(frame[offset:offset + 2]))
296 offset += 2
297
298 self.delay = bin2dec(bytes2bin(frame[offset:offset + 2]))
299 offset += 2
300
301 self.quality = bin2dec(bytes2bin(frame[offset:offset + 2]))
302 offset += 2
303
304 self.num_bytes = bin2dec(bytes2bin(frame[offset:offset + 4]))
305 offset += 4
306
307 self.num_frames = bin2dec(bytes2bin(frame[offset:offset + 4]))
308 offset += 4
309
310 return True
311
312
313 class XingHeader:
314 """Header class for the Xing header extensions."""
315
316 def __init__(self):
317 self.numFrames = int()
318 self.numBytes = int()
319 self.toc = [0] * 100
320 self.vbrScale = int()
321
322 # Pass in the first mp3 frame from the file as a byte string.
323 # If an Xing header is present in the file it'll be in the first mp3
324 # frame. This method returns true if the Xing header is found in the
325 # frame, and false otherwise.
326 def decode(self, frame):
327 # mp3 version
328 version = (compat.byteOrd(frame[1]) >> 3) & 0x1
329 # channel mode.
330 mode = (compat.byteOrd(frame[3]) >> 6) & 0x3
331
332 # Find the start of the Xing header.
333 if version:
334 # +4 in all of these to skip initial mp3 frame header.
335 if mode != 3:
336 pos = 32 + 4
337 else:
338 pos = 17 + 4
339 else:
340 if mode != 3:
341 pos = 17 + 4
342 else:
343 pos = 9 + 4
344 head = frame[pos:pos + 4]
345 self.vbr = (head == b'Xing') and True or False
346 if head not in [b'Xing', b'Info']:
347 return False
348 log.debug("%s header detected @ %x" % (head, pos))
349 pos += 4
350
351 # Read Xing flags.
352 headFlags = bin2dec(bytes2bin(frame[pos:pos + 4]))
353 pos += 4
354 log.debug("%s header flags: 0x%x" % (head, headFlags))
355
356 # Read frames header flag and value if present
357 if headFlags & FRAMES_FLAG:
358 self.numFrames = bin2dec(bytes2bin(frame[pos:pos + 4]))
359 pos += 4
360 log.debug("%s numFrames: %d" % (head, self.numFrames))
361
362 # Read bytes header flag and value if present
363 if headFlags & BYTES_FLAG:
364 self.numBytes = bin2dec(bytes2bin(frame[pos:pos + 4]))
365 pos += 4
366 log.debug("%s numBytes: %d" % (head, self.numBytes))
367
368 # Read TOC header flag and value if present
369 if headFlags & TOC_FLAG:
370 self.toc = frame[pos:pos + 100]
371 pos += 100
372 log.debug("%s TOC (100 bytes): PRESENT" % head)
373 else:
374 log.debug("%s TOC (100 bytes): NOT PRESENT" % head)
375
376 # Read vbr scale header flag and value if present
377 if headFlags & VBR_SCALE_FLAG and head == b'Xing':
378 self.vbrScale = bin2dec(bytes2bin(frame[pos:pos + 4]))
379 pos += 4
380 log.debug("%s vbrScale: %d" % (head, self.vbrScale))
381
382 return True
383
384
385 ##
386 # \brief Mp3 Info tag (AKA LAME Tag)
387 #
388 # Lame (and some other encoders) write a tag containing various bits of info
389 # about the options used at encode time. If available, the following are
390 # parsed and stored in the LameHeader dict:
391 #
392 # encoder_version: short encoder version [str]
393 # tag_revision: revision number of the tag [int]
394 # vbr_method: VBR method used for encoding [str]
395 # lowpass_filter: lowpass filter frequency in Hz [int]
396 # replaygain: if available, radio and audiofile gain (see below) [dict]
397 # encoding_flags: encoding flags used [list]
398 # nogap: location of gaps when --nogap was used [list]
399 # ath_type: ATH type [int]
400 # bitrate: bitrate and type (Constant, Target, Minimum) [tuple]
401 # encoder_delay: samples added at the start of the mp3 [int]
402 # encoder_padding: samples added at the end of the mp3 [int]
403 # noise_shaping: noise shaping method [int]
404 # stereo_mode: stereo mode used [str]
405 # unwise_settings: whether unwise settings were used [boolean]
406 # sample_freq: source sample frequency [str]
407 # mp3_gain: mp3 gain adjustment (rarely used) [float]
408 # preset: preset used [str]
409 # surround_info: surround information [str]
410 # music_length: length in bytes of original mp3 [int]
411 # music_crc: CRC-16 of the mp3 music data [int]
412 # infotag_crc: CRC-16 of the info tag [int]
413 #
414 # Prior to ~3.90, Lame simply stored the encoder version in the first frame.
415 # If the infotag_crc is invalid, then we try to read this version string. A
416 # simple way to tell if the LAME Tag is complete is to check for the
417 # infotag_crc key.
418 #
419 # Replay Gain data is only available since Lame version 3.94b. If set, the
420 # replaygain dict has the following structure:
421 #
422 # \code
423 # peak_amplitude: peak signal amplitude [float]
424 # radio:
425 # name: name of the gain adjustment [str]
426 # adjustment: gain adjustment [float]
427 # originator: originator of the gain adjustment [str]
428 # audiofile: [same as radio]
429 # \endcode
430 #
431 # Note that as of 3.95.1, Lame uses 89dB as a reference level instead of the
432 # 83dB that is specified in the Replay Gain spec. This is not automatically
433 # compensated for. You can do something like this if you want:
434 #
435 # \code
436 # import eyeD3
437 # af = eyeD3.mp3.Mp3AudioFile('/path/to/some.mp3')
438 # lamever = af.lameTag['encoder_version']
439 # name, ver = lamever[:4], lamever[4:]
440 # gain = af.lameTag['replaygain']['radio']['adjustment']
441 # if name == 'LAME' and eyeD3.mp3.lamevercmp(ver, '3.95') > 0:
442 # gain -= 6
443 # \endcode
444 #
445 # Radio and Audiofile Replay Gain are often referrered to as Track and Album
446 # gain, respectively. See http://replaygain.hydrogenaudio.org/ for futher
447 # details on Replay Gain.
448 #
449 # See http://gabriel.mp3-tech.org/mp3infotag.html for the gory details of the
450 # LAME Tag.
451 class LameHeader(dict):
452
453 # from the LAME source:
454 # http://lame.cvs.sourceforge.net/*checkout*/lame/lame/libmp3lame/VbrTag.c
455 _crc16_table = [
456 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
457 0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440,
458 0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40,
459 0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841,
460 0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40,
461 0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41,
462 0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641,
463 0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040,
464 0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240,
465 0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441,
466 0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41,
467 0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840,
468 0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41,
469 0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40,
470 0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640,
471 0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041,
472 0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240,
473 0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441,
474 0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41,
475 0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840,
476 0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41,
477 0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40,
478 0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640,
479 0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041,
480 0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241,
481 0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440,
482 0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40,
483 0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841,
484 0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40,
485 0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41,
486 0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641,
487 0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040]
488
489 ENCODER_FLAGS = {
490 'NSPSYTUNE': 0x0001,
491 'NSSAFEJOINT': 0x0002,
492 'NOGAP_NEXT': 0x0004,
493 'NOGAP_PREV': 0x0008}
494
495 PRESETS = {
496 0: 'Unknown',
497 # 8 to 320 are reserved for ABR bitrates
498 410: 'V9',
499 420: 'V8',
500 430: 'V7',
501 440: 'V6',
502 450: 'V5',
503 460: 'V4',
504 470: 'V3',
505 480: 'V2',
506 490: 'V1',
507 500: 'V0',
508 1000: 'r3mix',
509 1001: 'standard',
510 1002: 'extreme',
511 1003: 'insane',
512 1004: 'standard/fast',
513 1005: 'extreme/fast',
514 1006: 'medium',
515 1007: 'medium/fast'}
516
517 REPLAYGAIN_NAME = {
518 0: 'Not set',
519 1: 'Radio',
520 2: 'Audiofile'}
521
522 REPLAYGAIN_ORIGINATOR = {
523 0: 'Not set',
524 1: 'Set by artist',
525 2: 'Set by user',
526 3: 'Set automatically',
527 100: 'Set by simple RMS average'}
528
529 SAMPLE_FREQUENCIES = {
530 0: '<= 32 kHz',
531 1: '44.1 kHz',
532 2: '48 kHz',
533 3: '> 48 kHz'}
534
535 STEREO_MODES = {
536 0: 'Mono',
537 1: 'Stereo',
538 2: 'Dual',
539 3: 'Joint',
540 4: 'Force',
541 5: 'Auto',
542 6: 'Intensity',
543 7: 'Undefined'}
544
545 SURROUND_INFO = {
546 0: 'None',
547 1: 'DPL encoding',
548 2: 'DPL2 encoding',
549 3: 'Ambisonic encoding',
550 8: 'Reserved'}
551
552 VBR_METHODS = {
553 0: 'Unknown',
554 1: 'Constant Bitrate',
555 2: 'Average Bitrate',
556 3: 'Variable Bitrate method1 (old/rh)',
557 4: 'Variable Bitrate method2 (mtrh)',
558 5: 'Variable Bitrate method3 (mt)',
559 6: 'Variable Bitrate method4',
560 8: 'Constant Bitrate (2 pass)',
561 9: 'Average Bitrate (2 pass)',
562 15: 'Reserved'}
563
564 def __init__(self, frame):
565 """Read the LAME info tag.
566 frame should be the first frame of an mp3.
567 """
568 self.decode(frame)
569
570 def _crc16(self, data, val=0):
571 """Compute a CRC-16 checksum on a data stream."""
572 for c in compat.byteiter(data):
573 val = self._crc16_table[ord(c) ^ (val & 0xff)] ^ (val >> 8)
574 return val
575
576 def decode(self, frame):
577 """Decode the LAME info tag."""
578 try:
579 pos = frame.index(b"LAME")
580 except: # noqa: B901
581 return
582
583 log.debug('Lame info tag found at position %d' % pos)
584
585 # check the info tag crc.Iif it's not valid, no point parsing much more.
586 lamecrc = bin2dec(bytes2bin(frame[190:192]))
587 if self._crc16(frame[:190]) != lamecrc:
588 log.warning('Lame tag CRC check failed')
589
590 try:
591 # Encoder short VersionString, 9 bytes
592 self['encoder_version'] = \
593 compat.unicode(frame[pos:pos + 9].rstrip(), "latin1")
594 log.debug('Lame Encoder Version: %s' % self['encoder_version'])
595 pos += 9
596
597 # Info Tag revision + VBR method, 1 byte
598 self['tag_revision'] = bin2dec(bytes2bin(frame[pos:pos + 1])[:5])
599 vbr_method = bin2dec(bytes2bin(frame[pos:pos + 1])[5:])
600 self['vbr_method'] = self.VBR_METHODS.get(vbr_method, 'Unknown')
601 log.debug('Lame info tag version: %s' % self['tag_revision'])
602 log.debug('Lame VBR method: %s' % self['vbr_method'])
603 pos += 1
604
605 # Lowpass filter value, 1 byte
606 self['lowpass_filter'] = bin2dec(
607 bytes2bin(frame[pos:pos + 1])) * 100
608 log.debug('Lame Lowpass filter value: %s Hz' %
609 self['lowpass_filter'])
610 pos += 1
611
612 # Replay Gain, 8 bytes total
613 replaygain = {}
614
615 # Peak signal amplitude, 4 bytes
616 peak = bin2dec(bytes2bin(frame[pos:pos + 4])) << 5
617 if peak > 0:
618 peak /= float(1 << 28)
619 db = 20 * log10(peak)
620 replaygain['peak_amplitude'] = peak
621 log.debug('Lame Peak signal amplitude: %.8f (%+.1f dB)' %
622 (peak, db))
623 pos += 4
624
625 # Radio and Audiofile Gain, AKA track and album, 2 bytes each
626 for gaintype in ['radio', 'audiofile']:
627 name = bin2dec(bytes2bin(frame[pos:pos + 2])[:3])
628 orig = bin2dec(bytes2bin(frame[pos:pos + 2])[3:6])
629 sign = bin2dec(bytes2bin(frame[pos:pos + 2])[6:7])
630 adj = bin2dec(bytes2bin(frame[pos:pos + 2])[7:]) / 10.0
631 if sign:
632 adj *= -1
633 # FIXME Lame 3.95.1 and above use 89dB as a reference instead of
634 # 83dB as defined by the Replay Gain spec. Should this be
635 # compensated for?
636 # lamever =self['encoder_version']
637 # if (lamever[:4] == 'LAME' and
638 # lamevercmp(lamever[4:], '3.95') > 0):
639 # adj -= 6
640 if orig:
641 name = self.REPLAYGAIN_NAME.get(name, 'Unknown')
642 orig = self.REPLAYGAIN_ORIGINATOR.get(orig, 'Unknown')
643 replaygain[gaintype] = {'name': name, 'adjustment': adj,
644 'originator': orig}
645 log.debug('Lame %s Replay Gain: %s dB (%s)' %
646 (name, adj, orig))
647 pos += 2
648 if replaygain:
649 self['replaygain'] = replaygain
650
651 # Encoding flags + ATH Type, 1 byte
652 encflags = bin2dec(bytes2bin(frame[pos:pos + 1])[:4])
653 (self['encoding_flags'],
654 self['nogap']) = self._parse_encflags(encflags)
655 self['ath_type'] = bin2dec(bytes2bin(frame[pos:pos + 1])[4:])
656 log.debug('Lame Encoding flags: %s' %
657 ' '.join(self['encoding_flags']))
658 if self['nogap']:
659 log.debug('Lame No gap: %s' % ' and '.join(self['nogap']))
660 log.debug('Lame ATH type: %s' % self['ath_type'])
661 pos += 1
662
663 # if ABR {specified bitrate} else {minimal bitrate}, 1 byte
664 btype = 'Constant'
665 if 'Average' in self['vbr_method']:
666 btype = 'Target'
667 elif 'Variable' in self['vbr_method']:
668 btype = 'Minimum'
669 # bitrate may be modified below after preset is read
670 self['bitrate'] = (bin2dec(bytes2bin(frame[pos:pos + 1])), btype)
671 log.debug('Lame Bitrate (%s): %s' % (btype, self['bitrate'][0]))
672 pos += 1
673
674 # Encoder delays, 3 bytes
675 self['encoder_delay'] = bin2dec(bytes2bin(frame[pos:pos + 3])[:12])
676 self['encoder_padding'] = bin2dec(
677 bytes2bin(frame[pos:pos + 3])[12:])
678 log.debug('Lame Encoder delay: %s samples' % self['encoder_delay'])
679 log.debug('Lame Encoder padding: %s samples' %
680 self['encoder_padding'])
681 pos += 3
682
683 # Misc, 1 byte
684 sample_freq = bin2dec(bytes2bin(frame[pos:pos + 1])[:2])
685 unwise_settings = bin2dec(bytes2bin(frame[pos:pos + 1])[2:3])
686 stereo_mode = bin2dec(bytes2bin(frame[pos:pos + 1])[3:6])
687 self['noise_shaping'] = bin2dec(bytes2bin(frame[pos:pos + 1])[6:])
688 self['sample_freq'] = self.SAMPLE_FREQUENCIES.get(sample_freq,
689 'Unknown')
690 self['unwise_settings'] = bool(unwise_settings)
691 self['stereo_mode'] = self.STEREO_MODES.get(stereo_mode, 'Unknown')
692 log.debug('Lame Source Sample Frequency: %s' % self['sample_freq'])
693 log.debug('Lame Unwise settings used: %s' % self['unwise_settings'])
694 log.debug('Lame Stereo mode: %s' % self['stereo_mode'])
695 log.debug('Lame Noise Shaping: %s' % self['noise_shaping'])
696 pos += 1
697
698 # MP3 Gain, 1 byte
699 sign = bytes2bin(frame[pos:pos + 1])[0]
700 gain = bin2dec(bytes2bin(frame[pos:pos + 1])[1:])
701 if sign:
702 gain *= -1
703 self['mp3_gain'] = gain
704 db = gain * 1.5
705 log.debug('Lame MP3 Gain: %s (%+.1f dB)' % (self['mp3_gain'], db))
706 pos += 1
707
708 # Preset and surround info, 2 bytes
709 surround = bin2dec(bytes2bin(frame[pos:pos + 2])[2:5])
710 preset = bin2dec(bytes2bin(frame[pos:pos + 2])[5:])
711 if preset in range(8, 321):
712 if self['bitrate'][0] >= 255:
713 # the value from preset is better in this case
714 self['bitrate'] = (preset, btype)
715 log.debug('Lame Bitrate (%s): %s' %
716 (btype, self['bitrate'][0]))
717 if 'Average' in self['vbr_method']:
718 preset = 'ABR %s' % preset
719 else:
720 preset = 'CBR %s' % preset
721 else:
722 preset = self.PRESETS.get(preset, preset)
723 self['surround_info'] = self.SURROUND_INFO.get(surround, surround)
724 self['preset'] = preset
725 log.debug('Lame Surround Info: %s' % self['surround_info'])
726 log.debug('Lame Preset: %s' % self['preset'])
727 pos += 2
728
729 # MusicLength, 4 bytes
730 self['music_length'] = bin2dec(bytes2bin(frame[pos:pos + 4]))
731 log.debug('Lame Music Length: %s bytes' % self['music_length'])
732 pos += 4
733
734 # MusicCRC, 2 bytes
735 self['music_crc'] = bin2dec(bytes2bin(frame[pos:pos + 2]))
736 log.debug('Lame Music CRC: %04X' % self['music_crc'])
737 pos += 2
738
739 # CRC-16 of Info Tag, 2 bytes
740 self['infotag_crc'] = lamecrc # we read this earlier
741 log.debug('Lame Info Tag CRC: %04X' % self['infotag_crc'])
742 pos += 2
743 except IndexError:
744 log.warning("Truncated LAME info header, values incomplete.")
745
746 def _parse_encflags(self, flags):
747 """Parse encoder flags.
748
749 Returns a tuple containing lists of encoder flags and nogap data in
750 human readable format.
751 """
752
753 encoder_flags, nogap = [], []
754
755 if not flags:
756 return encoder_flags, nogap
757
758 if flags & self.ENCODER_FLAGS['NSPSYTUNE']:
759 encoder_flags.append('--nspsytune')
760 if flags & self.ENCODER_FLAGS['NSSAFEJOINT']:
761 encoder_flags.append('--nssafejoint')
762
763 NEXT = self.ENCODER_FLAGS['NOGAP_NEXT']
764 PREV = self.ENCODER_FLAGS['NOGAP_PREV']
765 if flags & (NEXT | PREV):
766 encoder_flags.append('--nogap')
767 if flags & PREV:
768 nogap.append('before')
769 if flags & NEXT:
770 nogap.append('after')
771 return encoder_flags, nogap
772
773
774 ##
775 # \brief Compare LAME version strings.
776 #
777 # alpha and beta versions are considered older.
778 # Versions with sub minor parts or end with 'r' are considered newer.
779 #
780 # \param x The first version to compare.
781 # \param y The second version to compare.
782 # \returns Return negative if x<y, zero if x==y, positive if x>y.
783 def lamevercmp(x, y):
784 x = x.ljust(5)
785 y = y.ljust(5)
786 if x[:5] == y[:5]:
787 return 0
788 ret = compat.cmp(x[:4], y[:4])
789 if ret:
790 return ret
791 xmaj, xmin = x.split('.')[:2]
792 ymaj, ymin = y.split('.')[:2]
793 minparts = ['.']
794 # lame 3.96.1 added the use of r in the very short version for post releases
795 if (xmaj == '3' and xmin >= '96') or (ymaj == '3' and ymin >= '96'):
796 minparts.append('r')
797 if x[4] in minparts:
798 return 1
799 if y[4] in minparts:
800 return -1
801 if x[4] == ' ':
802 return 1
803 if y[4] == ' ':
804 return -1
805 return compat.cmp(x[4], y[4])
806
807
808 # MPEG1 MPEG2 MPEG2.5
809 SAMPLE_FREQ_TABLE = ((44100, 22050, 11025),
810 (48000, 24000, 12000),
811 (32000, 16000, 8000),
812 (None, None, None))
813
814 # V1/L1 V1/L2 V1/L3 V2/L1 V2/L2&L3
815 BIT_RATE_TABLE = ((0, 0, 0, 0, 0), # noqa
816 (32, 32, 32, 32, 8), # noqa
817 (64, 48, 40, 48, 16), # noqa
818 (96, 56, 48, 56, 24), # noqa
819 (128, 64, 56, 64, 32), # noqa
820 (160, 80, 64, 80, 40), # noqa
821 (192, 96, 80, 96, 48), # noqa
822 (224, 112, 96, 112, 56), # noqa
823 (256, 128, 112, 128, 64), # noqa
824 (288, 160, 128, 144, 80), # noqa
825 (320, 192, 160, 160, 96), # noqa
826 (352, 224, 192, 176, 112), # noqa
827 (384, 256, 224, 192, 128), # noqa
828 (416, 320, 256, 224, 144), # noqa
829 (448, 384, 320, 256, 160), # noqa
830 (None, None, None, None, None))
831
832 # Rows 1 and 2 (mpeg 2.x) are only used for those versions *and* VBR.
833 # L1 L2 L3
834 SAMPLES_PER_FRAME_TABLE = ((None, 384, 1152, 1152), # MPEG 1
835 (None, 384, 1152, 576), # MPEG 2
836 (None, 384, 1152, 576), # MPEG 2.5
837 )
838
839 # Emphasis constants
840 EMPHASIS_NONE = "None"
841 EMPHASIS_5015 = "50/15 ms"
842 EMPHASIS_CCIT = "CCIT J.17"
843
844 # Mode constants
845 MODE_STEREO = "Stereo"
846 MODE_JOINT_STEREO = "Joint stereo"
847 MODE_DUAL_CHANNEL_STEREO = "Dual channel stereo"
848 MODE_MONO = "Mono"
849
850 # Xing flag bits
851 FRAMES_FLAG = 0x0001
852 BYTES_FLAG = 0x0002
853 TOC_FLAG = 0x0004
854 VBR_SCALE_FLAG = 0x0008
855
856
857 def _mp3VersionKey(version):
858 """Map mp3 version float to a data structure index.
859 1 -> 0, 2 -> 1, 2.5 -> 2
860 """
861 key = None
862 if version == 2.5:
863 key = 2
864 else:
865 key = int(version - 1)
866 assert(0 <= key <= 2)
867 return key
+0
-31
src/eyed3/plugins/DisplayPattern.ebnf less more
0 (*
1 ################################################################################
2 # Copyright (C) 2016 Sebastian Patschorke <physicspatschi@gmx.de>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18
19 parser generation:
20 $ python -m grako -o src/eyed3/plugins/display_parser.py src/eyed3/plugins/DisplayPattern.ebnf
21
22 *)
23
24 start = pattern $ ;
25 pattern = { text | tag | function }* ;
26 tag = tag:( "%" name:string { "," parameters+:(parameter) }* "%" );
27 function = function:("$" name:string "(" [ parameters+:(parameter) { "," parameters+:(parameter) }* ] ")" );
28 parameter = [ {" "}* name:string "=" ] [ value:pattern ] ;
29 text = text:?/(\\\\|\\%|\\\$|\\,|\\\(|\\\)|\\=|\\n|\\t|[^\\%$,()])+/? ;
30 string = ?/([^\\%$,()=])+/? ;
+0
-200
src/eyed3/plugins/__init__.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2012 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 from __future__ import print_function
19 import os
20 import sys
21
22 from eyed3 import core, utils
23 from eyed3.utils import guessMimetype
24 from eyed3.utils.console import printMsg, printError
25 from eyed3.utils.log import getLogger
26
27 _PLUGINS = {}
28
29 log = getLogger(__name__)
30
31
32 def load(name=None, reload=False, paths=None):
33 """Returns the eyed3.plugins.Plugin *class* identified by ``name``.
34 If ``name`` is ``None`` then the full list of plugins is returned.
35 Once a plugin is loaded its class object is cached, and future calls to
36 this function will returned the cached version. Use ``reload=True`` to
37 refresh the cache."""
38 global _PLUGINS
39
40 if len(list(_PLUGINS.keys())) and not reload:
41 # Return from the cache if possible
42 try:
43 return _PLUGINS[name] if name else _PLUGINS
44 except KeyError:
45 # It's not in the cache, look again and refresh cash
46 _PLUGINS = {}
47 else:
48 _PLUGINS = {}
49
50 def _isValidModule(f, d):
51 """Determine if file ``f`` is a valid module file name."""
52 # 1) tis a file
53 # 2) does not start with '_', or '.'
54 # 3) avoid the .pyc dup
55 return bool(os.path.isfile(os.path.join(d, f)) and
56 f[0] not in ('_', '.') and f.endswith(".py"))
57
58 log.debug("Extra plugin paths: %s" % paths)
59 for d in [os.path.dirname(__file__)] + (paths if paths else []):
60 log.debug("Searching '%s' for plugins", d)
61 if not os.path.isdir(d):
62 continue
63
64 if d not in sys.path:
65 sys.path.append(d)
66 try:
67 for f in os.listdir(d):
68 if not _isValidModule(f, d):
69 continue
70
71 mod_name = os.path.splitext(f)[0]
72 try:
73 mod = __import__(mod_name, globals=globals(),
74 locals=locals())
75 except ImportError as ex:
76 log.warning("Plugin '%s' requires packages that are not "
77 "installed: %s" % ((f, d), ex))
78 continue
79 except Exception:
80 log.exception("Bad plugin '%s'", (f, d))
81 continue
82
83 for attr in [getattr(mod, a) for a in dir(mod)]:
84 if (type(attr) == type and issubclass(attr, Plugin)):
85 # This is a eyed3.plugins.Plugin
86 PluginClass = attr
87 if (PluginClass not in list(_PLUGINS.values()) and
88 len(PluginClass.NAMES)):
89 log.debug("loading plugin '%s' from '%s%s%s'",
90 mod, d, os.path.sep, f)
91 # Setting the main name outside the loop to ensure
92 # there is at least one, otherwise a KeyError is
93 # thrown.
94 main_name = PluginClass.NAMES[0]
95 _PLUGINS[main_name] = PluginClass
96 for alias in PluginClass.NAMES[1:]:
97 # Add alternate names
98 _PLUGINS[alias] = PluginClass
99
100 # If 'plugin' is found return it immediately
101 if name and name in PluginClass.NAMES:
102 return PluginClass
103
104 finally:
105 if d in sys.path:
106 sys.path.remove(d)
107
108 log.debug("Plugins loaded: %s", _PLUGINS)
109 if name:
110 # If a specific plugin was requested and we've not returned yet...
111 return None
112 return _PLUGINS
113
114
115 class Plugin(utils.FileHandler):
116 """Base class for all eyeD3 plugins"""
117
118 SUMMARY = u"eyeD3 plugin"
119 """One line about the plugin"""
120
121 DESCRIPTION = u""
122 """Detailed info about the plugin"""
123
124 NAMES = []
125 """A list of **at least** one name for invoking the plugin, values [1:]
126 are treated as alias"""
127
128 def __init__(self, arg_parser):
129 self.arg_parser = arg_parser
130 self.arg_group = arg_parser.add_argument_group(
131 "Plugin options", u"%s\n%s" % (self.SUMMARY, self.DESCRIPTION))
132
133 def start(self, args, config):
134 """Called after command line parsing but before any paths are
135 processed. The ``self.args`` argument (the parsed command line) and
136 ``self.config`` (the user config, if any) is set here."""
137 self.args = args
138 self.config = config
139
140 def handleFile(self, f):
141 pass
142
143 def handleDone(self):
144 """Called after all file/directory processing; before program exit.
145 The return value is passed to sys.exit (None results in 0)."""
146 pass
147
148
149 class LoaderPlugin(Plugin):
150 """A base class that provides auto loading of audio files"""
151
152 def __init__(self, arg_parser, cache_files=False, track_images=False):
153 """Constructor. If ``cache_files`` is True (off by default) then each
154 AudioFile is appended to ``_file_cache`` during ``handleFile`` and
155 the list is cleared by ``handleDirectory``."""
156 super(LoaderPlugin, self).__init__(arg_parser)
157 self._num_loaded = 0
158 self._file_cache = [] if cache_files else None
159 self._dir_images = [] if track_images else None
160
161 def handleFile(self, f, *args, **kwargs):
162 """Loads ``f`` and sets ``self.audio_file`` to an instance of
163 :class:`eyed3.core.AudioFile` or ``None`` if an error occurred or the
164 file is not a recognized type.
165
166 The ``*args`` and ``**kwargs`` are passed to :func:`eyed3.core.load`.
167 """
168 self.audio_file = None
169
170 try:
171 self.audio_file = core.load(f, *args, **kwargs)
172 except NotImplementedError as ex:
173 # Frame decryption, for instance...
174 printError(str(ex))
175 return
176
177 if self.audio_file:
178 self._num_loaded += 1
179 if self._file_cache is not None:
180 self._file_cache.append(self.audio_file)
181 elif self._dir_images is not None:
182 mt = guessMimetype(f)
183 if mt and mt.startswith("image/"):
184 self._dir_images.append(f)
185
186 def handleDirectory(self, d, _):
187 """Override to make use of ``self._file_cache``. By default the list
188 is cleared, subclasses should consider doing the same otherwise every
189 AudioFile will be cached."""
190 if self._file_cache is not None:
191 self._file_cache = []
192
193 if self._dir_images is not None:
194 self._dir_images = []
195
196 def handleDone(self):
197 """If no audio files were loaded this simply prints 'Nothing to do'."""
198 if self._num_loaded == 0:
199 printMsg("Nothing to do")
+0
-228
src/eyed3/plugins/_display_parser.py less more
0 #!/usr/bin/env python
1 # -*- coding: utf-8 -*-
2
3 # CAVEAT UTILITOR
4 #
5 # This file was automatically generated by Grako.
6 #
7 # https://pypi.python.org/pypi/grako/
8 #
9 # Any changes you make to it will be overwritten the next time
10 # the file is generated.
11
12
13 from __future__ import (print_function, division, absolute_import,
14 unicode_literals)
15
16 from grako.parsing import graken, Parser
17 from grako.util import re, RE_FLAGS # noqa
18
19
20 __version__ = (2016, 2, 17, 20, 35, 22, 2)
21
22 __all__ = [
23 'DisplayPatternParser',
24 'DisplayPatternSemantics',
25 'main'
26 ]
27
28
29 class DisplayPatternParser(Parser):
30 def __init__(self,
31 whitespace=None,
32 nameguard=None,
33 comments_re=None,
34 eol_comments_re=None,
35 ignorecase=None,
36 left_recursion=True,
37 **kwargs):
38 super(DisplayPatternParser, self).__init__(
39 whitespace=whitespace,
40 nameguard=nameguard,
41 comments_re=comments_re,
42 eol_comments_re=eol_comments_re,
43 ignorecase=ignorecase,
44 left_recursion=left_recursion,
45 **kwargs
46 )
47
48 @graken()
49 def _start_(self):
50 self._pattern_()
51 self._check_eof()
52
53 @graken()
54 def _pattern_(self):
55
56 def block0():
57 with self._choice():
58 with self._option():
59 self._text_()
60 with self._option():
61 self._tag_()
62 with self._option():
63 self._function_()
64 self._error('no available options')
65 self._closure(block0)
66
67 @graken()
68 def _tag_(self):
69 with self._group():
70 self._token('%')
71 self._string_()
72 self.ast['name'] = self.last_node
73
74 def block2():
75 self._token(',')
76 with self._group():
77 self._parameter_()
78 self.ast.setlist('parameters', self.last_node)
79 self._closure(block2)
80 self._token('%')
81 self.ast['tag'] = self.last_node
82
83 self.ast._define(
84 ['tag', 'name'],
85 ['parameters']
86 )
87
88 @graken()
89 def _function_(self):
90 with self._group():
91 self._token('$')
92 self._string_()
93 self.ast['name'] = self.last_node
94 self._token('(')
95 with self._optional():
96 with self._group():
97 self._parameter_()
98 self.ast.setlist('parameters', self.last_node)
99
100 def block3():
101 self._token(',')
102 with self._group():
103 self._parameter_()
104 self.ast.setlist('parameters', self.last_node)
105 self._closure(block3)
106 self._token(')')
107 self.ast['function'] = self.last_node
108
109 self.ast._define(
110 ['function', 'name'],
111 ['parameters']
112 )
113
114 @graken()
115 def _parameter_(self):
116 with self._optional():
117
118 def block0():
119 self._token(' ')
120 self._closure(block0)
121 self._string_()
122 self.ast['name'] = self.last_node
123 self._token('=')
124 with self._optional():
125 self._pattern_()
126 self.ast['value'] = self.last_node
127
128 self.ast._define(
129 ['name', 'value'],
130 []
131 )
132
133 @graken()
134 def _text_(self):
135 self._pattern(r'(\\\\|\\%|\\\$|\\,|\\\(|\\\)|\\=|\\n|\\t|[^\\%$,()])+')
136 self.ast['text'] = self.last_node
137
138 self.ast._define(
139 ['text'],
140 []
141 )
142
143 @graken()
144 def _string_(self):
145 self._pattern(r'([^\\%$,()=])+')
146
147
148 class DisplayPatternSemantics(object):
149 def start(self, ast):
150 return ast
151
152 def pattern(self, ast):
153 return ast
154
155 def tag(self, ast):
156 return ast
157
158 def function(self, ast):
159 return ast
160
161 def parameter(self, ast):
162 return ast
163
164 def text(self, ast):
165 return ast
166
167 def string(self, ast):
168 return ast
169
170
171 def main(filename, startrule, trace=False, whitespace=None, nameguard=None):
172 import json
173 with open(filename) as f:
174 text = f.read()
175 parser = DisplayPatternParser(parseinfo=False)
176 ast = parser.parse(
177 text,
178 startrule,
179 filename=filename,
180 trace=trace,
181 whitespace=whitespace,
182 nameguard=nameguard)
183 print('AST:')
184 print(ast)
185 print()
186 print('JSON:')
187 print(json.dumps(ast, indent=2))
188 print()
189
190
191 if __name__ == '__main__':
192 import argparse
193 import string
194 import sys
195
196 class ListRules(argparse.Action):
197 def __call__(self, parser, namespace, values, option_string):
198 print('Rules:')
199 for r in DisplayPatternParser.rule_list():
200 print(r)
201 print()
202 sys.exit(0)
203
204 parser = argparse.ArgumentParser(
205 description="Simple parser for DisplayPattern.")
206 parser.add_argument('-l', '--list', action=ListRules, nargs=0,
207 help="list all rules and exit")
208 parser.add_argument('-n', '--no-nameguard', action='store_true',
209 dest='no_nameguard',
210 help="disable the 'nameguard' feature")
211 parser.add_argument('-t', '--trace', action='store_true',
212 help="output trace information")
213 parser.add_argument('-w', '--whitespace', type=str,
214 default=string.whitespace,
215 help="whitespace specification")
216 parser.add_argument('file', metavar="FILE", help="the input file to parse")
217 parser.add_argument('startrule', metavar="STARTRULE",
218 help="the start rule for parsing")
219 args = parser.parse_args()
220
221 main(
222 args.file,
223 args.startrule,
224 trace=args.trace,
225 whitespace=args.whitespace,
226 nameguard=not args.no_nameguard
227 )
+0
-287
src/eyed3/plugins/art.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2014 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 from __future__ import print_function
19 import io
20 import os
21 import hashlib
22 from pathlib import Path
23
24 from eyed3.utils import art
25 from eyed3 import compat, log
26 from eyed3.utils import guessMimetype
27 from eyed3.plugins import LoaderPlugin
28 from eyed3.core import VARIOUS_ARTISTS
29 from eyed3.id3.frames import ImageFrame
30 from eyed3.utils import makeUniqueFileName
31 from eyed3.utils.console import printMsg, printWarning, cformat, Fore
32
33 DESCR_FNAME_PREFIX = "filename: "
34 md5_file_cache = {}
35
36 _have_PIL = False
37 try:
38 import PIL # noqa
39 _have_PIL = True
40 except ImportError:
41 log.info("Install `pillow` and get images details.")
42
43 _have_lastfm = False
44 try:
45 from eyed3.plugins.lastfm import getAlbumArt
46 import requests
47 _have_lastfm = True
48 except ImportError:
49 log.info("Install `pylast` and activate the --download option")
50
51
52 class ArtFile(object):
53 def __init__(self, file_path):
54 self.art_type = art.matchArtFile(file_path)
55 self.file_path = file_path
56 self.id3_art_type = (art.TO_ID3_ART_TYPES[self.art_type][0]
57 if self.art_type else None)
58 self._img_data = None
59 self._mime_type = None
60
61 @property
62 def image_data(self):
63 if self._img_data:
64 return self._img_data
65 with open(self.file_path, "rb") as f:
66 self._img_data = f.read()
67 return self._img_data
68
69 @property
70 def mime_type(self):
71 if self._mime_type:
72 return self._mime_type
73 self._mime_type = guessMimetype(self.file_path)
74 return self._mime_type
75
76
77 class ArtPlugin(LoaderPlugin):
78 SUMMARY = u"Art for albums, artists, etc."
79 DESCRIPTION = u""
80 NAMES = ["art"]
81
82 def __init__(self, arg_parser):
83 super(ArtPlugin, self).__init__(arg_parser, cache_files=True,
84 track_images=True)
85 self._retval = 0
86
87 g = self.arg_group
88 g.add_argument("-F", "--update-files", action="store_true",
89 help="Write art files from tag images.")
90 g.add_argument("-T", "--update-tags", action="store_true",
91 help="Write tag image from art files.")
92 if _have_lastfm:
93 g.add_argument("-D", "--download", action="store_true",
94 help="Attempt to download album art if missing.")
95 g.add_argument("-v", "--verbose", action="store_true",
96 help="Show detailed information for all art found.")
97
98 def start(self, args, config):
99 if args.update_files and args.update_tags:
100 # Not using add_mutually_exclusive_group from argparse because
101 # the options belong to the plugin opts group (self.arg_group)
102 raise StopIteration("The --update-tags and --update-files options "
103 "are mutually exclusive, use only one at a "
104 "time.")
105 super(ArtPlugin, self).start(args, config)
106
107 def _verbose(self, s):
108 if self.args.verbose:
109 printMsg(s)
110
111 def handleDirectory(self, d, _):
112 global md5_file_cache
113 md5_file_cache.clear()
114
115 if not self._file_cache:
116 log.debug("%s: nothing to do." % d)
117 return
118
119 try:
120 all_tags = sorted([f.tag for f in self._file_cache if f.tag],
121 key=lambda x: x.file_info.name)
122
123 # If not deemed an album, move on.
124 if len(set([t.album for t in all_tags])) > 1:
125 log.debug("Skipping directory '%s', non-album." % d)
126 return
127
128 printMsg(cformat("\nChecking: ", Fore.BLUE) + d)
129
130 # File images
131 dir_art = []
132 for img_file in self._dir_images:
133 img_base = os.path.basename(img_file)
134 art_file = ArtFile(img_file)
135 try:
136 pil_img = pilImage(img_file)
137 except IOError as ex:
138 printWarning(compat.unicode(ex))
139 continue
140
141 if art_file.art_type:
142 self._verbose("file %s: %s\n\t%s" %
143 (img_base, art_file.art_type,
144 pilImageDetails(pil_img)))
145 dir_art.append(art_file)
146 else:
147 self._verbose("file %s: unknown (ignored)" % img_base)
148
149 if not dir_art:
150 print(cformat("NONE", Fore.RED))
151 self._retval += 1
152 else:
153 print(cformat("OK", Fore.GREEN))
154
155 # --download handling
156 if not dir_art and self.args.download and _have_lastfm:
157 tag = all_tags[0]
158 artists = set([t.artist for t in all_tags])
159 if len(artists) > 1:
160 artist_query = VARIOUS_ARTISTS
161 else:
162 artist_query = tag.album_artist or tag.artist
163
164 try:
165 url = getAlbumArt(artist_query, tag.album)
166 resp = requests.get(url)
167 if resp.status_code != 200:
168 raise ValueError()
169 except ValueError:
170 print("Album art download not found")
171 else:
172 print("Downloading album art...")
173 img = pilImage(io.BytesIO(resp.content))
174 cover = Path(d) / "cover.{}".format(img.format.lower())
175 assert not cover.exists()
176 img.save(str(cover))
177 print("Save {cover}".format(cover=cover))
178
179 # Tag images
180 for tag in all_tags:
181 file_base = os.path.basename(tag.file_info.name)
182 for img in tag.images:
183 try:
184 pil_img = pilImage(img)
185 pil_img_details = pilImageDetails(pil_img)
186 except (OSError, IOError) as ex:
187 printWarning(compat.unicode(ex))
188 continue
189
190 if img.picture_type in art.FROM_ID3_ART_TYPES:
191 img_type = art.FROM_ID3_ART_TYPES[img.picture_type]
192 self._verbose("tag %s: %s (Description: %s)\n\t%s" %
193 (file_base, img_type, img.description,
194 pil_img_details))
195 if self.args.update_files:
196 assert(not self.args.update_tags)
197 path = os.path.dirname(tag.file_info.name)
198 if img.description.startswith(DESCR_FNAME_PREFIX):
199 # Use filename from Image description
200 fname = img.description[
201 len(DESCR_FNAME_PREFIX):].strip()
202 fname = os.path.splitext(fname)[0]
203 else:
204 fname = art.FILENAMES[img_type][0].strip("*")
205 fname = img.makeFileName(name=fname)
206
207 if (md5File(os.path.join(path, fname)) ==
208 md5Data(img.image_data)):
209 printMsg("Skipping writing of %s, file "
210 "exists and is exactly the same." %
211 fname)
212 else:
213 img_file = makeUniqueFileName(
214 os.path.join(path, fname),
215 uniq=img.description)
216 printWarning("Writing %s..." % img_file)
217 with open(img_file, "wb") as fp:
218 fp.write(img.image_data)
219 else:
220 self._verbose(
221 "tag %s: unhandled image type %d (ignored)" %
222 (file_base, img.picture_type)
223 )
224
225 # Copy file art to tags.
226 if self.args.update_tags:
227 assert(not self.args.update_files)
228 for tag in all_tags:
229 for art_file in dir_art:
230 art_path = os.path.basename(art_file.file_path)
231 printMsg("Copying %s to tag '%s' image" %
232 (art_path, art_file.id3_art_type))
233
234 descr = "filename: %s" % os.path.splitext(art_path)[0]
235 tag.images.set(art_file.id3_art_type,
236 art_file.image_data, art_file.mime_type,
237 description=descr)
238 tag.save()
239
240 finally:
241 # Cleans up...
242 super(ArtPlugin, self).handleDirectory(d, _)
243
244 def handleDone(self):
245 return self._retval
246
247
248 def pilImage(source):
249 if not _have_PIL:
250 return None
251
252 from PIL import Image
253 if isinstance(source, ImageFrame):
254 return Image.open(io.BytesIO(source.image_data))
255 else:
256 return Image.open(source)
257
258
259 def pilImageDetails(img):
260 return "[%dx%d %s md5:%s]" % (img.size[0], img.size[1],
261 img.format.lower(),
262 md5Data(img.tobytes())) if img else ""
263
264
265 def md5Data(data):
266 md5 = hashlib.md5()
267 md5.update(data)
268 return md5.hexdigest()
269
270
271 def md5File(file_name):
272 """Compute md5 hash for contents of ``file_name``."""
273
274 global md5_file_cache
275 if file_name in md5_file_cache:
276 return md5_file_cache[file_name]
277
278 md5 = hashlib.md5()
279 try:
280 with open(file_name, "rb") as f:
281 md5.update(f.read())
282
283 md5_file_cache[file_name] = md5.hexdigest()
284 return md5_file_cache[file_name]
285 except IOError:
286 return None
+0
-1185
src/eyed3/plugins/classic.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2007-2016 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 from __future__ import print_function
19
20 import os
21 import re
22 from functools import partial
23 from argparse import ArgumentTypeError
24
25 from eyed3 import LOCAL_ENCODING
26 from eyed3.plugins import LoaderPlugin
27 from eyed3 import core, id3, mp3, utils, compat
28 from eyed3.utils import makeUniqueFileName
29 from eyed3.utils.console import (printMsg, printError, printWarning, boldText,
30 HEADER_COLOR, Fore, getTtySize)
31 from eyed3.id3.frames import ImageFrame
32
33 from eyed3.utils.log import getLogger
34 log = getLogger(__name__)
35
36 FIELD_DELIM = ':'
37
38 DEFAULT_MAX_PADDING = 64 * 1024
39
40
41 class ClassicPlugin(LoaderPlugin):
42 SUMMARY = u"Classic eyeD3 interface for viewing and editing tags."
43 DESCRIPTION = u"""
44 All PATH arguments are parsed and displayed. Directory paths are searched
45 recursively. Any editing options (--artist, --title) are applied to each file
46 read.
47
48 All date options (-Y, --release-year excepted) follow ISO 8601 format. This is
49 ``yyyy-mm-ddThh:mm:ss``. The year is required, and each component thereafter is
50 optional. For example, 2012-03 is valid, 2012--12 is not.
51 """
52 NAMES = ["classic"]
53
54 def __init__(self, arg_parser):
55 super(ClassicPlugin, self).__init__(arg_parser)
56 g = self.arg_group
57
58 def UnicodeArg(arg):
59 return _unicodeArgValue(arg)
60
61 def PositiveIntArg(i):
62 i = int(i)
63 if i < 0:
64 raise ArgumentTypeError("positive number required")
65 return i
66
67 # Common options
68 g.add_argument("-a", "--artist", type=UnicodeArg, dest="artist",
69 metavar="STRING", help=ARGS_HELP["--artist"])
70 g.add_argument("-A", "--album", type=UnicodeArg, dest="album",
71 metavar="STRING", help=ARGS_HELP["--album"])
72 g.add_argument("-b", "--album-artist", type=UnicodeArg,
73 dest="album_artist", metavar="STRING",
74 help=ARGS_HELP["--album-artist"])
75 g.add_argument("-t", "--title", type=UnicodeArg, dest="title",
76 metavar="STRING", help=ARGS_HELP["--title"])
77 g.add_argument("-n", "--track", type=PositiveIntArg, dest="track",
78 metavar="NUM", help=ARGS_HELP["--track"])
79 g.add_argument("-N", "--track-total", type=PositiveIntArg,
80 dest="track_total", metavar="NUM",
81 help=ARGS_HELP["--track-total"])
82
83 g.add_argument("--track-offset", type=int, dest="track_offset",
84 metavar="N", help=ARGS_HELP["--track-offset"])
85
86 g.add_argument("--composer", type=UnicodeArg, dest="composer",
87 metavar="STRING", help=ARGS_HELP["--composer"])
88 g.add_argument("-d", "--disc-num", type=PositiveIntArg, dest="disc_num",
89 metavar="NUM", help=ARGS_HELP["--disc-num"])
90 g.add_argument("-D", "--disc-total", type=PositiveIntArg,
91 dest="disc_total", metavar="NUM",
92 help=ARGS_HELP["--disc-total"])
93 g.add_argument("-G", "--genre", type=UnicodeArg, dest="genre",
94 metavar="GENRE", help=ARGS_HELP["--genre"])
95 g.add_argument("--non-std-genres", dest="non_std_genres",
96 action="store_true", help=ARGS_HELP["--non-std-genres"])
97 g.add_argument("-Y", "--release-year", type=PositiveIntArg,
98 dest="release_year", metavar="YEAR",
99 help=ARGS_HELP["--release-year"])
100 g.add_argument("-c", "--comment", dest="simple_comment",
101 type=UnicodeArg, metavar="STRING",
102 help=ARGS_HELP["--comment"])
103 g.add_argument("--rename", dest="rename_pattern", metavar="PATTERN",
104 help=ARGS_HELP["--rename"])
105
106 gid3 = arg_parser.add_argument_group("ID3 options")
107
108 def _splitArgs(arg, maxsplit=None):
109 NEW_DELIM = "#DELIM#"
110 arg = re.sub(r"\\%s" % FIELD_DELIM, NEW_DELIM, arg)
111 t = tuple(re.sub(NEW_DELIM, FIELD_DELIM, s)
112 for s in arg.split(FIELD_DELIM))
113 if maxsplit is not None and maxsplit < 2:
114 raise ValueError("Invalid maxsplit value: {}".format(maxsplit))
115 elif maxsplit and len(t) > maxsplit:
116 t = t[:maxsplit - 1] + (FIELD_DELIM.join(t[maxsplit - 1:]),)
117 assert len(t) <= maxsplit
118 return t
119
120 def _unicodeArgValue(arg):
121 if not isinstance(arg, compat.UnicodeType):
122 return compat.unicode(arg, LOCAL_ENCODING)
123 else:
124 return arg
125
126 def DescLangArg(arg):
127 """DESCRIPTION[:LANG]"""
128 arg = _unicodeArgValue(arg)
129 vals = _splitArgs(arg, 2)
130 desc = vals[0]
131 lang = vals[1] if len(vals) > 1 else id3.DEFAULT_LANG
132 return (desc, compat.b(lang)[:3] or id3.DEFAULT_LANG)
133
134 def DescTextArg(arg):
135 """DESCRIPTION:TEXT"""
136 arg = _unicodeArgValue(arg)
137 vals = _splitArgs(arg, 2)
138 desc = vals[0].strip()
139 text = FIELD_DELIM.join(vals[1:] if len(vals) > 1 else [])
140 return (desc or u"", text or u"")
141 KeyValueArg = DescTextArg
142
143 def DescUrlArg(arg):
144 desc, url = DescTextArg(arg)
145 return (desc, url.encode("latin1"))
146
147 def FidArg(arg):
148 arg = _unicodeArgValue(arg)
149 fid = arg.strip().encode("ascii")
150 if not fid:
151 raise ArgumentTypeError("No frame ID")
152 return fid
153
154 def TextFrameArg(arg):
155 """FID:TEXT"""
156 arg = _unicodeArgValue(arg)
157 vals = _splitArgs(arg, 2)
158 fid = vals[0].strip().encode("ascii")
159 if not fid:
160 raise ArgumentTypeError("No frame ID")
161 text = vals[1] if len(vals) > 1 else u""
162 return (fid, text)
163
164 def UrlFrameArg(arg):
165 """FID:TEXT"""
166 fid, url = TextFrameArg(arg)
167 return (fid, url.encode("latin1"))
168
169 def DateArg(date_str):
170 return core.Date.parse(date_str) if date_str else ""
171
172 def CommentArg(arg):
173 """
174 COMMENT[:DESCRIPTION[:LANG]
175 """
176 arg = _unicodeArgValue(arg)
177 vals = _splitArgs(arg, 3)
178 text = vals[0]
179 if not text:
180 raise ArgumentTypeError("text required")
181 desc = vals[1] if len(vals) > 1 else u""
182 lang = vals[2] if len(vals) > 2 else id3.DEFAULT_LANG
183 return (text, desc, compat.b(lang)[:3])
184
185 def LyricsArg(arg):
186 text, desc, lang = CommentArg(arg)
187 try:
188 with open(text, "rb") as fp:
189 data = fp.read()
190 except Exception: # noqa: B901
191 raise ArgumentTypeError("Unable to read file")
192 return (_unicodeArgValue(data), desc, lang)
193
194 def PlayCountArg(pc):
195 if not pc:
196 raise ArgumentTypeError("value required")
197 increment = False
198 if pc[0] == "+":
199 pc = int(pc[1:])
200 increment = True
201 else:
202 pc = int(pc)
203 if pc < 0:
204 raise ArgumentTypeError("out of range")
205 return (increment, pc)
206
207 def BpmArg(bpm):
208 bpm = int(float(bpm) + 0.5)
209 if bpm <= 0:
210 raise ArgumentTypeError("out of range")
211 return bpm
212
213 def DirArg(d):
214 if not d or not os.path.isdir(d):
215 raise ArgumentTypeError("invalid directory: %s" % d)
216 return d
217
218 def ImageArg(s):
219 """PATH:TYPE[:DESCRIPTION]
220 Returns (path, type_id, mime_type, description)
221 """
222 args = _splitArgs(s, 3)
223 if len(args) < 2:
224 raise ArgumentTypeError("Format is: PATH:TYPE[:DESCRIPTION]")
225
226 path, type_str = args[:2]
227 desc = UnicodeArg(args[2]) if len(args) > 2 else u""
228 mt = None
229 try:
230 type_id = id3.frames.ImageFrame.stringToPicType(type_str)
231 except: # noqa: B901
232 raise ArgumentTypeError("invalid pic type: {}".format(type_str))
233
234 if not path:
235 raise ArgumentTypeError("path required")
236 elif True in [path.startswith(prefix)
237 for prefix in ["http://", "https://"]]:
238 mt = ImageFrame.URL_MIME_TYPE
239 else:
240 if not os.path.isfile(path):
241 raise ArgumentTypeError("file does not exist")
242 mt = utils.guessMimetype(path)
243 if mt is None:
244 raise ArgumentTypeError("Cannot determine mime-type")
245
246 return (path, type_id, mt, desc)
247
248 def ObjectArg(s):
249 """OBJ_PATH:MIME-TYPE[:DESCRIPTION[:FILENAME]],
250 Returns (path, mime_type, description, filename)
251 """
252 args = _splitArgs(s, 4)
253 if len(args) < 2:
254 raise ArgumentTypeError("too few parts")
255
256 path = args[0]
257 mt = None
258 desc = None
259 filename = None
260 if path:
261 mt = args[1]
262 desc = UnicodeArg(args[2]) if len(args) > 2 else u""
263 filename = UnicodeArg(args[3]) \
264 if len(args) > 3 \
265 else UnicodeArg(os.path.basename(path))
266 if not os.path.isfile(path):
267 raise ArgumentTypeError("file does not exist")
268 if not mt:
269 raise ArgumentTypeError("mime-type required")
270 else:
271 raise ArgumentTypeError("path required")
272 return (path, mt, desc, filename)
273
274 def UniqFileIdArg(arg):
275 owner_id, id = KeyValueArg(arg)
276 if not owner_id:
277 raise ArgumentTypeError("owner_id required")
278 id = id.encode("latin1") # don't want to pass unicode
279 if len(id) > 64:
280 raise ArgumentTypeError("id must be <= 64 bytes")
281 return (owner_id, id)
282
283 def PopularityArg(arg):
284 """EMAIL:RATING[:PLAY_COUNT]
285 Returns (email, rating, play_count)
286 """
287 args = _splitArgs(arg, 3)
288 if len(args) < 2:
289 raise ArgumentTypeError("Incorrect number of argument "
290 "components")
291 email = args[0]
292 rating = int(float(args[1]))
293 if rating < 0 or rating > 255:
294 raise ArgumentTypeError("Rating out-of-range")
295 play_count = 0
296 if len(args) > 2:
297 play_count = int(args[2])
298 if play_count < 0:
299 raise ArgumentTypeError("Play count out-of-range")
300 return (email, rating, play_count)
301
302 # Tag versions
303 gid3.add_argument("-1", "--v1", action="store_const", const=id3.ID3_V1,
304 dest="tag_version", default=id3.ID3_ANY_VERSION,
305 help=ARGS_HELP["--v1"])
306 gid3.add_argument("-2", "--v2", action="store_const", const=id3.ID3_V2,
307 dest="tag_version", default=id3.ID3_ANY_VERSION,
308 help=ARGS_HELP["--v2"])
309 gid3.add_argument("--to-v1.1", action="store_const", const=id3.ID3_V1_1,
310 dest="convert_version", help=ARGS_HELP["--to-v1.1"])
311 gid3.add_argument("--to-v2.3", action="store_const", const=id3.ID3_V2_3,
312 dest="convert_version", help=ARGS_HELP["--to-v2.3"])
313 gid3.add_argument("--to-v2.4", action="store_const", const=id3.ID3_V2_4,
314 dest="convert_version", help=ARGS_HELP["--to-v2.4"])
315
316 # Dates
317 gid3.add_argument("--release-date", type=DateArg, dest="release_date",
318 metavar="DATE",
319 help=ARGS_HELP["--release-date"])
320 gid3.add_argument("--orig-release-date", type=DateArg,
321 dest="orig_release_date", metavar="DATE",
322 help=ARGS_HELP["--orig-release-date"])
323 gid3.add_argument("--recording-date", type=DateArg,
324 dest="recording_date", metavar="DATE",
325 help=ARGS_HELP["--recording-date"])
326 gid3.add_argument("--encoding-date", type=DateArg, dest="encoding_date",
327 metavar="DATE", help=ARGS_HELP["--encoding-date"])
328 gid3.add_argument("--tagging-date", type=DateArg, dest="tagging_date",
329 metavar="DATE", help=ARGS_HELP["--tagging-date"])
330
331 # Misc
332 gid3.add_argument("--publisher", action="store", type=UnicodeArg,
333 dest="publisher", metavar="STRING",
334 help=ARGS_HELP["--publisher"])
335 gid3.add_argument("--play-count", type=PlayCountArg, dest="play_count",
336 metavar="<+>N", default=None,
337 help=ARGS_HELP["--play-count"])
338 gid3.add_argument("--bpm", type=BpmArg, dest="bpm", metavar="N",
339 default=None, help=ARGS_HELP["--bpm"])
340 gid3.add_argument("--unique-file-id", action="append",
341 type=UniqFileIdArg, dest="unique_file_ids",
342 metavar="OWNER_ID:ID", default=[],
343 help=ARGS_HELP["--unique-file-id"])
344
345 # Comments
346 gid3.add_argument("--add-comment", action="append", dest="comments",
347 metavar="COMMENT[:DESCRIPTION[:LANG]]", default=[],
348 type=CommentArg, help=ARGS_HELP["--add-comment"])
349 gid3.add_argument("--remove-comment", action="append", type=DescLangArg,
350 dest="remove_comment", default=[],
351 metavar="DESCRIPTION[:LANG]",
352 help=ARGS_HELP["--remove-comment"])
353 gid3.add_argument("--remove-all-comments", action="store_true",
354 dest="remove_all_comments",
355 help=ARGS_HELP["--remove-all-comments"])
356
357 gid3.add_argument("--add-lyrics", action="append", type=LyricsArg,
358 dest="lyrics", default=[],
359 metavar="LYRICS_FILE[:DESCRIPTION[:LANG]]",
360 help=ARGS_HELP["--add-lyrics"])
361 gid3.add_argument("--remove-lyrics", action="append", type=DescLangArg,
362 dest="remove_lyrics", default=[],
363 metavar="DESCRIPTION[:LANG]",
364 help=ARGS_HELP["--remove-lyrics"])
365 gid3.add_argument("--remove-all-lyrics", action="store_true",
366 dest="remove_all_lyrics",
367 help=ARGS_HELP["--remove-all-lyrics"])
368
369 gid3.add_argument("--text-frame", action="append", type=TextFrameArg,
370 dest="text_frames", metavar="FID:TEXT", default=[],
371 help=ARGS_HELP["--text-frame"])
372 gid3.add_argument("--user-text-frame", action="append",
373 type=DescTextArg,
374 dest="user_text_frames", metavar="DESC:TEXT",
375 default=[], help=ARGS_HELP["--user-text-frame"])
376
377 gid3.add_argument("--url-frame", action="append", type=UrlFrameArg,
378 dest="url_frames", metavar="FID:URL", default=[],
379 help=ARGS_HELP["--url-frame"])
380 gid3.add_argument("--user-url-frame", action="append", type=DescUrlArg,
381 dest="user_url_frames", metavar="DESCRIPTION:URL",
382 default=[], help=ARGS_HELP["--user-url-frame"])
383
384 gid3.add_argument("--add-image", action="append", type=ImageArg,
385 dest="images", metavar="IMG_PATH:TYPE[:DESCRIPTION]",
386 default=[], help=ARGS_HELP["--add-image"])
387 gid3.add_argument("--remove-image", action="append", type=UnicodeArg,
388 dest="remove_image", default=[],
389 metavar="DESCRIPTION",
390 help=ARGS_HELP["--remove-image"])
391 gid3.add_argument("--remove-all-images", action="store_true",
392 dest="remove_all_images",
393 help=ARGS_HELP["--remove-all-images"])
394 gid3.add_argument("--write-images", dest="write_images_dir",
395 metavar="DIR", type=DirArg,
396 help=ARGS_HELP["--write-images"])
397
398 gid3.add_argument("--add-object", action="append", type=ObjectArg,
399 dest="objects", default=[],
400 metavar="OBJ_PATH:MIME-TYPE[:DESCRIPTION[:FILENAME]]",
401 help=ARGS_HELP["--add-object"])
402 gid3.add_argument("--remove-object", action="append", type=UnicodeArg,
403 dest="remove_object", default=[],
404 metavar="DESCRIPTION",
405 help=ARGS_HELP["--remove-object"])
406 gid3.add_argument("--write-objects", action="store",
407 dest="write_objects_dir", metavar="DIR", default=None,
408 help=ARGS_HELP["--write-objects"])
409 gid3.add_argument("--remove-all-objects", action="store_true",
410 dest="remove_all_objects",
411 help=ARGS_HELP["--remove-all-objects"])
412
413 gid3.add_argument("--add-popularity", action="append",
414 type=PopularityArg, dest="popularities", default=[],
415 metavar="EMAIL:RATING[:PLAY_COUNT]",
416 help=ARGS_HELP["--add-popularty"])
417 gid3.add_argument("--remove-popularity", action="append", type=str,
418 dest="remove_popularity", default=[],
419 metavar="EMAIL",
420 help=ARGS_HELP["--remove-popularity"])
421
422 gid3.add_argument("--remove-v1", action="store_true", dest="remove_v1",
423 default=False, help=ARGS_HELP["--remove-v1"])
424 gid3.add_argument("--remove-v2", action="store_true", dest="remove_v2",
425 default=False, help=ARGS_HELP["--remove-v2"])
426 gid3.add_argument("--remove-all", action="store_true", default=False,
427 dest="remove_all", help=ARGS_HELP["--remove-all"])
428 gid3.add_argument("--remove-frame", action="append", default=[],
429 dest="remove_fids", metavar="FID", type=FidArg,
430 help=ARGS_HELP["--remove-frame"])
431
432 # 'True' means 'apply default max_padding, but only if saving anyhow'
433 gid3.add_argument("--max-padding", type=int, dest="max_padding",
434 default=True, metavar="NUM_BYTES",
435 help=ARGS_HELP["--max-padding"])
436 gid3.add_argument("--no-max-padding", dest="max_padding",
437 action="store_const", const=None,
438 help=ARGS_HELP["--no-max-padding"])
439
440 _encodings = ["latin1", "utf8", "utf16", "utf16-be"]
441 gid3.add_argument("--encoding", dest="text_encoding", default=None,
442 choices=_encodings, metavar='|'.join(_encodings),
443 help=ARGS_HELP["--encoding"])
444
445 # Misc options
446 gid4 = arg_parser.add_argument_group("Misc options")
447 gid4.add_argument("--force-update", action="store_true", default=False,
448 dest="force_update", help=ARGS_HELP["--force-update"])
449 gid4.add_argument("-v", "--verbose", action="store_true",
450 dest="verbose", help=ARGS_HELP["--verbose"])
451 gid4.add_argument("--preserve-file-times", action="store_true",
452 dest="preserve_file_time",
453 help=ARGS_HELP["--preserve-file-times"])
454
455 def handleFile(self, f):
456 parse_version = self.args.tag_version
457
458 super(ClassicPlugin, self).handleFile(f, tag_version=parse_version)
459 if not self.audio_file:
460 return
461
462 self.terminal_width = getTtySize()[1]
463 self.printHeader(f)
464 printMsg("-" * self.terminal_width)
465
466 if self.audio_file.tag and self.handleRemoves(self.audio_file.tag):
467 # Reload after removal
468 super(ClassicPlugin, self).handleFile(f, tag_version=parse_version)
469 if not self.audio_file:
470 return
471
472 new_tag = False
473 if not self.audio_file.tag:
474 self.audio_file.initTag(version=parse_version)
475 new_tag = True
476
477 try:
478 save_tag = (self.handleEdits(self.audio_file.tag) or
479 self.handlePadding(self.audio_file.tag) or
480 self.args.force_update or self.args.convert_version)
481 except ValueError as ex:
482 printError(str(ex))
483 return
484
485 self.printAudioInfo(self.audio_file.info)
486
487 if not save_tag and new_tag:
488 printError("No ID3 %s tag found!" %
489 id3.versionToString(self.args.tag_version))
490 return
491
492 self.printTag(self.audio_file.tag)
493
494 if self.args.write_images_dir:
495 for img in self.audio_file.tag.images:
496 if img.mime_type not in ImageFrame.URL_MIME_TYPE_VALUES:
497 img_path = "%s%s" % (self.args.write_images_dir,
498 os.path.sep)
499 if not os.path.isdir(img_path):
500 raise IOError("Directory does not exist: %s" % img_path)
501 img_file = makeUniqueFileName(
502 os.path.join(img_path, img.makeFileName()))
503 printWarning("Writing %s..." % img_file)
504 with open(img_file, "wb") as fp:
505 fp.write(img.image_data)
506
507 if save_tag:
508 # Use current tag version unless a convert was supplied
509 version = (self.args.convert_version or
510 self.audio_file.tag.version)
511 printWarning("Writing ID3 version %s" %
512 id3.versionToString(version))
513
514 # DEFAULT_MAX_PADDING is not set up as argument default,
515 # because we don't want to rewrite the file if the user
516 # did not trigger that explicitly:
517 max_padding = self.args.max_padding
518 if max_padding is True:
519 max_padding = DEFAULT_MAX_PADDING
520
521 self.audio_file.tag.save(
522 version=version, encoding=self.args.text_encoding,
523 backup=self.args.backup,
524 preserve_file_time=self.args.preserve_file_time,
525 max_padding=max_padding)
526
527 if self.args.rename_pattern:
528 # Handle file renaming.
529 from eyed3.id3.tag import TagTemplate
530 template = TagTemplate(self.args.rename_pattern)
531 name = template.substitute(self.audio_file.tag, zeropad=True)
532 orig = self.audio_file.path
533 try:
534 self.audio_file.rename(name)
535 printWarning("Renamed '%s' to '%s'" %
536 (orig, self.audio_file.path))
537 except IOError as ex:
538 printError(str(ex))
539
540 printMsg("-" * self.terminal_width)
541
542 def printHeader(self, file_path):
543 file_len = len(file_path)
544 from stat import ST_SIZE
545 file_size = os.stat(file_path)[ST_SIZE]
546 size_str = utils.formatSize(file_size)
547 size_len = len(size_str) + 5
548 if file_len + size_len >= self.terminal_width:
549 file_path = "..." + file_path[-(75 - size_len):]
550 file_len = len(file_path)
551 pat_len = self.terminal_width - file_len - size_len
552 printMsg("%s%s%s[ %s ]%s" %
553 (boldText(file_path, c=HEADER_COLOR()),
554 HEADER_COLOR(), " " * pat_len, size_str, Fore.RESET))
555
556 def printAudioInfo(self, info):
557 if isinstance(info, mp3.Mp3AudioInfo):
558 printMsg(boldText("Time: ") +
559 "%s\tMPEG%d, Layer %s\t[ %s @ %s Hz - %s ]" %
560 (utils.formatTime(info.time_secs),
561 info.mp3_header.version,
562 "I" * info.mp3_header.layer,
563 info.bit_rate_str,
564 info.mp3_header.sample_freq, info.mp3_header.mode))
565 printMsg("-" * self.terminal_width)
566
567 @staticmethod
568 def _getDefaultNameForObject(obj_frame, suffix=""):
569 if obj_frame.filename:
570 name_str = obj_frame.filename
571 else:
572 name_str = obj_frame.description
573 name_str += ".%s" % obj_frame.mime_type.split("/")[1]
574 if suffix:
575 name_str += suffix
576 return name_str
577
578 def printTag(self, tag):
579 if isinstance(tag, id3.Tag):
580 if self.args.quiet:
581 printMsg("ID3 %s: %d frames" %
582 (id3.versionToString(tag.version),
583 len(tag.frame_set)))
584 return
585
586 printMsg("ID3 %s:" % id3.versionToString(tag.version))
587 artist = tag.artist if tag.artist else u""
588 title = tag.title if tag.title else u""
589 album = tag.album if tag.album else u""
590 printMsg("%s: %s" % (boldText("title"), title))
591 printMsg("%s: %s" % (boldText("artist"), artist))
592 printMsg("%s: %s" % (boldText("album"), album))
593 if tag.album_artist:
594 printMsg("%s: %s" % (boldText("album artist"),
595 tag.album_artist))
596 if tag.composer:
597 printMsg("%s: %s" % (boldText("composer"), tag.composer))
598
599 for date, date_label in [
600 (tag.release_date, "release date"),
601 (tag.original_release_date, "original release date"),
602 (tag.recording_date, "recording date"),
603 (tag.encoding_date, "encoding date"),
604 (tag.tagging_date, "tagging date"),
605 ]:
606 if date:
607 printMsg("%s: %s" % (boldText(date_label), str(date)))
608
609 track_str = ""
610 (track_num, track_total) = tag.track_num
611 if track_num is not None:
612 track_str = str(track_num)
613 if track_total:
614 track_str += "/%d" % track_total
615
616 genre = tag._getGenre(id3_std=not self.args.non_std_genres)
617 genre_str = "%s: %s (id %s)" % (boldText("genre"),
618 genre.name,
619 str(genre.id)) if genre else u""
620 printMsg("%s: %s\t\t%s" % (boldText("track"), track_str, genre_str))
621
622 (num, total) = tag.disc_num
623 if num is not None:
624 disc_str = str(num)
625 if total:
626 disc_str += "/%d" % total
627 printMsg("%s: %s" % (boldText("disc"), disc_str))
628
629 # PCNT
630 play_count = tag.play_count
631 if tag.play_count is not None:
632 printMsg("%s %d" % (boldText("Play Count:"), play_count))
633
634 # POPM
635 for popm in tag.popularities:
636 printMsg("%s [email: %s] [rating: %d] [play count: %d]" %
637 (boldText("Popularity:"), popm.email, popm.rating,
638 popm.count))
639
640 # TBPM
641 bpm = tag.bpm
642 if bpm is not None:
643 printMsg("%s %d" % (boldText("BPM:"), bpm))
644
645 # TPUB
646 pub = tag.publisher
647 if pub is not None:
648 printMsg("%s %s" % (boldText("Publisher/label:"), pub))
649
650 # UFID
651 for ufid in tag.unique_file_ids:
652 printMsg("%s [%s] : %s" %
653 (boldText("Unique File ID:"), ufid.owner_id,
654 ufid.uniq_id.decode("unicode_escape")))
655
656 # COMM
657 for c in tag.comments:
658 printMsg("%s: [Description: %s] [Lang: %s]\n%s" %
659 (boldText("Comment"), c.description or "",
660 c.lang.decode("ascii") or "", c.text or ""))
661
662 # USLT
663 for l in tag.lyrics:
664 printMsg("%s: [Description: %s] [Lang: %s]\n%s" %
665 (boldText("Lyrics"), l.description or u"",
666 l.lang.decode("ascii") or "", l.text))
667
668 # TXXX
669 for f in tag.user_text_frames:
670 printMsg("%s: [Description: %s]\n%s" %
671 (boldText("UserTextFrame"), f.description, f.text))
672
673 # URL frames
674 for desc, url in (("Artist URL", tag.artist_url),
675 ("Audio source URL", tag.audio_source_url),
676 ("Audio file URL", tag.audio_file_url),
677 ("Internet radio URL", tag.internet_radio_url),
678 ("Commercial URL", tag.commercial_url),
679 ("Payment URL", tag.payment_url),
680 ("Publisher URL", tag.publisher_url),
681 ("Copyright URL", tag.copyright_url),
682 ):
683 if url:
684 printMsg("%s: %s" % (boldText(desc), url))
685
686 # user url frames
687 for u in tag.user_url_frames:
688 printMsg("%s [Description: %s]: %s" % (u.id, u.description,
689 u.url))
690
691 # APIC
692 for img in tag.images:
693 if img.mime_type not in ImageFrame.URL_MIME_TYPE_VALUES:
694 printMsg("%s: [Size: %d bytes] [Type: %s]" %
695 (boldText(img.picTypeToString(img.picture_type) +
696 " Image"),
697 len(img.image_data),
698 img.mime_type))
699 printMsg("Description: %s" % img.description)
700 printMsg("")
701 else:
702 printMsg("%s: [Type: %s] [URL: %s]" %
703 (boldText(img.picTypeToString(img.picture_type) +
704 " Image"),
705 img.mime_type, img.image_url))
706 printMsg("Description: %s" % img.description)
707 printMsg("")
708
709 # GOBJ
710 for obj in tag.objects:
711 printMsg("%s: [Size: %d bytes] [Type: %s]" %
712 (boldText("GEOB"), len(obj.object_data),
713 obj.mime_type))
714 printMsg("Description: %s" % obj.description)
715 printMsg("Filename: %s" % obj.filename)
716 printMsg("\n")
717 if self.args.write_objects_dir:
718 obj_path = "%s%s" % (self.args.write_objects_dir, os.sep)
719 if not os.path.isdir(obj_path):
720 raise IOError("Directory does not exist: %s" % obj_path)
721 obj_file = self._getDefaultNameForObject(obj)
722 count = 1
723 while os.path.exists(os.path.join(obj_path, obj_file)):
724 obj_file = self._getDefaultNameForObject(obj,
725 str(count))
726 count += 1
727 printWarning("Writing %s..." % os.path.join(obj_path,
728 obj_file))
729 with open(os.path.join(obj_path, obj_file), "wb") as fp:
730 fp.write(obj.object_data)
731
732 # PRIV
733 for p in tag.privates:
734 printMsg("%s: [Data: %d bytes]" % (boldText("PRIV"),
735 len(p.data)))
736 printMsg("Owner Id: %s" % p.owner_id.decode("ascii"))
737
738 # MCDI
739 if tag.cd_id:
740 printMsg("\n%s: [Data: %d bytes]" % (boldText("MCDI"),
741 len(tag.cd_id)))
742
743 # USER
744 if tag.terms_of_use:
745 printMsg("\nTerms of Use (%s): %s" % (boldText("USER"),
746 tag.terms_of_use))
747
748 # --verbose
749 if self.args.verbose:
750 printMsg("-" * self.terminal_width)
751 printMsg("%d ID3 Frames:" % len(tag.frame_set))
752 for fid in tag.frame_set:
753 frames = tag.frame_set[fid]
754 num_frames = len(frames)
755 count = " x %d" % num_frames if num_frames > 1 else ""
756 if not tag.isV1():
757 total_bytes = sum(
758 tuple(frame.header.data_size + frame.header.size
759 for frame in frames if frame.header))
760 else:
761 total_bytes = 30
762 if total_bytes:
763 printMsg("%s%s (%d bytes)" % (fid.decode("ascii"),
764 count, total_bytes))
765 printMsg("%d bytes unused (padding)" %
766 (tag.file_info.tag_padding_size, ))
767 else:
768 raise TypeError("Unknown tag type: " + str(type(tag)))
769
770 def handleRemoves(self, tag):
771 remove_version = 0
772 status = False
773 rm_str = ""
774 if self.args.remove_all:
775 remove_version = id3.ID3_ANY_VERSION
776 rm_str = "v1.x and/or v2.x"
777 elif self.args.remove_v1:
778 remove_version = id3.ID3_V1
779 rm_str = "v1.x"
780 elif self.args.remove_v2:
781 remove_version = id3.ID3_V2
782 rm_str = "v2.x"
783
784 if remove_version:
785 status = id3.Tag.remove(
786 tag.file_info.name, remove_version,
787 preserve_file_time=self.args.preserve_file_time)
788 printWarning("Removing ID3 %s tag: %s" %
789 (rm_str, "SUCCESS" if status else "FAIL"))
790
791 return status
792
793 def handlePadding(self, tag):
794 max_padding = self.args.max_padding
795 if max_padding is None or max_padding is True:
796 return False
797 padding = tag.file_info.tag_padding_size
798 needs_change = padding > max_padding
799 return needs_change
800
801 def handleEdits(self, tag):
802 retval = False
803
804 # --remove-all-*, Handling removes first means later options are still
805 # applied
806 for what, arg, fid in (("comments", self.args.remove_all_comments,
807 id3.frames.COMMENT_FID),
808 ("lyrics", self.args.remove_all_lyrics,
809 id3.frames.LYRICS_FID),
810 ("images", self.args.remove_all_images,
811 id3.frames.IMAGE_FID),
812 ("objects", self.args.remove_all_objects,
813 id3.frames.OBJECT_FID),
814 ):
815 if arg and tag.frame_set[fid]:
816 printWarning("Removing all %s..." % what)
817 del tag.frame_set[fid]
818 retval = True
819
820 # --artist, --title, etc. All common/simple text frames.
821 for (what, setFunc) in (
822 ("artist", partial(tag._setArtist, self.args.artist)),
823 ("album", partial(tag._setAlbum, self.args.album)),
824 ("album artist", partial(tag._setAlbumArtist,
825 self.args.album_artist)),
826 ("title", partial(tag._setTitle, self.args.title)),
827 ("genre", partial(tag._setGenre, self.args.genre,
828 id3_std=not self.args.non_std_genres)),
829 ("release date", partial(tag._setReleaseDate,
830 self.args.release_date)),
831 ("original release date", partial(tag._setOrigReleaseDate,
832 self.args.orig_release_date)),
833 ("recording date", partial(tag._setRecordingDate,
834 self.args.recording_date)),
835 ("encoding date", partial(tag._setEncodingDate,
836 self.args.encoding_date)),
837 ("tagging date", partial(tag._setTaggingDate,
838 self.args.tagging_date)),
839 ("beats per minute", partial(tag._setBpm, self.args.bpm)),
840 ("publisher", partial(tag._setPublisher, self.args.publisher)),
841 ("composer", partial(tag._setComposer, self.args.composer)),
842 ):
843 if setFunc.args[0] is not None:
844 printWarning("Setting %s: %s" % (what, setFunc.args[0]))
845 setFunc()
846 retval = True
847
848 def _checkNumberedArgTuples(curr, new):
849 n = None
850 if new not in [(None, None), curr]:
851 n = [None] * 2
852 for i in (0, 1):
853 if new[i] == 0:
854 n[i] = None
855 else:
856 n[i] = new[i] or curr[i]
857 n = tuple(n)
858 # Returing None means do nothing, (None, None) would clear both vals
859 return n
860
861 # --track, --track-total
862 track_info = _checkNumberedArgTuples(tag.track_num,
863 (self.args.track,
864 self.args.track_total))
865 if track_info is not None:
866 printWarning("Setting track info: %s" % str(track_info))
867 tag.track_num = track_info
868 retval = True
869
870 # --track-offset
871 if self.args.track_offset:
872 offset = self.args.track_offset
873 tag.track_num = (tag.track_num[0] + offset, tag.track_num[1])
874 printWarning("%s track info by %d: %d" %
875 ("Incrementing" if offset > 0 else "Decrementing",
876 offset, tag.track_num[0]))
877 retval = True
878
879 # --disc-num, --disc-total
880 disc_info = _checkNumberedArgTuples(tag.disc_num,
881 (self.args.disc_num,
882 self.args.disc_total))
883 if disc_info is not None:
884 printWarning("Setting disc info: %s" % str(disc_info))
885 tag.disc_num = disc_info
886 retval = True
887
888 # -Y, --release-year
889 if self.args.release_year is not None:
890 # empty string means clean, None means not given
891 year = self.args.release_year
892 printWarning("Setting release year: %s" % year)
893 tag.release_date = int(year) if year else None
894 retval = True
895
896 # -c , simple comment
897 if self.args.simple_comment:
898 # Just add it as if it came in --add-comment
899 self.args.comments.append((self.args.simple_comment, u"",
900 id3.DEFAULT_LANG))
901
902 # --remove-comment, remove-lyrics, --remove-image, --remove-object
903 for what, arg, accessor in (("comment", self.args.remove_comment,
904 tag.comments),
905 ("lyrics", self.args.remove_lyrics,
906 tag.lyrics),
907 ("image", self.args.remove_image,
908 tag.images),
909 ("object", self.args.remove_object,
910 tag.objects),
911 ):
912 for vals in arg:
913 if type(vals) in compat.StringTypes:
914 frame = accessor.remove(vals)
915 else:
916 frame = accessor.remove(*vals)
917 if frame:
918 printWarning("Removed %s %s" % (what, str(vals)))
919 retval = True
920 else:
921 printError("Removing %s failed, %s not found" %
922 (what, str(vals)))
923
924 # --add-comment, --add-lyrics
925 for what, arg, accessor in (("comment", self.args.comments,
926 tag.comments),
927 ("lyrics", self.args.lyrics, tag.lyrics),
928 ):
929 for text, desc, lang in arg:
930 printWarning("Setting %s: %s/%s" %
931 (what, desc, compat.unicode(lang, "ascii")))
932 accessor.set(text, desc, compat.b(lang))
933 retval = True
934
935 # --play-count
936 playcount_arg = self.args.play_count
937 if playcount_arg:
938 increment, pc = playcount_arg
939 if increment:
940 printWarning("Increment play count by %d" % pc)
941 tag.play_count += pc
942 else:
943 printWarning("Setting play count to %d" % pc)
944 tag.play_count = pc
945 retval = True
946
947 # --add-popularty
948 for email, rating, play_count in self.args.popularities:
949 tag.popularities.set(email.encode("latin1"), rating, play_count)
950 retval = True
951
952 # --remove-popularity
953 for email in self.args.remove_popularity:
954 popm = tag.popularities.remove(email.encode("latin1"))
955 if popm:
956 retval = True
957
958 # --text-frame, --url-frame
959 for what, arg, setter in (
960 ("text frame", self.args.text_frames, tag.setTextFrame),
961 ("url frame", self.args.url_frames, tag._setUrlFrame),
962 ):
963 for fid, text in arg:
964 if text:
965 printWarning("Setting %s %s to '%s'" % (fid, what, text))
966 else:
967 printWarning("Removing %s %s" % (fid, what))
968 setter(fid, text)
969 retval = True
970
971 # --user-text-frame, --user-url-frame
972 for what, arg, accessor in (
973 ("user text frame", self.args.user_text_frames,
974 tag.user_text_frames),
975 ("user url frame", self.args.user_url_frames,
976 tag.user_url_frames),
977 ):
978 for desc, text in arg:
979 if text:
980 printWarning("Setting '%s' %s to '%s'" % (desc, what, text))
981 accessor.set(text, desc)
982 else:
983 printWarning("Removing '%s' %s" % (desc, what))
984 accessor.remove(desc)
985 retval = True
986
987 # --add-image
988 for img_path, img_type, img_mt, img_desc in self.args.images:
989 assert(img_path)
990 printWarning("Adding image %s" % img_path)
991 if img_mt not in ImageFrame.URL_MIME_TYPE_VALUES:
992 with open(img_path, "rb") as img_fp:
993 tag.images.set(img_type, img_fp.read(), img_mt, img_desc)
994 else:
995 tag.images.set(img_type, None, None, img_desc, img_url=img_path)
996 retval = True
997
998 # --add-object
999 for obj_path, obj_mt, obj_desc, obj_fname in self.args.objects or []:
1000 assert(obj_path)
1001 printWarning("Adding object %s" % obj_path)
1002 with open(obj_path, "rb") as obj_fp:
1003 tag.objects.set(obj_fp.read(), obj_mt, obj_desc, obj_fname)
1004 retval = True
1005
1006 # --unique-file-id
1007 for arg in self.args.unique_file_ids:
1008 owner_id, id = arg
1009 if not id:
1010 if tag.unique_file_ids.remove(owner_id):
1011 printWarning("Removed unique file ID '%s'" % owner_id)
1012 retval = True
1013 else:
1014 printWarning("Unique file ID '%s' not found" % owner_id)
1015 else:
1016 tag.unique_file_ids.set(id, owner_id.encode("latin1"))
1017 printWarning("Setting unique file ID '%s' to %s" %
1018 (owner_id, id))
1019 retval = True
1020
1021 # --remove-frame
1022 for fid in self.args.remove_fids:
1023 assert(isinstance(fid, compat.BytesType))
1024 if fid in tag.frame_set:
1025 del tag.frame_set[fid]
1026 retval = True
1027
1028 return retval
1029
1030
1031 def _getTemplateKeys():
1032 keys = list(id3.TagTemplate("")._makeMapping(None, False).keys())
1033 keys.sort()
1034 return ", ".join(["$%s" % v for v in keys])
1035
1036
1037 ARGS_HELP = {
1038 "--artist": "Set the artist name.",
1039 "--album": "Set the album name.",
1040 "--album-artist": u"Set the album artist name. '%s', for example. "
1041 "Another example is collaborations when the "
1042 "track artist might be 'Eminem featuring Proof' "
1043 "the album artist would be 'Eminem'." %
1044 core.VARIOUS_ARTISTS,
1045 "--title": "Set the track title.",
1046 "--track": "Set the track number. Use 0 to clear.",
1047 "--track-total": "Set total number of tracks. Use 0 to clear.",
1048 "--disc-num": "Set the disc number. Use 0 to clear.",
1049 "--disc-total": "Set total number of discs in set. Use 0 to clear.",
1050 "--genre": "Set the genre. If the argument is a standard ID3 genre "
1051 "name or number both will be set. Otherwise, any string "
1052 "can be used. Run 'eyeD3 --plugin=genres' for a list of "
1053 "standard ID3 genre names/ids.",
1054 "--non-std-genres": "Disables certain ID3 genre standards, such as the "
1055 "mapping of numeric value to genre names.",
1056 "--release-year": "Set the year the track was released. Use the date "
1057 "options for more precise values or dates other "
1058 "than release.",
1059
1060 "--v1": "Only read and write ID3 v1.x tags. By default, v1.x tags are "
1061 "only read or written if there is not a v2 tag in the file.",
1062 "--v2": "Only read/write ID3 v2.x tags. This is the default unless "
1063 "the file only contains a v1 tag.",
1064
1065 "--to-v1.1": "Convert the file's tag to ID3 v1.1 (Or 1.0 if there is "
1066 "no track number)",
1067 "--to-v2.3": "Convert the file's tag to ID3 v2.3",
1068 "--to-v2.4": "Convert the file's tag to ID3 v2.4",
1069
1070 "--release-date": "Set the date the track/album was released",
1071 "--orig-release-date": "Set the original date the track/album was "
1072 "released",
1073 "--recording-date": "Set the date the track/album was recorded",
1074 "--encoding-date": "Set the date the file was encoded",
1075 "--tagging-date": "Set the date the file was tagged",
1076
1077 "--comment": "Set a comment. In ID3 tags this is the comment with "
1078 "an empty description. See --add-comment to add multiple "
1079 "comment frames.",
1080 "--add-comment":
1081 "Add or replace a comment. There may be more than one comment in a "
1082 "tag, as long as the DESCRIPTION and LANG values are unique. The "
1083 "default DESCRIPTION is '' and the default language code is '%s'." %
1084 compat.unicode(id3.DEFAULT_LANG, "ascii"),
1085 "--remove-comment": "Remove comment matching DESCRIPTION and LANG. "
1086 "The default language code is '%s'." %
1087 compat.unicode(id3.DEFAULT_LANG, "ascii"),
1088 "--remove-all-comments": "Remove all comments from the tag.",
1089
1090 "--add-lyrics":
1091 "Add or replace a lyrics. There may be more than one set of lyrics "
1092 "in a tag, as long as the DESCRIPTION and LANG values are unique. "
1093 "The default DESCRIPTION is '' and the default language code is "
1094 "'%s'." % compat.unicode(id3.DEFAULT_LANG, "ascii"),
1095 "--remove-lyrics": "Remove lyrics matching DESCRIPTION and LANG. "
1096 "The default language code is '%s'." %
1097 compat.unicode(id3.DEFAULT_LANG, "ascii"),
1098 "--remove-all-lyrics": "Remove all lyrics from the tag.",
1099
1100 "--publisher": "Set the publisher/label name",
1101 "--play-count": "Set the number of times played counter. If the "
1102 "argument value begins with '+' the tag's play count "
1103 "is incremented by N, otherwise the value is set to "
1104 "exactly N.",
1105 "--bpm": "Set the beats per minute value.",
1106
1107 "--text-frame": "Set the value of a text frame. To remove the "
1108 "frame, specify an empty value. For example, "
1109 "--text-frame='TDRC:'",
1110 "--user-text-frame": "Set the value of a user text frame (i.e., TXXX). "
1111 "To remove the frame, specify an empty value. "
1112 "e.g., --user-text-frame='SomeDesc:'",
1113 "--url-frame": "Set the value of a URL frame. To remove the frame, "
1114 "specify an empty value. e.g., --url-frame='WCOM:'",
1115 "--user-url-frame": "Set the value of a user URL frame (i.e., WXXX). "
1116 "To remove the frame, specify an empty value. "
1117 "e.g., --user-url-frame='SomeDesc:'",
1118
1119 "--add-image": "Add or replace an image. There may be more than one "
1120 "image in a tag, as long as the DESCRIPTION values are "
1121 "unique. The default DESCRIPTION is ''. If PATH begins "
1122 "with 'http[s]://' then it is interpreted as a URL "
1123 "instead of a file containing image data. The TYPE must "
1124 "be one of the following: %s."
1125 % (", ".join([ImageFrame.picTypeToString(t)
1126 for t in range(ImageFrame.MIN_TYPE,
1127 ImageFrame.MAX_TYPE + 1)]),
1128 ),
1129 "--remove-image": "Remove image matching DESCRIPTION.",
1130 "--remove-all-images": "Remove all images from the tag",
1131 "--write-images": "Causes all attached images (APIC frames) to be "
1132 "written to the specified directory.",
1133
1134 "--add-object": "Add or replace an object. There may be more than one "
1135 "object in a tag, as long as the DESCRIPTION values "
1136 "are unique. The default DESCRIPTION is ''.",
1137 "--remove-object": "Remove object matching DESCRIPTION.",
1138 "--remove-all-objects": "Remove all objects from the tag",
1139 "--write-objects": "Causes all attached objects (GEOB frames) to be "
1140 "written to the specified directory.",
1141
1142 "--add-popularty": "Adds a pupularity metric. There may be multiples "
1143 "popularity values, but each must have a unique "
1144 "email address component. The rating is a number "
1145 "between 0 (worst) and 255 (best). The play count "
1146 "is optional, and defaults to 0, since there is "
1147 "already a dedicated play count frame.",
1148 "--remove-popularity": "Removes the popularity frame with the "
1149 "specified email key.",
1150
1151 "--remove-v1": "Remove ID3 v1.x tag.",
1152 "--remove-v2": "Remove ID3 v2.x tag.",
1153 "--remove-all": "Remove ID3 v1.x and v2.x tags.",
1154
1155 "--remove-frame": "Remove all frames with the given ID. This option "
1156 "may be specified multiple times.",
1157
1158 "--max-padding": "Shrink file if tag padding (unused space) exceeds "
1159 "the given number of bytes. "
1160 "(Useful e.g. after removal of large cover art.) "
1161 "Default is 64 KiB, file will be rewritten with "
1162 "default padding (1 KiB) or max padding, whichever "
1163 "is smaller.",
1164 "--no-max-padding": "Disable --max-padding altogether.",
1165
1166 "--force-update": "Rewrite the tag despite there being no edit "
1167 "options.",
1168 "--verbose": "Show all available tag data",
1169 "--unique-file-id": "Add a unique file ID frame. If the ID arg is "
1170 "empty the frame is removed. An OWNER_ID is "
1171 "required. The ID may be no more than 64 bytes.",
1172 "--encoding": "Set the encoding that is used for all text frames. "
1173 "This option is only applied if the tag is updated "
1174 "as the result of an edit option (e.g. --artist, "
1175 "--title, etc.) or --force-update is specified.",
1176 "--rename": "Rename file (the extension is not affected) "
1177 "based on data in the tag using substitution "
1178 "variables: " + _getTemplateKeys(),
1179 "--preserve-file-times": "When writing, do not update file "
1180 "modification times.",
1181 "--track-offset": "Increment/decrement the track number by [-]N. "
1182 "This option is applied after --track=N is set.",
1183 "--composer": "Set the composer's name.",
1184 }
+0
-1146
src/eyed3/plugins/display.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2014-2016 Sebastian Patschorke <physicspatschi@gmx.de>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 from __future__ import print_function
19 import os
20 import re
21 import abc
22
23 from argparse import ArgumentTypeError
24
25 from eyed3 import id3, compat
26 from eyed3.utils import console, formatSize, formatTime
27 from eyed3.plugins import LoaderPlugin
28 try:
29 from eyed3.plugins._display_parser import DisplayPatternParser
30 _have_grako = True
31 except ImportError:
32 _have_grako = False
33
34
35 class Pattern(object):
36
37 def __init__(self, text=None, sub_patterns=None):
38 self.__text = text
39 self.__sub_patterns = sub_patterns
40
41 def output_for(self, audio_file):
42 output = u""
43 for sub_pattern in self.sub_patterns or []:
44 output += sub_pattern.output_for(audio_file)
45 return output
46
47 def __get_sub_patterns(self):
48 if self.__sub_patterns is None and self.__text is not None:
49 self.__compile()
50 return self.__sub_patterns
51
52 def __set_sub_patterns(self, sub_patterns):
53 self.__sub_patterns = sub_patterns
54
55 sub_patterns = property(__get_sub_patterns, __set_sub_patterns)
56
57 def __compile(self):
58 # TODO: add support for comments in pattern
59 parser = DisplayPatternParser(whitespace='')
60 try:
61 asts = parser.parse(self.__text, rule_name='start')
62 self.sub_patterns = self.__compile_asts(asts)
63 self.__text = None
64 except BaseException as parsing_error:
65 raise PatternCompileException(compat.unicode(parsing_error))
66
67 def __compile_asts(self, asts):
68 patterns = []
69 for ast in asts:
70 patterns.append(self.__compile_ast(ast))
71 return patterns
72
73 def __compile_ast(self, ast):
74 if ast is None:
75 return None
76 if "text" in ast:
77 return TextPattern(ast["text"])
78 if "tag" in ast:
79 parameters = self.__compile_parameters(ast["parameters"])
80 return self.__create_complex_pattern(TagPattern, ast["name"],
81 parameters)
82 if "function" in ast:
83 parameters = self.__compile_parameters(ast["parameters"])
84 if len(parameters) == 1 and parameters[0][0] is None and len(
85 parameters[0][1].sub_patterns) == 0:
86 parameters = []
87 return self.__create_complex_pattern(FunctionPattern, ast["name"],
88 parameters)
89
90 def __compile_parameters(self, parameter_asts):
91 parameters = []
92 for parameter_ast in parameter_asts:
93 sub_patterns = self.__compile_asts(parameter_ast["value"])
94 parameters.append((parameter_ast["name"],
95 Pattern(sub_patterns=sub_patterns)))
96 return parameters
97
98 def __create_complex_pattern(self, base_class, class_name, parameters):
99 pattern_class = self.__find_pattern_class(base_class, class_name)
100 if pattern_class is not None:
101 return pattern_class(class_name, parameters)
102 raise PatternCompileException("Unknown " + base_class.TYPE + " '" +
103 class_name + "'")
104
105 def __find_pattern_class(self, base_class, class_name):
106 for pattern_class in Pattern.sub_pattern_classes(base_class):
107 if class_name in pattern_class.NAMES:
108 return pattern_class
109
110 @staticmethod
111 def sub_pattern_classes(base_class):
112 sub_classes = []
113 for pattern_class in base_class.__subclasses__():
114 if len(pattern_class.__subclasses__()) > 0:
115 sub_classes.extend(Pattern.sub_pattern_classes(pattern_class))
116 continue
117 sub_classes.append(pattern_class)
118 return sub_classes
119
120 @staticmethod
121 def pattern_class_parameters(pattern_class):
122 try:
123 return pattern_class.PARAMETERS
124 except AttributeError:
125 return []
126
127 def __repr__(self):
128 return self.__str__()
129
130 def __str__(self):
131 return str(self.__sub_patterns)
132
133
134 class TextPattern(Pattern):
135 SPECIAL_CHARACTERS = list("\\%$,()=nt")
136 SPECIAL_CHARACTERS_DESCRIPTIONS = list("\\%$,()=") + ["New line", "Tab"]
137
138 def __init__(self, text):
139 super(TextPattern, self).__init__(text)
140 self.__text = text
141 self.__replace_escapes()
142
143 def __replace_escapes(self):
144 escape_matches = list(re.compile('\\\\.').finditer(self.__text))
145 escape_matches.reverse()
146 for escape_match in escape_matches:
147 character = self.__text[escape_match.end() - 1]
148 if character not in TextPattern.SPECIAL_CHARACTERS:
149 raise PatternCompileException("Unknown escape character '" +
150 character + "'")
151 if character == 'n':
152 character = os.linesep
153 if character == 't':
154 character = '\t'
155 self.__text = self.__text[:escape_match.start()] + character + \
156 self.__text[escape_match.end():]
157
158 def output_for(self, audio_file):
159 return self.__text
160
161 def __str__(self):
162 return "text:" + self.__text
163
164
165 class ComplexPattern(Pattern):
166 __metaclass__ = abc.ABCMeta
167
168 TYPE = "unknown"
169 NAMES = []
170 DESCRIPTION = ""
171 PARAMETERS = []
172
173 class ExpectedParameter(object):
174 def __init__(self, name, **kwargs):
175 self.name = name
176 if "default" in kwargs:
177 self.requried = False
178 self.default = kwargs["default"]
179 else:
180 self.requried = True
181
182 def __repr__(self):
183 return self.__str__()
184
185 def __str__(self):
186 if self.requried:
187 return self.name
188 return self.name + "(" + str(self.default) + ")"
189
190 class Parameter(object):
191 def __init__(self, value, provided):
192 self.value = value
193 self.provided = provided
194
195 def __repr__(self):
196 return self.__str__()
197
198 def __str__(self):
199 return str(self.value) + "(" + str(self.provided) + ")"
200
201 def __init__(self, name, parameters):
202 super(ComplexPattern, self).__init__()
203 self.__name = name
204 self.__import_parameters(parameters)
205
206 def output_for(self, audio_file):
207 output = self._get_output_for(audio_file)
208 return output or ""
209
210 @abc.abstractmethod
211 def _get_output_for(self, audio_file):
212 pass
213
214 def __import_parameters(self, parameters):
215 expected_parameters = Pattern.pattern_class_parameters(self.__class__)
216 self.__parameters = {}
217 for expected_parameter in expected_parameters:
218 found = False
219 for parameter in parameters:
220 if (parameter[0] is None or
221 parameter[0] == expected_parameter.name):
222 self.__parameters[expected_parameter.name] = \
223 ComplexPattern.Parameter(parameter[1], True)
224 parameters.remove(parameter)
225 found = True
226 break
227 if parameter[0] is not None:
228 break
229 if not expected_parameter.requried:
230 self.__parameters[expected_parameter.name] = \
231 ComplexPattern.Parameter(
232 Pattern(text=expected_parameter.default), True)
233 found = True
234 break
235 raise PatternCompileException(
236 self._error_message("Unexpected parameter"))
237 if not found:
238 if expected_parameter.requried:
239 raise PatternCompileException(self._error_message(
240 "Missing required parameter '" +
241 expected_parameter.name + "'"))
242 self.__parameters[expected_parameter.name] = \
243 ComplexPattern.Parameter(
244 Pattern(text=expected_parameter.default), False)
245 if len(parameters) > 0:
246 raise PatternCompileException(
247 self._error_message("Unexpected parameter"))
248
249 def __get_parameters(self):
250 return self.__parameters
251
252 parameters = property(__get_parameters)
253
254 def _parameter_value(self, name, audio_file):
255 return self.parameters[name].value.output_for(audio_file)
256
257 def _parameter_bool(self, name, audio_file):
258 value = self._parameter_value(name, audio_file)
259 return value.lower() in ("yes", "true", "y", "t", "1", "on")
260
261 def __get_name(self):
262 return self.__name
263
264 name = property(__get_name)
265
266 def _error_message(self, message):
267 return self.TYPE.capitalize() + " " + self.__name + ": " + message
268
269 def __str__(self):
270 return self.TYPE + ":" + self.name + str(self.parameters)
271
272
273 class PlaceholderUsagePattern(object):
274 __metaclass__ = abc.ABCMeta
275
276 def _replace_placeholders(self, text, replacements):
277 if len(replacements) == 0:
278 return text
279
280 replacement = replacements.pop(0)
281 subtexts = []
282 for subtext in text.split(replacement[0]):
283 subtexts.append(
284 self._replace_placeholders(subtext, list(replacements)))
285 return (str(replacement[1]) or "").join(subtexts)
286
287
288 class TagPattern(ComplexPattern):
289 __metaclass__ = abc.ABCMeta
290 TYPE = "tag"
291
292
293 class ArtistTagPattern(TagPattern):
294 NAMES = ["a", "artist"]
295 DESCRIPTION = "Artist"
296
297 def _get_output_for(self, audio_file):
298 return audio_file.tag.artist
299
300
301 class AlbumTagPattern(TagPattern):
302 NAMES = ["A", "album"]
303 DESCRIPTION = "Album"
304
305 def _get_output_for(self, audio_file):
306 return audio_file.tag.album
307
308
309 class AlbumArtistTagPattern(TagPattern):
310 NAMES = ["b", "album-artist"]
311 DESCRIPTION = "Album artist"
312
313 def _get_output_for(self, audio_file):
314 return audio_file.tag.album_artist
315
316
317 class ComposerTagPattern(TagPattern):
318 NAMES = ["C", "composer"]
319 DESCRIPTION = "Composer"
320
321 def _get_output_for(self, audio_file):
322 return audio_file.tag.composer
323
324
325 class TitleTagPattern(TagPattern):
326 NAMES = ["t", "title"]
327 DESCRIPTION = "Title"
328
329 def _get_output_for(self, audio_file):
330 return audio_file.tag.title
331
332
333 class TrackTagPattern(TagPattern):
334 NAMES = ["n", "track"]
335 DESCRIPTION = "Track number"
336
337 def _get_output_for(self, audio_file):
338 n = audio_file.tag.track_num[0]
339 return str(n or "")
340
341
342 class TrackTotalTagPattern(TagPattern):
343 NAMES = ["N", "track-total"]
344 DESCRIPTION = "Total track number"
345
346 def _get_output_for(self, audio_file):
347 n = audio_file.tag.track_num[1]
348 return str(n or "")
349
350
351 class DiscTagPattern(TagPattern):
352 NAMES = ["d", "disc", "disc-num"]
353 DESCRIPTION = "Disc number"
354
355 def _get_output_for(self, audio_file):
356 n = audio_file.tag.disc_num[0]
357 return str(n or "")
358
359
360 class DiscTotalTagPattern(TagPattern):
361 NAMES = ["D", "disc-total"]
362 DESCRIPTION = "Total disc number"
363
364 def _get_output_for(self, audio_file):
365 n = audio_file.tag.disc_num[1]
366 return str(n or "")
367
368
369 class GenreTagPattern(TagPattern):
370 NAMES = ["G", "genre"]
371 DESCRIPTION = "Genre"
372
373 def _get_output_for(self, audio_file):
374 return audio_file.tag.genre.name
375
376
377 class GenreIdTagPattern(TagPattern):
378 NAMES = ["genre-id"]
379 DESCRIPTION = "Genre ID"
380
381 def _get_output_for(self, audio_file):
382 return str(audio_file.tag.genre.id) if audio_file.tag.genre else None
383
384
385 class YearTagPattern(TagPattern):
386 NAMES = ["Y", "year"]
387 DESCRIPTION = "Release year"
388
389 def _get_output_for(self, audio_file):
390 return audio_file.tag.release_date.year
391
392
393 class DescriptableTagPattern(TagPattern):
394 __metaclass__ = abc.ABCMeta
395
396 PARAMETERS = [ComplexPattern.ExpectedParameter("description", default=None),
397 ComplexPattern.ExpectedParameter("language", default=None)]
398
399 def _get_matching_elements(self, elements, audio_file):
400 matching_elements = []
401 for element in elements:
402 if (self.__matches("description", element.description,
403 audio_file) and
404 self.__matches("language", element.lang, audio_file)):
405 matching_elements.append(element)
406 return matching_elements
407
408 def __matches(self, parameter_name, comment_attribute_value, audio_file):
409 if not self.parameters[parameter_name].provided:
410 return True
411 if self.parameters[parameter_name].value is None:
412 return (comment_attribute_value is None or
413 comment_attribute_value == "")
414 return (self._parameter_value(parameter_name, audio_file) ==
415 comment_attribute_value)
416
417
418 class CommentTagPattern(DescriptableTagPattern):
419 NAMES = ["c", "comment"]
420 PARAMETERS = DescriptableTagPattern.PARAMETERS
421 DESCRIPTION = "First comment that matches description and language."
422
423 def _get_output_for(self, audio_file):
424 matching_comments = self._get_matching_elements(audio_file.tag.comments,
425 audio_file)
426 return matching_comments[0].text if len(matching_comments) > 0 else None
427
428
429 class AllCommentsTagPattern(DescriptableTagPattern, PlaceholderUsagePattern):
430 NAMES = ["comments"]
431 PARAMETERS = DescriptableTagPattern.PARAMETERS + \
432 [ComplexPattern.ExpectedParameter("output",
433 default="Comment: [Description: #d] [Lang: #l]: #t"),
434 ComplexPattern.ExpectedParameter("separation", default="\\n")]
435 DESCRIPTION = "All comments that are matching description and language " \
436 "(with output placeholders #d as description, #l as " \
437 " language & #t as text)."
438
439 def _get_output_for(self, audio_file):
440 output_pattern = self._parameter_value("output", audio_file)
441 separation = self._parameter_value("separation", audio_file)
442 outputs = []
443 for comment in self._get_matching_elements(audio_file.tag.comments,
444 audio_file):
445 replacements = [["#d", comment.description],
446 ["#l", comment.lang.decode("ascii")],
447 ["#t", comment.text]]
448 outputs.append(self._replace_placeholders(output_pattern,
449 replacements))
450 return separation.join(outputs)
451
452
453 class AbstractDateTagPattern(TagPattern):
454 __metaclass__ = abc.ABCMeta
455
456 def _get_output_for(self, audio_file):
457 return str(self._get_date(audio_file) or "")
458
459 @abc.abstractmethod
460 def _get_date(self, audio_file):
461 pass
462
463
464 class ReleaseDateTagPattern(AbstractDateTagPattern):
465 NAMES = ["release-date"]
466 DESCRIPTION = "Relase date"
467
468 def _get_date(self, audio_file):
469 return audio_file.tag.release_date
470
471
472 class OriginalReleaseDateTagPattern(AbstractDateTagPattern):
473 NAMES = ["original-release-date"]
474 DESCRIPTION = "Original Relase date"
475
476 def _get_date(self, audio_file):
477 return audio_file.tag.original_release_date
478
479
480 class RecordingDateTagPattern(AbstractDateTagPattern):
481 NAMES = ["recording-date"]
482 DESCRIPTION = "Recording date"
483
484 def _get_date(self, audio_file):
485 return audio_file.tag.recording_date
486
487
488 class EncodingDateTagPattern(AbstractDateTagPattern):
489 NAMES = ["encoding-date"]
490 DESCRIPTION = "Encoding date"
491
492 def _get_date(self, audio_file):
493 return audio_file.tag.encoding_date
494
495
496 class TaggingDateTagPattern(AbstractDateTagPattern):
497 NAMES = ["tagging-date"]
498 DESCRIPTION = "Tagging date"
499
500 def _get_date(self, audio_file):
501 return audio_file.tag.tagging_date
502
503
504 class PlayCountTagPattern(TagPattern):
505 NAMES = ["play-count"]
506 DESCRIPTION = "Play count"
507
508 def _get_output_for(self, audio_file):
509 return audio_file.tag.play_count
510
511
512 class PopularitiesTagPattern(TagPattern, PlaceholderUsagePattern):
513 NAMES = ["popm", "popularities"]
514 PARAMETERS = [ComplexPattern.ExpectedParameter("output",
515 default="Popularity: [email: #e] [rating: #r] [play count: #c]"),
516 ComplexPattern.ExpectedParameter("separation", default="\\n")]
517 DESCRIPTION = "Popularities (with output placeholders #e as email, "\
518 "#r as rating & #c as count)"
519
520 def _get_output_for(self, audio_file):
521 output_pattern = self._parameter_value("output", audio_file)
522 separation = self._parameter_value("separation", audio_file)
523
524 outputs = []
525 for popularity in audio_file.tag.popularities:
526 replacements = [["#e", popularity.email],
527 ["#r", popularity.rating],
528 ["#c", popularity.count]]
529 outputs.append(self._replace_placeholders(output_pattern,
530 replacements))
531 return separation.join(outputs)
532
533
534 class BPMTagPattern(TagPattern):
535 NAMES = ["bpm"]
536 DESCRIPTION = "BPM"
537
538 def _get_output_for(self, audio_file):
539 return audio_file.tag.bpm
540
541
542 class PublisherTagPattern(TagPattern):
543 NAMES = ["publisher"]
544 DESCRIPTION = "Publisher"
545
546 def _get_output_for(self, audio_file):
547 return audio_file.tag.publisher
548
549
550 class UniqueFileIDTagPattern(TagPattern, PlaceholderUsagePattern):
551 NAMES = ["ufids", "unique-file-ids"]
552 PARAMETERS = [ComplexPattern.ExpectedParameter("output",
553 default="Unique File ID: [#o] : #i"),
554 ComplexPattern.ExpectedParameter("separation", default="\\n")]
555 DESCRIPTION = "Unique File IDs (with output placeholders #o as owner & #i "\
556 " as unique id)"
557
558 def _get_output_for(self, audio_file):
559 output_pattern = self._parameter_value("output", audio_file)
560 separation = self._parameter_value("separation", audio_file)
561
562 outputs = []
563 for ufid in audio_file.tag.unique_file_ids:
564 replacements = [["#o", ufid.owner_id],
565 ["#i", ufid.uniq_id.encode("string_escape")]]
566 outputs.append(self._replace_placeholders(output_pattern,
567 replacements))
568 return separation.join(outputs)
569
570
571 class LyricsTagPattern(DescriptableTagPattern, PlaceholderUsagePattern):
572 NAMES = ["lyrics"]
573 PARAMETERS = DescriptableTagPattern.PARAMETERS + \
574 [ComplexPattern.ExpectedParameter(
575 "output",
576 default="Lyrics: [Description: #d] [Lang: #l]: #t"),
577 ComplexPattern.ExpectedParameter("separation", default="\\n")]
578 DESCRIPTION = "All lyrics that are matching description and language " + \
579 "(with output placeholders #d as description, #l as "\
580 "language & #t as text)."
581
582 def _get_output_for(self, audio_file):
583 output_pattern = self._parameter_value("output", audio_file)
584 separation = self._parameter_value("separation", audio_file)
585
586 outputs = []
587 for l in self._get_matching_elements(audio_file.tag.lyrics, audio_file):
588 replacements = [["#d", l.description],
589 ["#l", l.lang.decode("ascii")],
590 ["#t", l.text]]
591 outputs.append(self._replace_placeholders(output_pattern,
592 replacements))
593 return separation.join(outputs)
594
595
596 class TextsTagPattern(TagPattern, PlaceholderUsagePattern):
597 NAMES = ["txxx", "texts"]
598 PARAMETERS = [
599 ComplexPattern.ExpectedParameter(
600 "output", default="UserTextFrame: [Description: #d] #t"),
601 ComplexPattern.ExpectedParameter("separation", default="\\n")]
602 DESCRIPTION = "User text frames (with output placeholders #d as "\
603 "description & #t as text)"
604
605 def _get_output_for(self, audio_file):
606 output_pattern = self._parameter_value("output", audio_file)
607 separation = self._parameter_value("separation", audio_file)
608
609 outputs = []
610 for frame in audio_file.tag.user_text_frames:
611 replacements = [["#d", frame.description],
612 ["#t", frame.text]]
613 outputs.append(self._replace_placeholders(output_pattern,
614 replacements))
615 return separation.join(outputs)
616
617
618 class ArtistURLTagPattern(TagPattern):
619 NAMES = ["artist-url"]
620 DESCRIPTION = "Artist URL"
621
622 def _get_output_for(self, audio_file):
623 return audio_file.tag.artist_url
624
625
626 class AudioSourceURLTagPattern(TagPattern):
627 NAMES = ["audio-source-url"]
628 DESCRIPTION = "Audio source URL"
629
630 def _get_output_for(self, audio_file):
631 return audio_file.tag.audio_source_url
632
633
634 class AudioFileURLTagPattern(TagPattern):
635 NAMES = ["audio-file-url"]
636 DESCRIPTION = "Audio file URL"
637
638 def _get_output_for(self, audio_file):
639 return audio_file.tag.audio_file_url
640
641
642 class InternetRadioURLTagPattern(TagPattern):
643 NAMES = ["internet-radio-url"]
644 DESCRIPTION = "Internet radio URL"
645
646 def _get_output_for(self, audio_file):
647 return audio_file.tag.internet_radio_url
648
649
650 class CommercialURLTagPattern(TagPattern):
651 NAMES = ["commercial-url"]
652 DESCRIPTION = "Comercial URL"
653
654 def _get_output_for(self, audio_file):
655 return audio_file.tag.copyright_url
656
657
658 class PaymentURLTagPattern(TagPattern):
659 NAMES = ["payment-url"]
660 DESCRIPTION = "Payment URL"
661
662 def _get_output_for(self, audio_file):
663 return audio_file.tag.payment_url
664
665
666 class PublisherURLTagPattern(TagPattern):
667 NAMES = ["publisher-url"]
668 DESCRIPTION = "Publisher URL"
669
670 def _get_output_for(self, audio_file):
671 return audio_file.tag.publisher_url
672
673
674 class CopyrightTagPattern(TagPattern):
675 NAMES = ["copyright-url"]
676 DESCRIPTION = "Copyright URL"
677
678 def _get_output_for(self, audio_file):
679 return audio_file.tag.copyright_url
680
681
682 class UserURLsTagPattern(TagPattern, PlaceholderUsagePattern):
683 NAMES = ["user-urls"]
684 PARAMETERS = [ComplexPattern.ExpectedParameter("output",
685 default="#i [Description: #d]: #u"),
686 ComplexPattern.ExpectedParameter("separation", default="\\n")]
687 DESCRIPTION = "User URL frames (with output placeholders #i as frame id, "\
688 "#d as description & #u as url)"
689
690 def _get_output_for(self, audio_file):
691 output_pattern = self._parameter_value("output", audio_file)
692 separation = self._parameter_value("separation", audio_file)
693
694 outputs = []
695 for frame in audio_file.tag.user_url_frames:
696 replacements = [["#i", frame.id],
697 ["#d", frame.description],
698 ["#u", frame.url]]
699 outputs.append(self._replace_placeholders(output_pattern,
700 replacements))
701 return separation.join(outputs)
702
703
704 class ImagesTagPattern(TagPattern, PlaceholderUsagePattern):
705 NAMES = ["images", "apic"]
706 PARAMETERS = [ComplexPattern.ExpectedParameter(
707 "output",
708 default="#t Image: [Type: #m] [Size: #s bytes] #d"),
709 ComplexPattern.ExpectedParameter("separation", default="\\n")]
710 DESCRIPTION = "Attached pictures (APIC)" \
711 "(with output placeholders #t as image type, "\
712 "#m as mime type, #s as size in bytes & #d as description)"
713
714 def _get_output_for(self, audio_file):
715 output_pattern = self._parameter_value("output", audio_file)
716 separation = self._parameter_value("separation", audio_file)
717
718 outputs = []
719 for img in audio_file.tag.images:
720 if img.mime_type not in id3.frames.ImageFrame.URL_MIME_TYPE_VALUES:
721 replacements = [["#t", img.picTypeToString(img.picture_type)],
722 ["#m", img.mime_type],
723 ["#s", len(img.image_data)],
724 ["#d", img.description]]
725 outputs.append(self._replace_placeholders(output_pattern,
726 replacements))
727 return separation.join(outputs)
728
729
730 class ImageURLsTagPattern(TagPattern, PlaceholderUsagePattern):
731 NAMES = ["image-urls"]
732 PARAMETERS = [ComplexPattern.ExpectedParameter(
733 "output", default="#t Image: [Type: #m] [URL: #u] #d"),
734 ComplexPattern.ExpectedParameter("separation", default="\\n")]
735 DESCRIPTION = "Attached pictures URLs" \
736 "(with output placeholders #t as image type, "\
737 "#m as mime type, #u as URL & #d as description)"
738
739 def _get_output_for(self, audio_file):
740 output_pattern = self._parameter_value("output", audio_file)
741 separation = self._parameter_value("separation", audio_file)
742
743 outputs = []
744 for img in audio_file.tag.images:
745 if img.mime_type in id3.frames.ImageFrame.URL_MIME_TYPE_VALUES:
746 replacements = [["#t", img.picTypeToString(img.picture_type)],
747 ["#m", img.mime_type],
748 ["#u", img.image_url],
749 ["#d", img.description]]
750 outputs.append(self._replace_placeholders(output_pattern,
751 replacements))
752 return separation.join(outputs)
753
754
755 class ObjectsTagPattern(TagPattern, PlaceholderUsagePattern):
756 NAMES = ["objects", "gobj"]
757 PARAMETERS = [ComplexPattern.ExpectedParameter("output",
758 default="GEOB: [Size: #s bytes] [Type: #t] "
759 "Description: #d | Filename: #f"),
760 ComplexPattern.ExpectedParameter("separation", default="\\n")]
761 DESCRIPTION = "Objects (GOBJ)" \
762 "(with output placeholders #s as size, #m as mime type, "\
763 "#d as description and #f as file name)"
764
765 def _get_output_for(self, audio_file):
766 output_pattern = self._parameter_value("output", audio_file)
767 separation = self._parameter_value("separation", audio_file)
768
769 outputs = []
770 for obj in audio_file.tag.objects:
771 replacements = [["#s", len(obj.object_data)],
772 ["#m", obj.mime_type],
773 ["#d", obj.description],
774 ["#f", obj.filename]]
775 outputs.append(self._replace_placeholders(output_pattern,
776 replacements))
777 return separation.join(outputs)
778
779
780 class PrivatesTagPattern(TagPattern, PlaceholderUsagePattern):
781 NAMES = ["privates", "priv"]
782 PARAMETERS = [ComplexPattern.ExpectedParameter("output",
783 default="PRIV-Content: #b bytes | Owner: #o"),
784 ComplexPattern.ExpectedParameter("separation", default="\\n")]
785 DESCRIPTION = "Privates (APIC) (with output placeholders #c as content, "\
786 "#b as number of bytes & #o as owner)"
787
788 def _get_output_for(self, audio_file):
789 output_pattern = self._parameter_value("output", audio_file)
790 separation = self._parameter_value("separation", audio_file)
791
792 outputs = []
793 for private in audio_file.tag.privates:
794 replacements = [["#b", "%i" % len(private.data)],
795 ["#c", private.data.decode("ascii")],
796 ["#o", private.owner_id.decode("ascii")]]
797 outputs.append(self._replace_placeholders(output_pattern,
798 replacements))
799 return separation.join(outputs)
800
801
802 class MusicCDIdTagPattern(TagPattern):
803 NAMES = ["music-cd-id", "mcdi"]
804 DESCRIPTION = "Music CD Identification"
805
806 def _get_output_for(self, audio_file):
807 if audio_file.tag.cd_id is not None:
808 return audio_file.tag.cd_id.decode("ascii")
809 else:
810 return None
811
812
813 class TermsOfUseTagPattern(TagPattern):
814 NAMES = ["terms-of-use"]
815 DESCRIPTION = "Terms of use"
816
817 def _get_output_for(self, audio_file):
818 return audio_file.tag.terms_of_use
819
820
821 class FunctionPattern(ComplexPattern):
822 __metaclass__ = abc.ABCMeta
823 TYPE = "function"
824
825
826 class FunctionFormatPattern(FunctionPattern):
827 NAMES = ["format"]
828 PARAMETERS = [ComplexPattern.ExpectedParameter("text"),
829 ComplexPattern.ExpectedParameter("bold", default=None),
830 ComplexPattern.ExpectedParameter("color", default=None)]
831 DESCRIPTION = "Formats text bold and colored (grey, red, green, yellow, "\
832 "blue, magenta, cyan or white)"
833
834 def _get_output_for(self, audio_file):
835 text = self._parameter_value("text", audio_file)
836 bold = self._parameter_bool("bold", audio_file)
837 color_name = self._parameter_value("color", audio_file)
838 return console.formatText(text, b=bold, c=self.__color(color_name))
839
840 def __color(self, color_name):
841 return {"GREY": console.Fore.GREY,
842 "RED": console.Fore.RED,
843 "GREEN": console.Fore.GREEN,
844 "YELLOW": console.Fore.YELLOW,
845 "BLUE": console.Fore.BLUE,
846 "MAGENTA": console.Fore.MAGENTA,
847 "CYAN": console.Fore.CYAN,
848 "WHITE": console.Fore.WHITE}.get(color_name.upper(), None)
849
850
851 class FunctionNumberPattern(FunctionPattern):
852 NAMES = ["num", "number-format"]
853 PARAMETERS = [ComplexPattern.ExpectedParameter("number"),
854 ComplexPattern.ExpectedParameter("digits")]
855 DESCRIPTION = "Appends leading zeros"
856
857 def _get_output_for(self, audio_file):
858 number = self._parameter_value("number", audio_file)
859 digits = self._parameter_value("digits", audio_file)
860 try:
861 number = int(number)
862 except ValueError:
863 raise DisplayException(self._error_message("'" + number +
864 "' is not a number."))
865 try:
866 digits = int(digits)
867 except ValueError:
868 raise DisplayException(self._error_message("'" + digits +
869 "' is not a number."))
870
871 output = str(number)
872 return ("0" * max(0, digits - len(output))) + output
873
874
875 class FunctionFilenamePattern(FunctionPattern):
876 NAMES = ["filename", "fn"]
877 PARAMETERS = [ComplexPattern.ExpectedParameter("basename", default=None)]
878 DESCRIPTION = "File name"
879
880 def _get_output_for(self, audio_file):
881 if self._parameter_bool("basename", audio_file):
882 return os.path.basename(audio_file.path)
883 return audio_file.path
884
885
886 class FunctionFilesizePattern(FunctionPattern):
887 NAMES = ["filesize"]
888 DESCRIPTION = "Size of file"
889
890 def _get_output_for(self, audio_file):
891 from stat import ST_SIZE
892 file_size = os.stat(audio_file.path)[ST_SIZE]
893 return formatSize(file_size)
894
895
896 class FunctionTagVersionPattern(FunctionPattern):
897 NAMES = ["tag-version"]
898 DESCRIPTION = "Tag version"
899
900 def _get_output_for(self, audio_file):
901 return id3.versionToString(audio_file.tag.version)
902
903
904 class FunctionLengthPattern(FunctionPattern):
905 NAMES = ["length"]
906 DESCRIPTION = "Length of aufio file"
907
908 def _get_output_for(self, audio_file):
909 return formatTime(audio_file.info.time_secs)
910
911
912 class FunctionMPEGVersionPattern(FunctionPattern, PlaceholderUsagePattern):
913 NAMES = ["mpeg-version"]
914 PARAMETERS = [ComplexPattern.ExpectedParameter("output",
915 default=r"MPEG#v\, Layer #l")]
916 DESCRIPTION = "MPEG version (with output placeholders #v as version & "\
917 "#l as layer)"
918
919 def _get_output_for(self, audio_file):
920 output = self._parameter_value("output", audio_file)
921 replacements = [["#v", str(audio_file.info.mp3_header.version)],
922 ["#l", "I" * audio_file.info.mp3_header.layer]]
923 return self._replace_placeholders(output, replacements)
924
925
926 class FunctionBitRatePattern(FunctionPattern):
927 NAMES = ["bit-rate"]
928 DESCRIPTION = "Bit rate of aufio file"
929
930 def _get_output_for(self, audio_file):
931 return audio_file.info.bit_rate_str
932
933
934 class FunctionSampleFrequencePattern(FunctionPattern):
935 NAMES = ["sample-freq"]
936 DESCRIPTION = "Sample frequence of aufio file in Hz"
937
938 def _get_output_for(self, audio_file):
939 return str(audio_file.info.mp3_header.sample_freq)
940
941
942 class FunctionAudioModePattern(FunctionPattern):
943 NAMES = ["audio-mode"]
944 DESCRIPTION = "Mode of aufio file: mono/stereo"
945
946 def _get_output_for(self, audio_file):
947 return audio_file.info.mp3_header.mode
948
949
950 class FunctionNotEmptyPattern(FunctionPattern, PlaceholderUsagePattern):
951 NAMES = ["not-empty"]
952 PARAMETERS = [ComplexPattern.ExpectedParameter("text"),
953 ComplexPattern.ExpectedParameter("output", default="#t"),
954 ComplexPattern.ExpectedParameter("empty", default=None)]
955 DESCRIPTION = "If condition is not empty (with output placeholder #t as "\
956 "text)"
957
958 def _get_output_for(self, audio_file):
959 text = self._parameter_value("text", audio_file)
960 if len(text) > 0:
961 output = self._parameter_value("output", audio_file)
962 return self._replace_placeholders(output, [["#t", text]])
963 return output.replace("#t", text)
964 else:
965 return self._parameter_value("empty", audio_file)
966
967
968 class FunctionRepeatPattern(FunctionPattern):
969 NAMES = ["repeat"]
970 PARAMETERS = [ComplexPattern.ExpectedParameter("text"),
971 ComplexPattern.ExpectedParameter("count")]
972 DESCRIPTION = "Repeats text"
973
974 def _get_output_for(self, audio_file):
975 output = u""
976 content = self._parameter_value("text", audio_file)
977 count = self._parameter_value("count", audio_file)
978 try:
979 count = int(count)
980 except ValueError:
981 raise DisplayException(self._error_message("'" + count +
982 "' is not a number."))
983 for i in range(count):
984 output += content
985 return output
986
987
988 class DisplayPlugin(LoaderPlugin):
989 NAMES = ["display"]
990 SUMMARY = u"Tag Display"
991 DESCRIPTION = ""u"""
992 Prints specific tag information.
993 """
994
995 def __init__(self, arg_parser):
996 super(DisplayPlugin, self).__init__(arg_parser)
997
998 def filename(fn):
999 if not os.path.exists(fn):
1000 raise ArgumentTypeError("The file %s does not exist!" % fn)
1001 return fn
1002
1003 pattern_group = \
1004 self.arg_group.add_mutually_exclusive_group(required=True)
1005 pattern_group.add_argument("--pattern-help", action="store_true",
1006 dest="pattern_help",
1007 help=ARGS_HELP["--pattern-help"])
1008 pattern_group.add_argument("-p", "--pattern", dest="pattern_string",
1009 metavar="STRING",
1010 help=ARGS_HELP["--pattern"])
1011 pattern_group.add_argument("-f", "--pattern-file", dest="pattern_file",
1012 metavar="FILE", type=filename,
1013 help=ARGS_HELP["--pattern-file"])
1014 self.arg_group.add_argument("--no-newline", action="store_true",
1015 dest="no_newline",
1016 help=ARGS_HELP["--no-newline"])
1017
1018 self.__pattern = None
1019 self.__return_code = 0
1020
1021 def start(self, args, config):
1022 super(DisplayPlugin, self).start(args, config)
1023
1024 if args.pattern_help:
1025 self.__print_pattern_help()
1026 return
1027
1028 if not _have_grako:
1029 console.printError(u"Unknown module 'grako'" + os.linesep +
1030 u"Please install grako! " +
1031 u"E.g. $ pip install grako")
1032 self.__return_code = 2
1033 return
1034
1035 if args.pattern_string is not None:
1036 self.__pattern = Pattern(args.pattern_string)
1037 if args.pattern_file is not None:
1038 pfile = open(args.pattern_file, "r")
1039 self.__pattern = Pattern(''.join(pfile.read().splitlines()))
1040 pfile.close()
1041 self.__output_ending = "" if args.no_newline else os.linesep
1042
1043 def handleFile(self, f, *args, **kwargs):
1044 if self.args.pattern_help:
1045 return
1046 if self.__return_code != 0:
1047 return
1048
1049 super(DisplayPlugin, self).handleFile(f)
1050 if not self.audio_file:
1051 return
1052
1053 try:
1054 print(self.__pattern.output_for(self.audio_file),
1055 end=self.__output_ending)
1056 except PatternCompileException as e:
1057 self.__return_code = 1
1058 console.printError(e.message)
1059 except DisplayException as e:
1060 self.__return_code = 1
1061 console.printError(e.message)
1062
1063 def handleDone(self):
1064 return self.__return_code
1065
1066 def __print_pattern_help(self):
1067 # FIXME: Force some order
1068 print(console.formatText("ID3 Tags:", b=True))
1069 self.__print_complex_pattern_help(TagPattern)
1070 print(os.linesep)
1071
1072 print(console.formatText("Functions:", b=True))
1073 self.__print_complex_pattern_help(FunctionPattern)
1074 print(os.linesep)
1075
1076 print(console.formatText("Special characters:", b=True))
1077 print(console.formatText("\tescape seq. character"))
1078 for i in range(len(TextPattern.SPECIAL_CHARACTERS)):
1079 print(("\t\\%s" + (" " * 12) + "%s") %
1080 (TextPattern.SPECIAL_CHARACTERS[i],
1081 TextPattern.SPECIAL_CHARACTERS_DESCRIPTIONS[i]))
1082
1083 def __print_complex_pattern_help(self, base_class):
1084 rows = []
1085 # TODO line wrap for description
1086 for pattern_class in Pattern.sub_pattern_classes(base_class):
1087 rows.append([", ".join(pattern_class.NAMES),
1088 pattern_class.DESCRIPTION])
1089 parameters = Pattern.pattern_class_parameters(pattern_class)
1090 if len(parameters) > 0:
1091 rows.append(["", "Parameter" +
1092 ("s:" if len(parameters) > 1 else ":")])
1093 for parameter in parameters:
1094 parameter_desc = parameter.name
1095 if not parameter.requried:
1096 default = ", default='" + parameter.default + \
1097 "'" if parameter.default else ""
1098 parameter_desc += " (optional" + default + ")"
1099 rows.append(["", " " + parameter_desc])
1100 self.__print_rows(rows, "\t", " ")
1101
1102 def __print_rows(self, rows, indent, spacing):
1103 row_widths = []
1104 for row in rows:
1105 for n in range(len(row)):
1106 width = len(row[n])
1107 if len(row_widths) <= n:
1108 row_widths.append(width)
1109 else:
1110 row_widths[n] = max(row_widths[n], width)
1111 for row in rows:
1112 out = indent
1113 for n in range(len(row)):
1114 out += row[n]
1115 if n < len(row) - 1:
1116 out += (" " * (row_widths[n] - len(row[n]))) + spacing
1117 print(out)
1118
1119
1120 class DisplayException(Exception):
1121 def __init__(self, message):
1122 self.__message = message
1123
1124 def __get_message(self):
1125 return self.__message
1126
1127 message = property(__get_message)
1128
1129
1130 class PatternCompileException(Exception):
1131 def __init__(self, message):
1132 self.__message = message
1133
1134 def __get_message(self):
1135 return self.__message
1136
1137 message = property(__get_message)
1138
1139
1140 ARGS_HELP = {
1141 "--pattern-help": "Detailed pattern help",
1142 "--pattern": "Pattern string",
1143 "--pattern-file": "Pattern file",
1144 "--no-newline": "Print no newline after each output"
1145 }
+0
-658
src/eyed3/plugins/fixup.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2013-2014 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 from __future__ import print_function
19 import os
20 from collections import defaultdict
21
22 from eyed3.id3 import ID3_V2_4
23 from eyed3.id3.tag import TagTemplate
24 from eyed3.plugins import LoaderPlugin
25 from eyed3.compat import UnicodeType
26 from eyed3.utils import art
27 from eyed3.utils.prompt import prompt
28 from eyed3.utils.console import printMsg, Style, Fore
29 from eyed3 import core, compat
30
31 from eyed3.core import (ALBUM_TYPE_IDS, TXXX_ALBUM_TYPE, EP_MAX_SIZE_HINT,
32 LP_TYPE, EP_TYPE, COMP_TYPE, VARIOUS_TYPE, DEMO_TYPE,
33 LIVE_TYPE, SINGLE_TYPE, VARIOUS_ARTISTS)
34
35 NORMAL_FNAME_FORMAT = u"${artist} - ${track:num} - ${title}"
36 VARIOUS_FNAME_FORMAT = u"${track:num} - ${artist} - ${title}"
37 SINGLE_FNAME_FORMAT = u"${artist} - ${title}"
38
39 NORMAL_DNAME_FORMAT = u"${best_date:prefer_release} - ${album}"
40 LIVE_DNAME_FORMAT = u"${best_date:prefer_recording} - ${album}"
41
42
43 def _printChecking(msg, end='\n'):
44 print(Style.BRIGHT + Fore.GREEN + u"Checking" + Style.RESET_ALL +
45 " %s" % msg,
46 end=end)
47
48
49 def _fixCase(s):
50 if s:
51 fixed_values = []
52 for word in s.split():
53 fixed_values.append(word.capitalize())
54 return u" ".join(fixed_values)
55 else:
56 return s
57
58
59 def dirDate(d):
60 s = str(d)
61 if "T" in s:
62 s = s.split("T")[0]
63 return s.replace('-', '.')
64
65
66 class FixupPlugin(LoaderPlugin):
67 NAMES = ["fixup"]
68 SUMMARY = \
69 u"Performs various checks and fixes to directories of audio files."
70 DESCRIPTION = u"""
71 Operates on directories at a time, fixing each as a unit (album,
72 compilation, live set, etc.). All of these should have common dates,
73 for example but other characteristics may vary. The ``--type`` should be used
74 whenever possible, ``lp`` is the default.
75
76 The following test and fixes always apply:
77
78 1. Every file will be given an ID3 tag if one is missing.
79 2. Set ID3 v2.4.
80 3. Set a consistent album name for all files in the directory.
81 4. Set a consistent artist name for all files, unless the type is
82 ``various`` in which case the artist may vary (but must exist).
83 5. Ensure each file has a title.
84 6. Ensure each file has a track # and track total.
85 7. Ensure all files have a release and original release date, unless the
86 type is ``live`` in which case the recording date is set.
87 8. All ID3 frames of the following types are removed: USER, PRIV
88 9. All ID3 files have TLEN (track length in ms) set (or updated).
89 10. The album/dir type is set in the tag. Types of ``lp`` and ``various``
90 do not have this field set since the latter is the default and the
91 former can be determined during sync. In ID3 terms the value is in
92 TXXX (description: ``%(TXXX_ALBUM_TYPE)s``).
93 11. Files are renamed as follows:
94 - Type ``various``: %(VARIOUS_FNAME_FORMAT)s
95 - Type ``single``: %(SINGLE_FNAME_FORMAT)s
96 - All other types: %(NORMAL_FNAME_FORMAT)s
97 - A rename template can be supplied in --file-rename-pattern
98 12. Directories are renamed as follows:
99 - Type ``live``: %(LIVE_DNAME_FORMAT)s
100 - All other types: %(NORMAL_DNAME_FORMAT)s
101 - A rename template can be supplied in --dir-rename-pattern
102
103 Album types:
104
105 - ``lp``: A traditinal "album" of songs from a single artist.
106 No extra info is written to the tag since this is the default.
107 - ``ep``: A short collection of songs from a single artist. The string 'ep'
108 is written to the tag's ``%(TXXX_ALBUM_TYPE)s`` field.
109 - ``various``: A collection of songs from different artists. The string
110 'various' is written to the tag's ``%(TXXX_ALBUM_TYPE)s`` field.
111 - ``live``: A collection of live recordings from a single artist. The string
112 'live' is written to the tag's ``%(TXXX_ALBUM_TYPE)s`` field.
113 - ``compilation``: A collection of songs from various recordings by a single
114 artist. The string 'compilation' is written to the tag's
115 ``%(TXXX_ALBUM_TYPE)s`` field. Compilation dates, unlike other types, may
116 differ.
117 - ``demo``: A demo recording by a single artist. The string 'demo' is
118 written to the tag's ``%(TXXX_ALBUM_TYPE)s`` field.
119 - ``single``: A track that should no be associated with an album (even if
120 it has album metadata). The string 'single' is written to the tag's
121 ``%(TXXX_ALBUM_TYPE)s`` field.
122
123 """ % globals()
124
125 def __init__(self, arg_parser):
126 super(FixupPlugin, self).__init__(arg_parser, cache_files=True,
127 track_images=True)
128 g = self.arg_group
129 self._handled_one = False
130
131 g.add_argument("--type", choices=ALBUM_TYPE_IDS, dest="dir_type",
132 default=None, type=UnicodeType,
133 help=ARGS_HELP["--type"])
134 g.add_argument("--fix-case", action="store_true", dest="fix_case",
135 help=ARGS_HELP["--fix-case"])
136 g.add_argument("-n", "--dry-run", action="store_true", dest="dry_run",
137 help=ARGS_HELP["--dry-run"])
138 g.add_argument("--no-prompt", action="store_true", dest="no_prompt",
139 help=ARGS_HELP["--no-prompt"])
140 g.add_argument("--dotted-dates", action="store_true",
141 help=ARGS_HELP["--dotted-dates"])
142 g.add_argument("--file-rename-pattern", dest="file_rename_pattern",
143 help=ARGS_HELP["--file-rename-pattern"])
144 g.add_argument("--dir-rename-pattern", dest="dir_rename_pattern",
145 help=ARGS_HELP["--dir-rename-pattern"])
146 self._curr_dir_type = None
147 self._dir_files_to_remove = set()
148
149 def _getOne(self, key, values, default=None, Type=UnicodeType,
150 required=True):
151 values = set(values)
152 if None in values:
153 values.remove(None)
154
155 if len(values) != 1:
156 printMsg(
157 u"Detected %s %s names%s" %
158 ("0" if len(values) == 0 else "multiple",
159 key,
160 "." if not values
161 else (":\n\t%s" % "\n\t".join([compat.unicode(v)
162 for v in values])),
163 ))
164
165 value = prompt(u"Enter %s" % key.title(), default=default,
166 type_=Type, required=required)
167 else:
168 value = values.pop()
169
170 return value
171
172 def _getDates(self, audio_files):
173 tags = [f.tag for f in audio_files if f.tag]
174
175 rel_dates = set([t.release_date for t in tags if t.release_date])
176 orel_dates = set([t.original_release_date for t in tags
177 if t.original_release_date])
178 rec_dates = set([t.recording_date for t in tags if t.recording_date])
179
180 release_date, original_release_date, recording_date = None, None, None
181
182 def reduceDate(type_str, dates_set, default_date=None):
183 if len(dates_set or []) != 1:
184 reduced = self._getOne(type_str, dates_set,
185 default=str(default_date) if default_date
186 else None,
187 Type=core.Date.parse)
188 else:
189 reduced = dates_set.pop()
190 return reduced
191
192 if (False not in [a.tag.album_type == LIVE_TYPE for a in audio_files] or
193 self._curr_dir_type == LIVE_TYPE):
194 # The recording date is most meaningful for live music.
195 recording_date = reduceDate("recording date",
196 rec_dates | orel_dates | rel_dates)
197 rec_dates = set([recording_date])
198
199 # Want when these set if they may recording time.
200 orel_dates.difference_update(rec_dates)
201 rel_dates.difference_update(rec_dates)
202
203 if orel_dates:
204 original_release_date = reduceDate("original release date",
205 orel_dates | rel_dates)
206 orel_dates = set([original_release_date])
207
208 if rel_dates | orel_dates:
209 release_date = reduceDate("release date",
210 rel_dates | orel_dates)
211 elif (False not in [a.tag.album_type == COMP_TYPE
212 for a in audio_files] or
213 self._curr_dir_type == COMP_TYPE):
214 # The release date is most meaningful for comps, other track dates
215 # may differ.
216 if len(rel_dates) != 1:
217 release_date = reduceDate("release date",
218 rel_dates | orel_dates)
219 rel_dates = set([release_date])
220 else:
221 release_date = list(rel_dates)[0]
222 else:
223 if len(orel_dates) != 1:
224 # The original release date is most meaningful for studio music.
225 original_release_date = reduceDate("original release date",
226 orel_dates | rel_dates |
227 rec_dates)
228 orel_dates = set([original_release_date])
229 else:
230 original_release_date = list(orel_dates)[0]
231
232 if len(rel_dates) != 1:
233 release_date = reduceDate("release date",
234 rel_dates | orel_dates)
235 rel_dates = set([release_date])
236 else:
237 release_date = list(rel_dates)[0]
238
239 if rec_dates.difference(orel_dates | rel_dates):
240 recording_date = reduceDate("recording date", rec_dates)
241
242 return release_date, original_release_date, recording_date
243
244 def _resolveArtistInfo(self, audio_files):
245 assert(self._curr_dir_type != SINGLE_TYPE)
246
247 tags = [f.tag for f in audio_files if f.tag]
248 artists = set([t.album_artist for t in tags if t.album_artist])
249
250 # There can be 0 or 1 album artist values.
251 album_artist = None
252 if len(artists) > 1:
253 album_artist = self._getOne("album artist", artists, required=False)
254 elif artists:
255 album_artist = artists.pop()
256
257 artists = list(set([t.artist for t in tags if t.artist]))
258
259 if len(artists) > 1:
260 # There can be more then 1 artist when VARIOUS_TYPE or
261 # album_artist != None.
262 if not album_artist and self._curr_dir_type != VARIOUS_TYPE:
263 if prompt("Multiple artist names exist, process directory as "
264 "various artists", default=True):
265 self._curr_dir_type = VARIOUS_TYPE
266 else:
267 artists = [self._getOne("artist", artists, required=True)]
268 elif (album_artist == VARIOUS_ARTISTS and
269 self._curr_dir_type != VARIOUS_TYPE):
270 self._curr_dir_type = VARIOUS_TYPE
271 elif len(artists) == 0:
272 artists = [self._getOne("artist", [], required=True)]
273
274 # Fix up artist and album artist discrepancies
275 if len(artists) == 1 and album_artist:
276 artist = artists[0]
277 if (album_artist != artist):
278 print("When there is only one artist it should match the "
279 "album artist. Choices are: ")
280 for s in [artist, album_artist]:
281 print("\t%s" % s)
282 album_artist = prompt("Select common artist and album artist",
283 choices=[artist, album_artist])
284 artists = [album_artist]
285
286 if self.args.fix_case:
287 album_artist = _fixCase(album_artist)
288 artists = [_fixCase(a) for a in artists]
289 return album_artist, artists
290
291 def _getAlbum(self, audio_files):
292 tags = [f.tag for f in audio_files if f.tag]
293 albums = set([t.album for t in tags if t.album])
294 album_name = (albums.pop() if len(albums) == 1
295 else self._getOne("album", albums))
296 assert(album_name)
297 return album_name if not self.args.fix_case else _fixCase(album_name)
298
299 def _checkCoverArt(self, directory, audio_files):
300 valid_cover = False
301
302 # Check for cover file.
303 _printChecking("for cover art...")
304 for dimg in self._dir_images:
305 art_type = art.matchArtFile(dimg)
306 if art_type == art.FRONT_COVER:
307 dimg_name = os.path.basename(dimg)
308 print("\t%s" % dimg_name)
309 valid_cover = True
310
311 if not valid_cover:
312 # FIXME: move the logic out fixup and into art.
313 # Look for a cover in the tags.
314 for tag in [af.tag for af in audio_files if af.tag]:
315 if valid_cover:
316 # It could be set below...
317 break
318 for img in tag.images:
319 if img.picture_type == img.FRONT_COVER:
320 file_name = img.makeFileName("cover")
321 print("\tFound front cover in tag, writing '%s'" %
322 file_name)
323 with open(os.path.join(directory, file_name),
324 "wb") as img_file:
325 img_file.write(img.image_data)
326 img_file.close()
327 valid_cover = True
328
329 return valid_cover
330
331 def start(self, args, config):
332 import eyed3.utils.prompt
333 eyed3.utils.prompt.DISABLE_PROMPT = "exit" if args.no_prompt else None
334
335 super(FixupPlugin, self).start(args, config)
336
337 def handleFile(self, f, *args, **kwargs):
338 super(FixupPlugin, self).handleFile(f, *args, **kwargs)
339 if not self.audio_file and f not in self._dir_images:
340 self._dir_files_to_remove.add(f)
341
342 def handleDirectory(self, directory, _):
343 if not self._file_cache:
344 return
345
346 directory = os.path.abspath(directory)
347 print("\n" + Style.BRIGHT + Fore.GREY +
348 "Scanning directory%s %s" % (Style.RESET_ALL, directory))
349
350 def _path(af):
351 return af.path
352
353 self._handled_one = True
354
355 # Make sure all of the audio files has a tag.
356 for f in self._file_cache:
357 if f.tag is None:
358 f.initTag()
359
360 audio_files = sorted(list(self._file_cache), key=_path)
361
362 self._file_cache = []
363 edited_files = set()
364 self._curr_dir_type = self.args.dir_type
365 if self._curr_dir_type is None:
366 types = set([a.tag.album_type for a in audio_files])
367 if len(types) == 1:
368 self._curr_dir_type = types.pop()
369
370 # Check for corrections to LP, EP, COMP
371 if (self._curr_dir_type is None and
372 len(audio_files) < EP_MAX_SIZE_HINT):
373 # Do you want EP?
374 if False in [a.tag.album_type == EP_TYPE for a in audio_files]:
375 if prompt("Only %d audio files, process directory as an EP" %
376 len(audio_files),
377 default=True):
378 self._curr_dir_type = EP_TYPE
379 else:
380 self._curr_dir_type = EP_TYPE
381 elif (self._curr_dir_type in (EP_TYPE, DEMO_TYPE) and
382 len(audio_files) > EP_MAX_SIZE_HINT):
383 # Do you want LP?
384 if prompt("%d audio files is large for type %s, process "
385 "directory as an LP" % (len(audio_files),
386 self._curr_dir_type),
387 default=True):
388 self._curr_dir_type = LP_TYPE
389
390 last = defaultdict(lambda: None)
391
392 album_artist = None
393 artists = set()
394 album = None
395
396 if self._curr_dir_type != SINGLE_TYPE:
397 album_artist, artists = self._resolveArtistInfo(audio_files)
398 print(Fore.BLUE + u"Album artist: " + Style.RESET_ALL +
399 (album_artist or u""))
400 print(Fore.BLUE + "Artist" + ("s" if len(artists) > 1 else "") +
401 ": " + Style.RESET_ALL + u", ".join(artists))
402
403 album = self._getAlbum(audio_files)
404 print(Fore.BLUE + "Album: " + Style.RESET_ALL + album)
405
406 rel_date, orel_date, rec_date = self._getDates(audio_files)
407 for what, d in [("Release", rel_date),
408 ("Original", orel_date),
409 ("Recording", rec_date)]:
410 print(Fore.BLUE + ("%s date: " % what) + Style.RESET_ALL +
411 str(d))
412
413 num_audio_files = len(audio_files)
414 track_nums = set([f.tag.track_num[0] for f in audio_files])
415 fix_track_nums = set(range(1, num_audio_files + 1)) != track_nums
416 new_track_nums = []
417
418 dir_type = self._curr_dir_type
419 for f in sorted(audio_files, key=_path):
420 print(Style.BRIGHT + Fore.GREEN + u"Checking" + Fore.RESET +
421 Fore.GREY + (" %s" % os.path.basename(f.path)) +
422 Style.RESET_ALL)
423
424 if not f.tag:
425 print("\tAdding new tag")
426 f.initTag()
427 edited_files.add(f)
428 tag = f.tag
429
430 if tag.version != ID3_V2_4:
431 print("\tConverting to ID3 v2.4")
432 tag.version = ID3_V2_4
433 edited_files.add(f)
434
435 if (dir_type != SINGLE_TYPE and album_artist != tag.album_artist):
436 print(u"\tSetting album artist: %s" % album_artist)
437 tag.album_artist = album_artist
438 edited_files.add(f)
439
440 if not tag.artist and dir_type in (VARIOUS_TYPE, SINGLE_TYPE):
441 # Prompt artist
442 tag.artist = prompt("Artist name", default=last["artist"])
443 last["artist"] = tag.artist
444 elif len(artists) == 1 and tag.artist != artists[0]:
445 assert(dir_type != SINGLE_TYPE)
446 print(u"\tSetting artist: %s" % artists[0])
447 tag.artist = artists[0]
448 edited_files.add(f)
449
450 if tag.album != album and dir_type != SINGLE_TYPE:
451 print(u"\tSetting album: %s" % album)
452 tag.album = album
453 edited_files.add(f)
454
455 orig_title = tag.title
456 if not tag.title:
457 tag.title = prompt("Track title")
458 tag.title = tag.title.strip()
459 if self.args.fix_case:
460 tag.title = _fixCase(tag.title)
461 if orig_title != tag.title:
462 print(u"\tSetting title: %s" % tag.title)
463 edited_files.add(f)
464
465 if dir_type != SINGLE_TYPE:
466 # Track numbers
467 tnum, ttot = tag.track_num
468 update = False
469 if ttot != num_audio_files:
470 update = True
471 ttot = num_audio_files
472
473 if fix_track_nums or not (1 <= tnum <= num_audio_files):
474 tnum = None
475 while tnum is None:
476 tnum = int(prompt("Track #", type_=int))
477 if not (1 <= tnum <= num_audio_files):
478 print(Fore.RED + "Out of range: " + Fore.RESET +
479 "1 <= %d <= %d" % (tnum, num_audio_files))
480 tnum = None
481 elif tnum in new_track_nums:
482 print(Fore.RED + "Duplicate value: " + Fore.RESET +
483 str(tnum))
484 tnum = None
485 else:
486 update = True
487 new_track_nums.append(tnum)
488
489 if update:
490 tag.track_num = (tnum, ttot)
491 print("\tSetting track numbers: %s" % str(tag.track_num))
492 edited_files.add(f)
493 else:
494 # Singles
495 if tag.track_num != (None, None):
496 tag.track_num = (None, None)
497 edited_files.add(f)
498
499 if dir_type != SINGLE_TYPE:
500 # Dates
501 if rec_date and tag.recording_date != rec_date:
502 print("\tSetting %s date (%s)" %
503 ("recording", str(rec_date)))
504 tag.recording_date = rec_date
505 edited_files.add(f)
506 if rel_date and tag.release_date != rel_date:
507 print("\tSetting %s date (%s)" % ("release", str(rel_date)))
508 tag.release_date = rel_date
509 edited_files.add(f)
510 if orel_date and tag.original_release_date != orel_date:
511 print("\tSetting %s date (%s)" % ("original release",
512 str(orel_date)))
513 tag.original_release_date = orel_date
514 edited_files.add(f)
515
516 for frame in list(tag.frameiter(["USER", "PRIV"])):
517 print("\tRemoving %s frames: %s" %
518 (frame.id,
519 frame.owner_id if frame.id == b"PRIV" else frame.text))
520 tag.frame_set[frame.id].remove(frame)
521 edited_files.add(f)
522
523 # Add TLEN
524 tlen = tag.getTextFrame("TLEN")
525 if tlen is not None:
526 real_tlen_ms = f.info.time_secs * 1000
527 tlen_ms = float(tlen)
528 if tlen_ms != real_tlen_ms:
529 print("\tSetting TLEN (%d)" % real_tlen_ms)
530 tag.setTextFrame("TLEN", UnicodeType(real_tlen_ms))
531 edited_files.add(f)
532
533 # Add custom album type if special and otherwise not able to be
534 # determined.
535 curr_type = tag.album_type
536 if curr_type != dir_type:
537 print("\tSetting %s = %s" % (TXXX_ALBUM_TYPE, dir_type))
538 tag.album_type = dir_type
539 edited_files.add(f)
540
541 try:
542 if not self._checkCoverArt(directory, audio_files):
543 if not prompt("Proceed without valid cover file", default=True):
544 return
545 finally:
546 self._dir_images = []
547
548 # Determine other changes, like file and/or directory renames
549 # so they can be reported before save confirmation.
550
551 # File renaming
552 file_renames = []
553 if self.args.file_rename_pattern:
554 format_str = self.args.file_rename_pattern
555 else:
556 if dir_type == SINGLE_TYPE:
557 format_str = SINGLE_FNAME_FORMAT
558 elif dir_type in (VARIOUS_TYPE, COMP_TYPE):
559 format_str = VARIOUS_FNAME_FORMAT
560 else:
561 format_str = NORMAL_FNAME_FORMAT
562
563 for f in audio_files:
564 orig_name, orig_ext = os.path.splitext(os.path.basename(f.path))
565 new_name = TagTemplate(format_str).substitute(f.tag, zeropad=True)
566 if orig_name != new_name:
567 printMsg(u"Rename file to %s%s" % (new_name, orig_ext))
568 file_renames.append((f, new_name, orig_ext))
569
570 # Directory renaming
571 dir_rename = None
572 if dir_type != SINGLE_TYPE:
573 if self.args.dir_rename_pattern:
574 dir_format = self.args.dir_rename_pattern
575 else:
576 if dir_type == LIVE_TYPE:
577 dir_format = LIVE_DNAME_FORMAT
578 else:
579 dir_format = NORMAL_DNAME_FORMAT
580 template = TagTemplate(dir_format,
581 dotted_dates=self.args.dotted_dates)
582
583 pref_dir = template.substitute(audio_files[0].tag, zeropad=True)
584 if os.path.basename(directory) != pref_dir:
585 new_dir = os.path.join(os.path.dirname(directory), pref_dir)
586 printMsg("Rename directory to %s" % new_dir)
587 dir_rename = (directory, new_dir)
588
589 # Cruft files to remove
590 file_removes = []
591 if self._dir_files_to_remove:
592 for f in self._dir_files_to_remove:
593 print("Remove file: " + os.path.basename(f))
594 file_removes.append(f)
595 self._dir_files_to_remove = set()
596
597 if not self.args.dry_run:
598 confirmed = False
599
600 if (edited_files or file_renames or dir_rename or file_removes):
601 confirmed = prompt("\nSave changes", default=True)
602
603 if confirmed:
604 for f in edited_files:
605 print(u"Saving %s" % os.path.basename(f.path))
606 f.tag.save(version=ID3_V2_4, preserve_file_time=True)
607
608 for f, new_name, orig_ext in file_renames:
609 printMsg(u"Renaming file to %s%s" % (new_name, orig_ext))
610 f.rename(new_name, preserve_file_time=True)
611
612 if file_removes:
613 for f in file_removes:
614 printMsg("Removing file %s" % os.path.basename(f))
615 os.remove(f)
616
617 if dir_rename:
618 printMsg("Renaming directory to %s" % dir_rename[1])
619 s = os.stat(dir_rename[0])
620 os.rename(dir_rename[0], dir_rename[1])
621 # With a rename use the origianl access time
622 os.utime(dir_rename[1], (s.st_atime, s.st_atime))
623
624 else:
625 printMsg("\nNo changes made (run without -n/--dry-run)")
626
627 def handleDone(self):
628 if not self._handled_one:
629 printMsg("Nothing to do")
630
631
632 def _getTemplateKeys():
633 from eyed3.id3.tag import TagTemplate
634
635 keys = list(TagTemplate("")._makeMapping(None, False).keys())
636 keys.sort()
637 return ", ".join(["$%s" % v for v in keys])
638
639
640 ARGS_HELP = {
641 "--type": "How to treat each directory. The default is '%s', "
642 "although you may be prompted for an alternate choice "
643 "if the files look like another type." % ALBUM_TYPE_IDS[0],
644 "--fix-case": "Fix casing on each string field by capitalizing each "
645 "word.",
646 "--dry-run": "Only print the operations that would take place, but do "
647 "not execute them.",
648 "--no-prompt": "Exit if prompted.",
649 "--dotted-dates": "Separate date with '.' instead of '-' when naming "
650 "directories.",
651 "--file-rename-pattern": "Rename file (the extension is not affected) "
652 "based on data in the tag using substitution "
653 "variables: " + _getTemplateKeys(),
654 "--dir-rename-pattern": "Rename directory based on data in the tag "
655 "using substitution variables: " +
656 _getTemplateKeys(),
657 }
+0
-67
src/eyed3/plugins/genres.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2012 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 from __future__ import print_function
19 import math
20 from eyed3 import id3
21 from eyed3.plugins import Plugin
22
23
24 class GenreListPlugin(Plugin):
25 SUMMARY = u"Display the full list of standard ID3 genres."
26 DESCRIPTION = u"ID3 v1 defined a list of genres and mapped them to "\
27 "to numeric values so they can be stored as a single "\
28 "byte.\nIt is *recommended* that these genres are used "\
29 "although most newer software (including eyeD3) does not "\
30 "care."
31 NAMES = ["genres"]
32
33 def __init__(self, arg_parser):
34 super(GenreListPlugin, self).__init__(arg_parser)
35 self.arg_group.add_argument("-1", "--single-column",
36 action="store_true",
37 help="List on genre per line.")
38
39 def start(self, args, config):
40 self._printGenres(args)
41
42 def _printGenres(self, args):
43 # Filter out 'Unknown'
44 genre_ids = [i for i in id3.genres
45 if type(i) is int and id3.genres[i] is not None]
46 genre_ids.sort()
47
48 if args.single_column:
49 for gid in genre_ids:
50 print(u"%3d: %s" % (gid, id3.genres[gid]))
51 else:
52 offset = int(math.ceil(float(len(genre_ids)) / 2))
53 for i in range(offset):
54 if i < len(genre_ids):
55 c1 = u"%3d: %s" % (i, id3.genres[i])
56 else:
57 c1 = u""
58 if (i * 2) < len(genre_ids):
59 try:
60 c2 = u"%3d: %s" % (i + offset, id3.genres[i + offset])
61 except IndexError:
62 break
63 else:
64 c2 = u""
65 print(c1 + (u" " * (40 - len(c1))) + c2)
66 print(u"")
+0
-83
src/eyed3/plugins/itunes.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2012 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 from __future__ import print_function
19 from eyed3.plugins import LoaderPlugin
20 from eyed3.id3.apple import PCST, PCST_FID, WFED, WFED_FID
21
22
23 class Podcast(LoaderPlugin):
24 NAMES = ['itunes-podcast']
25 SUMMARY = u"Adds (or removes) the tags necessary for Apple iTunes to "\
26 "identify the file as a podcast."
27
28 def __init__(self, arg_parser):
29 super(Podcast, self).__init__(arg_parser)
30 g = self.arg_group
31 g.add_argument("--add", action="store_true",
32 help="Add the podcast frames.")
33 g.add_argument("--remove", action="store_true",
34 help="Remove the podcast frames.")
35
36 def _add(self, tag):
37 save = False
38 if PCST_FID not in tag.frame_set:
39 tag.frame_set[PCST_FID] = PCST()
40 save = True
41 if WFED_FID not in tag.frame_set:
42 tag.frame_set[WFED_FID] = WFED(u"http://eyeD3.nicfit.net/")
43 save = True
44
45 if save:
46 print("\tAdding...")
47 tag.save(backup=self.args.backup)
48 self._printStatus(tag)
49
50 def _remove(self, tag):
51 save = False
52 for fid in [PCST_FID, WFED_FID]:
53 try:
54 del tag.frame_set[fid]
55 save = True
56 except KeyError:
57 continue
58
59 if save:
60 print("\tRemoving...")
61 tag.save(backup=self.args.backup)
62 self._printStatus(tag)
63
64 def _printStatus(self, tag):
65 status = ":-("
66 if PCST_FID in tag.frame_set:
67 status = ":-/"
68 if WFED_FID in tag.frame_set:
69 status = ":-)"
70 print("\tiTunes podcast? %s" % status)
71
72 def handleFile(self, f):
73 super(Podcast, self).handleFile(f)
74
75 if self.audio_file and self.audio_file.tag:
76 print(f)
77 tag = self.audio_file.tag
78 self._printStatus(tag)
79 if self.args.remove:
80 self._remove(self.audio_file.tag)
81 elif self.args.add:
82 self._add(self.audio_file.tag)
+0
-111
src/eyed3/plugins/lameinfo.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2009 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 import os
19 import math
20 from eyed3.utils import formatSize
21 from eyed3.utils.console import printMsg, boldText, Fore, HEADER_COLOR
22 from eyed3.plugins import LoaderPlugin
23
24
25 class LameInfoPlugin(LoaderPlugin):
26 NAMES = ["lameinfo", "xing"]
27 SUMMARY = u"Outputs lame header (if one exists) for file."
28 DESCRIPTION = (
29 u"The 'lame' (or xing) header provides extra information about the mp3 "
30 "that is useful to players and encoders but not officially part of "
31 "the mp3 specification. Variable bit rate mp3s, for example, use this "
32 "header.\n\n"
33 "For more details see "
34 "`here <http://gabriel.mp3-tech.org/mp3infotag.html>`_"
35 )
36
37 def printHeader(self, filePath):
38 from stat import ST_SIZE
39 fileSize = os.stat(filePath)[ST_SIZE]
40 size_str = formatSize(fileSize)
41 print("\n%s\t%s[ %s ]%s" % (boldText(os.path.basename(filePath),
42 HEADER_COLOR()),
43 HEADER_COLOR(), size_str,
44 Fore.RESET))
45 print("-" * 79)
46
47 def handleFile(self, f):
48 super(LameInfoPlugin, self).handleFile(f)
49
50 self.printHeader(f)
51 if (self.audio_file is None or self.audio_file.info is None or
52 not self.audio_file.info.lame_tag):
53 printMsg('No LAME Tag')
54 return
55
56 format = '%-20s: %s'
57 lt = self.audio_file.info.lame_tag
58 if "infotag_crc" not in lt:
59 try:
60 printMsg('%s: %s' % ('Encoder Version', lt['encoder_version']))
61 except KeyError:
62 pass
63 return
64
65 values = []
66
67 values.append(('Encoder Version', lt['encoder_version']))
68 values.append(('LAME Tag Revision', lt['tag_revision']))
69 values.append(('VBR Method', lt['vbr_method']))
70 values.append(('Lowpass Filter', lt['lowpass_filter']))
71
72 if "replaygain" in lt:
73 try:
74 peak = lt['replaygain']['peak_amplitude']
75 db = 20 * math.log10(peak)
76 val = '%.8f (%+.1f dB)' % (peak, db)
77 values.append(('Peak Amplitude', val))
78 except KeyError:
79 pass
80 for type in ['radio', 'audiofile']:
81 try:
82 gain = lt['replaygain'][type]
83 name = '%s Replay Gain' % gain['name'].capitalize()
84 val = '%s dB (%s)' % (gain['adjustment'],
85 gain['originator'])
86 values.append((name, val))
87 except KeyError:
88 pass
89
90 values.append(('Encoding Flags', ' '.join((lt['encoding_flags']))))
91 if lt['nogap']:
92 values.append(('No Gap', ' and '.join(lt['nogap'])))
93 values.append(('ATH Type', lt['ath_type']))
94 values.append(('Bitrate (%s)' % lt['bitrate'][1], lt['bitrate'][0]))
95 values.append(('Encoder Delay', '%s samples' % lt['encoder_delay']))
96 values.append(('Encoder Padding', '%s samples' % lt['encoder_padding']))
97 values.append(('Noise Shaping', lt['noise_shaping']))
98 values.append(('Stereo Mode', lt['stereo_mode']))
99 values.append(('Unwise Settings', lt['unwise_settings']))
100 values.append(('Sample Frequency', lt['sample_freq']))
101 values.append(('MP3 Gain', '%s (%+.1f dB)' % (lt['mp3_gain'],
102 lt['mp3_gain'] * 1.5)))
103 values.append(('Preset', lt['preset']))
104 values.append(('Surround Info', lt['surround_info']))
105 values.append(('Music Length', '%s' % formatSize(lt['music_length'])))
106 values.append(('Music CRC-16', '%04X' % lt['music_crc']))
107 values.append(('LAME Tag CRC-16', '%04X' % lt['infotag_crc']))
108
109 for v in values:
110 printMsg(format % (v))
+0
-51
src/eyed3/plugins/lastfm.py less more
0 from pylast import (COVER_EXTRA_LARGE, COVER_LARGE, COVER_MEDIUM, COVER_MEGA,
1 COVER_SMALL)
2 from pylast import LastFMNetwork, WSError
3
4 api_k = "a5f0ac61e7db2481b054ba52ff9a654f"
5 api_s = "0c4a52ae5dcdbba1f9e782833a50b623"
6 _network = None
7
8
9 def Client():
10 global _network
11 if not _network:
12 _network = LastFMNetwork(api_key=api_k, api_secret=api_s)
13 _network.enable_rate_limit()
14 return _network
15
16
17 def getArtist(artist):
18 return Client().get_artist(artist)
19
20
21 def getAlbum(artist, title):
22 return Client().get_album(artist, title)
23
24
25 def getAlbumArt(artist, title, size=COVER_EXTRA_LARGE):
26 return _getArt(getAlbum(artist, title), size=size)
27
28
29 def getArtistArt(artist, size=COVER_EXTRA_LARGE):
30 return _getArt(getArtist(artist), size=size)
31
32
33 def _getArt(obj, size=COVER_EXTRA_LARGE):
34 try:
35 return obj.get_cover_image(size)
36 except WSError:
37 raise ValueError("{} not found.".format(obj.__class__.__name__))
38
39
40 if __name__ == "__main__":
41 album = getAlbum("Melvins", "Houdini")
42 for sz in (COVER_SMALL, COVER_MEGA, COVER_MEDIUM, COVER_LARGE,
43 COVER_EXTRA_LARGE):
44 print(album.get_cover_image(sz))
45
46 melvins = getArtist("Melvins")
47 print(melvins)
48 for sz in (COVER_SMALL, COVER_MEGA, COVER_MEDIUM, COVER_LARGE,
49 COVER_EXTRA_LARGE):
50 print(melvins.get_cover_image(sz))
+0
-142
src/eyed3/plugins/nfo.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2009-2012 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 from __future__ import print_function
19 import time
20 import eyed3
21 from eyed3.utils.console import printMsg
22 from eyed3.utils import formatSize, formatTime
23 from eyed3.id3 import versionToString
24 from eyed3.plugins import LoaderPlugin
25
26
27 class NfoPlugin(LoaderPlugin):
28 NAMES = ["nfo"]
29 SUMMARY = u"Create NFO files for each directory scanned."
30 DESCRIPTION = u"Each directory scanned is treated as an album and a "\
31 "`NFO <http://en.wikipedia.org/wiki/.nfo>`_ file is "\
32 "written to standard out.\n\n"\
33 "NFO files are often found in music archives."
34
35 def __init__(self, arg_parser):
36 super(NfoPlugin, self).__init__(arg_parser)
37 self.albums = {}
38
39 def handleFile(self, f):
40 super(NfoPlugin, self).handleFile(f)
41
42 if self.audio_file and self.audio_file.tag:
43 tag = self.audio_file.tag
44 album = tag.album
45 if album and album not in self.albums:
46 self.albums[album] = []
47 self.albums[album].append(self.audio_file)
48 elif album:
49 self.albums[album].append(self.audio_file)
50
51 def handleDone(self):
52 if not self.albums:
53 printMsg(u"No albums found.")
54 return
55
56 for album in self.albums:
57 audio_files = self.albums[album]
58 if not audio_files:
59 continue
60 audio_files.sort(key=lambda af: (af.tag.track_num[0] or 999,
61 af.tag.track_num[1] or 999))
62
63 max_title_len = 0
64 avg_bitrate = 0
65 encoder_info = ''
66 for audio_file in audio_files:
67 tag = audio_file.tag
68 # Compute maximum title length
69 title_len = len(tag.title or u"")
70 if title_len > max_title_len:
71 max_title_len = title_len
72 # Compute average bitrate
73 avg_bitrate += audio_file.info.bit_rate[1]
74 # Grab the last lame version in case not all files have one
75 if "encoder_version" in audio_file.info.lame_tag:
76 version = audio_file.info.lame_tag['encoder_version']
77 encoder_info = (version or encoder_info)
78 avg_bitrate = avg_bitrate / len(audio_files)
79
80 printMsg("")
81 printMsg("Artist : %s" % audio_files[0].tag.artist)
82 printMsg("Album : %s" % album)
83 printMsg("Released : %s" %
84 (audio_files[0].tag.original_release_date or
85 audio_files[0].tag.release_date))
86 printMsg("Recorded : %s" % audio_files[0].tag.recording_date)
87 genre = audio_files[0].tag.genre
88 if genre:
89 genre = genre.name
90 else:
91 genre = ""
92 printMsg("Genre : %s" % genre)
93
94 printMsg("")
95 printMsg("Source : ")
96 printMsg("Encoder : %s" % encoder_info)
97 printMsg("Codec : mp3")
98 printMsg("Bitrate : ~%s K/s @ %s Hz, %s" %
99 (avg_bitrate, audio_files[0].info.sample_freq,
100 audio_files[0].info.mode))
101 printMsg("Tag : ID3 %s" %
102 versionToString(audio_files[0].tag.version))
103
104 printMsg("")
105 printMsg("Ripped By: ")
106
107 printMsg("")
108 printMsg("Track Listing")
109 printMsg("-------------")
110 count = 0
111 total_time = 0
112 total_size = 0
113 for audio_file in audio_files:
114 tag = audio_file.tag
115 count += 1
116
117 title = tag.title or u""
118 title_len = len(title)
119 padding = " " * ((max_title_len - title_len) + 3)
120 time_secs = audio_file.info.time_secs
121 total_time += time_secs
122 total_size += audio_file.info.size_bytes
123
124 zero_pad = "0" * (len(str(len(audio_files))) - len(str(count)))
125 printMsg(" %s%d. %s%s(%s)" %
126 (zero_pad, count, title, padding,
127 formatTime(time_secs)))
128
129 printMsg("")
130 printMsg("Total play time : %s" %
131 formatTime(total_time))
132 printMsg("Total size : %s" %
133 formatSize(total_size))
134
135 printMsg("")
136 printMsg("=" * 78)
137 printMsg(".NFO file created with eyeD3 %s on %s" %
138 (eyed3.version, time.asctime()))
139 printMsg("For more information about eyeD3 go to %s" %
140 "http://eyeD3.nicfit.net/")
141 printMsg("=" * 78)
+0
-90
src/eyed3/plugins/pymod.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2014 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 from __future__ import print_function
19
20 from eyed3.plugins import LoaderPlugin
21 from eyed3.compat import importmod
22
23
24 _DEFAULT_MOD = "eyeD3mod.py"
25
26
27 class PyModulePlugin(LoaderPlugin):
28 SUMMARY = u"Imports a Python module file and calls its functions for the "\
29 "the various plugin events."
30 DESCRIPTION = u"""
31 If no module if provided (see -m/--module) a file named %(_DEFAULT_MOD)s in
32 the current working directory is imported. If any of the following methods
33 exist they still be invoked:
34
35 def audioFile(audio_file):
36 '''Invoked for every audio file that is encountered. The ``audio_file``
37 is of type ``eyed3.core.AudioFile``; currently this is the concrete type
38 ``eyed3.mp3.Mp3AudioFile``.'''
39 pass
40
41 def audioDir(d, audio_files, images):
42 '''This function is invoked for any directory (``d``) that contains audio
43 (``audio_files``) or image (``images``) media.'''
44 pass
45
46 def done():
47 '''This method is invoke before successful exit.'''
48 pass
49 """ % globals()
50 NAMES = ["pymod"]
51
52 def __init__(self, arg_parser):
53 super(PyModulePlugin, self).__init__(arg_parser, cache_files=True,
54 track_images=True)
55 self._mod = None
56 self.arg_group.add_argument("-m", "--module", dest="module",
57 help="The Python module module to invoke. "
58 "The default is ./%s" % _DEFAULT_MOD)
59
60 def start(self, args, config):
61 mod_file = args.module or _DEFAULT_MOD
62 try:
63 self._mod = importmod(mod_file)
64 except IOError:
65 raise IOError("Module file not found: %s" % mod_file)
66 except (NameError, IndentationError, ImportError, SyntaxError) as ex:
67 raise IOError("Module load error: %s" % str(ex))
68
69 def handleFile(self, f):
70 super(PyModulePlugin, self).handleFile(f)
71 if not self.audio_file:
72 return
73
74 if "audioFile" in dir(self._mod):
75 self._mod.audioFile(self.audio_file)
76
77 def handleDirectory(self, d, _):
78 if not self._file_cache and not self._dir_images:
79 return
80
81 if "audioDir" in dir(self._mod):
82 self._mod.audioDir(d, self._file_cache, self._dir_images)
83
84 super(PyModulePlugin, self).handleDirectory(d, _)
85
86 def handleDone(self):
87 super(PyModulePlugin, self).handleDone()
88 if "done" in dir(self._mod):
89 self._mod.done()
+0
-499
src/eyed3/plugins/stats.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2009 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 from __future__ import print_function
19 import os
20 import sys
21 import operator
22 from collections import Counter
23
24 from eyed3 import id3, mp3
25 from eyed3.core import AUDIO_MP3
26 from eyed3.utils import guessMimetype
27 from eyed3.utils.console import Fore, Style, printMsg
28 from eyed3.plugins import LoaderPlugin
29 from eyed3.id3 import frames
30
31 ID3_VERSIONS = [id3.ID3_V1_0, id3.ID3_V1_1,
32 id3.ID3_V2_2, id3.ID3_V2_3, id3.ID3_V2_4]
33
34 _OP_STRINGS = {operator.le: "<=",
35 operator.lt: "< ",
36 operator.ge: ">=",
37 operator.gt: "> ",
38 operator.eq: "= ",
39 operator.ne: "!=",
40 }
41
42
43 class Rule(object):
44 def test(self):
45 raise NotImplementedError()
46
47
48 PREFERRED_ID3_VERSIONS = [id3.ID3_V2_3,
49 id3.ID3_V2_4,
50 ]
51
52
53 class Id3TagRules(Rule):
54 def test(self, path, audio_file):
55 scores = []
56
57 if audio_file is None:
58 return None
59
60 if not audio_file.tag:
61 return [(-75, "Missing ID3 tag")]
62
63 tag = audio_file.tag
64 if tag.version not in PREFERRED_ID3_VERSIONS:
65 scores.append((-30, "ID3 version not in %s" %
66 PREFERRED_ID3_VERSIONS))
67 if not tag.title:
68 scores.append((-30, "Tag missing title"))
69 if not tag.artist:
70 scores.append((-28, "Tag missing artist"))
71 if not tag.album:
72 scores.append((-26, "Tag missing album"))
73 if not tag.track_num[0]:
74 scores.append((-24, "Tag missing track number"))
75 if not tag.track_num[1]:
76 scores.append((-22, "Tag missing total # of tracks"))
77
78 if not tag.getBestDate():
79 scores.append((-30, "Tag missing any useful dates"))
80 else:
81 if not tag.original_release_date:
82 # Original release date is so rarely used but is almost always
83 # what I mean or wanna know.
84 scores.append((-10, "No original release date"))
85 elif not tag.release_date:
86 scores.append((-5, "No release date"))
87
88 # TLEN, best gotten from audio_file.info.time_secs but having it in
89 # the tag is good, I guess.
90 if b"TLEN" not in tag.frame_set:
91 scores.append((-5, "No TLEN frame"))
92
93 return scores
94
95
96 class BitrateRule(Rule):
97 BITRATE_DEDUCTIONS = [(128, -20), (192, -10)]
98
99 def test(self, path, audio_file):
100 scores = []
101
102 if not audio_file:
103 return None
104
105 if not audio_file.info:
106 # Detected as an audio file but not real audio data found.
107 return [(-90, "No audio data found")]
108
109 is_vbr, bitrate = audio_file.info.bit_rate
110 for threshold, score in self.BITRATE_DEDUCTIONS:
111 if bitrate < threshold:
112 scores.append((score, "Bit rate < %d" % threshold))
113 break
114
115 return scores
116
117
118 VALID_MIME_TYPES = mp3.MIME_TYPES + ["image/png",
119 "image/gif",
120 "image/jpeg",
121 ]
122
123
124 class FileRule(Rule):
125 def test(self, path, audio_file):
126 mt = guessMimetype(path)
127
128 for name in os.path.split(path):
129 if name.startswith('.'):
130 return [(-100, "Hidden file type")]
131
132 if mt not in VALID_MIME_TYPES:
133 return [(-100, "Unsupported file type: %s" % mt)]
134 return None
135
136
137 VALID_ARTWORK_NAMES = ("cover", "cover-front", "cover-back")
138
139
140 class ArtworkRule(Rule):
141 def test(self, path, audio_file):
142 mt = guessMimetype(path)
143 if mt and mt.startswith("image/"):
144 name, ext = os.path.splitext(os.path.basename(path))
145 if name not in VALID_ARTWORK_NAMES:
146 return [(-10, "Artwork file not in %s" %
147 str(VALID_ARTWORK_NAMES))]
148
149 return None
150
151
152 BAD_FRAMES = [frames.PRIVATE_FID, frames.OBJECT_FID]
153
154
155 class Id3FrameRules(Rule):
156 def test(self, path, audio_file):
157 scores = []
158 if not audio_file or not audio_file.tag:
159 return
160
161 tag = audio_file.tag
162 for fid in tag.frame_set:
163 if fid[0] == 'T' and fid != "TXXX" and len(tag.frame_set[fid]) > 1:
164 scores.append((-10, "Multiple %s frames" % fid.decode('ascii')))
165 elif fid in BAD_FRAMES:
166 scores.append((-13, "%s frames are bad, mmmkay?" %
167 fid.decode('ascii')))
168
169 return scores
170
171
172 class Stat(Counter):
173 TOTAL = "total"
174
175 def __init__(self, *args, **kwargs):
176 super(Stat, self).__init__(*args, **kwargs)
177 self[self.TOTAL] = 0
178 self._key_names = {}
179
180 def compute(self, file, audio_file):
181 self[self.TOTAL] += 1
182 self._compute(file, audio_file)
183
184 def _compute(self, file, audio_file):
185 pass
186
187 def report(self):
188 self._report()
189
190 def _sortedKeys(self, most_common=False):
191 def keyDisplayName(k):
192 return self._key_names[k] if k in self._key_names else k
193
194 key_map = {}
195 for k in list(self.keys()):
196 key_map[keyDisplayName(k)] = k
197
198 if not most_common:
199 sorted_names = [k for k in key_map.keys() if k]
200 sorted_names.remove(self.TOTAL)
201 sorted_names.sort()
202 sorted_names.append(self.TOTAL)
203 else:
204 most_common = self.most_common()
205 sorted_names = []
206 remainder_names = []
207 for k, v in most_common:
208 if k != self.TOTAL and v > 0:
209 sorted_names.append(keyDisplayName(k))
210 elif k != self.TOTAL:
211 remainder_names.append(keyDisplayName(k))
212
213 remainder_names.sort()
214 sorted_names = sorted_names + remainder_names
215 sorted_names.append(self.TOTAL)
216
217 return [key_map[name] for name in sorted_names]
218
219 def _report(self, most_common=False):
220 keys = self._sortedKeys(most_common=most_common)
221
222 key_col_width = 0
223 val_col_width = 0
224 for key in keys:
225 key = self._key_names[key] if key in self._key_names else key
226 key_col_width = max(key_col_width, len(str(key)))
227 val_col_width = max(val_col_width, len(str(self[key])))
228 key_col_width += 1
229 val_col_width += 1
230
231 for k in keys:
232 key_name = self._key_names[k] if k in self._key_names else k
233 value = self[k]
234 percent = self.percent(k) if value and k != "total" else ""
235 print("{padding}{key}:{value}{percent}".format(
236 padding=' ' * 4,
237 key=str(key_name).ljust(key_col_width),
238 value=str(value).rjust(val_col_width),
239 percent=" ( %s%.2f%%%s )" % (Fore.GREEN, percent, Fore.RESET)
240 if percent else "",
241 ))
242
243 def percent(self, key):
244 return (float(self[key]) / float(self["total"])) * 100
245
246
247 class AudioStat(Stat):
248 def compute(self, audio_file):
249 assert(audio_file)
250 self["total"] += 1
251 self._compute(audio_file)
252
253 def _compute(self, audio_file):
254 pass
255
256
257 class FileCounterStat(Stat):
258 SUPPORTED_AUDIO = "audio"
259 UNSUPPORTED_AUDIO = "audio (unsupported)"
260 HIDDEN_FILES = "hidden"
261 OTHER_FILES = "other"
262
263 def __init__(self):
264 super(FileCounterStat, self).__init__()
265 for k in ("audio", "hidden", "audio (unsupported)"):
266 self[k] = 0
267
268 def _compute(self, file, audio_file):
269 mt = guessMimetype(file)
270
271 if audio_file:
272 self[self.SUPPORTED_AUDIO] += 1
273 elif mt and mt.startswith("audio/"):
274 self[self.UNSUPPORTED_AUDIO] += 1
275 elif os.path.basename(file).startswith('.'):
276 self[self.HIDDEN_FILES] += 1
277 else:
278 self[self.OTHER_FILES] += 1
279
280 def _report(self):
281 print(Style.BRIGHT + Fore.GREY + "Files:" + Style.RESET_ALL)
282 super(FileCounterStat, self)._report()
283
284
285 class MimeTypeStat(Stat):
286 def _compute(self, file, audio_file):
287 mt = guessMimetype(file)
288 self[mt] += 1
289
290 def _report(self):
291 print(Style.BRIGHT + Fore.GREY + "Mime-Types:" + Style.RESET_ALL)
292 super(MimeTypeStat, self)._report(most_common=True)
293
294
295 class Id3VersionCounter(AudioStat):
296 def __init__(self):
297 super(Id3VersionCounter, self).__init__()
298 for v in ID3_VERSIONS:
299 self[v] = 0
300 self._key_names[v] = id3.versionToString(v)
301
302 def _compute(self, audio_file):
303 if audio_file.tag:
304 self[audio_file.tag.version] += 1
305 else:
306 self[None] += 1
307
308 def _report(self):
309 print(Style.BRIGHT + Fore.GREY + "ID3 versions:" + Style.RESET_ALL)
310 super(Id3VersionCounter, self)._report()
311
312
313 class Id3FrameCounter(AudioStat):
314 def _compute(self, audio_file):
315 if audio_file.tag:
316 for frame_id in audio_file.tag.frame_set:
317 self[frame_id] += len(audio_file.tag.frame_set[frame_id])
318
319 def _report(self):
320 print(Style.BRIGHT + Fore.GREY + "ID3 frames:" + Style.RESET_ALL)
321 super(Id3FrameCounter, self)._report(most_common=True)
322
323
324 class BitrateCounter(AudioStat):
325 def __init__(self):
326 super(BitrateCounter, self).__init__()
327 self["cbr"] = 0
328 self["vbr"] = 0
329 self.bitrate_keys = [(operator.le, 96),
330 (operator.le, 112),
331 (operator.le, 128),
332 (operator.le, 160),
333 (operator.le, 192),
334 (operator.le, 256),
335 (operator.le, 320),
336 (operator.gt, 320),
337 ]
338 for k in self.bitrate_keys:
339 self[k] = 0
340 op, bitrate = k
341 self._key_names[k] = "%s %d" % (_OP_STRINGS[op], bitrate)
342
343 def _compute(self, audio_file):
344 if audio_file.type != AUDIO_MP3 or audio_file.info is None:
345 self["total"] -= 1
346 return
347
348 vbr, br = audio_file.info.bit_rate
349 if vbr:
350 self["vbr"] += 1
351 else:
352 self["cbr"] += 1
353
354 for key in self.bitrate_keys:
355 key_op, key_br = key
356 if key_op(br, key_br):
357 self[key] += 1
358 break
359
360 def _report(self):
361 print(Style.BRIGHT + Fore.GREY + "MP3 bitrates:" + Style.RESET_ALL)
362 super(BitrateCounter, self)._report(most_common=True)
363
364 def _sortedKeys(self, most_common=False):
365 keys = super(BitrateCounter, self)._sortedKeys(most_common=most_common)
366 keys.remove("cbr")
367 keys.remove("vbr")
368 keys.insert(0, "cbr")
369 keys.insert(1, "vbr")
370 return keys
371
372
373 class RuleViolationStat(Stat):
374 def _report(self):
375 print(Style.BRIGHT + Fore.GREY + "Rule Violations:" + Style.RESET_ALL)
376 super(RuleViolationStat, self)._report(most_common=True)
377
378
379 class Id3ImageTypeCounter(AudioStat):
380 def __init__(self):
381 super(Id3ImageTypeCounter, self).__init__()
382
383 self._key_names = {}
384 for attr in dir(frames.ImageFrame):
385 val = getattr(frames.ImageFrame, attr)
386 if isinstance(val, int) and not attr.endswith("_TYPE"):
387 self._key_names[val] = attr
388
389 for v in self._key_names:
390 self[v] = 0
391
392 def _compute(self, audio_file):
393 if audio_file.tag:
394 for img in audio_file.tag.images:
395 self[img.picture_type] += 1
396
397 def _report(self):
398 print(Style.BRIGHT + Fore.GREY + "APIC image types:" + Style.RESET_ALL)
399 super(Id3ImageTypeCounter, self)._report()
400
401
402 class StatisticsPlugin(LoaderPlugin):
403 NAMES = ['stats']
404 SUMMARY = u"Computes statistics for all audio files scanned."
405
406 def __init__(self, arg_parser):
407 super(StatisticsPlugin, self).__init__(arg_parser)
408
409 self.arg_group.add_argument(
410 "--verbose", action="store_true", default=False,
411 help="Show details for each file with rule violations.")
412
413 self._stats = []
414 self._rules_stat = RuleViolationStat()
415
416 self._stats.append(FileCounterStat())
417 self._stats.append(MimeTypeStat())
418 self._stats.append(Id3VersionCounter())
419 self._stats.append(Id3FrameCounter())
420 self._stats.append(Id3ImageTypeCounter())
421 self._stats.append(BitrateCounter())
422
423 self._score_sum = 0
424 self._score_count = 0
425 self._rules_log = {}
426 self._rules = [Id3TagRules(),
427 FileRule(),
428 ArtworkRule(),
429 BitrateRule(),
430 Id3FrameRules(),
431 ]
432
433 def handleFile(self, path):
434 super(StatisticsPlugin, self).handleFile(path)
435 if not self.args.quiet:
436 sys.stdout.write('.')
437 sys.stdout.flush()
438
439 for stat in self._stats:
440 if isinstance(stat, AudioStat):
441 if self.audio_file:
442 stat.compute(self.audio_file)
443 else:
444 stat.compute(path, self.audio_file)
445
446 self._score_count += 1
447 total_score = 100
448 for rule in self._rules:
449 scores = rule.test(path, self.audio_file) or []
450 if scores:
451 if path not in self._rules_log:
452 self._rules_log[path] = []
453
454 for score, text in scores:
455 self._rules_stat[text] += 1
456 self._rules_log[path].append((score, text))
457 # += because negative values are returned
458 total_score += score
459
460 if total_score != 100:
461 self._rules_stat[Stat.TOTAL] += 1
462
463 self._score_sum += total_score
464
465 def handleDone(self):
466 if self._num_loaded == 0:
467 super(StatisticsPlugin, self).handleDone()
468 return
469
470 print()
471 for stat in self._stats + [self._rules_stat]:
472 stat.report()
473 print()
474
475 # Detailed rule violations
476 if self.args.verbose:
477 for path in self._rules_log:
478 printMsg(path) # does the right thing for unicode
479 for score, text in self._rules_log[path]:
480 print("\t%s%s%s (%s)" % (Fore.RED, str(score).center(3),
481 Fore.RESET, text))
482
483 def prettyScore():
484 score = float(self._score_sum) / float(self._score_count)
485 if score > 80:
486 color = Fore.GREEN
487 elif score > 70:
488 color = Fore.YELLOW
489 else:
490 color = Fore.RED
491 return (score, color)
492
493 score, color = prettyScore()
494 print("%sScore%s = %s%d%%%s" % (Style.BRIGHT, Style.RESET_BRIGHT,
495 color, score, Fore.RESET))
496 if not self.args.verbose:
497 print("Run with --verbose to see files and their rule violations")
498 print()
+0
-54
src/eyed3/plugins/xep_118.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2009 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 import os
19
20 from eyed3 import compat
21 from eyed3.plugins import LoaderPlugin
22 from eyed3.utils.console import printMsg
23
24
25 class Xep118Plugin(LoaderPlugin):
26 NAMES = ["xep-118"]
27 SUMMARY = u"Outputs all tags in XEP-118 XML format. "\
28 "(see: http://xmpp.org/extensions/xep-0118.html)"
29
30 def handleFile(self, f):
31 super(Xep118Plugin, self).handleFile(f)
32
33 if self.audio_file and self.audio_file.tag:
34 xml = self.getXML(self.audio_file)
35 printMsg(xml)
36
37 def getXML(self, audio_file):
38 tag = audio_file.tag
39 xml = u"<tune xmlns='http://jabber.org/protocol/tune'>\n"
40 if tag.artist:
41 xml += " <artist>%s</artist>\n" % tag.artist
42 if tag.title:
43 xml += " <title>%s</title>\n" % tag.title
44 if tag.album:
45 xml += " <source>%s</source>\n" % tag.album
46 xml += (" <track>file://%s</track>\n" %
47 compat.unicode(os.path.abspath(audio_file.path)))
48 if audio_file.info:
49 xml += " <length>%s</length>\n" % \
50 compat.unicode(audio_file.info.time_secs)
51 xml += "</tune>\n"
52
53 return xml
+0
-489
src/eyed3/utils/__init__.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2002-2015 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 from __future__ import print_function
19 import os
20 import re
21 import math
22 import pathlib
23 import logging
24 import argparse
25 import warnings
26 import magic
27 import functools
28
29 from ..compat import unicode, PY2
30 from ..utils.log import getLogger
31 from .. import LOCAL_ENCODING, LOCAL_FS_ENCODING
32
33 if hasattr(os, "fwalk"):
34 os_walk = functools.partial(os.fwalk, follow_symlinks=True)
35
36 def os_walk_unpack(w):
37 return w[0:3]
38
39 else:
40 os_walk = functools.partial(os.walk, followlinks=True)
41
42 def os_walk_unpack(w):
43 return w
44
45 log = getLogger(__name__)
46 ID3_MIME_TYPE = "application/x-id3"
47 ID3_MIME_TYPE_EXTENSIONS = (".id3", ".tag")
48
49
50 class MagicTypes(magic.Magic):
51 def __init__(self):
52 magic.Magic.__init__(self, mime=True, mime_encoding=False, keep_going=True)
53
54 def guess_type(self, filename, all_types=False):
55 if os.path.splitext(filename)[1] in ID3_MIME_TYPE_EXTENSIONS:
56 return ID3_MIME_TYPE if not all_types else [ID3_MIME_TYPE]
57 try:
58 types = self.from_file(filename)
59 except UnicodeEncodeError:
60 # https://github.com/ahupp/python-magic/pull/144
61 types = self.from_file(filename.encode("utf-8", 'surrogateescape'))
62
63 delim = r"\012- "
64 if all_types:
65 return types.split(delim)
66 else:
67 return types.split(delim)[0]
68
69
70 _mime_types = MagicTypes()
71
72
73 def guessMimetype(filename, with_encoding=False, all_types=False):
74 """Return the mime-type for ``filename`` (or list of possible types when `all_types` is True).
75
76 If ``with_encoding`` is True the encoding is included and a 2-tuple is returned, (mine, enc).
77 """
78
79 filename = str(filename) if isinstance(filename, pathlib.Path) else filename
80 mime = _mime_types.guess_type(filename, all_types=all_types)
81 if not with_encoding:
82 return mime
83 else:
84 warnings.warn("File character encoding no longer returned, value is None",
85 UserWarning, stacklevel=2)
86 return mime, None
87
88
89 def walk(handler, path, excludes=None, fs_encoding=LOCAL_FS_ENCODING):
90 """A wrapper around os.walk which handles exclusion patterns and multiple
91 path types (unicode, pathlib.Path, bytes).
92 """
93 if isinstance(path, pathlib.Path):
94 path = str(path)
95 else:
96 path = unicode(path, fs_encoding) if type(path) is not unicode else path
97
98 excludes = excludes if excludes else []
99 excludes_re = []
100 for e in excludes:
101 excludes_re.append(re.compile(e))
102
103 def _isExcluded(_p):
104 for ex in excludes_re:
105 match = ex.match(_p)
106 if match:
107 return True
108 return False
109
110 if not os.path.exists(path):
111 raise IOError("file not found: %s" % path)
112 elif os.path.isfile(path) and not _isExcluded(path):
113 # If not given a directory, invoke the handler and return
114 handler.handleFile(os.path.abspath(path))
115 return
116
117 for root, dirs, files in [os_walk_unpack(w) for w in os_walk(path)]:
118 root = root if type(root) is unicode else unicode(root, fs_encoding)
119 dirs.sort()
120 files.sort()
121 for f in files:
122 f = f if type(f) is unicode else unicode(f, fs_encoding)
123 f = os.path.abspath(os.path.join(root, f))
124 if not _isExcluded(f):
125 try:
126 handler.handleFile(f)
127 except StopIteration:
128 return
129
130 if files:
131 handler.handleDirectory(root, files)
132
133
134 class FileHandler(object):
135 """A handler interface for :func:`eyed3.utils.walk` callbacks."""
136
137 def handleFile(self, f):
138 """Called for each file walked. The file ``f`` is the full path and
139 the return value is ignored. If the walk should abort the method should
140 raise a ``StopIteration`` exception."""
141 pass
142
143 def handleDirectory(self, d, files):
144 """Called for each directory ``d`` **after** ``handleFile`` has been
145 called for each file in ``files``. ``StopIteration`` may be raised to
146 halt iteration."""
147 pass
148
149 def handleDone(self):
150 """Called when there are no more files to handle."""
151 pass
152
153
154 def _requireArgType(arg_type, *args):
155 arg_indices = []
156 kwarg_names = []
157 for a in args:
158 if type(a) is int:
159 arg_indices.append(a)
160 else:
161 kwarg_names.append(a)
162 assert(arg_indices or kwarg_names)
163
164 def wrapper(fn):
165 def wrapped_fn(*args, **kwargs):
166 for i in arg_indices:
167 if i >= len(args):
168 # The ith argument is not there, as in optional arguments
169 break
170 if args[i] is not None and not isinstance(args[i], arg_type):
171 raise TypeError("%s(argument %d) must be %s" %
172 (fn.__name__, i, str(arg_type)))
173 for name in kwarg_names:
174 if (name in kwargs and kwargs[name] is not None and
175 not isinstance(kwargs[name], arg_type)):
176 raise TypeError("%s(argument %s) must be %s" %
177 (fn.__name__, name, str(arg_type)))
178 return fn(*args, **kwargs)
179 return wrapped_fn
180 return wrapper
181
182
183 def requireUnicode(*args):
184 """Function decorator to enforce unicode argument types.
185 ``None`` is a valid argument value, in all cases, regardless of not being
186 unicode. ``*args`` Positional arguments may be numeric argument index
187 values (requireUnicode(1, 3) - requires argument 1 and 3 are unicode)
188 or keyword argument names (requireUnicode("title")) or a combination
189 thereof.
190 """
191 return _requireArgType(unicode, *args)
192
193
194 def requireBytes(*args):
195 """Function decorator to enforce unicode argument types.
196 ``None`` is a valid argument value, in all cases, regardless of not being
197 unicode. ``*args`` Positional arguments may be numeric argument index
198 values (requireUnicode(1, 3) - requires argument 1 and 3 are unicode)
199 or keyword argument names (requireUnicode("title")) or a combination
200 thereof.
201 """
202 return _requireArgType(bytes, *args)
203
204
205 def encodeUnicode(replace=True):
206 warnings.warn("use compat PY2 and be more python3", DeprecationWarning,
207 stacklevel=2)
208 enc_err = "replace" if replace else "strict"
209
210 if PY2:
211 def wrapper(fn):
212 def wrapped_fn(*args, **kwargs):
213 new_args = []
214 for a in args:
215 if type(a) is unicode:
216 new_args.append(a.encode(LOCAL_ENCODING, enc_err))
217 else:
218 new_args.append(a)
219 args = tuple(new_args)
220
221 for kw in kwargs:
222 if type(kwargs[kw]) is unicode:
223 kwargs[kw] = kwargs[kw].encode(LOCAL_ENCODING, enc_err)
224 return fn(*args, **kwargs)
225 return wrapped_fn
226 return wrapper
227 else:
228 # This decorator is used to encode unicode to bytes for sys.std*
229 # write calls. In python3 unicode (or str) is required by these
230 # functions, the encodig happens internally.. So return a noop
231 def noop(fn):
232 def call(*args, **kwargs):
233 return fn(*args, **kwargs)
234 return noop
235
236
237 def formatTime(seconds, total=None, short=False):
238 """
239 Format ``seconds`` (number of seconds) as a string representation.
240 When ``short`` is False (the default) the format is:
241
242 HH:MM:SS.
243
244 Otherwise, the format is exacly 6 characters long and of the form:
245
246 1w 3d
247 2d 4h
248 1h 5m
249 1m 4s
250 15s
251
252 If ``total`` is not None it will also be formatted and
253 appended to the result seperated by ' / '.
254 """
255 seconds = round(seconds)
256
257 def time_tuple(ts):
258 if ts is None or ts < 0:
259 ts = 0
260 hours = ts / 3600
261 mins = (ts % 3600) / 60
262 secs = (ts % 3600) % 60
263 tstr = '%02d:%02d' % (mins, secs)
264 if int(hours):
265 tstr = '%02d:%s' % (hours, tstr)
266 return (int(hours), int(mins), int(secs), tstr)
267
268 if not short:
269 hours, mins, secs, curr_str = time_tuple(seconds)
270 retval = curr_str
271 if total:
272 hours, mins, secs, total_str = time_tuple(total)
273 retval += ' / %s' % total_str
274 return retval
275 else:
276 units = [
277 (u'y', 60 * 60 * 24 * 7 * 52),
278 (u'w', 60 * 60 * 24 * 7),
279 (u'd', 60 * 60 * 24),
280 (u'h', 60 * 60),
281 (u'm', 60),
282 (u's', 1),
283 ]
284
285 seconds = int(seconds)
286
287 if seconds < 60:
288 return u' {0:02d}s'.format(seconds)
289 for i in range(len(units) - 1):
290 unit1, limit1 = units[i]
291 unit2, limit2 = units[i + 1]
292 if seconds >= limit1:
293 return u'{0:02d}{1}{2:02d}{3}'.format(
294 seconds // limit1, unit1,
295 (seconds % limit1) // limit2, unit2)
296 return u' ~inf'
297
298
299 KB_BYTES = 1024
300 """Number of bytes per KB (2^10)"""
301 MB_BYTES = 1048576
302 """Number of bytes per MB (2^20)"""
303 GB_BYTES = 1073741824
304 """Number of bytes per GB (2^30)"""
305 KB_UNIT = "KB"
306 """Kilobytes abbreviation"""
307 MB_UNIT = "MB"
308 """Megabytes abbreviation"""
309 GB_UNIT = "GB"
310 """Gigabytes abbreviation"""
311
312
313 def formatSize(size, short=False):
314 """Format ``size`` (nuber of bytes) into string format doing KB, MB, or GB
315 conversion where necessary.
316
317 When ``short`` is False (the default) the format is smallest unit of
318 bytes and largest gigabytes; '234 GB'.
319 The short version is 2-4 characters long and of the form
320
321 256b
322 64k
323 1.1G
324 """
325 if not short:
326 unit = "Bytes"
327 if size >= GB_BYTES:
328 size = float(size) / float(GB_BYTES)
329 unit = GB_UNIT
330 elif size >= MB_BYTES:
331 size = float(size) / float(MB_BYTES)
332 unit = MB_UNIT
333 elif size >= KB_BYTES:
334 size = float(size) / float(KB_BYTES)
335 unit = KB_UNIT
336 return "%.2f %s" % (size, unit)
337 else:
338 suffixes = u' kMGTPEH'
339 if size == 0:
340 num_scale = 0
341 else:
342 num_scale = int(math.floor(math.log(size) / math.log(1000)))
343 if num_scale > 7:
344 suffix = '?'
345 else:
346 suffix = suffixes[num_scale]
347 num_scale = int(math.pow(1000, num_scale))
348 value = size / num_scale
349 str_value = str(value)
350 if len(str_value) >= 3 and str_value[2] == '.':
351 str_value = str_value[:2]
352 else:
353 str_value = str_value[:3]
354 return "{0:>3s}{1}".format(str_value, suffix)
355
356
357 def formatTimeDelta(td):
358 """Format a timedelta object ``td`` into a string. """
359 days = td.days
360 hours = td.seconds / 3600
361 mins = (td.seconds % 3600) / 60
362 secs = (td.seconds % 3600) % 60
363 tstr = "%02d:%02d:%02d" % (hours, mins, secs)
364 if days:
365 tstr = "%d days %s" % (days, tstr)
366 return tstr
367
368
369 def chunkCopy(src_fp, dest_fp, chunk_sz=(1024 * 512)):
370 """Copy ``src_fp`` to ``dest_fp`` in ``chunk_sz`` byte increments."""
371 done = False
372 while not done:
373 data = src_fp.read(chunk_sz)
374 if data:
375 dest_fp.write(data)
376 else:
377 done = True
378 del data
379
380
381 class ArgumentParser(argparse.ArgumentParser):
382 """Subclass of argparse.ArgumentParser that adds version and log level
383 options."""
384
385 def __init__(self, *args, **kwargs):
386 from eyed3 import version as VERSION
387 from eyed3.utils.log import LEVELS
388 from eyed3.utils.log import MAIN_LOGGER
389
390 def pop_kwarg(name, default):
391 if name in kwargs:
392 value = kwargs.pop(name) or default
393 else:
394 value = default
395 return value
396 main_logger = pop_kwarg("main_logger", MAIN_LOGGER)
397 version = pop_kwarg("version", VERSION)
398
399 self.log_levels = [logging.getLevelName(l).lower() for l in LEVELS]
400
401 formatter = argparse.RawDescriptionHelpFormatter
402 super(ArgumentParser, self).__init__(*args, formatter_class=formatter,
403 **kwargs)
404
405 self.add_argument("--version", action="version", version=version,
406 help="Display version information and exit")
407
408 debug_group = self.add_argument_group("Debugging")
409 debug_group.add_argument(
410 "-l", "--log-level", metavar="LEVEL[:LOGGER]",
411 action=LoggingAction, main_logger=main_logger,
412 help="Set a log level. This option may be specified multiple "
413 "times. If a logger name is specified than the level "
414 "applies only to that logger, otherwise the level is set "
415 "on the top-level logger. Acceptable levels are %s. " %
416 (", ".join("'%s'" % l for l in self.log_levels)))
417 debug_group.add_argument("--profile", action="store_true",
418 default=False, dest="debug_profile",
419 help="Run using python profiler.")
420 debug_group.add_argument("--pdb", action="store_true", dest="debug_pdb",
421 help="Drop into 'pdb' when errors occur.")
422
423
424 class LoggingAction(argparse._AppendAction):
425 def __init__(self, *args, **kwargs):
426 self.main_logger = kwargs.pop("main_logger")
427 super(LoggingAction, self).__init__(*args, **kwargs)
428
429 def __call__(self, parser, namespace, values, option_string=None):
430 values = values.split(':')
431 level, logger = values if len(values) > 1 else (values[0],
432 self.main_logger)
433
434 logger = logging.getLogger(logger)
435 try:
436 logger.setLevel(logging._nameToLevel[level.upper()])
437 except KeyError:
438 msg = "invalid level choice: %s (choose from %s)" % \
439 (level, parser.log_levels)
440 raise argparse.ArgumentError(self, msg)
441
442 super(LoggingAction, self).__call__(parser, namespace, values,
443 option_string)
444
445
446 def datePicker(thing, prefer_recording_date=False):
447 """This function returns a date of some sort, amongst all the possible
448 dates (members called release_date, original_release_date,
449 and recording_date of type eyed3.core.Date).
450
451 The order of preference is:
452 1) date of original release
453 2) date of this versions release
454 3) the recording date.
455
456 Unless ``prefer_recording_date`` is ``True`` in which case the order is
457 3, 1, 2.
458
459 ``None`` will be returned if no dates are available."""
460 if not prefer_recording_date:
461 return (thing.original_release_date or
462 thing.release_date or
463 thing.recording_date)
464 else:
465 return (thing.recording_date or
466 thing.original_release_date or
467 thing.release_date)
468
469
470 def makeUniqueFileName(file_path, uniq=u''):
471 """The ``file_path`` is the desired file name, and it is returned if the
472 file does not exist. In the case that it already exists the path is
473 adjusted to be unique. First, the ``uniq`` string is added, and then
474 a couter is used to find a unique name."""
475
476 path = os.path.dirname(file_path)
477 file = os.path.basename(file_path)
478 name, ext = os.path.splitext(file)
479 count = 1
480 while os.path.exists(os.path.join(path, file)):
481 if uniq:
482 name = "%s_%s" % (name, uniq)
483 file = "".join([name, ext])
484 uniq = u''
485 else:
486 file = "".join(["%s_%s" % (name, count), ext])
487 count += 1
488 return os.path.join(path, file)
+0
-97
src/eyed3/utils/art.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2014 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 from os.path import basename, splitext
19 from fnmatch import fnmatch
20 from ..id3.frames import ImageFrame
21
22
23 FRONT_COVER = "FRONT_COVER"
24 """Album front cover."""
25 BACK_COVER = "BACK_COVER"
26 """Album back cover."""
27 MISC_COVER = "MISC_COVER"
28 """Other part of the album cover; liner notes, gate-fold, etc."""
29 LOGO = "LOGO"
30 """Artist/band logo."""
31 ARTIST = "ARTIST"
32 """Artist/band images."""
33 LIVE = "LIVE"
34 """Artist/band images."""
35
36 FILENAMES = {
37 FRONT_COVER: ["cover-front", "cover-alternate*", "cover",
38 "folder", "front", "cover-front_*", "flier"],
39 BACK_COVER: ["cover-back", "back", "cover-back_*"],
40 MISC_COVER: ["cover-insert*", "cover-liner*", "cover-disc",
41 "cover-media*"],
42 LOGO: ["logo*"],
43 ARTIST: ["artist*"],
44 LIVE: ["live*"],
45 }
46 """A mapping of art types to lists of filename patterns (excluding file
47 extension): type -> [file_pattern, ..]."""
48
49 TO_ID3_ART_TYPES = {
50 FRONT_COVER: [ImageFrame.FRONT_COVER, ImageFrame.OTHER, ImageFrame.ICON,
51 ImageFrame.LEAFLET],
52 BACK_COVER: [ImageFrame.BACK_COVER],
53 MISC_COVER: [ImageFrame.MEDIA],
54 LOGO: [ImageFrame.BAND_LOGO],
55 ARTIST: [ImageFrame.LEAD_ARTIST, ImageFrame.ARTIST, ImageFrame.BAND],
56 LIVE: [ImageFrame.DURING_PERFORMANCE, ImageFrame.DURING_RECORDING]
57 }
58 """A mapping of art types to ID3 APIC (image) types: type -> [apic_type, ..]"""
59 # ID3 image types not mapped above:
60 # OTHER_ICON = 0x02
61 # CONDUCTOR = 0x09
62 # COMPOSER = 0x0B
63 # LYRICIST = 0x0C
64 # RECORDING_LOCATION = 0x0D
65 # VIDEO = 0x10
66 # BRIGHT_COLORED_FISH = 0x11
67 # ILLUSTRATION = 0x12
68 # PUBLISHER_LOGO = 0x14
69
70 FROM_ID3_ART_TYPES = {}
71 """A mapping of ID3 art types to eyeD3 art types; the opposite of
72 TO_ID3_ART_TYPES."""
73 for _type in TO_ID3_ART_TYPES:
74 for _id3_type in TO_ID3_ART_TYPES[_type]:
75 FROM_ID3_ART_TYPES[_id3_type] = _type
76
77
78 def matchArtFile(filename):
79 """Compares ``filename`` (case insensitive) with lists of common art file
80 names and returns the type of art that was matched, or None if no types
81 were matched."""
82 base = splitext(basename(filename))[0]
83 for type_ in FILENAMES.keys():
84 if True in [fnmatch(base.lower(), fname) for fname in FILENAMES[type_]]:
85 return type_
86 return None
87
88
89 def getArtFromTag(tag, type_=None):
90 """Returns a list of eyed3.id3.frames.ImageFrame objects matching ``type_``,
91 all if ``type_`` is None, or empty if tag does not contain art."""
92 art = []
93 for img in tag.images:
94 if not type_ or type_ == img.picture_type:
95 art.append(img)
96 return art
+0
-144
src/eyed3/utils/binfuncs.py less more
0 ################################################################################
1 # Copyright (C) 2001 Ryan Finne <ryan@finnie.org>
2 # Copyright (C) 2002-2011 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 from ..compat import intToByteString, BytesType, byteiter
19
20
21 def bytes2bin(bytes, sz=8):
22 '''Accepts a string of ``bytes`` (chars) and returns an array of bits
23 representing the bytes in big endian byte order. An optional max ``sz`` for
24 each byte (default 8 bits/byte) which can be used to mask out higher
25 bits.'''
26 if sz < 1 or sz > 8:
27 raise ValueError("Invalid sz value: %d" % sz)
28
29 '''
30 # I was willing to bet this implementation was gonna be faster, tis not
31 retval = []
32 for bite in bytes:
33 bits = [int(b) for b in bin(ord(bite))[2:].zfill(8)][-sz:]
34 assert(len(bits) == sz)
35 retval.extend(bits)
36 return retval
37 '''
38
39 retVal = []
40 for b in byteiter(bytes):
41 bits = []
42 b = ord(b)
43 while b > 0:
44 bits.append(b & 1)
45 b >>= 1
46
47 if len(bits) < sz:
48 bits.extend([0] * (sz - len(bits)))
49 elif len(bits) > sz:
50 bits = bits[:sz]
51
52 # Big endian byte order.
53 bits.reverse()
54 retVal.extend(bits)
55
56 return retVal
57
58
59 # Convert an array of bits (MSB first) into a string of characters.
60 def bin2bytes(x):
61 bits = []
62 bits.extend(x)
63 bits.reverse()
64
65 i = 0
66 out = b''
67 multi = 1
68 ttl = 0
69 for b in bits:
70 i += 1
71 ttl += b * multi
72 multi *= 2
73 if i == 8:
74 i = 0
75 out += intToByteString(ttl)
76 multi = 1
77 ttl = 0
78
79 if multi > 1:
80 out += intToByteString(ttl)
81
82 out = bytearray(out)
83 out.reverse()
84 out = BytesType(out)
85 return out
86
87
88 def bin2dec(x):
89 '''Convert ``x``, an array of "bits" (MSB first), to it's decimal value.'''
90 bits = []
91 bits.extend(x)
92 bits.reverse() # MSB
93
94 multi = 1
95 value = 0
96 for b in bits:
97 value += b * multi
98 multi *= 2
99 return value
100
101
102 def bytes2dec(bytes, sz=8):
103 return bin2dec(bytes2bin(bytes, sz))
104
105
106 def dec2bin(n, p=1):
107 '''Convert a decimal value ``n`` to an array of bits (MSB first).
108 Optionally, pad the overall size to ``p`` bits.'''
109 assert(n >= 0)
110 retVal = []
111
112 while n > 0:
113 retVal.append(n & 1)
114 n >>= 1
115
116 if p > 0:
117 retVal.extend([0] * (p - len(retVal)))
118 retVal.reverse()
119 return retVal
120
121
122 def dec2bytes(n, p=1):
123 return bin2bytes(dec2bin(n, p))
124
125
126 def bin2synchsafe(x):
127 '''Convert ``x``, a list of bits (MSB first), to a synch safe list of bits.
128 (section 6.2 of the ID3 2.4 spec).'''
129 n = bin2dec(x)
130 if len(x) > 32 or n > 268435456: # 2^28
131 raise ValueError("Invalid value: %s" % str(x))
132 elif len(x) < 8:
133 return x
134
135 bites = b""
136 bites += intToByteString((n >> 21) & 0x7f)
137 bites += intToByteString((n >> 14) & 0x7f)
138 bites += intToByteString((n >> 7) & 0x7f)
139 bites += intToByteString((n >> 0) & 0x7f)
140 bits = bytes2bin(bites)
141 assert(len(bits) == 32)
142
143 return bits
+0
-579
src/eyed3/utils/console.py less more
0 # -*- coding: utf-8 -*-
1 from __future__ import print_function
2
3 import os
4 import struct
5 import sys
6 import time
7
8 from . import formatSize, formatTime
9 from .. import LOCAL_ENCODING, compat
10 from .log import log
11
12 try:
13 import fcntl
14 import termios
15 import signal
16 _CAN_RESIZE_TERMINAL = True
17 except ImportError:
18 _CAN_RESIZE_TERMINAL = False
19
20
21 class AnsiCodes(object):
22 _USE_ANSI = False
23 _CSI = '\033['
24
25 def __init__(self, codes):
26 def code_to_chars(code):
27 return AnsiCodes._CSI + str(code) + 'm'
28
29 for name in dir(codes):
30 if not name.startswith('_'):
31 value = getattr(codes, name)
32 setattr(self, name, code_to_chars(value))
33
34 # Add color function
35 for reset_name in ("RESET_%s" % name, "RESET"):
36 if hasattr(codes, reset_name):
37 reset_value = getattr(codes, reset_name)
38 setattr(self, "%s" % name.lower(),
39 AnsiCodes._mkfunc(code_to_chars(value),
40 code_to_chars(reset_value)))
41 break
42
43 @staticmethod
44 def _mkfunc(color, reset):
45 def _cwrap(text, *styles):
46 if not AnsiCodes._USE_ANSI:
47 return text
48
49 s = u''
50 for st in styles:
51 s += st
52 s += color + text + reset
53 if styles:
54 s += Style.RESET_ALL
55 return s
56 return _cwrap
57
58 def __getattribute__(self, name):
59 attr = super(AnsiCodes, self).__getattribute__(name)
60 if (hasattr(attr, "startswith") and
61 attr.startswith(AnsiCodes._CSI) and
62 not AnsiCodes._USE_ANSI):
63 return ""
64 else:
65 return attr
66
67 def __getitem__(self, name):
68 return getattr(self, name.upper())
69
70 @classmethod
71 def init(cls, allow_colors):
72 cls._USE_ANSI = allow_colors and cls._term_supports_color()
73
74 @staticmethod
75 def _term_supports_color():
76 if (os.environ.get("TERM") == "dumb" or
77 os.environ.get("OS") == "Windows_NT"):
78 return False
79 return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
80
81
82 class AnsiFore:
83 GREY = 30 # noqa
84 RED = 31 # noqa
85 GREEN = 32 # noqa
86 YELLOW = 33 # noqa
87 BLUE = 34 # noqa
88 MAGENTA = 35 # noqa
89 CYAN = 36 # noqa
90 WHITE = 37 # noqa
91 RESET = 39 # noqa
92
93
94 class AnsiBack:
95 GREY = 40 # noqa
96 RED = 41 # noqa
97 GREEN = 42 # noqa
98 YELLOW = 43 # noqa
99 BLUE = 44 # noqa
100 MAGENTA = 45 # noqa
101 CYAN = 46 # noqa
102 WHITE = 47 # noqa
103 RESET = 49 # noqa
104
105
106 class AnsiStyle:
107 RESET_ALL = 0 # noqa
108 BRIGHT = 1 # noqa
109 RESET_BRIGHT = 22 # noqa
110 DIM = 2 # noqa
111 RESET_DIM = RESET_BRIGHT # noqa
112 ITALICS = 3 # noqa
113 RESET_ITALICS = 23 # noqa
114 UNDERLINE = 4 # noqa
115 RESET_UNDERLINE = 24 # noqa
116 BLINK_SLOW = 5 # noqa
117 RESET_BLINK_SLOW = 25 # noqa
118 BLINK_FAST = 6 # noqa
119 RESET_BLINK_FAST = 26 # noqa
120 INVERSE = 7 # noqa
121 RESET_INVERSE = 27 # noqa
122 STRIKE_THRU = 9 # noqa
123 RESET_STRIKE_THRU = 29 # noqa
124
125
126 Fore = AnsiCodes(AnsiFore)
127 Back = AnsiCodes(AnsiBack)
128 Style = AnsiCodes(AnsiStyle)
129
130
131 def ERROR_COLOR():
132 return Fore.RED
133
134
135 def WARNING_COLOR():
136 return Fore.YELLOW
137
138
139 def HEADER_COLOR():
140 return Fore.GREEN
141
142
143 class Spinner(object):
144 """
145 A class to display a spinner in the terminal.
146
147 It is designed to be used with the `with` statement::
148
149 with Spinner("Reticulating splines", "green") as s:
150 for item in enumerate(items):
151 s.next()
152 """
153 _default_unicode_chars = u"◓◑◒◐"
154 _default_ascii_chars = u"-/|\\"
155
156 def __init__(self, msg, file=None, step=1,
157 chars=None, use_unicode=True, print_done=True):
158
159 self._msg = msg
160 self._file = file or sys.stdout
161 self._step = step
162 if not chars:
163 if use_unicode:
164 chars = self._default_unicode_chars
165 else:
166 chars = self._default_ascii_chars
167 self._chars = chars
168
169 self._silent = not self._file.isatty()
170 self._print_done = print_done
171
172 def _iterator(self):
173 chars = self._chars
174 index = 0
175 write = self._file.write
176 flush = self._file.flush
177
178 while True:
179 write(u'\r')
180 write(self._msg)
181 write(u' ')
182 write(chars[index])
183 flush()
184 yield
185
186 for i in range(self._step):
187 yield
188
189 index += 1
190 if index == len(chars):
191 index = 0
192
193 def __enter__(self):
194 if self._silent:
195 return self._silent_iterator()
196 else:
197 return self._iterator()
198
199 def __exit__(self, exc_type, exc_value, traceback):
200 write = self._file.write
201 flush = self._file.flush
202
203 if not self._silent:
204 write(u'\r')
205 write(self._msg)
206 if self._print_done:
207 if exc_type is None:
208 write(Fore.GREEN + u' [Done]\n')
209 else:
210 write(Fore.RED + u' [Failed]\n')
211 else:
212 write("\n")
213 flush()
214
215 def _silent_iterator(self):
216 self._file.write(self._msg)
217 self._file.flush()
218
219 while True:
220 yield
221
222
223 class ProgressBar(object):
224 """
225 A class to display a progress bar in the terminal.
226
227 It is designed to be used either with the `with` statement::
228
229 with ProgressBar(len(items)) as bar:
230 for item in enumerate(items):
231 bar.update()
232
233 or as a generator::
234
235 for item in ProgressBar(items):
236 item.process()
237 """
238 def __init__(self, total_or_items, file=None):
239 """
240 total_or_items : int or sequence
241 If an int, the number of increments in the process being
242 tracked. If a sequence, the items to iterate over.
243
244 file : writable file-like object, optional
245 The file to write the progress bar to. Defaults to
246 `sys.stdout`. If `file` is not a tty (as determined by
247 calling its `isatty` member, if any), the scrollbar will
248 be completely silent.
249 """
250 self._file = file or sys.stdout
251
252 if not self._file.isatty():
253 self.update = self._silent_update
254 self._silent = True
255 else:
256 self._silent = False
257
258 try:
259 self._items = iter(total_or_items)
260 self._total = len(total_or_items)
261 except TypeError:
262 try:
263 self._total = int(total_or_items)
264 self._items = iter(range(self._total))
265 except TypeError:
266 raise TypeError("First argument must be int or sequence")
267
268 self._start_time = time.time()
269
270 self._should_handle_resize = (
271 _CAN_RESIZE_TERMINAL and self._file.isatty())
272 self._handle_resize()
273 if self._should_handle_resize:
274 signal.signal(signal.SIGWINCH, self._handle_resize)
275 self._signal_set = True
276 else:
277 self._signal_set = False
278
279 self.update(0)
280
281 def _handle_resize(self, signum=None, frame=None):
282 self._terminal_width = getTtySize(self._file,
283 self._should_handle_resize)[1]
284
285 def __enter__(self):
286 return self
287
288 def __exit__(self, exc_type, exc_value, traceback):
289 if not self._silent:
290 if exc_type is None:
291 self.update(self._total)
292 self._file.write('\n')
293 self._file.flush()
294 if self._signal_set:
295 signal.signal(signal.SIGWINCH, signal.SIG_DFL)
296
297 def __iter__(self):
298 return self
299
300 def next(self):
301 try:
302 rv = next(self._items)
303 except StopIteration:
304 self.__exit__(None, None, None)
305 raise
306 else:
307 self.update()
308 return rv
309
310 def update(self, value=None):
311 """
312 Update the progress bar to the given value (out of the total
313 given to the constructor).
314 """
315 if value is None:
316 value = self._current_value = self._current_value + 1
317 else:
318 self._current_value = value
319 if self._total == 0:
320 frac = 1.0
321 else:
322 frac = float(value) / float(self._total)
323
324 file = self._file
325 write = file.write
326
327 suffix = self._formatSuffix(value, frac)
328 self._bar_length = self._terminal_width - 37
329
330 bar_fill = int(float(self._bar_length) * frac)
331 write(u'\r|')
332 write(Fore.BLUE + u'=' * bar_fill + Fore.RESET)
333 if bar_fill < self._bar_length:
334 write(Fore.GREEN + u'>' + Fore.RESET)
335 write(u'-' * (self._bar_length - bar_fill - 1))
336 write(u'|')
337 write(suffix)
338
339 self._file.flush()
340
341 def _formatSuffix(self, value, frac):
342
343 if value >= self._total:
344 t = time.time() - self._start_time
345 time_str = ' '
346 elif value <= 0:
347 t = None
348 time_str = ''
349 else:
350 t = ((time.time() - self._start_time) * (1.0 - frac)) / frac
351 time_str = u' ETA '
352 if t is not None:
353 time_str += formatTime(t, short=True)
354
355 suffix = ' {0:>4s}/{1:>4s}'.format(formatSize(value, short=True),
356 formatSize(self._total, short=True))
357 suffix += u' ({0:>6s}%)'.format(u'{0:.2f}'.format(frac * 100.0))
358 suffix += time_str
359
360 return suffix
361
362 def _silent_update(self, value=None):
363 pass
364
365 @classmethod
366 def map(cls, function, items, multiprocess=False, file=None):
367 """
368 Does a `map` operation while displaying a progress bar with
369 percentage complete.
370
371 ::
372
373 def work(i):
374 print(i)
375
376 ProgressBar.map(work, range(50))
377
378 Parameters:
379
380 function : function
381 Function to call for each step
382
383 items : sequence
384 Sequence where each element is a tuple of arguments to pass to
385 *function*.
386
387 multiprocess : bool, optional
388 If `True`, use the `multiprocessing` module to distribute each
389 task to a different processor core.
390
391 file : writeable file-like object, optional
392 The file to write the progress bar to. Defaults to
393 `sys.stdout`. If `file` is not a tty (as determined by
394 calling its `isatty` member, if any), the scrollbar will
395 be completely silent.
396 """
397 results = []
398
399 if file is None:
400 file = sys.stdout
401
402 with cls(len(items), file=file) as bar:
403 step_size = max(200, bar._bar_length)
404 steps = max(int(float(len(items)) / step_size), 1)
405 if not multiprocess:
406 for i, item in enumerate(items):
407 function(item)
408 if (i % steps) == 0:
409 bar.update(i)
410 else:
411 import multiprocessing
412 p = multiprocessing.Pool()
413 for i, result in enumerate(p.imap_unordered(function, items,
414 steps)):
415 bar.update(i)
416 results.append(result)
417
418 return results
419
420
421 def _encode(s):
422 '''This is a helper for output of unicode. With Python2 it is necessary to
423 do encoding to the LOCAL_ENCODING since by default unicode will be encoded
424 to ascii. In python3 this conversion is not necessary for the user to
425 to perform; in fact sys.std*.write, for example, requires unicode strings
426 be passed in. This function will encode for python2 and do nothing
427 for python3 (except assert that ``s`` is a unicode type).'''
428 if compat.PY2:
429 if isinstance(s, compat.unicode):
430 try:
431 return s.encode(LOCAL_ENCODING)
432 except Exception as ex:
433 log.error("Encoding error: " + str(ex))
434 return s.encode(LOCAL_ENCODING, "replace")
435 elif isinstance(s, str):
436 return s
437 else:
438 raise TypeError("Argument must be str or unicode")
439 else:
440 assert(isinstance(s, str))
441 return s
442
443
444 def printMsg(s):
445 fp = sys.stdout
446 s = _encode(s)
447 try:
448 fp.write("%s\n" % s)
449 except UnicodeEncodeError:
450 fp.write("%s\n" % compat.unicode(s.encode("utf-8", "replace"), "utf-8"))
451 fp.flush()
452
453
454 def printError(s):
455 _printWithColor(s, ERROR_COLOR(), sys.stderr)
456
457
458 def printWarning(s):
459 _printWithColor(s, WARNING_COLOR(), sys.stdout)
460
461
462 def printHeader(s):
463 _printWithColor(s, HEADER_COLOR(), sys.stdout)
464
465
466 def boldText(s, c=None):
467 return formatText(s, b=True, c=c)
468
469
470 def formatText(s, b=False, c=None):
471 return ((Style.BRIGHT if b else '') +
472 (c or '') +
473 s +
474 (Fore.RESET if c else '') +
475 (Style.RESET_BRIGHT if b else ''))
476
477
478 def _printWithColor(s, color, file):
479 s = _encode(s)
480 file.write(color + s + Fore.RESET + '\n')
481 file.flush()
482
483
484 def cformat(msg, fg, bg=None, styles=None):
485 '''Format ``msg`` with foreground and optional background. Optional
486 ``styles`` lists will also be applied. The formatted string is returned.'''
487 fg = fg or ""
488 bg = bg or ""
489 styles = "".join(styles or [])
490 reset = Fore.RESET + Back.RESET + Style.RESET_ALL if (fg or bg or styles) \
491 else ""
492
493 output = u"%(fg)s%(bg)s%(styles)s%(msg)s%(reset)s" % locals()
494 return output
495
496
497 def getTtySize(fd=sys.stdout, check_tty=True):
498 hw = None
499 if check_tty:
500 try:
501 data = fcntl.ioctl(fd, termios.TIOCGWINSZ, '\0' * 4)
502 hw = struct.unpack("hh", data)
503 except (OSError, IOError, NameError):
504 pass
505 if not hw:
506 try:
507 hw = (int(os.environ.get('LINES')),
508 int(os.environ.get('COLUMNS')))
509 except (TypeError, ValueError):
510 hw = (78, 25)
511 return hw
512
513
514 def cprint(msg, fg, bg=None, styles=None, file=sys.stdout):
515 '''Calls ``cformat`` and prints the result to output stream ``file``.'''
516 print(cformat(msg, fg, bg=bg, styles=styles), file=file)
517
518
519 if __name__ == "__main__":
520 AnsiCodes.init(True)
521
522 def checkCode(c):
523 return (c[0] != '_' and
524 "RESET" not in c and
525 c[0] == c[0].upper()
526 )
527
528 for bg_name, bg_code in ((c, getattr(Back, c))
529 for c in dir(Back) if checkCode(c)):
530 sys.stdout.write('%s%-7s%s %s ' %
531 (bg_code, bg_name, Back.RESET, bg_code))
532 for fg_name, fg_code in ((c, getattr(Fore, c))
533 for c in dir(Fore) if checkCode(c)):
534 sys.stdout.write(fg_code)
535 for st_name, st_code in ((c, getattr(Style, c))
536 for c in dir(Style) if checkCode(c)):
537 sys.stdout.write('%s%s %s %s' %
538 (st_code, st_name,
539 getattr(Style, "RESET_%s" % st_name),
540 bg_code))
541 sys.stdout.write("%s\n" % Style.RESET_ALL)
542
543 sys.stdout.write("\n")
544
545 with Spinner(Fore.GREEN + u"Phase #1") as spinner:
546 for i in range(50):
547 time.sleep(.05)
548 spinner.next()
549 with Spinner(Fore.RED + u"Phase #2" + Fore.RESET,
550 print_done=False) as spinner:
551 for i in range(50):
552 time.sleep(.05)
553 spinner.next()
554 with Spinner(u"Phase #3", print_done=False, use_unicode=False) as spinner:
555 for i in range(50):
556 spinner.next()
557 time.sleep(.05)
558 with Spinner(u"Phase #4", print_done=False, chars='.oO°Oo.') as spinner:
559 for i in range(50):
560 spinner.next()
561 time.sleep(.05)
562
563 items = range(200)
564 with ProgressBar(len(items)) as bar:
565 for item in enumerate(items):
566 bar.update()
567 time.sleep(.05)
568
569 for item in ProgressBar(items):
570 time.sleep(.05)
571
572 progress = 0
573 max = 320000000
574 with ProgressBar(max) as bar:
575 while progress < max:
576 progress += 23400
577 bar.update(progress)
578 time.sleep(.001)
+0
-79
src/eyed3/utils/log.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2002-2015 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 import logging
19 from ..__about__ import __version__ as VERSION
20
21 DEFAULT_FORMAT = '%(name)s:%(levelname)s: %(message)s'
22 MAIN_LOGGER = "eyed3"
23
24 # Add some levels
25 logging.VERBOSE = logging.DEBUG + 1
26 logging.addLevelName(logging.VERBOSE, "VERBOSE")
27
28
29 class Logger(logging.Logger):
30 """Base class for all loggers"""
31
32 def __init__(self, name):
33 logging.Logger.__init__(self, name)
34
35 # Using propogation of child to parent, by default
36 self.propagate = True
37 self.setLevel(logging.NOTSET)
38
39 def verbose(self, msg, *args, **kwargs):
40 """Log \a msg at 'verbose' level, debug < verbose < info"""
41 self.log(logging.VERBOSE, msg, *args, **kwargs)
42
43
44 def getLogger(name):
45 og_class = logging.getLoggerClass()
46 try:
47 logging.setLoggerClass(Logger)
48 return logging.getLogger(name)
49 finally:
50 logging.setLoggerClass(og_class)
51
52
53 # The main 'eyed3' logger
54 log = getLogger(MAIN_LOGGER)
55 log.debug("eyeD3 version " + VERSION)
56 del VERSION
57
58
59 def initLogging():
60 """initialize the default logger with console output"""
61 global log
62
63 logging.basicConfig()
64
65 # Don't propgate base 'eyed3'
66 log.propagate = False
67
68 console_handler = logging.StreamHandler()
69 console_handler.setFormatter(logging.Formatter(DEFAULT_FORMAT))
70 log.addHandler(console_handler)
71
72 log.setLevel(logging.WARNING)
73
74 return log
75
76
77 LEVELS = (logging.DEBUG, logging.VERBOSE, logging.INFO,
78 logging.WARNING, logging.ERROR, logging.CRITICAL)
+0
-113
src/eyed3/utils/prompt.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2013 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 import sys as _sys
19 from .. import LOCAL_ENCODING
20 from .console import Fore as fg
21 from .. import compat
22
23 DISABLE_PROMPT = None
24 '''Whenever a prompt occurs and this value is not ``None`` it can be ``exit``
25 to call sys.exit (see EXIT_STATUS) or ``raise`` to throw a RuntimeError,
26 which can be caught if desired.'''
27
28 EXIT_STATUS = 2
29
30 BOOL_TRUE_RESPONSES = ("yes", "y", "true")
31
32
33 class PromptExit(RuntimeError):
34 '''Raised when ``DISABLE_PROMPT`` is 'raise' and ``prompt`` is called.'''
35 pass
36
37
38 def parseIntList(resp):
39 ints = set()
40 resp = resp.replace(',', ' ')
41 for c in resp.split():
42 i = int(c)
43 ints.add(i)
44 return list(ints)
45
46
47 def prompt(msg, default=None, required=True, type_=compat.UnicodeType,
48 validate=None, choices=None):
49 '''Prompt user for imput, the prequest is in ``msg``. If ``default`` is
50 not ``None`` it will be displayed as the default and returned if not
51 input is entered. The value ``None`` is only returned if ``required`` is
52 ``False``. The response is passed to ``type_`` for conversion (default
53 is unicode) before being returned. An optional list of valid responses can
54 be provided in ``choices``.'''
55 yes_no_prompt = default is True or default is False
56
57 if yes_no_prompt:
58 default_str = "Yn" if default is True else "yN"
59 else:
60 default_str = str(default) if default else None
61
62 if default is not None:
63 msg = "%s [%s]" % (msg, default_str)
64 msg += ": " if not yes_no_prompt else "? "
65
66 if DISABLE_PROMPT:
67 if DISABLE_PROMPT == "exit":
68 print(msg + "\nPrompting is disabled, exiting.")
69 _sys.exit(EXIT_STATUS)
70 else:
71 raise PromptExit(msg)
72
73 resp = None
74 while resp is None:
75
76 try:
77 resp = compat.input(msg)
78 if not isinstance(resp, compat.UnicodeType):
79 # Python2
80 resp = resp.decode(LOCAL_ENCODING)
81 except EOFError:
82 # Converting this allows main functions to catch without
83 # catching other eofs
84 raise PromptExit()
85
86 if not resp and default not in (None, ""):
87 resp = str(default)
88
89 if resp:
90 if yes_no_prompt:
91 resp = True if resp.lower() in BOOL_TRUE_RESPONSES else False
92 else:
93 resp = resp.strip()
94 try:
95 resp = type_(resp)
96 except Exception as ex:
97 print(fg.red(str(ex)))
98 resp = None
99 elif not required:
100 return None
101 else:
102 resp = None
103
104 if ((choices and resp not in choices) or
105 (validate and not validate(resp))):
106 if choices:
107 print(fg.red("Invalid response, choose from: ") + str(choices))
108 else:
109 print(fg.red("Invalid response"))
110 resp = None
111
112 return resp
+0
-61
src/test/__init__.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2010-2015 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 import eyed3
19 from eyed3.compat import StringIO
20 import os
21 import sys
22 import logging
23 if sys.version_info[:2] == (2, 6):
24 import unittest2 as unittest
25 else:
26 import unittest
27
28 DATA_D = os.path.join(os.path.dirname(__file__), "data")
29
30 eyed3.log.setLevel(logging.ERROR)
31
32
33 class RedirectStdStreams(object):
34 '''This class is used to capture sys.stdout and sys.stderr for tests that
35 invoke command line scripts and wish to inspect the output.'''
36
37 def __init__(self, stdout=None, stderr=None, seek_on_exit=0):
38 self.stdout = stdout or StringIO()
39 self.stderr = stderr or StringIO()
40 self._seek_offset = seek_on_exit
41
42 def __enter__(self):
43 self._orig_stdout, self._orig_stderr = sys.stdout, sys.stderr
44 sys.stdout, sys.stderr = self.stdout, self.stderr
45 return self
46
47 def __exit__(self, exc_type, exc_value, traceback):
48 try:
49 for s in [self.stdout, self.stderr]:
50 s.flush()
51 if not s.isatty():
52 s.seek(self._seek_offset)
53 finally:
54 sys.stdout, sys.stderr = self._orig_stdout, self._orig_stderr
55
56
57 class ExternalDataTestCase(unittest.TestCase):
58 '''Test case for external data files.'''
59 def setUp(self):
60 pass
+0
-35
src/test/compat.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2013 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 import sys
19
20 # assert functions that are not in unittest in python 2.6, and therefore not
21 # import from nost.tools as in python >= 2.7
22 if sys.version_info[:2] == (2, 6):
23
24 def assert_is_none(data):
25 assert data is None
26
27 def assert_is_not_none(data):
28 assert data is not None
29
30 def assert_in(data, container):
31 assert data in container
32
33 def assert_is(data1, data2):
34 assert data1 is data2
+0
-52
src/test/conftest.py less more
0 import shutil
1 import pytest
2 import eyed3
3 from uuid import uuid4
4 from pathlib import Path
5
6
7 DATA_D = Path(__file__).parent / "data"
8
9
10 def _tempCopy(src, dest_dir):
11 testfile = Path(str(dest_dir)) / "{}.mp3".format(uuid4())
12 shutil.copyfile(str(src), str(testfile))
13 return testfile
14
15
16 @pytest.fixture(scope="function")
17 def audiofile(tmpdir):
18 """Makes a copy of test.mp3 and loads it using eyed3.load()."""
19 testfile = _tempCopy(DATA_D / "test.mp3", tmpdir)
20 yield eyed3.load(testfile)
21 if testfile.exists():
22 testfile.unlink()
23
24
25 @pytest.fixture(scope="function")
26 def id3tag():
27 """Returns a default-constructed eyed3.id3.Tag."""
28 from eyed3.id3 import Tag
29 return Tag()
30
31
32 @pytest.fixture(scope="function")
33 def image(tmpdir):
34 img_file = _tempCopy(DATA_D / "CypressHill3TemplesOfBoom.jpg", tmpdir)
35 return img_file
36
37
38 @pytest.fixture(scope="session")
39 def eyeD3():
40 from eyed3 import main
41 def func(audiofile, args, expected_retval=0, reload_version=None):
42 try:
43 args, _, config = main.parseCommandLine(args + [audiofile.path])
44 retval = main.main(args, config)
45 except SystemExit as exit:
46 retval = exit.code
47 assert retval == expected_retval
48 return eyed3.load(audiofile.path, tag_version=reload_version)
49
50 return func
51
+0
-0
src/test/id3/__init__.py less more
(Empty file)
+0
-342
src/test/id3/test_frames.py less more
0 # -*- coding: utf-8 -*-
1 import sys
2 import pytest
3 import unittest
4
5 from pathlib import Path
6
7 import eyed3
8 from eyed3.id3 import (LATIN1_ENCODING, UTF_8_ENCODING, UTF_16_ENCODING,
9 UTF_16BE_ENCODING)
10 from eyed3.id3 import ID3_V1_0, ID3_V1_1, ID3_V2_3, ID3_V2_4
11 from eyed3.id3.frames import (Frame, TextFrame, FrameHeader, ImageFrame,
12 LanguageCodeMixin, ObjectFrame, TermsOfUseFrame,
13 DEFAULT_LANG, TOS_FID, OBJECT_FID)
14 from eyed3.compat import unicode
15
16 if sys.version_info[0:2] > (2, 7):
17 from unittest.mock import patch
18 else:
19 from mock import patch
20
21
22 class FrameTest(unittest.TestCase):
23 def testCtor(self):
24 f = Frame(b"ABCD")
25 assert f.id == b"ABCD"
26 assert f.header is None
27 assert f.decompressed_size == 0
28 assert f.group_id is None
29 assert f.encrypt_method is None
30 assert f.data is None
31 assert f.data_len == 0
32 assert f.encoding is None
33
34 f = Frame(b"EFGH")
35 assert f.id == b"EFGH"
36 assert f.header is None
37 assert f.decompressed_size == 0
38 assert f.group_id is None
39 assert f.encrypt_method is None
40 assert f.data is None
41 assert f.data_len == 0
42 assert f.encoding is None
43
44 def testTextDelim(self):
45 for enc in [LATIN1_ENCODING, UTF_16BE_ENCODING, UTF_16_ENCODING,
46 UTF_8_ENCODING]:
47 f = Frame(b"XXXX")
48 f.encoding = enc
49 if enc in [LATIN1_ENCODING, UTF_8_ENCODING]:
50 assert (f.text_delim == b"\x00")
51 else:
52 assert (f.text_delim == b"\x00\x00")
53
54 def testInitEncoding(self):
55 # Default encodings per version
56 for ver in [ID3_V1_0, ID3_V1_1, ID3_V2_3, ID3_V2_4]:
57 f = Frame(b"XXXX")
58 f.header = FrameHeader(f.id, ver)
59 f._initEncoding()
60 if ver[0] == 1:
61 assert (f.encoding == LATIN1_ENCODING)
62 elif ver[:2] == (2, 3):
63 assert (f.encoding == UTF_16_ENCODING)
64 else:
65 assert (f.encoding == UTF_8_ENCODING)
66
67 # Invalid encoding for a version is coerced
68 for ver in [ID3_V1_0, ID3_V1_1]:
69 for enc in [UTF_8_ENCODING, UTF_16_ENCODING, UTF_16BE_ENCODING]:
70 f = Frame(b"XXXX")
71 f.header = FrameHeader(f.id, ver)
72 f.encoding = enc
73 f._initEncoding()
74 assert (f.encoding == LATIN1_ENCODING)
75
76 for ver in [ID3_V2_3]:
77 for enc in [UTF_8_ENCODING, UTF_16BE_ENCODING]:
78 f = Frame(b"XXXX")
79 f.header = FrameHeader(f.id, ver)
80 f.encoding = enc
81 f._initEncoding()
82 assert (f.encoding == UTF_16_ENCODING)
83
84 # No coersion for v2.4
85 for ver in [ID3_V2_4]:
86 for enc in [LATIN1_ENCODING, UTF_8_ENCODING, UTF_16BE_ENCODING,
87 UTF_16_ENCODING]:
88 f = Frame(b"XXXX")
89 f.header = FrameHeader(f.id, ver)
90 f.encoding = enc
91 f._initEncoding()
92 assert (f.encoding == enc)
93
94
95 class TextFrameTest(unittest.TestCase):
96 def testCtor(self):
97 with pytest.raises(TypeError):
98 TextFrame(u"TCON")
99
100 f = TextFrame(b"TCON")
101 assert f.text == u""
102
103 f = TextFrame(b"TCON", u"content")
104 assert f.text == u"content"
105
106 def testRenderParse(self):
107 fid = b"TPE1"
108 for ver in [ID3_V2_3, ID3_V2_4]:
109 h1 = FrameHeader(fid, ver)
110 h2 = FrameHeader(fid, ver)
111 f1 = TextFrame(b"TPE1", u"Ambulance LTD")
112 f1.header = h1
113 data = f1.render()
114
115 # FIXME: right here is why parse should be static
116 f2 = TextFrame(b"TIT2")
117 f2.parse(data[h1.size:], h2)
118 assert f1.id == f2.id
119 assert f1.text == f2.text
120 assert f1.encoding == f2.encoding
121
122
123 class ImageFrameTest(unittest.TestCase):
124 def testPicTypeConversions(self):
125 count = 0
126 for s in ("OTHER", "ICON", "OTHER_ICON", "FRONT_COVER", "BACK_COVER",
127 "LEAFLET", "MEDIA", "LEAD_ARTIST", "ARTIST", "CONDUCTOR",
128 "BAND", "COMPOSER", "LYRICIST", "RECORDING_LOCATION",
129 "DURING_RECORDING", "DURING_PERFORMANCE", "VIDEO",
130 "BRIGHT_COLORED_FISH", "ILLUSTRATION", "BAND_LOGO",
131 "PUBLISHER_LOGO"):
132 c = getattr(ImageFrame, s)
133 assert (ImageFrame.picTypeToString(c) == s)
134 assert (ImageFrame.stringToPicType(s) == c)
135 count += 1
136 assert (count == ImageFrame.MAX_TYPE + 1)
137
138 assert (ImageFrame.MIN_TYPE == ImageFrame.OTHER)
139 assert (ImageFrame.MAX_TYPE == ImageFrame.PUBLISHER_LOGO)
140 assert ImageFrame.picTypeToString(ImageFrame.MAX_TYPE) == \
141 "PUBLISHER_LOGO"
142 assert ImageFrame.picTypeToString(ImageFrame.MIN_TYPE) == "OTHER"
143
144 with pytest.raises(ValueError):
145 ImageFrame.picTypeToString(ImageFrame.MAX_TYPE + 1)
146 with pytest.raises(ValueError):
147 ImageFrame.picTypeToString(ImageFrame.MIN_TYPE - 1)
148
149 with pytest.raises(ValueError):
150 ImageFrame.stringToPicType("Prust")
151
152
153 def test_DateFrame():
154 from eyed3.id3.frames import DateFrame
155 from eyed3.core import Date
156
157 # Default ctor
158 df = DateFrame(b"TDRC")
159 assert df.text == u""
160 assert df.date is None
161
162 # Ctor with eyed3.core.Date arg
163 for d in [Date(2012),
164 Date(2012, 1),
165 Date(2012, 1, 4),
166 Date(2012, 1, 4, 18),
167 Date(2012, 1, 4, 18, 15),
168 Date(2012, 1, 4, 18, 15, 30),
169 ]:
170 df = DateFrame(b"TDRC", d)
171 assert (df.text == unicode(str(d)))
172 # Comparison is on each member, not reference ID
173 assert (df.date == d)
174
175 # Test ctor str arg is converted
176 for d in ["2012",
177 "2010-01",
178 "2010-01-04",
179 "2010-01-04T18",
180 "2010-01-04T06:20",
181 "2010-01-04T06:20:15",
182 u"2012",
183 u"2010-01",
184 u"2010-01-04",
185 u"2010-01-04T18",
186 u"2010-01-04T06:20",
187 u"2010-01-04T06:20:15",
188 ]:
189 df = DateFrame(b"TDRC", d)
190 dt = Date.parse(d)
191 assert (df.text == unicode(str(dt)))
192 assert (df.text == unicode(d))
193 # Comparison is on each member, not reference ID
194 assert (df.date == dt)
195
196 # Invalid dates
197 for d in ["1234:12"]:
198 date = DateFrame(b"TDRL")
199 date.date = d
200 assert not date.date
201
202 try:
203 date.date = 9
204 except TypeError:
205 pass
206 else:
207 pytest.fail("TypeError not thrown")
208
209
210 def test_compression():
211 f = open(__file__, "rb")
212 try:
213 data = f.read()
214 compressed = Frame.compress(data)
215 assert data == Frame.decompress(compressed)
216 finally:
217 f.close()
218
219
220 '''
221 FIXME:
222 def test_tag_compression(id3tag):
223 # FIXME: going to refactor FrameHeader, bbl
224 data = Path(__file__).read_text()
225 aframe = TextFrame(ARTIST_FID, text=data)
226 aframe.header = FrameHeader(ARTIST_FID)
227 import ipdb; ipdb.set_trace()
228 pass
229 '''
230
231
232 def test_encryption():
233 with pytest.raises(NotImplementedError):
234 Frame.encrypt("Iceburn")
235 with pytest.raises(NotImplementedError):
236 Frame.decrypt("Iceburn")
237
238
239 def test_LanguageCodeMixin():
240 with pytest.raises(TypeError):
241 LanguageCodeMixin().lang = u"eng"
242
243 l = LanguageCodeMixin()
244 l.lang = b"\x80"
245 assert l.lang == b"eng"
246
247 l.lang = b""
248 assert l.lang == b""
249 l.lang = None
250 assert l.lang == b""
251
252
253 def test_TermsOfUseFrame(audiofile, id3tag):
254 terms = TermsOfUseFrame()
255 assert terms.id == b"USER"
256 assert terms.text == u""
257 assert terms.lang == DEFAULT_LANG
258
259 id3tag.terms_of_use = u"Fucking MANDATORY!"
260 audiofile.tag = id3tag
261 audiofile.tag.save()
262 file = eyed3.load(audiofile.path)
263 assert file.tag.terms_of_use == u"Fucking MANDATORY!"
264
265 id3tag.terms_of_use = u"Fucking MANDATORY!"
266 audiofile.tag = id3tag
267 audiofile.tag.save()
268 file = eyed3.load(audiofile.path)
269 assert file.tag.terms_of_use == u"Fucking MANDATORY!"
270
271 id3tag.terms_of_use = (u"Fucking MANDATORY!", b"jib")
272 audiofile.tag = id3tag
273 audiofile.tag.save()
274 file = eyed3.load(audiofile.path)
275 assert file.tag.terms_of_use == u"Fucking MANDATORY!"
276 assert file.tag.frame_set[TOS_FID][0].lang == b"jib"
277
278 id3tag.terms_of_use = (u"Fucking MANDATORY!", b"en")
279 audiofile.tag = id3tag
280 audiofile.tag.save()
281 file = eyed3.load(audiofile.path)
282 assert file.tag.terms_of_use == u"Fucking MANDATORY!"
283 assert file.tag.frame_set[TOS_FID][0].lang == b"en"
284
285
286 def test_ObjectFrame(audiofile, id3tag):
287 sixsixsix = b"\x29\x0a" * 666
288 with Path(__file__).open("rb") as fp:
289 thisfile = fp.read()
290
291 obj1 = ObjectFrame(description=u"Test Object", object_data=sixsixsix,
292 filename=u"666.txt", mime_type="text/satan")
293 obj2 = ObjectFrame(description=u"Test Object2", filename=unicode(__file__),
294 mime_type="text/python", object_data=thisfile)
295 id3tag.frame_set[OBJECT_FID] = obj1
296 id3tag.frame_set[OBJECT_FID].append(obj2)
297
298 audiofile.tag = id3tag
299 audiofile.tag.save()
300 file = eyed3.load(audiofile.path)
301 assert len(file.tag.objects) == 2
302 obj1_2 = file.tag.objects.get(u"Test Object")
303 assert obj1_2.mime_type == "text/satan"
304 assert obj1_2.object_data == sixsixsix
305 assert obj1_2.filename == u"666.txt"
306
307 obj2_2 = file.tag.objects.get(u"Test Object2")
308 assert obj2_2.mime_type == "text/python"
309 assert obj2_2.object_data == thisfile
310 assert obj2_2.filename == __file__
311
312
313 def test_ObjectFrame_no_mimetype(audiofile, id3tag):
314 # Setting no mime-type is invalid
315 obj1 = ObjectFrame(object_data=b"Deep Purple")
316 id3tag.frame_set[OBJECT_FID] = obj1
317
318 audiofile.tag = id3tag
319 audiofile.tag.save()
320 with patch("eyed3.core.parseError") as mock:
321 file = eyed3.load(audiofile.path)
322 assert mock.call_count == 2
323
324 obj1.mime_type = "Deep"
325 audiofile.tag.save()
326 with patch("eyed3.core.parseError") as mock:
327 file = eyed3.load(audiofile.path)
328 assert mock.call_count == 1
329
330 obj1.mime_type = "Deep/Purple"
331 audiofile.tag.save()
332 with patch("eyed3.core.parseError") as mock:
333 file = eyed3.load(audiofile.path)
334 mock.assert_not_called()
335
336 obj1.object_data = b""
337 audiofile.tag.save()
338 with patch("eyed3.core.parseError") as mock:
339 file = eyed3.load(audiofile.path)
340 assert mock.call_count == 1
341 assert file
+0
-493
src/test/id3/test_headers.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2012 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 import unittest
19 import pytest
20 from eyed3.id3.headers import *
21 from eyed3.id3 import ID3_DEFAULT_VERSION, TagException
22 from ..compat import *
23
24 from io import BytesIO
25
26
27 class TestTagHeader(unittest.TestCase):
28 def testCtor(self):
29 h = TagHeader()
30 assert (h.version == ID3_DEFAULT_VERSION)
31 assert not(h.unsync)
32 assert not(h.extended)
33 assert not(h.experimental)
34 assert not(h.footer)
35 assert h.tag_size == 0
36
37 def testTagVersion(self):
38 for maj, min, rev in [(1, 0, 0), (1, 1, 0), (2, 2, 0), (2, 3, 0),
39 (2, 4, 0)]:
40 h = TagHeader((maj, min, rev))
41
42 assert (h.major_version == maj)
43 assert (h.minor_version == min)
44 assert (h.rev_version == rev)
45
46 for maj, min, rev in [(1, 0, None), (1, None, 0), (2, 5, 0), (3, 4, 0)]:
47 try:
48 h = TagHeader((maj, min, rev))
49 except ValueError:
50 pass
51 else:
52 assert not("Invalid version, expected ValueError")
53
54 def testParse(self):
55 # Incomplete headers
56 for data in [b"", b"ID3", b"ID3\x04\x00",
57 b"ID3\x02\x00\x00",
58 b"ID3\x03\x00\x00",
59 b"ID3\x04\x00\x00",
60 ]:
61 header = TagHeader()
62 found = header.parse(BytesIO(data))
63 assert not(found)
64
65 # Invalid versions
66 for data in [b"ID3\x01\x00\x00",
67 b"ID3\x05\x00\x00",
68 b"ID3\x06\x00\x00",
69 ]:
70 header = TagHeader()
71 try:
72 found = header.parse(BytesIO(data))
73 except TagException:
74 pass
75 else:
76 assert not("Expected TagException invalid version")
77
78
79 # Complete headers
80 for data in [b"ID3\x02\x00\x00",
81 b"ID3\x03\x00\x00",
82 b"ID3\x04\x00\x00",
83 ]:
84 for sz in [0, 10, 100, 1000, 2500, 5000, 7500, 10000]:
85 sz_bytes = bin2bytes(bin2synchsafe(dec2bin(sz, 32)))
86 header = TagHeader()
87 found = header.parse(BytesIO(data + sz_bytes))
88 assert (found)
89 assert header.tag_size == sz
90
91 def testRenderWithUnsyncTrue(self):
92 h = TagHeader()
93 h.unsync = True
94 with pytest.raises(NotImplementedError):
95 h.render(100)
96
97 def testRender(self):
98 h = TagHeader()
99 h.unsync = False
100 header = h.render(100)
101
102 h2 = TagHeader()
103 found = h2.parse(BytesIO(header))
104 assert not(h2.unsync)
105 assert (found)
106 assert header == h2.render(100)
107
108 h = TagHeader()
109 h.footer = True
110 h.extended = True
111 header = h.render(666)
112
113 h2 = TagHeader()
114 found = h2.parse(BytesIO(header))
115 assert (found)
116 assert not(h2.unsync)
117 assert not(h2.experimental)
118 assert h2.footer
119 assert h2.extended
120 assert (h2.tag_size == 666)
121 assert (header == h2.render(666))
122
123 class TestExtendedHeader(unittest.TestCase):
124 def testCtor(self):
125 h = ExtendedTagHeader()
126 assert (h.size == 0)
127 assert (h._flags == 0)
128 assert (h.crc is None)
129 assert (h._restrictions == 0)
130
131 assert not(h.update_bit)
132 assert not(h.crc_bit)
133 assert not(h.restrictions_bit)
134
135 def testUpdateBit(self):
136 h = ExtendedTagHeader()
137
138 h.update_bit = 1
139 assert (h.update_bit)
140 h.update_bit = 0
141 assert not(h.update_bit)
142 h.update_bit = 1
143 assert (h.update_bit)
144 h.update_bit = False
145 assert not(h.update_bit)
146 h.update_bit = True
147 assert (h.update_bit)
148
149 def testCrcBit(self):
150 h = ExtendedTagHeader()
151 h.update_bit = True
152
153 h.crc_bit = 1
154 assert (h.update_bit)
155 assert (h.crc_bit)
156 h.crc_bit = 0
157 assert (h.update_bit)
158 assert not(h.crc_bit)
159 h.crc_bit = 1
160 assert (h.update_bit)
161 assert (h.crc_bit)
162 h.crc_bit = False
163 assert (h.update_bit)
164 assert not(h.crc_bit)
165 h.crc_bit = True
166 assert (h.update_bit)
167 assert (h.crc_bit)
168
169 def testRestrictionsBit(self):
170 h = ExtendedTagHeader()
171 h.update_bit = True
172 h.crc_bit = True
173
174 h.restrictions_bit = 1
175 assert (h.update_bit)
176 assert (h.crc_bit)
177 assert (h.restrictions_bit)
178 h.restrictions_bit = 0
179 assert (h.update_bit)
180 assert (h.crc_bit)
181 assert not(h.restrictions_bit)
182 h.restrictions_bit = 1
183 assert (h.update_bit)
184 assert (h.crc_bit)
185 assert (h.restrictions_bit)
186 h.restrictions_bit = False
187 assert (h.update_bit)
188 assert (h.crc_bit)
189 assert not(h.restrictions_bit)
190 h.restrictions_bit = True
191 assert (h.update_bit)
192 assert (h.crc_bit)
193 assert (h.restrictions_bit)
194
195 h = ExtendedTagHeader()
196 h.restrictions_bit = True
197 assert (h.tag_size_restriction ==
198 ExtendedTagHeader.RESTRICT_TAG_SZ_LARGE)
199 assert (h.text_enc_restriction ==
200 ExtendedTagHeader.RESTRICT_TEXT_ENC_NONE)
201 assert (h.text_length_restriction ==
202 ExtendedTagHeader.RESTRICT_TEXT_LEN_NONE)
203 assert (h.image_enc_restriction ==
204 ExtendedTagHeader.RESTRICT_IMG_ENC_NONE)
205 assert (h.image_size_restriction ==
206 ExtendedTagHeader.RESTRICT_IMG_SZ_NONE)
207
208 h.tag_size_restriction = ExtendedTagHeader.RESTRICT_TAG_SZ_TINY
209 assert (h.tag_size_restriction ==
210 ExtendedTagHeader.RESTRICT_TAG_SZ_TINY)
211 assert (h.text_enc_restriction ==
212 ExtendedTagHeader.RESTRICT_TEXT_ENC_NONE)
213 assert (h.text_length_restriction ==
214 ExtendedTagHeader.RESTRICT_TEXT_LEN_NONE)
215 assert (h.image_enc_restriction ==
216 ExtendedTagHeader.RESTRICT_IMG_ENC_NONE)
217 assert (h.image_size_restriction ==
218 ExtendedTagHeader.RESTRICT_IMG_SZ_NONE)
219
220 h.text_enc_restriction = ExtendedTagHeader.RESTRICT_TEXT_ENC_UTF8
221 assert (h.tag_size_restriction ==
222 ExtendedTagHeader.RESTRICT_TAG_SZ_TINY)
223 assert (h.text_enc_restriction ==
224 ExtendedTagHeader.RESTRICT_TEXT_ENC_UTF8)
225 assert (h.text_length_restriction ==
226 ExtendedTagHeader.RESTRICT_TEXT_LEN_NONE)
227 assert (h.image_enc_restriction ==
228 ExtendedTagHeader.RESTRICT_IMG_ENC_NONE)
229 assert (h.image_size_restriction ==
230 ExtendedTagHeader.RESTRICT_IMG_SZ_NONE)
231
232 h.text_length_restriction = ExtendedTagHeader.RESTRICT_TEXT_LEN_30
233 assert (h.tag_size_restriction ==
234 ExtendedTagHeader.RESTRICT_TAG_SZ_TINY)
235 assert (h.text_enc_restriction ==
236 ExtendedTagHeader.RESTRICT_TEXT_ENC_UTF8)
237 assert (h.text_length_restriction ==
238 ExtendedTagHeader.RESTRICT_TEXT_LEN_30)
239 assert (h.image_enc_restriction ==
240 ExtendedTagHeader.RESTRICT_IMG_ENC_NONE)
241 assert (h.image_size_restriction ==
242 ExtendedTagHeader.RESTRICT_IMG_SZ_NONE)
243
244 h.image_enc_restriction = ExtendedTagHeader.RESTRICT_IMG_ENC_PNG_JPG
245 assert (h.tag_size_restriction ==
246 ExtendedTagHeader.RESTRICT_TAG_SZ_TINY)
247 assert (h.text_enc_restriction ==
248 ExtendedTagHeader.RESTRICT_TEXT_ENC_UTF8)
249 assert (h.text_length_restriction ==
250 ExtendedTagHeader.RESTRICT_TEXT_LEN_30)
251 assert (h.image_enc_restriction ==
252 ExtendedTagHeader.RESTRICT_IMG_ENC_PNG_JPG)
253 assert (h.image_size_restriction ==
254 ExtendedTagHeader.RESTRICT_IMG_SZ_NONE)
255
256 h.image_size_restriction = ExtendedTagHeader.RESTRICT_IMG_SZ_256
257 assert (h.tag_size_restriction ==
258 ExtendedTagHeader.RESTRICT_TAG_SZ_TINY)
259 assert (h.text_enc_restriction ==
260 ExtendedTagHeader.RESTRICT_TEXT_ENC_UTF8)
261 assert (h.text_length_restriction ==
262 ExtendedTagHeader.RESTRICT_TEXT_LEN_30)
263 assert (h.image_enc_restriction ==
264 ExtendedTagHeader.RESTRICT_IMG_ENC_PNG_JPG)
265 assert (h.image_size_restriction ==
266 ExtendedTagHeader.RESTRICT_IMG_SZ_256)
267
268 assert " 32 frames " in h.tag_size_restriction_description
269 assert " 4 KB " in h.tag_size_restriction_description
270 h.tag_size_restriction = ExtendedTagHeader.RESTRICT_TAG_SZ_LARGE
271 assert " 128 frames " in h.tag_size_restriction_description
272 h.tag_size_restriction = ExtendedTagHeader.RESTRICT_TAG_SZ_MED
273 assert " 64 frames " in h.tag_size_restriction_description
274 h.tag_size_restriction = ExtendedTagHeader.RESTRICT_TAG_SZ_SMALL
275 assert " 32 frames " in h.tag_size_restriction_description
276 assert " 40 KB " in h.tag_size_restriction_description
277
278 assert (" UTF-8" in h.text_enc_restriction_description)
279 h.text_enc_restriction = ExtendedTagHeader.RESTRICT_TEXT_ENC_NONE
280 assert ("None" == h.text_enc_restriction_description)
281
282 assert " 30 " in h.text_length_restriction_description
283 h.text_length_restriction = ExtendedTagHeader.RESTRICT_TEXT_LEN_NONE
284 assert ("None" == h.text_length_restriction_description)
285 h.text_length_restriction = ExtendedTagHeader.RESTRICT_TEXT_LEN_1024
286 assert " 1024 " in h.text_length_restriction_description
287 h.text_length_restriction = ExtendedTagHeader.RESTRICT_TEXT_LEN_128
288 assert " 128 " in h.text_length_restriction_description
289
290 assert " PNG " in h.image_enc_restriction_description
291 h.image_enc_restriction = ExtendedTagHeader.RESTRICT_IMG_ENC_NONE
292 assert ("None" == h.image_enc_restriction_description)
293
294 assert " 256x256 " in h.image_size_restriction_description
295 h.image_size_restriction = ExtendedTagHeader.RESTRICT_IMG_SZ_NONE
296 assert ("None" == h.image_size_restriction_description)
297 h.image_size_restriction = ExtendedTagHeader.RESTRICT_IMG_SZ_64
298 assert (" 64x64 pixels or smaller" in
299 h.image_size_restriction_description)
300 h.image_size_restriction = ExtendedTagHeader.RESTRICT_IMG_SZ_64_EXACT
301 assert "exactly 64x64 pixels" in h.image_size_restriction_description
302
303 def testRender(self):
304 version = (2, 4, 0)
305 dummy_data = b"\xab" * 50
306 dummy_padding_len = 1024
307
308 h = ExtendedTagHeader()
309 h.update_bit = 1
310 h.crc_bit = 1
311 h.tag_size_restriction = ExtendedTagHeader.RESTRICT_TAG_SZ_MED
312 h.text_enc_restriction = ExtendedTagHeader.RESTRICT_TEXT_ENC_UTF8
313 h.text_length_restriction = ExtendedTagHeader.RESTRICT_TEXT_LEN_128
314 h.image_enc_restriction = ExtendedTagHeader.RESTRICT_IMG_ENC_PNG_JPG
315 h.image_size_restriction = ExtendedTagHeader.RESTRICT_IMG_SZ_256
316 header = h.render(version, dummy_data, dummy_padding_len)
317
318 h2 = ExtendedTagHeader()
319 h2.parse(BytesIO(header), version)
320 assert (h2.update_bit)
321 assert (h2.crc_bit)
322 assert (h2.restrictions_bit)
323 assert (h.crc == h2.crc)
324 assert (h.tag_size_restriction == h2.tag_size_restriction)
325 assert (h.text_enc_restriction == h2.text_enc_restriction)
326 assert (h.text_length_restriction == h2.text_length_restriction)
327 assert (h.image_enc_restriction == h2.image_enc_restriction)
328 assert (h.image_size_restriction == h2.image_size_restriction)
329
330 assert h2.render(version, dummy_data, dummy_padding_len) == header
331
332 # version 2.3
333 header_23 = h.render((2,3,0), dummy_data, dummy_padding_len)
334
335 h3 = ExtendedTagHeader()
336 h3.parse(BytesIO(header_23), (2,3,0))
337 assert not(h3.update_bit)
338 assert (h3.crc_bit)
339 assert not(h3.restrictions_bit)
340 assert (h.crc == h3.crc)
341 assert (0 == h3.tag_size_restriction)
342 assert (0 == h3.text_enc_restriction)
343 assert (0 == h3.text_length_restriction)
344 assert (0 == h3.image_enc_restriction)
345 assert (0 == h3.image_size_restriction)
346
347 def testRenderCrcPadding(self):
348 version = (2, 4, 0)
349
350 h = ExtendedTagHeader()
351 h.crc_bit = 1
352 header = h.render(version, b"\x01", 0)
353
354 h2 = ExtendedTagHeader()
355 h2.parse(BytesIO(header), version)
356 assert h.crc == h2.crc
357
358 def testInvalidFlagBits(self):
359 for bad_flags in [b"\x00\x20", b"\x01\x01"]:
360 h = ExtendedTagHeader()
361 try:
362 h.parse(BytesIO(b"\x00\x00\x00\xff" + bad_flags), (2, 4, 0))
363 except TagException:
364 pass
365 else:
366 assert not("Bad ExtendedTagHeader flags, expected "
367 "TagException")
368
369 class TestFrameHeader(unittest.TestCase):
370 def testCtor(self):
371 h = FrameHeader(b"TIT2", ID3_DEFAULT_VERSION)
372 assert (h.size == 10)
373 assert (h.id == b"TIT2")
374 assert (h.data_size == 0)
375 assert (h._flags == [0] * 16)
376
377 h = FrameHeader(b"TIT2", (2, 3, 0))
378 assert (h.size == 10)
379 assert (h.id == b"TIT2")
380 assert (h.data_size == 0)
381 assert (h._flags == [0] * 16)
382
383 h = FrameHeader(b"TIT2", (2, 2, 0))
384 assert (h.size == 6)
385 assert (h.id == b"TIT2")
386 assert (h.data_size == 0)
387 assert (h._flags == [0] * 16)
388
389 def testBitMask(self):
390 for v in [(2, 2, 0), (2, 3, 0)]:
391 h = FrameHeader(b"TXXX", v)
392 assert (h.TAG_ALTER == 0)
393 assert (h.FILE_ALTER == 1)
394 assert (h.READ_ONLY == 2)
395 assert (h.COMPRESSED == 8)
396 assert (h.ENCRYPTED == 9)
397 assert (h.GROUPED == 10)
398 assert (h.UNSYNC == 14)
399 assert (h.DATA_LEN == 4)
400
401 for v in [(2, 4, 0), (1, 0, 0), (1, 1, 0)]:
402 h = FrameHeader(b"TXXX", v)
403 assert (h.TAG_ALTER == 1)
404 assert (h.FILE_ALTER == 2)
405 assert (h.READ_ONLY == 3)
406 assert (h.COMPRESSED == 12)
407 assert (h.ENCRYPTED == 13)
408 assert (h.GROUPED == 9)
409 assert (h.UNSYNC == 14)
410 assert (h.DATA_LEN == 15)
411
412 for v in [(2, 5, 0), (3, 0, 0)]:
413 try:
414 h = FrameHeader(b"TIT2", v)
415 except ValueError:
416 pass
417 else:
418 assert not("Expected a ValueError from invalid version, "
419 "but got success")
420
421 for v in [1, "yes", "no", True, 23]:
422 h = FrameHeader(b"APIC", (2, 4, 0))
423 h.tag_alter = v
424 h.file_alter = v
425 h.read_only = v
426 h.compressed = v
427 h.encrypted = v
428 h.grouped = v
429 h.unsync = v
430 h.data_length_indicator = v
431 assert (h.tag_alter == 1)
432 assert (h.file_alter == 1)
433 assert (h.read_only == 1)
434 assert (h.compressed == 1)
435 assert (h.encrypted == 1)
436 assert (h.grouped == 1)
437 assert (h.unsync == 1)
438 assert (h.data_length_indicator == 1)
439
440 for v in [0, False, None]:
441 h = FrameHeader(b"APIC", (2, 4, 0))
442 h.tag_alter = v
443 h.file_alter = v
444 h.read_only = v
445 h.compressed = v
446 h.encrypted = v
447 h.grouped = v
448 h.unsync = v
449 h.data_length_indicator = v
450 assert (h.tag_alter == 0)
451 assert (h.file_alter == 0)
452 assert (h.read_only == 0)
453 assert (h.compressed == 0)
454 assert (h.encrypted == 0)
455 assert (h.grouped == 0)
456 assert (h.unsync == 0)
457 assert (h.data_length_indicator == 0)
458
459 h1 = FrameHeader(b"APIC", (2, 3, 0))
460 h1.tag_alter = True
461 h1.grouped = True
462 h1.file_alter = 1
463 h1.encrypted = None
464 h1.compressed = 4
465 h1.data_length_indicator = 0
466 h1.read_only = 1
467 h1.unsync = 1
468
469 h2 = FrameHeader(b"APIC", (2, 4, 0))
470 assert (h2.tag_alter == 0)
471 assert (h2.grouped == 0)
472 h2.copyFlags(h1)
473 assert (h2.tag_alter)
474 assert (h2.grouped)
475 assert (h2.file_alter)
476 assert not(h2.encrypted)
477 assert (h2.compressed)
478 assert not(h2.data_length_indicator)
479 assert (h2.read_only)
480 assert (h2.unsync)
481
482 def testValidFrameId(self):
483 for id in [b"", b"a", b"tx", b"tit", b"TIT", b"Tit2", b"aPic"]:
484 assert not(FrameHeader._isValidFrameId(id))
485 for id in [b"TIT2", b"APIC", b"1234"]:
486 assert FrameHeader._isValidFrameId(id)
487
488 def testRenderWithUnsyncTrue(self):
489 h = FrameHeader(b"TIT2", ID3_DEFAULT_VERSION)
490 h.unsync = True
491 with pytest.raises(NotImplementedError):
492 h.render(100)
+0
-172
src/test/id3/test_id3.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2012 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 import unittest
19 import pytest
20 from eyed3.id3 import *
21 from eyed3.compat import unicode
22
23 class GenreTests(unittest.TestCase):
24 def testEmptyGenre(self):
25 g = Genre()
26 assert g.id is None
27 assert g.name is None
28
29 def testValidGenres(self):
30 # Create with id
31 for i in range(genres.GENRE_MAX):
32 g = Genre()
33 g.id = i
34 assert (g.id == i)
35 assert (g.name == genres[i])
36
37 g = Genre(id=i)
38 assert (g.id == i)
39 assert (g.name == genres[i])
40
41 # Create with name
42 for name in [n for n in genres if n is not None and type(n) is not int]:
43 g = Genre()
44 g.name = name
45 assert (g.id == genres[name])
46 assert (g.name == genres[g.id])
47 assert (g.name.lower() == name)
48
49 g = Genre(name=name)
50 assert (g.id == genres[name])
51 assert (g.name.lower() == name)
52
53 def test255Padding(self):
54 for i in range(GenreMap.GENRE_MAX + 1, 256):
55 assert genres[i] is None
56 with pytest.raises(KeyError):
57 genres.__getitem__(256)
58
59
60 def testCustomGenres(self):
61 # Genres can be created for any name, their ID is None
62 g = Genre(name=u"Grindcore")
63 assert g.name == u"Grindcore"
64 assert g.id is None
65
66 # But when constructing with IDs they must map.
67 with pytest.raises(ValueError):
68 Genre.__call__(id=1024)
69
70 def testRemappedNames(self):
71 g = Genre(id=3, name=u"dance stuff")
72 assert (g.id == 3)
73 assert (g.name == u"Dance")
74
75 g = Genre(id=666, name=u"Funky")
76 assert (g.id is None)
77 assert (g.name == u"Funky")
78
79
80 def testGenreEq(self):
81 for s in [u"Hardcore", u"(129)Hardcore",
82 u"(129)", u"(0129)",
83 u"129", u"0129"]:
84 assert Genre.parse(s) == Genre.parse(s)
85 assert Genre.parse(s) != Genre.parse(u"Blues")
86
87 def testParseGenre(self):
88 test_list = [u"Hardcore", u"(129)Hardcore",
89 u"(129)", u"(0129)",
90 u"129", u"0129"]
91
92 # This is typically what will happen when parsing tags, a blob of text
93 # is parsed into Genre
94 for s in test_list:
95 g = Genre.parse(s)
96 assert g.name == u"Hardcore"
97 assert g.id == 129
98
99 g = Genre.parse(u"")
100 assert g is None
101
102 g = Genre.parse(u"1")
103 assert (g.id == 1)
104 assert(g.name == u"Classic Rock")
105
106 def testUnicode(self):
107 assert (unicode(Genre(u"Hardcore")) == u"(129)Hardcore")
108 assert (unicode(Genre(u"Grindcore")) == u"Grindcore")
109
110
111 class VersionTests(unittest.TestCase):
112 def setUp(self):
113 self.id3_versions = [(ID3_V1, (1, None, None), "v1.x"),
114 (ID3_V1_0, (1, 0, 0), "v1.0"),
115 (ID3_V1_1, (1, 1, 0), "v1.1"),
116 (ID3_V2, (2, None, None), "v2.x"),
117 (ID3_V2_2, (2, 2, 0), "v2.2"),
118 (ID3_V2_3, (2, 3, 0), "v2.3"),
119 (ID3_V2_4, (2, 4, 0), "v2.4"),
120 (ID3_DEFAULT_VERSION, (2, 4, 0), "v2.4"),
121 (ID3_ANY_VERSION, (1|2, None, None), "v1.x/v2.x"),
122 ]
123
124 def testId3Versions(self):
125 for v in [ID3_V1, ID3_V1_0, ID3_V1_1]:
126 assert (v[0] == 1)
127
128 assert (ID3_V1_0[1] == 0)
129 assert (ID3_V1_0[2] == 0)
130 assert (ID3_V1_1[1] == 1)
131 assert (ID3_V1_1[2] == 0)
132
133 for v in [ID3_V2, ID3_V2_2, ID3_V2_3, ID3_V2_4]:
134 assert (v[0] == 2)
135
136 assert (ID3_V2_2[1] == 2)
137 assert (ID3_V2_3[1] == 3)
138 assert (ID3_V2_4[1] == 4)
139
140 assert (ID3_ANY_VERSION == (ID3_V1[0] | ID3_V2[0], None, None))
141 assert (ID3_DEFAULT_VERSION == ID3_V2_4)
142
143 def test_versionToString(self):
144 for const, tple, string in self.id3_versions:
145 assert versionToString(const) == string
146
147 with pytest.raises(TypeError):
148 versionToString(666)
149 with pytest.raises(ValueError):
150 versionToString((3,1,0))
151
152 def test_isValidVersion(self):
153 for v, _, _ in self.id3_versions:
154 assert isValidVersion(v)
155
156 for _, v, _ in self.id3_versions:
157 if None in v:
158 assert not isValidVersion(v, True)
159 else:
160 assert isValidVersion(v, True)
161
162 assert not isValidVersion((3, 1, 1))
163
164 def testNormalizeVersion(self):
165 assert (normalizeVersion(ID3_V1) == ID3_V1_1)
166 assert (normalizeVersion(ID3_V2) == ID3_V2_4)
167 assert (normalizeVersion(ID3_DEFAULT_VERSION) == ID3_V2_4)
168 assert (normalizeVersion(ID3_ANY_VERSION) == ID3_DEFAULT_VERSION)
169
170 # Correcting the bogus
171 assert (normalizeVersion((2, 2, 1)) == ID3_V2_2)
+0
-1247
src/test/id3/test_tag.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2011-2012 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 import os
19 import pytest
20 import unittest
21 import eyed3
22 from eyed3.core import Date
23 from eyed3.id3 import frames
24 from eyed3.mp3 import Mp3AudioFile
25 from eyed3.compat import unicode, BytesType
26 from eyed3.id3 import Tag, ID3_DEFAULT_VERSION, ID3_V2_3, ID3_V2_4
27 from ..compat import *
28 from .. import DATA_D
29
30
31 def testTagImport():
32 import eyed3.id3.tag
33 assert eyed3.id3.Tag == eyed3.id3.tag.Tag
34
35
36 def testTagConstructor():
37 t = Tag()
38 assert t.file_info is None
39 assert t.header is not None
40 assert t.extended_header is not None
41 assert t.frame_set is not None
42 assert len(t.frame_set) == 0
43
44
45 def testFileInfoConstructor():
46 from eyed3.id3.tag import FileInfo
47
48 # Both bytes and unicode input file names must be accepted and the former
49 # must be converted to unicode.
50 for name in [__file__, unicode(__file__)]:
51 fi = FileInfo(name)
52 assert type(fi.name) is unicode
53 assert name == unicode(name)
54 assert fi.tag_size == 0
55
56 # FIXME Passing invalid unicode
57
58
59 def testTagMainProps():
60 tag = Tag()
61
62 # No version yet
63 assert tag.version == ID3_DEFAULT_VERSION
64 assert not(tag.isV1())
65 assert tag.isV2()
66
67 assert tag.artist is None
68 tag.artist = u"Autolux"
69 assert tag.artist == u"Autolux"
70 assert len(tag.frame_set) == 1
71
72 tag.artist = u""
73 assert len(tag.frame_set) == 0
74 tag.artist = u"Autolux"
75
76 assert tag.album is None
77 tag.album = u"Future Perfect"
78 assert tag.album == u"Future Perfect"
79
80 assert tag.album_artist is None
81 tag.album_artist = u"Various Artists"
82 assert (tag.album_artist == u"Various Artists")
83
84 assert (tag.title is None)
85 tag.title = u"Robots in the Garden"
86 assert (tag.title == u"Robots in the Garden")
87
88 assert (tag.track_num == (None, None))
89 tag.track_num = 7
90 assert (tag.track_num == (7, None))
91 tag.track_num = (7, None)
92 assert (tag.track_num == (7, None))
93 tag.track_num = (7, 15)
94 assert (tag.frame_set[frames.TRACKNUM_FID][0].text == "07/15")
95 assert (tag.track_num == (7, 15))
96 tag.track_num = (7, 150)
97 assert (tag.frame_set[frames.TRACKNUM_FID][0].text == "007/150")
98 assert (tag.track_num == (7, 150))
99 tag.track_num = (1, 7)
100 assert (tag.frame_set[frames.TRACKNUM_FID][0].text == "01/07")
101 assert (tag.track_num == (1, 7))
102 tag.track_num = None
103 assert (tag.track_num == (None, None))
104 tag.track_num = None, None
105
106
107 def testTagDates():
108 tag = Tag()
109 tag.release_date = 2004
110 assert tag.release_date == Date(2004)
111
112 tag.release_date = None
113 assert tag.release_date is None
114
115 tag = Tag()
116 for date in [Date(2002), Date(2002, 11, 26), Date(2002, 11, 26),
117 Date(2002, 11, 26, 4), Date(2002, 11, 26, 4, 20),
118 Date(2002, 11, 26, 4, 20), Date(2002, 11, 26, 4, 20, 10)]:
119
120 tag.encoding_date = date
121 assert (tag.encoding_date == date)
122 tag.encoding_date = str(date)
123 assert (tag.encoding_date == date)
124
125 tag.release_date = date
126 assert (tag.release_date == date)
127 tag.release_date = str(date)
128 assert (tag.release_date == date)
129
130 tag.original_release_date = date
131 assert (tag.original_release_date == date)
132 tag.original_release_date = str(date)
133 assert (tag.original_release_date == date)
134
135 tag.recording_date = date
136 assert (tag.recording_date == date)
137 tag.recording_date = str(date)
138 assert (tag.recording_date == date)
139
140 tag.tagging_date = date
141 assert (tag.tagging_date == date)
142 tag.tagging_date = str(date)
143 assert (tag.tagging_date == date)
144
145
146 try:
147 tag._setDate(2.4)
148 except TypeError:
149 pass # expected
150 else:
151 assert not("Invalid date type, expected TypeError")
152
153
154 def testTagComments():
155 tag = Tag()
156 for c in tag.comments:
157 assert not("Expected not to be here")
158
159 # Adds
160 with pytest.raises(TypeError):
161 tag.comments.set(b"bold")
162 with pytest.raises(TypeError):
163 tag.comments.set(u"bold", b"search")
164
165 tag.comments.set(u"Always Try", u"")
166 assert (len(tag.comments) == 1)
167 c = tag.comments[0]
168 assert (c.description == u"")
169 assert (c.text == u"Always Try")
170 assert (c.lang == b"eng")
171
172 tag.comments.set(u"Speak Out", u"Bold")
173 assert (len(tag.comments) == 2)
174 c = tag.comments[1]
175 assert (c.description == u"Bold")
176 assert (c.text == u"Speak Out")
177 assert (c.lang == b"eng")
178
179 tag.comments.set(u"K Town Mosh Crew", u"Crippled Youth", b"sxe")
180 assert (len(tag.comments) == 3)
181 c = tag.comments[2]
182 assert (c.description == u"Crippled Youth")
183 assert (c.text == u"K Town Mosh Crew")
184 assert (c.lang == b"sxe")
185
186 # Lang is different, new frame
187 tag.comments.set(u"K Town Mosh Crew", u"Crippled Youth", b"eng")
188 assert (len(tag.comments) == 4)
189 c = tag.comments[3]
190 assert (c.description == u"Crippled Youth")
191 assert (c.text == u"K Town Mosh Crew")
192 assert (c.lang == b"eng")
193
194 # Gets
195 assert (tag.comments.get(u"", "fre") is None)
196 assert (tag.comments.get(u"Crippled Youth", b"esp") is None)
197
198 c = tag.comments.get(u"")
199 assert c
200 assert (c.description == u"")
201 assert (c.text == u"Always Try")
202 assert (c.lang == b"eng")
203
204 assert tag.comments.get(u"Bold") is not None
205 assert tag.comments.get(u"Bold", b"eng") is not None
206 assert tag.comments.get(u"Crippled Youth", b"eng") is not None
207 assert tag.comments.get(u"Crippled Youth", b"sxe") is not None
208
209 assert (len(tag.comments) == 4)
210
211 # Iterate
212 count = 0
213 for c in tag.comments:
214 count += 1
215 assert count == 4
216
217 # Index access
218 assert tag.comments[0]
219 assert tag.comments[1]
220 assert tag.comments[2]
221 assert tag.comments[3]
222
223 try:
224 c = tag.comments[4]
225 except IndexError:
226 pass # expected
227 else:
228 assert not("Expected IndexError, but got success")
229
230 # Removal
231 with pytest.raises(TypeError):
232 tag.comments.remove(b"not unicode")
233 assert (tag.comments.remove(u"foobazz") is None)
234
235 c = tag.comments.get(u"Bold")
236 assert c is not None
237 c2 = tag.comments.remove(u"Bold")
238 assert (c == c2)
239 assert (len(tag.comments) == 3)
240
241 c = tag.comments.get(u"Crippled Youth", b"eng")
242 assert c is not None
243 c2 = tag.comments.remove(u"Crippled Youth", b"eng")
244 assert (c == c2)
245 assert (len(tag.comments) == 2)
246
247 assert (tag.comments.remove(u"Crippled Youth", b"eng") is None)
248 assert (len(tag.comments) == 2)
249
250 assert (tag.comments.get(u"") == tag.comments.remove(u""))
251 assert (len(tag.comments) == 1)
252
253 assert (tag.comments.get(u"Crippled Youth", b"sxe") ==
254 tag.comments.remove(u"Crippled Youth", b"sxe"))
255 assert (len(tag.comments) == 0)
256
257 # Index Error when there are no comments
258 try:
259 c = tag.comments[0]
260 except IndexError:
261 pass # expected
262 else:
263 assert not("Expected IndexError, but got success")
264
265 # Replacing frames thru add and frame object preservation
266 tag = Tag()
267 c1 = tag.comments.set(u"Snoop", u"Dog", b"rap")
268 assert tag.comments.get(u"Dog", b"rap").text == u"Snoop"
269 c1.text = u"Lollipop"
270 assert tag.comments.get(u"Dog", b"rap").text == u"Lollipop"
271 # now thru add
272 c2 = tag.comments.set(u"Doggy", u"Dog", b"rap")
273 assert id(c1) == id(c2)
274 assert tag.comments.get(u"Dog", b"rap").text == u"Doggy"
275
276
277 def testTagBPM():
278 tag = Tag()
279 assert (tag.bpm is None)
280
281 tag.bpm = 150
282 assert (tag.bpm == 150)
283 assert (tag.frame_set[b"TBPM"])
284
285 tag.bpm = 180
286 assert (tag.bpm == 180)
287 assert (tag.frame_set[b"TBPM"])
288 assert (len(tag.frame_set[b"TBPM"]) == 1)
289
290 tag.bpm = 190.5
291 assert type(tag.bpm) is int
292 assert tag.bpm == 191
293 assert len(tag.frame_set[b"TBPM"]) == 1
294
295
296 def testTagPlayCount():
297 tag = Tag()
298 assert (tag.play_count is None)
299
300 tag.play_count = 0
301 assert tag.play_count == 0
302 tag.play_count = 1
303 assert tag.play_count == 1
304 tag.play_count += 1
305 assert tag.play_count == 2
306 tag.play_count -= 1
307 assert tag.play_count == 1
308 tag.play_count *= 5
309 assert tag.play_count == 5
310
311 tag.play_count = None
312 assert tag.play_count is None
313
314 try:
315 tag.play_count = -1
316 except ValueError:
317 pass # expected
318 else:
319 assert not("Invalid play count, expected ValueError")
320
321
322 def testTagPublisher():
323 t = Tag()
324 assert (t.publisher is None)
325
326 try:
327 t.publisher = b"not unicode"
328 except TypeError:
329 pass #expected
330 else:
331 assert not("Expected TypeError when setting non-unicode publisher")
332
333 t.publisher = u"Dischord"
334 assert t.publisher == u"Dischord"
335 t.publisher = u"Infinity Cat"
336 assert t.publisher == u"Infinity Cat"
337
338 t.publisher = None
339 assert t.publisher is None
340
341
342 def testTagCdId():
343 tag = Tag()
344 assert tag.cd_id is None
345
346 tag.cd_id = b"\x01\x02"
347 assert tag.cd_id == b"\x01\x02"
348
349 tag.cd_id = b"\xff" * 804
350 assert tag.cd_id == b"\xff" * 804
351
352 try:
353 tag.cd_id = b"\x00" * 805
354 except ValueError:
355 pass # expected
356 else:
357 assert not("CD id is too long, expected ValueError")
358
359
360 def testTagImages():
361 from eyed3.id3.frames import ImageFrame
362
363 tag = Tag()
364
365 # No images
366 assert len(tag.images) == 0
367 for i in tag.images:
368 assert not("Expected no images")
369 try:
370 img = tag.images[0]
371 except IndexError:
372 pass #expected
373 else:
374 assert not("Expected IndexError for no images")
375 assert (tag.images.get(u"") is None)
376
377 # Image types must be within range
378 for i in range(ImageFrame.MIN_TYPE, ImageFrame.MAX_TYPE):
379 tag.images.set(i, b"\xff", b"img")
380 for i in (ImageFrame.MIN_TYPE - 1, ImageFrame.MAX_TYPE + 1):
381 try:
382 tag.images.set(i, b"\xff", b"img")
383 except ValueError:
384 pass # expected
385 else:
386 assert not("Expected ValueError for invalid picture type")
387
388 tag = Tag()
389 tag.images.set(ImageFrame.FRONT_COVER, b"\xab\xcd", b"img/gif")
390 assert (len(tag.images) == 1)
391 assert (tag.images[0].description == u"")
392 assert (tag.images[0].picture_type == ImageFrame.FRONT_COVER)
393 assert (tag.images[0].image_data == b"\xab\xcd")
394 assert (tag.images[0].mime_type == "img/gif")
395 assert (tag.images[0]._mime_type == b"img/gif")
396 assert (tag.images[0].image_url is None)
397
398 assert (tag.images.get(u"").description == u"")
399 assert (tag.images.get(u"").picture_type == ImageFrame.FRONT_COVER)
400 assert (tag.images.get(u"").image_data == b"\xab\xcd")
401 assert (tag.images.get(u"").mime_type == "img/gif")
402 assert (tag.images.get(u"")._mime_type == b"img/gif")
403 assert (tag.images.get(u"").image_url is None)
404
405 tag.images.set(ImageFrame.FRONT_COVER, b"\xdc\xba", b"img/gif",
406 u"Different")
407 assert len(tag.images) == 2
408 assert tag.images[1].description == u"Different"
409 assert tag.images[1].picture_type == ImageFrame.FRONT_COVER
410 assert tag.images[1].image_data == b"\xdc\xba"
411 assert tag.images[1].mime_type == "img/gif"
412 assert tag.images[1]._mime_type == b"img/gif"
413 assert tag.images[1].image_url is None
414
415 assert (tag.images.get(u"Different").description == u"Different")
416 assert (tag.images.get(u"Different").picture_type == ImageFrame.FRONT_COVER)
417 assert (tag.images.get(u"Different").image_data == b"\xdc\xba")
418 assert (tag.images.get(u"Different").mime_type == "img/gif")
419 assert (tag.images.get(u"Different")._mime_type == b"img/gif")
420 assert (tag.images.get(u"Different").image_url is None)
421
422 # This is an update (same description)
423 tag.images.set(ImageFrame.BACK_COVER, b"\xff\xef", b"img/jpg", u"Different")
424 assert (len(tag.images) == 2)
425 assert (tag.images[1].description == u"Different")
426 assert (tag.images[1].picture_type == ImageFrame.BACK_COVER)
427 assert (tag.images[1].image_data == b"\xff\xef")
428 assert (tag.images[1].mime_type == "img/jpg")
429 assert (tag.images[1].image_url is None)
430
431 assert (tag.images.get(u"Different").description == u"Different")
432 assert (tag.images.get(u"Different").picture_type == ImageFrame.BACK_COVER)
433 assert (tag.images.get(u"Different").image_data == b"\xff\xef")
434 assert (tag.images.get(u"Different").mime_type == "img/jpg")
435 assert (tag.images.get(u"Different").image_url is None)
436
437 count = 0
438 for img in tag.images:
439 count += 1
440 assert count == 2
441
442 # Remove
443 img = tag.images.remove(u"")
444 assert (img.description == u"")
445 assert (img.picture_type == ImageFrame.FRONT_COVER)
446 assert (img.image_data == b"\xab\xcd")
447 assert (img.mime_type == "img/gif")
448 assert (img.image_url is None)
449 assert (len(tag.images) == 1)
450
451 img = tag.images.remove(u"Different")
452 assert img.description == u"Different"
453 assert img.picture_type == ImageFrame.BACK_COVER
454 assert img.image_data == b"\xff\xef"
455 assert img.mime_type == "img/jpg"
456 assert img.image_url is None
457 assert len(tag.images) == 0
458
459 assert (tag.images.remove(u"Lundqvist") is None)
460
461 # Unicode enforcement
462 with pytest.raises(TypeError):
463 tag.images.get(b"not Unicode")
464 with pytest.raises(TypeError):
465 tag.images.set(ImageFrame.ICON, "\xff", "img", b"not Unicode")
466 with pytest.raises(TypeError):
467 tag.images.remove(b"not Unicode")
468
469 # Image URL
470 tag = Tag()
471 tag.images.set(ImageFrame.BACK_COVER, None, None, u"A URL",
472 img_url=b"http://www.tumblr.com/tagged/ty-segall")
473 img = tag.images.get(u"A URL")
474 assert img is not None
475 assert (img.image_data is None)
476 assert (img.image_url == b"http://www.tumblr.com/tagged/ty-segall")
477 assert (img.mime_type == "-->")
478 assert (img._mime_type == b"-->")
479
480 # Unicode mime-type in, coverted to bytes
481 tag = Tag()
482 tag.images.set(ImageFrame.BACK_COVER, b"\x00", u"img/jpg")
483 img = tag.images[0]
484 assert isinstance(img._mime_type, BytesType)
485 img.mime_type = u""
486 assert isinstance(img._mime_type, BytesType)
487 img.mime_type = None
488 assert isinstance(img._mime_type, BytesType)
489 assert img.mime_type == ""
490
491
492 def testTagLyrics():
493 tag = Tag()
494 for c in tag.lyrics:
495 assert not("Expected not to be here")
496
497 # Adds
498 with pytest.raises(TypeError):
499 tag.lyrics.set(b"bold")
500 with pytest.raises(TypeError):
501 tag.lyrics.set(u"bold", b"search")
502
503 tag.lyrics.set(u"Always Try", u"")
504 assert (len(tag.lyrics) == 1)
505 c = tag.lyrics[0]
506 assert (c.description == u"")
507 assert (c.text == u"Always Try")
508 assert (c.lang == b"eng")
509
510 tag.lyrics.set(u"Speak Out", u"Bold")
511 assert (len(tag.lyrics) == 2)
512 c = tag.lyrics[1]
513 assert (c.description == u"Bold")
514 assert (c.text == u"Speak Out")
515 assert (c.lang == b"eng")
516
517 tag.lyrics.set(u"K Town Mosh Crew", u"Crippled Youth", b"sxe")
518 assert (len(tag.lyrics) == 3)
519 c = tag.lyrics[2]
520 assert (c.description == u"Crippled Youth")
521 assert (c.text == u"K Town Mosh Crew")
522 assert (c.lang == b"sxe")
523
524 # Lang is different, new frame
525 tag.lyrics.set(u"K Town Mosh Crew", u"Crippled Youth", b"eng")
526 assert (len(tag.lyrics) == 4)
527 c = tag.lyrics[3]
528 assert (c.description == u"Crippled Youth")
529 assert (c.text == u"K Town Mosh Crew")
530 assert (c.lang == b"eng")
531
532 # Gets
533 assert (tag.lyrics.get(u"", b"fre") is None)
534 assert (tag.lyrics.get(u"Crippled Youth", b"esp") is None)
535
536 c = tag.lyrics.get(u"")
537 assert (c)
538 assert (c.description == u"")
539 assert (c.text == u"Always Try")
540 assert (c.lang == b"eng")
541
542 assert tag.lyrics.get(u"Bold") is not None
543 assert tag.lyrics.get(u"Bold", b"eng") is not None
544 assert tag.lyrics.get(u"Crippled Youth", b"eng") is not None
545 assert tag.lyrics.get(u"Crippled Youth", b"sxe") is not None
546
547 assert (len(tag.lyrics) == 4)
548
549 # Iterate
550 count = 0
551 for c in tag.lyrics:
552 count += 1
553 assert (count == 4)
554
555 # Index access
556 assert (tag.lyrics[0])
557 assert (tag.lyrics[1])
558 assert (tag.lyrics[2])
559 assert (tag.lyrics[3])
560
561 try:
562 c = tag.lyrics[4]
563 except IndexError:
564 pass # expected
565 else:
566 assert not("Expected IndexError, but got success")
567
568 # Removal
569 with pytest.raises(TypeError):
570 tag.lyrics.remove(b"not unicode")
571 assert tag.lyrics.remove(u"foobazz") is None
572
573 c = tag.lyrics.get(u"Bold")
574 assert c is not None
575 c2 = tag.lyrics.remove(u"Bold")
576 assert c == c2
577 assert len(tag.lyrics) == 3
578
579 c = tag.lyrics.get(u"Crippled Youth", b"eng")
580 assert c is not None
581 c2 = tag.lyrics.remove(u"Crippled Youth", b"eng")
582 assert c == c2
583 assert len(tag.lyrics) == 2
584
585 assert tag.lyrics.remove(u"Crippled Youth", b"eng") is None
586 assert len(tag.lyrics) == 2
587
588 assert tag.lyrics.get(u"") == tag.lyrics.remove(u"")
589 assert len(tag.lyrics) == 1
590
591 assert (tag.lyrics.get(u"Crippled Youth", b"sxe") ==
592 tag.lyrics.remove(u"Crippled Youth", b"sxe"))
593 assert len(tag.lyrics) == 0
594
595 # Index Error when there are no lyrics
596 try:
597 c = tag.lyrics[0]
598 except IndexError:
599 pass # expected
600 else:
601 assert not("Expected IndexError, but got success")
602
603
604 def testTagObjects():
605 tag = Tag()
606
607 # No objects
608 assert len(tag.objects) == 0
609 for i in tag.objects:
610 assert not("Expected no objects")
611 try:
612 img = tag.objects[0]
613 except IndexError:
614 pass #expected
615 else:
616 assert not("Expected IndexError for no objects")
617 assert (tag.objects.get(u"") is None)
618
619 tag = Tag()
620 tag.objects.set(b"\xab\xcd", b"img/gif")
621 assert (len(tag.objects) == 1)
622 assert (tag.objects[0].description == u"")
623 assert (tag.objects[0].filename == u"")
624 assert (tag.objects[0].object_data == b"\xab\xcd")
625 assert (tag.objects[0]._mime_type == b"img/gif")
626 assert (tag.objects[0].mime_type == "img/gif")
627
628 assert (tag.objects.get(u"").description == u"")
629 assert (tag.objects.get(u"").filename == u"")
630 assert (tag.objects.get(u"").object_data == b"\xab\xcd")
631 assert (tag.objects.get(u"").mime_type == "img/gif")
632
633 tag.objects.set(b"\xdc\xba", b"img/gif", u"Different")
634 assert (len(tag.objects) == 2)
635 assert (tag.objects[1].description == u"Different")
636 assert (tag.objects[1].filename == u"")
637 assert (tag.objects[1].object_data == b"\xdc\xba")
638 assert (tag.objects[1]._mime_type == b"img/gif")
639 assert (tag.objects[1].mime_type == "img/gif")
640
641 assert (tag.objects.get(u"Different").description == u"Different")
642 assert (tag.objects.get(u"Different").filename == u"")
643 assert (tag.objects.get(u"Different").object_data == b"\xdc\xba")
644 assert (tag.objects.get(u"Different").mime_type == "img/gif")
645 assert (tag.objects.get(u"Different")._mime_type == b"img/gif")
646
647 # This is an update (same description)
648 tag.objects.set(b"\xff\xef", b"img/jpg", u"Different",
649 u"example_filename.XXX")
650 assert (len(tag.objects) == 2)
651 assert (tag.objects[1].description == u"Different")
652 assert (tag.objects[1].filename == u"example_filename.XXX")
653 assert (tag.objects[1].object_data == b"\xff\xef")
654 assert (tag.objects[1].mime_type == "img/jpg")
655
656 assert (tag.objects.get(u"Different").description == u"Different")
657 assert (tag.objects.get(u"Different").filename == u"example_filename.XXX")
658 assert (tag.objects.get(u"Different").object_data == b"\xff\xef")
659 assert (tag.objects.get(u"Different").mime_type == "img/jpg")
660
661 count = 0
662 for obj in tag.objects:
663 count += 1
664 assert (count == 2)
665
666 # Remove
667 obj = tag.objects.remove(u"")
668 assert (obj.description == u"")
669 assert (obj.filename == u"")
670 assert (obj.object_data == b"\xab\xcd")
671 assert (obj.mime_type == "img/gif")
672 assert (len(tag.objects) == 1)
673
674 obj = tag.objects.remove(u"Different")
675 assert (obj.description == u"Different")
676 assert (obj.filename == u"example_filename.XXX")
677 assert (obj.object_data == b"\xff\xef")
678 assert (obj.mime_type == "img/jpg")
679 assert (obj._mime_type == b"img/jpg")
680 assert (len(tag.objects) == 0)
681
682 assert (tag.objects.remove(u"Dubinsky") is None)
683
684 # Unicode enforcement
685 with pytest.raises(TypeError):
686 tag.objects.get(b"not Unicode")
687 with pytest.raises(TypeError):
688 tag.objects.set("\xff", "img", b"not Unicode")
689 with pytest.raises(TypeError):
690 tag.objects.set("\xff", "img", u"Unicode", b"not unicode")
691 with pytest.raises(TypeError):
692 tag.objects.remove(b"not Unicode")
693
694
695 def testTagPrivates():
696 tag = Tag()
697
698 # No private frames
699 assert len(tag.privates) == 0
700 for i in tag.privates:
701 assert not("Expected no privates")
702 try:
703 img = tag.privates[0]
704 except IndexError:
705 pass #expected
706 else:
707 assert not("Expected IndexError for no privates")
708 assert (tag.privates.get(b"") is None)
709
710 tag = Tag()
711 tag.privates.set(b"\xab\xcd", b"owner1")
712 assert (len(tag.privates) == 1)
713 assert (tag.privates[0].owner_id == b"owner1")
714 assert (tag.privates[0].owner_data == b"\xab\xcd")
715
716 assert (tag.privates.get(b"owner1").owner_id == b"owner1")
717 assert (tag.privates.get(b"owner1").owner_data == b"\xab\xcd")
718
719 tag.privates.set(b"\xba\xdc", b"owner2")
720 assert (len(tag.privates) == 2)
721 assert (tag.privates[1].owner_id == b"owner2")
722 assert (tag.privates[1].owner_data == b"\xba\xdc")
723
724 assert (tag.privates.get(b"owner2").owner_id == b"owner2")
725 assert (tag.privates.get(b"owner2").owner_data == b"\xba\xdc")
726
727
728 # This is an update (same description)
729 tag.privates.set(b"\x00\x00\x00", b"owner1")
730 assert (len(tag.privates) == 2)
731 assert (tag.privates[0].owner_id == b"owner1")
732 assert (tag.privates[0].owner_data == b"\x00\x00\x00")
733
734 assert (tag.privates.get(b"owner1").owner_id == b"owner1")
735 assert (tag.privates.get(b"owner1").owner_data == b"\x00\x00\x00")
736
737 count = 0
738 for f in tag.privates:
739 count += 1
740 assert (count == 2)
741
742 # Remove
743 priv = tag.privates.remove(b"owner1")
744 assert (priv.owner_id == b"owner1")
745 assert (priv.owner_data == b"\x00\x00\x00")
746 assert (len(tag.privates) == 1)
747
748 priv = tag.privates.remove(b"owner2")
749 assert (priv.owner_id == b"owner2")
750 assert (priv.owner_data == b"\xba\xdc")
751 assert (len(tag.privates) == 0)
752
753 assert tag.objects.remove(u"Callahan") is None
754
755
756 def testTagDiscNum():
757 tag = Tag()
758
759 assert (tag.disc_num == (None, None))
760 tag.disc_num = 7
761 assert (tag.disc_num == (7, None))
762 tag.disc_num = (7, None)
763 assert (tag.disc_num == (7, None))
764 tag.disc_num = (7, 15)
765 assert (tag.frame_set[frames.DISCNUM_FID][0].text == "07/15")
766 assert (tag.disc_num == (7, 15))
767 tag.disc_num = (7, 150)
768 assert (tag.frame_set[frames.DISCNUM_FID][0].text == "007/150")
769 assert (tag.disc_num == (7, 150))
770 tag.disc_num = (1, 7)
771 assert (tag.frame_set[frames.DISCNUM_FID][0].text == "01/07")
772 assert (tag.disc_num == (1, 7))
773 tag.disc_num = None
774 assert (tag.disc_num == (None, None))
775 tag.disc_num = None, None
776
777
778 def testTagGenre():
779 from eyed3.id3 import Genre
780
781 tag = Tag()
782
783 assert (tag.genre is None)
784
785 try:
786 tag.genre = b"Not Unicode"
787 except TypeError:
788 pass # expected
789 else:
790 assert not("Non unicode genre, expected TypeError")
791
792 gobj = Genre(u"Hardcore")
793
794 tag.genre = u"Hardcore"
795 assert (tag.genre.name == u"Hardcore")
796 assert (tag.genre == gobj)
797
798 tag.genre = 130
799 assert tag.genre.id == 130
800 assert tag.genre.name == u"Terror"
801
802 tag.genre = 0
803 assert tag.genre.id == 0
804 assert tag.genre.name == u"Blues"
805
806 tag.genre = None
807 assert tag.genre is None
808 assert tag.frame_set[b"TCON"] is None
809
810
811 def testTagUserTextFrames():
812 tag = Tag()
813
814 assert (len(tag.user_text_frames) == 0)
815 utf1 = tag.user_text_frames.set(u"Custom content")
816 assert (tag.user_text_frames.get(u"").text == u"Custom content")
817
818 utf2 = tag.user_text_frames.set(u"Content custom", u"Desc1")
819 assert (tag.user_text_frames.get(u"Desc1").text == u"Content custom")
820
821 assert (len(tag.user_text_frames) == 2)
822
823 utf3 = tag.user_text_frames.set(u"New content", u"")
824 assert (tag.user_text_frames.get(u"").text == u"New content")
825 assert (len(tag.user_text_frames) == 2)
826 assert (id(utf1) == id(utf3))
827
828 assert (tag.user_text_frames[0] == utf1)
829 assert (tag.user_text_frames[1] == utf2)
830 assert (tag.user_text_frames.get(u"") == utf1)
831 assert (tag.user_text_frames.get(u"Desc1") == utf2)
832
833 tag.user_text_frames.remove(u"")
834 assert (len(tag.user_text_frames) == 1)
835 tag.user_text_frames.remove(u"Desc1")
836 assert (len(tag.user_text_frames) == 0)
837
838 tag.user_text_frames.set(u"Foobazz", u"Desc2")
839 assert (len(tag.user_text_frames) == 1)
840
841
842 def testTagUrls():
843 tag = Tag()
844 url = "http://example.com/"
845 url2 = "http://sample.com/"
846
847 tag.commercial_url = url
848 assert (tag.commercial_url == url)
849 tag.commercial_url = url2
850 assert (tag.commercial_url == url2)
851 tag.commercial_url = None
852 assert (tag.commercial_url is None)
853
854 tag.copyright_url = url
855 assert (tag.copyright_url == url)
856 tag.copyright_url = url2
857 assert (tag.copyright_url == url2)
858 tag.copyright_url = None
859 assert (tag.copyright_url is None)
860
861 tag.audio_file_url = url
862 assert (tag.audio_file_url == url)
863 tag.audio_file_url = url2
864 assert (tag.audio_file_url == url2)
865 tag.audio_file_url = None
866 assert (tag.audio_file_url is None)
867
868 tag.audio_source_url = url
869 assert (tag.audio_source_url == url)
870 tag.audio_source_url = url2
871 assert (tag.audio_source_url == url2)
872 tag.audio_source_url = None
873 assert (tag.audio_source_url is None)
874
875 tag.artist_url = url
876 assert (tag.artist_url == url)
877 tag.artist_url = url2
878 assert (tag.artist_url == url2)
879 tag.artist_url = None
880 assert (tag.artist_url is None)
881
882 tag.internet_radio_url = url
883 assert (tag.internet_radio_url == url)
884 tag.internet_radio_url = url2
885 assert (tag.internet_radio_url == url2)
886 tag.internet_radio_url = None
887 assert (tag.internet_radio_url is None)
888
889 tag.payment_url = url
890 assert (tag.payment_url == url)
891 tag.payment_url = url2
892 assert (tag.payment_url == url2)
893 tag.payment_url = None
894 assert (tag.payment_url is None)
895
896 tag.publisher_url = url
897 assert (tag.publisher_url == url)
898 tag.publisher_url = url2
899 assert (tag.publisher_url == url2)
900 tag.publisher_url = None
901 assert (tag.publisher_url is None)
902
903 # Frame ID enforcement
904 with pytest.raises(ValueError):
905 tag._setUrlFrame("WDDD", "url")
906 with pytest.raises(ValueError):
907 tag._getUrlFrame("WDDD")
908
909
910 def testTagUniqIds():
911 tag = Tag()
912
913 assert (len(tag.unique_file_ids) == 0)
914
915 tag.unique_file_ids.set(b"http://music.com/12354", b"test")
916 tag.unique_file_ids.set(b"1234", b"http://eyed3.nicfit.net")
917 assert tag.unique_file_ids.get(b"test").uniq_id == b"http://music.com/12354"
918 assert (tag.unique_file_ids.get(b"http://eyed3.nicfit.net").uniq_id ==
919 b"1234")
920
921 assert len(tag.unique_file_ids) == 2
922 tag.unique_file_ids.remove(b"test")
923 assert len(tag.unique_file_ids) == 1
924
925 tag.unique_file_ids.set(b"4321", b"http://eyed3.nicfit.net")
926 assert len(tag.unique_file_ids) == 1
927 assert (tag.unique_file_ids.get(b"http://eyed3.nicfit.net").uniq_id ==
928 b"4321")
929
930
931 def testTagUserUrls():
932 tag = Tag()
933
934 assert (len(tag.user_url_frames) == 0)
935 uuf1 = tag.user_url_frames.set(b"http://yo.yo.com/")
936 assert (tag.user_url_frames.get(u"").url == b"http://yo.yo.com/")
937
938 utf2 = tag.user_url_frames.set(b"http://run.dmc.org", u"URL")
939 assert (tag.user_url_frames.get(u"URL").url == b"http://run.dmc.org")
940
941 assert len(tag.user_url_frames) == 2
942
943 utf3 = tag.user_url_frames.set(b"http://my.adidas.com", u"")
944 assert (tag.user_url_frames.get(u"").url == b"http://my.adidas.com")
945 assert (len(tag.user_url_frames) == 2)
946 assert (id(uuf1) == id(utf3))
947
948 assert (tag.user_url_frames[0] == uuf1)
949 assert (tag.user_url_frames[1] == utf2)
950 assert (tag.user_url_frames.get(u"") == uuf1)
951 assert (tag.user_url_frames.get(u"URL") == utf2)
952
953 tag.user_url_frames.remove(u"")
954 assert (len(tag.user_url_frames) == 1)
955 tag.user_url_frames.remove(u"URL")
956 assert (len(tag.user_url_frames) == 0)
957
958 tag.user_url_frames.set(b"Foobazz", u"Desc2")
959 assert (len(tag.user_url_frames) == 1)
960
961
962 def testSortOrderConversions():
963 test_file = "/tmp/soconvert.id3"
964
965 tag = Tag()
966 # 2.3 frames to 2.4
967 for fid in [b"XSOA", b"XSOP", b"XSOT"]:
968 frame = frames.TextFrame(fid)
969 frame.text = fid.decode("ascii")
970 tag.frame_set[fid] = frame
971 try:
972 tag.save(test_file) # v2.4 is the default
973 tag = eyed3.load(test_file).tag
974 assert (tag.version == ID3_V2_4)
975 assert (len(tag.frame_set) == 3)
976 del tag.frame_set[b"TSOA"]
977 del tag.frame_set[b"TSOP"]
978 del tag.frame_set[b"TSOT"]
979 assert (len(tag.frame_set) == 0)
980 finally:
981 os.remove(test_file)
982
983 tag = Tag()
984 # 2.4 frames to 2.3
985 for fid in [b"TSOA", b"TSOP", b"TSOT"]:
986 frame = frames.TextFrame(fid)
987 frame.text = unicode(fid)
988 tag.frame_set[fid] = frame
989 try:
990 tag.save(test_file, version=eyed3.id3.ID3_V2_3)
991 tag = eyed3.load(test_file).tag
992 assert (tag.version == ID3_V2_3)
993 assert (len(tag.frame_set) == 3)
994 del tag.frame_set[b"XSOA"]
995 del tag.frame_set[b"XSOP"]
996 del tag.frame_set[b"XSOT"]
997 assert (len(tag.frame_set) == 0)
998 finally:
999 os.remove(test_file)
1000
1001
1002 def test_XDOR_TDOR_Conversions():
1003 test_file = "/tmp/xdortdrc.id3"
1004
1005 tag = Tag()
1006 # 2.3 frames to 2.4
1007 frame = frames.DateFrame(b"XDOR", "1990-06-24")
1008 tag.frame_set[b"XDOR"] = frame
1009 try:
1010 tag.save(test_file) # v2.4 is the default
1011 tag = eyed3.load(test_file).tag
1012 assert tag.version == ID3_V2_4
1013 assert len(tag.frame_set) == 1
1014 del tag.frame_set[b"TDOR"]
1015 assert len(tag.frame_set) == 0
1016 finally:
1017 os.remove(test_file)
1018
1019 tag = Tag()
1020 # 2.4 frames to 2.3
1021 frame = frames.DateFrame(b"TDRC", "2012-10-21")
1022 tag.frame_set[frame.id] = frame
1023 try:
1024 tag.save(test_file, version=eyed3.id3.ID3_V2_3)
1025 tag = eyed3.load(test_file).tag
1026 assert tag.version == ID3_V2_3
1027 assert len(tag.frame_set) == 2
1028 del tag.frame_set[b"TYER"]
1029 del tag.frame_set[b"TDAT"]
1030 assert len(tag.frame_set) == 0
1031 finally:
1032 os.remove(test_file)
1033
1034
1035 def test_TSST_Conversions():
1036 test_file = "/tmp/tsst.id3"
1037
1038 tag = Tag()
1039 # 2.4 TSST to 2.3 TIT3
1040 tag.frame_set.setTextFrame(b"TSST", u"Subtitle")
1041 try:
1042 tag.save(test_file) # v2.4 is the default
1043 tag = eyed3.load(test_file).tag
1044 assert tag.version == ID3_V2_4
1045 assert len(tag.frame_set) == 1
1046 del tag.frame_set[b"TSST"]
1047 assert len(tag.frame_set) == 0
1048
1049 tag.frame_set.setTextFrame(b"TSST", u"Subtitle")
1050 tag.save(test_file, version=eyed3.id3.ID3_V2_3)
1051 tag = eyed3.load(test_file).tag
1052 assert b"TXXX" in tag.frame_set
1053 txxx = tag.frame_set[b"TXXX"][0]
1054 assert txxx.text == u"Subtitle"
1055 assert txxx.description == u"Subtitle (converted)"
1056
1057 finally:
1058 os.remove(test_file)
1059
1060
1061 @unittest.skipIf(not os.path.exists(DATA_D), "test requires data files")
1062 def testChapterExampleTag():
1063 tag = eyed3.load(os.path.join(DATA_D, "id3_chapters_example.mp3")).tag
1064
1065 assert len(tag.table_of_contents) == 1
1066 toc = list(tag.table_of_contents)[0]
1067
1068 assert id(toc) == id(tag.table_of_contents.get(toc.element_id))
1069
1070 assert toc.element_id == b"toc1"
1071 assert toc.description is None
1072 assert toc.toplevel
1073 assert toc.ordered
1074 assert toc.child_ids == [b'ch1', b'ch2', b'ch3']
1075
1076 assert tag.chapters.get(b"ch1").title == "start"
1077 assert tag.chapters.get(b"ch1").subtitle is None
1078 assert tag.chapters.get(b"ch1").user_url is None
1079 assert tag.chapters.get(b"ch1").times == (0, 5000)
1080 assert tag.chapters.get(b"ch1").offsets == (None, None)
1081 assert len(tag.chapters.get(b"ch1").sub_frames) == 1
1082
1083 assert tag.chapters.get(b"ch2").title == "5 seconds"
1084 assert tag.chapters.get(b"ch2").subtitle is None
1085 assert tag.chapters.get(b"ch2").user_url is None
1086 assert tag.chapters.get(b"ch2").times == (5000, 10000)
1087 assert tag.chapters.get(b"ch2").offsets == (None, None)
1088 assert len(tag.chapters.get(b"ch2").sub_frames) == 1
1089
1090 assert tag.chapters.get(b"ch3").title == "10 seconds"
1091 assert tag.chapters.get(b"ch3").subtitle is None
1092 assert tag.chapters.get(b"ch3").user_url is None
1093 assert tag.chapters.get(b"ch3").times == (10000, 15000)
1094 assert tag.chapters.get(b"ch3").offsets == (None, None)
1095 assert len(tag.chapters.get(b"ch3").sub_frames) == 1
1096
1097
1098 def testTableOfContents():
1099 test_file = "/tmp/toc.id3"
1100 t = Tag()
1101
1102 assert (len(t.table_of_contents) == 0)
1103
1104 toc_main = t.table_of_contents.set(b"main", toplevel=True,
1105 child_ids=[b"c1", b"c2", b"c3", b"c4"],
1106 description=u"Table of Conents")
1107 assert toc_main is not None
1108 assert (len(t.table_of_contents) == 1)
1109
1110 toc_dc = t.table_of_contents.set(b"director-cut", toplevel=False,
1111 ordered=False,
1112 child_ids=[b"d3", b"d1", b"d2"])
1113 assert toc_dc is not None
1114 assert (len(t.table_of_contents) == 2)
1115
1116 toc_dummy = t.table_of_contents.set(b"test")
1117 assert (len(t.table_of_contents) == 3)
1118 t.table_of_contents.remove(toc_dummy.element_id)
1119 assert (len(t.table_of_contents) == 2)
1120
1121 t.save(test_file)
1122 try:
1123 t2 = eyed3.load(test_file).tag
1124 finally:
1125 os.remove(test_file)
1126
1127 assert len(t.table_of_contents) == 2
1128
1129 assert t2.table_of_contents.get(b"main").toplevel
1130 assert t2.table_of_contents.get(b"main").ordered
1131 assert t2.table_of_contents.get(b"main").description == toc_main.description
1132 assert t2.table_of_contents.get(b"main").child_ids == toc_main.child_ids
1133
1134 assert (t2.table_of_contents.get(b"director-cut").toplevel ==
1135 toc_dc.toplevel)
1136 assert not t2.table_of_contents.get(b"director-cut").ordered
1137 assert (t2.table_of_contents.get(b"director-cut").description ==
1138 toc_dc.description)
1139 assert (t2.table_of_contents.get(b"director-cut").child_ids ==
1140 toc_dc.child_ids)
1141
1142
1143 def testChapters():
1144 test_file = "/tmp/chapters.id3"
1145 t = Tag()
1146
1147 ch1 = t.chapters.set(b"c1", (0, 200))
1148 ch2 = t.chapters.set(b"c2", (200, 300))
1149 ch3 = t.chapters.set(b"c3", (300, 375))
1150 ch4 = t.chapters.set(b"c4", (375, 600))
1151
1152 assert len(t.chapters) == 4
1153
1154 for i, c in enumerate(iter(t.chapters), 1):
1155 if i != 2:
1156 c.title = u"Chapter %d" % i
1157 c.subtitle = u"Subtitle %d" % i
1158 c.user_url = unicode("http://example.com/%d" % i).encode("ascii")
1159
1160 t.save(test_file)
1161
1162 try:
1163 t2 = eyed3.load(test_file).tag
1164 finally:
1165 os.remove(test_file)
1166
1167 assert len(t2.chapters) == 4
1168 for i in range(1, 5):
1169 c = t2.chapters.get(unicode("c%d" % i).encode("latin1"))
1170 if i == 2:
1171 assert c.title is None
1172 assert c.subtitle is None
1173 assert c.user_url is None
1174 else:
1175 assert c.title == u"Chapter %d" % i
1176 assert c.subtitle == u"Subtitle %d" % i
1177 assert (c.user_url ==
1178 unicode("http://example.com/%d" % i).encode("ascii"))
1179
1180
1181 def testReadOnly():
1182 assert not(Tag.read_only)
1183
1184 t = Tag()
1185 assert not(t.read_only)
1186
1187 t.read_only = True
1188 with pytest.raises(RuntimeError):
1189 t.save()
1190 with pytest.raises(RuntimeError):
1191 t._saveV1Tag(None)
1192 with pytest.raises(RuntimeError):
1193 t._saveV2Tag(None, None, None)
1194
1195
1196 def testIssue76(audiofile):
1197 """
1198 https://github.com/nicfit/eyeD3/issues/76
1199 """
1200 tag = audiofile.initTag(ID3_V2_4)
1201 tag.setTextFrame("TPE1", u"Confederacy of Ruined Lives")
1202 tag.setTextFrame("TPE2", u"Take as needed for pain")
1203 tag.setTextFrame("TSOP", u"In the name of suffering")
1204 tag.setTextFrame("TSO2", u"Dope sick")
1205 tag.save()
1206
1207 audiofile = eyed3.load(audiofile.path)
1208 tag = audiofile.tag
1209 assert (set(tag.frame_set.keys()) ==
1210 set([b"TPE1", b"TPE2", b"TSOP", b"TSO2"]))
1211 assert tag.getTextFrame("TSO2") == u"Dope sick"
1212 assert tag.getTextFrame("TSOP") == u"In the name of suffering"
1213 assert tag.getTextFrame("TPE2") == u"Take as needed for pain"
1214 assert tag.getTextFrame("TPE1") == u"Confederacy of Ruined Lives"
1215
1216 audiofile.tag.lyrics.set(u"some lyrics")
1217 audiofile = eyed3.load(audiofile.path)
1218 tag = audiofile.tag
1219 assert (set(tag.frame_set.keys()) ==
1220 set([b"TPE1", b"TPE2", b"TSOP", b"TSO2"]))
1221 assert tag.getTextFrame("TSO2") == u"Dope sick"
1222 assert tag.getTextFrame("TSOP") == u"In the name of suffering"
1223 assert tag.getTextFrame("TPE2") == u"Take as needed for pain"
1224 assert tag.getTextFrame("TPE1") == u"Confederacy of Ruined Lives"
1225
1226 # Convert to v2.3 and verify conversions
1227 tag.save(version=ID3_V2_3)
1228 audiofile = eyed3.load(audiofile.path)
1229 tag = audiofile.tag
1230 assert (set(tag.frame_set.keys()) ==
1231 set([b"TPE1", b"TPE2", b"XSOP", b"TSO2"]))
1232 assert tag.getTextFrame("TSO2") == u"Dope sick"
1233 assert tag.getTextFrame("TPE2") == u"Take as needed for pain"
1234 assert tag.getTextFrame("TPE1") == u"Confederacy of Ruined Lives"
1235 assert tag.frame_set[b"XSOP"][0].text == "In the name of suffering"
1236
1237 # Convert to v2.4 and verify conversions
1238 tag.save(version=ID3_V2_4)
1239 audiofile = eyed3.load(audiofile.path)
1240 tag = audiofile.tag
1241 assert (set(tag.frame_set.keys()) ==
1242 set([b"TPE1", b"TPE2", b"TSOP", b"TSO2"]))
1243 assert tag.getTextFrame("TSO2") == u"Dope sick"
1244 assert tag.getTextFrame("TPE2") == u"Take as needed for pain"
1245 assert tag.getTextFrame("TPE1") == u"Confederacy of Ruined Lives"
1246 assert tag.getTextFrame("TSOP") == u"In the name of suffering"
+0
-0
src/test/mp3/__init__.py less more
(Empty file)
+0
-82
src/test/mp3/test_infos.py less more
0 """
1 Test functions and data by Jason Penney.
2 https://bitbucket.org/nicfit/eyed3/issue/32/mp3audioinfotime_secs-incorrect-for-mpeg2
3
4 To test individual files use:::
5
6 python -m test.mp3.test_infos <file>
7 """
8 from __future__ import print_function
9 import eyed3
10 import sys
11 import os
12 from decimal import Decimal
13 from .. import DATA_D, unittest
14
15
16 def _do_test(reported, expected):
17 if reported != expected:
18 return (False, "eyed3 reported %s (expected %s)" %
19 (str(reported), str(expected)))
20 return (True, '')
21
22
23 def _translate_mode(mode):
24 if mode == 'simple':
25 return 'Stereo'
26 if mode == 'mono':
27 return 'Mono'
28 if mode == 'joint' or mode == 'force':
29 return 'Joint stereo'
30 if mode == 'dual-mono':
31 return 'Dual channel stereo'
32 raise RuntimeError("unknown mode: %s" % mode)
33
34
35 def _test_file(pth):
36 errors = []
37 info = os.path.splitext(os.path.basename(pth))[0].split(' ')
38 fil = eyed3.load(pth)
39
40 tests = [
41 ('mpeg_version', Decimal(str(fil.info.mp3_header.version)),
42 Decimal(info[0][-3:])),
43 ('sample_freq', Decimal(str(fil.info.mp3_header.sample_freq))/1000,
44 Decimal(info[1][:-3])),
45 ('vbr', fil.info.bit_rate[0], bool(info[2] == '__vbr__')),
46 ('stereo_mode', fil.info.mode, _translate_mode(info[3])),
47 ('duration', round(fil.info.time_secs), 10),
48
49 ]
50
51 if info[2] != '__vbr__':
52 tests.append(('bit_rate', fil.info.bit_rate[1], int(info[2][:-4])))
53
54 for test, reported, expected in tests:
55 (passed, msg) = _do_test(reported, expected)
56 if not passed:
57 errors.append("%s: %s" % (test, msg))
58
59 print("%s: %s" % (os.path.basename(pth), 'FAIL' if errors else 'ok'))
60 for err in errors:
61 print(" %s" % err)
62
63 return errors
64
65
66 @unittest.skipIf(not os.path.exists(DATA_D), "test requires data files")
67 def test_mp3_infos(do_assert=True):
68 data_d = os.path.join(DATA_D, "mp3_samples")
69 mp3s = sorted([f for f in os.listdir(data_d) if f.endswith(".mp3")])
70
71 for mp3_file in mp3s:
72 errors = _test_file(os.path.join(data_d, mp3_file))
73 if do_assert:
74 assert(len(errors) == 0)
75
76 if __name__ == "__main__":
77 if len(sys.argv) < 2:
78 test_mp3_infos(do_assert=False)
79 else:
80 for mp3_file in sys.argv[1:]:
81 errors = _test_file(mp3_file)
+0
-96
src/test/mp3/test_mp3.py less more
0 import unittest
1 import os
2 from io import BytesIO
3 from .. import DATA_D
4
5 import eyed3
6
7
8 def testvalidHeader():
9 from eyed3.mp3.headers import isValidHeader
10
11 # False sync, the layer is invalid
12 assert not isValidHeader(0xffe00000)
13 # False sync, bitrate is invalid
14 assert not isValidHeader(0xffe20000)
15 assert not isValidHeader(0xffe20001)
16 assert not isValidHeader(0xffe2000f)
17 # False sync, sample rate is invalid
18 assert not isValidHeader(0xffe21c34)
19 assert not isValidHeader(0xffe21c54)
20 # False sync, version is invalid
21 assert not isValidHeader(0xffea0000)
22 assert not isValidHeader(0xffea0001)
23 assert not isValidHeader(0xffeb0001)
24 assert not isValidHeader(0xffec0001)
25
26
27 assert not isValidHeader(0)
28 assert not isValidHeader(0xffffffff)
29 assert not isValidHeader(0xffe0ffff)
30 assert not isValidHeader(0xffe00000)
31 assert not isValidHeader(0xfffb0000)
32
33 assert isValidHeader(0xfffb9064)
34 assert isValidHeader(0xfffb9074)
35 assert isValidHeader(0xfffb900c)
36 assert isValidHeader(0xfffb1900)
37 assert isValidHeader(0xfffbd204)
38 assert isValidHeader(0xfffba040)
39 assert isValidHeader(0xfffba004)
40 assert isValidHeader(0xfffb83eb)
41 assert isValidHeader(0xfffb7050)
42 assert isValidHeader(0xfffb32c0)
43
44
45 def testFindHeader():
46 from eyed3.mp3.headers import findHeader
47
48 # No header
49 buffer = BytesIO(b'\x00' * 1024)
50 (offset, header_int, header_bytes) = findHeader(buffer, 0)
51 assert header_int is None
52
53 # Valid header
54 buffer = BytesIO(b'\x11\x12\x23' * 1024 + b"\xff\xfb\x90\x64" +
55 b"\x00" * 1024)
56 (offset, header_int, header_bytes) = findHeader(buffer, 0)
57 assert header_int == 0xfffb9064
58
59 # Same thing with a false sync in the mix
60 buffer = BytesIO(b'\x11\x12\x23' * 1024 +
61 b"\x11" * 100 +
62 b"\xff\xea\x00\x00" + # false sync
63 b"\x22" * 100 +
64 b"\xff\xe2\x1c\x34" + # false sync
65 b"\xee" * 100 +
66 b"\xff\xfb\x90\x64" +
67 b"\x00" * 1024)
68 (offset, header_int, header_bytes) = findHeader(buffer, 0)
69 assert header_int == 0xfffb9064
70
71
72 @unittest.skipIf(not os.path.exists(DATA_D), "test requires data files")
73 def testBasicVbrMp3():
74 audio_file = eyed3.load(os.path.join(DATA_D, "notag-vbr.mp3"))
75 assert isinstance(audio_file, eyed3.mp3.Mp3AudioFile)
76
77 assert audio_file.info is not None
78 assert round(audio_file.info.time_secs) == 262
79 assert audio_file.info.size_bytes == 6272220
80 # Variable bit rate, ~191
81 assert audio_file.info.bit_rate[0] == True
82 assert audio_file.info.bit_rate[1] == 191
83 assert audio_file.info.bit_rate_str == "~191 kb/s"
84
85 assert audio_file.info.mode == "Joint stereo"
86 assert audio_file.info.sample_freq == 44100
87
88 assert audio_file.info.mp3_header is not None
89 assert audio_file.info.mp3_header.version == 1.0
90 assert audio_file.info.mp3_header.layer == 3
91
92 assert audio_file.info.xing_header is not None
93 assert audio_file.info.lame_tag is not None
94 assert audio_file.info.vbri_header is None
95 assert audio_file.tag is None
+0
-48
src/test/test__init__.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2011 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 import eyed3
19 from .compat import *
20
21
22 def testLocale():
23 assert eyed3.LOCAL_ENCODING
24 assert eyed3.LOCAL_ENCODING != "ANSI_X3.4-1968"
25
26 assert eyed3.LOCAL_FS_ENCODING
27
28 def testException():
29
30 ex = eyed3.Error()
31 assert isinstance(ex, Exception)
32
33 msg = "this is a test"
34 ex = eyed3.Error(msg)
35 assert ex.message == msg
36 assert ex.args == (msg,)
37
38 ex = eyed3.Error(msg, 1, 2)
39 assert ex.message == msg
40 assert ex.args == (msg, 1, 2)
41
42
43 def test_log():
44 from eyed3 import log
45 assert log is not None
46
47 log.verbose("Hiya from Dr. Know")
+0
-81
src/test/test_binfuncs.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2009 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 import pytest
19 from eyed3.utils.binfuncs import *
20
21 def test_bytes2bin():
22 # test ones and zeros, sz==8
23 for i in range(1, 11):
24 zeros = bytes2bin(b"\x00" * i)
25 ones = bytes2bin(b"\xFF" * i)
26 assert len(zeros) == (8 * i) and len(zeros) == len(ones)
27 for i in range(len(zeros)):
28 assert zeros[i] == 0
29 assert ones[i] == 1
30
31 # test 'sz' bounds checking
32 with pytest.raises(ValueError):
33 bytes2bin(b"a", -1)
34 with pytest.raises(ValueError):
35 bytes2bin(b"a", 0)
36 with pytest.raises(ValueError):
37 bytes2bin(b"a", 9)
38
39 # Test 'sz'
40 for sz in range(1, 9):
41 res = bytes2bin(b"\x00\xFF", sz=sz)
42 assert len(res) == 2 * sz
43 assert res[:sz] == [0] * sz
44 assert res[sz:] == [1] * sz
45
46 def test_bin2bytes():
47 res = bin2bytes([0])
48 assert len(res) == 1
49 assert ord(res) == 0
50
51 res = bin2bytes([1] * 8)
52 assert len(res) == 1
53 assert ord(res) == 255
54
55 def test_bin2dec():
56 assert bin2dec([1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0]) == 2730
57
58 def test_bytes2dec():
59 assert bytes2dec(b"\x00\x11\x22\x33") == 1122867
60
61 def test_dec2bin():
62 assert dec2bin(3036790792) == [1, 0, 1, 1, 0, 1, 0, 1,
63 0, 0, 0, 0, 0, 0, 0, 1,
64 1, 1, 0, 0, 0, 0, 0, 0,
65 0, 0, 0, 0, 1, 0, 0, 0]
66 assert dec2bin(1, p=8) == [0, 0, 0, 0, 0, 0, 0, 1]
67
68 def test_dec2bytes():
69 assert dec2bytes(ord(b"a")) == b"\x61"
70
71 def test_bin2syncsafe():
72 with pytest.raises(ValueError):
73 bin2synchsafe(bytes2bin(b"\xff\xff\xff\xff"))
74 with pytest.raises(ValueError):
75 bin2synchsafe([0] * 33)
76 assert bin2synchsafe([1] * 7) == [1] * 7
77 assert bin2synchsafe(dec2bin(255)) == [0, 0, 0, 0, 0, 0, 0, 0,
78 0, 0, 0, 0, 0, 0, 0, 0,
79 0, 0, 0, 0, 0, 0, 0, 1,
80 0, 1, 1, 1, 1, 1, 1, 1]
+0
-840
src/test/test_classic_plugin.py less more
0 # -*- coding: utf-8 -*-
1 import os
2 import shutil
3 import unittest
4 import six
5 import pytest
6 import eyed3
7 from eyed3 import main, id3, core, compat
8 from . import DATA_D, RedirectStdStreams
9
10
11 def testPluginOption():
12 for arg in ["--help", "-h"]:
13 # When help is requested and no plugin is specified, use default
14 with RedirectStdStreams() as out:
15 try:
16 args, _, config = main.parseCommandLine([arg])
17 except SystemExit as ex:
18 assert ex.code == 0
19 out.stdout.seek(0)
20 sout = out.stdout.read()
21 assert sout.find("Plugin options:\n Classic eyeD3") != -1
22
23 # When help is requested and all default plugin names are specified
24 for plugin_name in ["classic"]:
25 for args in [["--plugin=%s" % plugin_name, "--help"]]:
26 with RedirectStdStreams() as out:
27 try:
28 args, _, config = main.parseCommandLine(args)
29 except SystemExit as ex:
30 assert ex.code == 0
31 out.stdout.seek(0)
32 sout = out.stdout.read()
33 assert sout.find("Plugin options:\n Classic eyeD3") != -1
34
35 @unittest.skipIf(not os.path.exists(DATA_D), "test requires data files")
36 def testReadEmptyMp3():
37 with RedirectStdStreams() as out:
38 args, _, config = main.parseCommandLine([os.path.join(DATA_D,
39 "test.mp3")])
40 retval = main.main(args, config)
41 assert retval == 0
42 assert out.stderr.read().find("No ID3 v1.x/v2.x tag found") != -1
43
44
45 class TestDefaultPlugin(unittest.TestCase):
46 def __init__(self, name):
47 super(TestDefaultPlugin, self).__init__(name)
48 self.orig_test_file = "%s/test.mp3" % DATA_D
49 self.test_file = "/tmp/test.mp3"
50
51 @unittest.skipIf(not os.path.exists(DATA_D), "test requires data files")
52 def setUp(self):
53 shutil.copy(self.orig_test_file, self.test_file)
54
55 def tearDown(self):
56 # TODO: could remove the tag and compare audio file to original
57 os.remove(self.test_file)
58
59 def _addVersionOpt(self, version, opts):
60 if version == id3.ID3_DEFAULT_VERSION:
61 return
62
63 if version[0] == 1:
64 opts.append("--to-v1.1")
65 elif version[:2] == (2, 3):
66 opts.append("--to-v2.3")
67 elif version[:2] == (2, 4):
68 opts.append("--to-v2.4")
69 else:
70 assert not("Unhandled version")
71
72 def testNewTagArtist(self, version=id3.ID3_DEFAULT_VERSION):
73 for opts in [ ["-a", "The Cramps", self.test_file],
74 ["--artist=The Cramps", self.test_file] ]:
75 self._addVersionOpt(version, opts)
76
77 with RedirectStdStreams() as out:
78 args, _, config = main.parseCommandLine(opts)
79 retval = main.main(args, config)
80 assert retval == 0
81
82 af = eyed3.load(self.test_file)
83 assert af is not None
84 assert af.tag is not None
85 assert af.tag.artist == u"The Cramps"
86
87 def testNewTagComposer(self, version=id3.ID3_DEFAULT_VERSION):
88 for opts in [ ["--composer=H.R.", self.test_file] ]:
89 self._addVersionOpt(version, opts)
90
91 with RedirectStdStreams() as out:
92 args, _, config = main.parseCommandLine(opts)
93 retval = main.main(args, config)
94 assert retval == 0
95
96 af = eyed3.load(self.test_file)
97 assert af is not None
98 assert af.tag is not None
99 assert af.tag.composer == u"H.R."
100
101 def testNewTagAlbum(self, version=id3.ID3_DEFAULT_VERSION):
102 for opts in [ ["-A", "Psychedelic Jungle", self.test_file],
103 ["--album=Psychedelic Jungle", self.test_file] ]:
104 self._addVersionOpt(version, opts)
105
106 with RedirectStdStreams() as out:
107 args, _, config = main.parseCommandLine(opts)
108 retval = main.main(args, config)
109 assert (retval == 0)
110
111 af = eyed3.load(self.test_file)
112 assert (af is not None)
113 assert (af.tag is not None)
114 assert (af.tag.album == u"Psychedelic Jungle")
115
116 def testNewTagAlbumArtist(self, version=id3.ID3_DEFAULT_VERSION):
117 for opts in [ ["-b", "Various Artists", self.test_file],
118 ["--album-artist=Various Artists", self.test_file] ]:
119 self._addVersionOpt(version, opts)
120
121 with RedirectStdStreams() as out:
122 args, _, config = main.parseCommandLine(opts)
123 retval = main.main(args, config)
124 assert (retval == 0)
125
126 af = eyed3.load(self.test_file)
127 assert af is not None
128 assert af.tag is not None
129 assert af.tag.album_artist == u"Various Artists"
130
131 def testNewTagTitle(self, version=id3.ID3_DEFAULT_VERSION):
132 for opts in [ ["-t", "Green Door", self.test_file],
133 ["--title=Green Door", self.test_file] ]:
134 self._addVersionOpt(version, opts)
135
136 with RedirectStdStreams() as out:
137 args, _, config = main.parseCommandLine(opts)
138 retval = main.main(args, config)
139 assert (retval == 0)
140
141 af = eyed3.load(self.test_file)
142 assert (af is not None)
143 assert (af.tag is not None)
144 assert (af.tag.title == u"Green Door")
145
146 def testNewTagTrackNum(self, version=id3.ID3_DEFAULT_VERSION):
147 for opts in [ ["-n", "14", self.test_file],
148 ["--track=14", self.test_file] ]:
149 self._addVersionOpt(version, opts)
150
151 with RedirectStdStreams() as out:
152 args, _, config = main.parseCommandLine(opts)
153 retval = main.main(args, config)
154 assert (retval == 0)
155
156 af = eyed3.load(self.test_file)
157 assert (af is not None)
158 assert (af.tag is not None)
159 assert (af.tag.track_num[0] == 14)
160
161 def testNewTagTrackNumInvalid(self):
162 for opts in [ ["-n", "abc", self.test_file],
163 ["--track=-14", self.test_file]
164 ]:
165
166 with RedirectStdStreams() as out:
167 try:
168 args, _, config = main.parseCommandLine(opts)
169 except SystemExit as ex:
170 assert ex.code != 0
171 else:
172 assert not("Should not have gotten here")
173
174 def testNewTagTrackTotal(self, version=id3.ID3_DEFAULT_VERSION):
175 if version[0] == 1:
176 # No support for this in v1.x
177 return
178
179 for opts in [ ["-N", "14", self.test_file],
180 ["--track-total=14", self.test_file] ]:
181 self._addVersionOpt(version, opts)
182
183 with RedirectStdStreams() as out:
184 args, _, config = main.parseCommandLine(opts)
185 retval = main.main(args, config)
186 assert (retval == 0)
187
188 af = eyed3.load(self.test_file)
189 assert (af is not None)
190 assert (af.tag is not None)
191 assert (af.tag.track_num[1] == 14)
192
193 def testNewTagGenre(self, version=id3.ID3_DEFAULT_VERSION):
194 for opts in [ ["-G", "Rock", self.test_file],
195 ["--genre=Rock", self.test_file] ]:
196 self._addVersionOpt(version, opts)
197
198 with RedirectStdStreams() as out:
199 args, _, config = main.parseCommandLine(opts)
200 retval = main.main(args, config)
201 assert (retval == 0)
202
203 af = eyed3.load(self.test_file)
204 assert (af is not None)
205 assert (af.tag is not None)
206 assert (af.tag.genre.name == "Rock")
207 assert (af.tag.genre.id == 17)
208
209 def testNewTagYear(self, version=id3.ID3_DEFAULT_VERSION):
210 for opts in [ ["-Y", "1981", self.test_file],
211 ["--release-year=1981", self.test_file] ]:
212 self._addVersionOpt(version, opts)
213
214 with RedirectStdStreams() as out:
215 args, _, config = main.parseCommandLine(opts)
216 retval = main.main(args, config)
217 assert (retval == 0)
218
219 af = eyed3.load(self.test_file)
220 assert (af is not None)
221 assert (af.tag is not None)
222 if version == id3.ID3_V2_3:
223 assert (af.tag.original_release_date.year == 1981)
224 else:
225 assert (af.tag.release_date.year == 1981)
226
227 def testNewTagReleaseDate(self, version=id3.ID3_DEFAULT_VERSION):
228 for date in ["1981", "1981-03-06", "1981-03"]:
229 orig_date = core.Date.parse(date)
230
231 for opts in [ ["--release-date=%s" % str(date), self.test_file] ]:
232 self._addVersionOpt(version, opts)
233
234 with RedirectStdStreams() as out:
235 args, _, config = main.parseCommandLine(opts)
236 retval = main.main(args, config)
237 assert (retval == 0)
238
239 af = eyed3.load(self.test_file)
240 assert (af is not None)
241 assert (af.tag is not None)
242 assert (af.tag.release_date == orig_date)
243
244 def testNewTagOrigRelease(self, version=id3.ID3_DEFAULT_VERSION):
245 for opts in [ ["--orig-release-date=1981", self.test_file] ]:
246 self._addVersionOpt(version, opts)
247
248 with RedirectStdStreams() as out:
249 args, _, config = main.parseCommandLine(opts)
250 retval = main.main(args, config)
251 assert (retval == 0)
252
253 af = eyed3.load(self.test_file)
254 assert (af is not None)
255 assert (af.tag is not None)
256 assert (af.tag.original_release_date.year == 1981)
257
258 def testNewTagRecordingDate(self, version=id3.ID3_DEFAULT_VERSION):
259 for opts in [ ["--recording-date=1993-10-30", self.test_file] ]:
260 self._addVersionOpt(version, opts)
261
262 with RedirectStdStreams() as out:
263 args, _, config = main.parseCommandLine(opts)
264 retval = main.main(args, config)
265 assert (retval == 0)
266
267 af = eyed3.load(self.test_file)
268 assert (af is not None)
269 assert (af.tag is not None)
270 assert (af.tag.recording_date.year == 1993)
271 assert (af.tag.recording_date.month == 10)
272 assert (af.tag.recording_date.day == 30)
273
274 def testNewTagEncodingDate(self, version=id3.ID3_DEFAULT_VERSION):
275 for opts in [ ["--encoding-date=2012-10-23T20:22", self.test_file] ]:
276 self._addVersionOpt(version, opts)
277
278 with RedirectStdStreams() as out:
279 args, _, config = main.parseCommandLine(opts)
280 retval = main.main(args, config)
281 assert (retval == 0)
282
283 af = eyed3.load(self.test_file)
284 assert (af is not None)
285 assert (af.tag is not None)
286 assert (af.tag.encoding_date.year == 2012)
287 assert (af.tag.encoding_date.month == 10)
288 assert (af.tag.encoding_date.day == 23)
289 assert (af.tag.encoding_date.hour == 20)
290 assert (af.tag.encoding_date.minute == 22)
291
292 def testNewTagTaggingDate(self, version=id3.ID3_DEFAULT_VERSION):
293 for opts in [ ["--tagging-date=2012-10-23T20:22", self.test_file] ]:
294 self._addVersionOpt(version, opts)
295
296 with RedirectStdStreams() as out:
297 args, _, config = main.parseCommandLine(opts)
298 retval = main.main(args, config)
299 assert (retval == 0)
300
301 af = eyed3.load(self.test_file)
302 assert (af is not None)
303 assert (af.tag is not None)
304 assert (af.tag.tagging_date.year == 2012)
305 assert (af.tag.tagging_date.month == 10)
306 assert (af.tag.tagging_date.day == 23)
307 assert (af.tag.tagging_date.hour == 20)
308 assert (af.tag.tagging_date.minute == 22)
309
310 def testNewTagPlayCount(self):
311 for expected, opts in [ (0, ["--play-count=0", self.test_file]),
312 (1, ["--play-count=+1", self.test_file]),
313 (6, ["--play-count=+5", self.test_file]),
314 (7, ["--play-count=7", self.test_file]),
315 (10000, ["--play-count=10000", self.test_file]),
316 ]:
317
318 with RedirectStdStreams() as out:
319 args, _, config = main.parseCommandLine(opts)
320 retval = main.main(args, config)
321 assert (retval == 0)
322
323 af = eyed3.load(self.test_file)
324 assert (af is not None)
325 assert (af.tag is not None)
326 assert (af.tag.play_count == expected)
327
328 def testNewTagPlayCountInvalid(self):
329 for expected, opts in [ (0, ["--play-count=", self.test_file]),
330 (0, ["--play-count=-24", self.test_file]),
331 (0, ["--play-count=+", self.test_file]),
332 (0, ["--play-count=abc", self.test_file]),
333 (0, ["--play-count=False", self.test_file]),
334 ]:
335
336 with RedirectStdStreams() as out:
337 try:
338 args, _, config = main.parseCommandLine(opts)
339 except SystemExit as ex:
340 assert ex.code != 0
341 else:
342 assert not("Should not have gotten here")
343
344 def testNewTagBpm(self):
345 for expected, opts in [ (1, ["--bpm=1", self.test_file]),
346 (180, ["--bpm=180", self.test_file]),
347 (117, ["--bpm", "116.7", self.test_file]),
348 (116, ["--bpm", "116.4", self.test_file]),
349 ]:
350
351 with RedirectStdStreams() as out:
352 args, _, config = main.parseCommandLine(opts)
353 retval = main.main(args, config)
354 assert (retval == 0)
355
356 af = eyed3.load(self.test_file)
357 assert (af is not None)
358 assert (af.tag is not None)
359 assert (af.tag.bpm == expected)
360
361 def testNewTagBpmInvalid(self):
362 for expected, opts in [ (0, ["--bpm=", self.test_file]),
363 (0, ["--bpm=-24", self.test_file]),
364 (0, ["--bpm=+", self.test_file]),
365 (0, ["--bpm=abc", self.test_file]),
366 (0, ["--bpm", "=180", self.test_file]),
367 ]:
368
369 with RedirectStdStreams() as out:
370 try:
371 args, _, config = main.parseCommandLine(opts)
372 except SystemExit as ex:
373 assert ex.code != 0
374 else:
375 assert not("Should not have gotten here")
376
377 def testNewTagPublisher(self):
378 for expected, opts in [
379 ("SST", ["--publisher", "SST", self.test_file]),
380 ("Dischord", ["--publisher=Dischord", self.test_file]),
381 ]:
382
383 with RedirectStdStreams() as out:
384 args, _, config = main.parseCommandLine(opts)
385 retval = main.main(args, config)
386 assert (retval == 0)
387
388 af = eyed3.load(self.test_file)
389 assert (af is not None)
390 assert (af.tag is not None)
391 assert (af.tag.publisher == expected)
392
393 def testUniqueFileId_1(self):
394 with RedirectStdStreams() as out:
395 assert out
396 args, _, config = main.parseCommandLine(["--unique-file-id", "Travis:Me",
397 self.test_file])
398 retval = main.main(args, config)
399 assert retval == 0
400
401 af = eyed3.load(self.test_file)
402 assert len(af.tag.unique_file_ids) == 1
403 assert af.tag.unique_file_ids.get("Travis").uniq_id == b"Me"
404
405 def testUniqueFileId_dup(self):
406 with RedirectStdStreams() as out:
407 assert out
408 args, _, config = \
409 main.parseCommandLine(["--unique-file-id", "Travis:Me",
410 "--unique-file-id=Travis:Me",
411 self.test_file])
412 retval = main.main(args, config)
413 assert retval == 0
414
415 af = eyed3.load(self.test_file)
416 assert len(af.tag.unique_file_ids) == 1
417 assert af.tag.unique_file_ids.get("Travis").uniq_id == b"Me"
418
419 def testUniqueFileId_N(self):
420 # Add 3
421 with RedirectStdStreams() as out:
422 assert out
423 args, _, config = \
424 main.parseCommandLine(["--unique-file-id", "Travis:Me",
425 "--unique-file-id=Engine:Kid",
426 "--unique-file-id", "Owner:Kid",
427 self.test_file])
428 retval = main.main(args, config)
429 assert retval == 0
430
431 af = eyed3.load(self.test_file)
432 assert len(af.tag.unique_file_ids) == 3
433 assert af.tag.unique_file_ids.get("Travis").uniq_id == b"Me"
434 assert af.tag.unique_file_ids.get("Engine").uniq_id == b"Kid"
435 assert af.tag.unique_file_ids.get(b"Owner").uniq_id == b"Kid"
436
437 # Remove 2
438 with RedirectStdStreams() as out:
439 assert out
440 args, _, config = \
441 main.parseCommandLine(["--unique-file-id", "Travis:",
442 "--unique-file-id=Engine:",
443 "--unique-file-id", "Owner:Kid",
444 self.test_file])
445 retval = main.main(args, config)
446 assert retval == 0
447
448 af = eyed3.load(self.test_file)
449 assert len(af.tag.unique_file_ids) == 1
450
451 # Remove not found ID
452 with RedirectStdStreams() as out:
453 args, _, config = \
454 main.parseCommandLine(["--unique-file-id", "Travis:",
455 self.test_file])
456 retval = main.main(args, config)
457 assert retval == 0
458
459 sout = out.stdout.read()
460 assert "Unique file ID 'Travis' not found" in sout
461
462 af = eyed3.load(self.test_file)
463 assert len(af.tag.unique_file_ids) == 1
464
465 # TODO:
466 # --text-frame, --user-text-frame
467 # --url-frame, --user-user-frame
468 # --add-image, --remove-image, --remove-all-images, --write-images
469 # etc.
470 # --rename, --force-update, -1, -2, --exclude
471
472 def testNewTagSimpleComment(self, version=id3.ID3_DEFAULT_VERSION):
473 if version[0] == 1:
474 # No support for this in v1.x
475 return
476
477 for opts in [ ["-c", "Starlette", self.test_file],
478 ["--comment=Starlette", self.test_file] ]:
479 self._addVersionOpt(version, opts)
480
481 with RedirectStdStreams() as out:
482 args, _, config = main.parseCommandLine(opts)
483 retval = main.main(args, config)
484 assert (retval == 0)
485
486 af = eyed3.load(self.test_file)
487 assert (af is not None)
488 assert (af.tag is not None)
489 assert (af.tag.comments[0].text == "Starlette")
490 assert (af.tag.comments[0].description == "")
491
492 def testAddRemoveComment(self, version=id3.ID3_DEFAULT_VERSION):
493 if version[0] == 1:
494 # No support for this in v1.x
495 return
496
497 comment = u"Why can't I be you?"
498 for i, (c, d, l) in enumerate([(comment, u"c0", None),
499 (comment, u"c1", None),
500 (comment, u"c2", 'eng'),
501 (u"¿Por qué no puedo ser tú ?", u"c2",
502 'esp'),
503 ]):
504
505 darg = u":{}".format(d) if d else ""
506 larg = u":{}".format(l) if l else ""
507 opts = [u"--add-comment={c}{darg}{larg}".format(**locals()),
508 self.test_file]
509
510 self._addVersionOpt(version, opts)
511
512 with RedirectStdStreams() as out:
513 args, _, config = main.parseCommandLine(opts)
514 retval = main.main(args, config)
515 assert (retval == 0)
516
517 af = eyed3.load(self.test_file)
518 assert (af is not None)
519 assert (af.tag is not None)
520
521 tag_comment = af.tag.comments.get(d or u"",
522 lang=compat.b(l if l else "eng"))
523 assert (tag_comment.text == c)
524 assert (tag_comment.description == d or u"")
525 assert (tag_comment.lang == compat.b(l if l else "eng"))
526
527 for d, l in [(u"c0", None),
528 (u"c1", None),
529 (u"c2", "eng"),
530 (u"c2", "esp"),
531 ]:
532
533 larg = u":{}".format(l) if l else ""
534 opts = [u"--remove-comment={d}{larg}".format(**locals()),
535 self.test_file]
536 self._addVersionOpt(version, opts)
537
538 with RedirectStdStreams() as out:
539 args, _, config = main.parseCommandLine(opts)
540 retval = main.main(args, config)
541 assert (retval == 0)
542
543 af = eyed3.load(self.test_file)
544 tag_comment = af.tag.comments.get(d,
545 lang=compat.b(l if l else "eng"))
546 assert tag_comment is None
547
548 assert (len(af.tag.comments) == 0)
549
550 def testRemoveAllComments(self, version=id3.ID3_DEFAULT_VERSION):
551 if version[0] == 1:
552 # No support for this in v1.x
553 return
554
555 comment = u"Why can't I be you?"
556 for i, (c, d, l) in enumerate([(comment, u"c0", None),
557 (comment, u"c1", None),
558 (comment, u"c2", 'eng'),
559 (u"¿Por qué no puedo ser tú ?", u"c2",
560 'esp'),
561 (comment, u"c4", "ger"),
562 (comment, u"c4", "rus"),
563 (comment, u"c5", "rus"),
564 ]):
565
566 darg = u":{}".format(d) if d else ""
567 larg = u":{}".format(l) if l else ""
568 opts = [u"--add-comment={c}{darg}{larg}".format(**locals()),
569 self.test_file]
570
571 self._addVersionOpt(version, opts)
572
573 with RedirectStdStreams() as out:
574 args, _, config = main.parseCommandLine(opts)
575 retval = main.main(args, config)
576 assert (retval == 0)
577
578 af = eyed3.load(self.test_file)
579 assert (af is not None)
580 assert (af.tag is not None)
581
582 tag_comment = af.tag.comments.get(d or u"",
583 lang=compat.b(l if l else "eng"))
584 assert (tag_comment.text == c)
585 assert (tag_comment.description == d or u"")
586 assert (tag_comment.lang == compat.b(l if l else "eng"))
587
588 opts = [u"--remove-all-comments", self.test_file]
589 self._addVersionOpt(version, opts)
590
591 with RedirectStdStreams() as out:
592 args, _, config = main.parseCommandLine(opts)
593 retval = main.main(args, config)
594 assert (retval == 0)
595
596 af = eyed3.load(self.test_file)
597 assert (len(af.tag.comments) == 0)
598
599 def testAddRemoveLyrics(self, version=id3.ID3_DEFAULT_VERSION):
600 if version[0] == 1:
601 # No support for this in v1.x
602 return
603
604 comment = u"Why can't I be you?"
605 for i, (c, d, l) in enumerate([(comment, u"c0", None),
606 (comment, u"c1", None),
607 (comment, u"c2", 'eng'),
608 (u"¿Por qué no puedo ser tú ?", u"c2",
609 'esp'),
610 ]):
611
612 darg = u":{}".format(d) if d else ""
613 larg = u":{}".format(l) if l else ""
614 opts = [u"--add-comment={c}{darg}{larg}".format(**locals()),
615 self.test_file]
616
617 self._addVersionOpt(version, opts)
618
619 with RedirectStdStreams() as out:
620 args, _, config = main.parseCommandLine(opts)
621 retval = main.main(args, config)
622 assert (retval == 0)
623
624 af = eyed3.load(self.test_file)
625 assert (af is not None)
626 assert (af.tag is not None)
627
628 tag_comment = af.tag.comments.get(d or u"",
629 lang=compat.b(l if l else "eng"))
630 assert (tag_comment.text == c)
631 assert (tag_comment.description == d or u"")
632 assert (tag_comment.lang == compat.b(l if l else "eng"))
633
634 for d, l in [(u"c0", None),
635 (u"c1", None),
636 (u"c2", "eng"),
637 (u"c2", "esp"),
638 ]:
639
640 larg = u":{}".format(l) if l else ""
641 opts = [u"--remove-comment={d}{larg}".format(**locals()),
642 self.test_file]
643 self._addVersionOpt(version, opts)
644
645 with RedirectStdStreams() as out:
646 args, _, config = main.parseCommandLine(opts)
647 retval = main.main(args, config)
648 assert (retval == 0)
649
650 af = eyed3.load(self.test_file)
651 tag_comment = af.tag.comments.get(d,
652 lang=compat.b(l if l else "eng"))
653 assert tag_comment is None
654
655 assert (len(af.tag.comments) == 0)
656
657 def testNewTagAll(self, version=id3.ID3_DEFAULT_VERSION):
658 self.testNewTagArtist(version)
659 self.testNewTagAlbum(version)
660 self.testNewTagTitle(version)
661 self.testNewTagTrackNum(version)
662 self.testNewTagTrackTotal(version)
663 self.testNewTagGenre(version)
664 self.testNewTagYear(version)
665 self.testNewTagSimpleComment(version)
666
667 af = eyed3.load(self.test_file)
668 assert (af.tag.artist == u"The Cramps")
669 assert (af.tag.album == u"Psychedelic Jungle")
670 assert (af.tag.title == u"Green Door")
671 assert (af.tag.track_num == (14, 14 if version[0] != 1 else None))
672 assert ((af.tag.genre.name, af.tag.genre.id) == ("Rock", 17))
673 if version == id3.ID3_V2_3:
674 assert (af.tag.original_release_date.year == 1981)
675 else:
676 assert (af.tag.release_date.year == 1981)
677
678 if version[0] != 1:
679 assert (af.tag.comments[0].text == "Starlette")
680 assert (af.tag.comments[0].description == "")
681
682 assert (af.tag.version == version)
683
684 def testNewTagAllVersion1(self):
685 self.testNewTagAll(version=id3.ID3_V1_1)
686
687 def testNewTagAllVersion2_3(self):
688 self.testNewTagAll(version=id3.ID3_V2_3)
689
690 def testNewTagAllVersion2_4(self):
691 self.testNewTagAll(version=id3.ID3_V2_4)
692
693
694 ## XXX: newer pytest test below.
695
696 def test_lyrics(audiofile, tmpdir, eyeD3):
697 lyrics_files = []
698 for i in range(1, 4):
699 lfile = tmpdir / "lryics{:d}".format(i)
700 lfile.write_text((six.u(str(i)) * (100 * i)), "utf8")
701 lyrics_files.append(lfile)
702
703 audiofile = eyeD3(audiofile,
704 ["--add-lyrics", "{}".format(lyrics_files[0]),
705 "--add-lyrics", "{}:desc".format(lyrics_files[1]),
706 "--add-lyrics", "{}:foo:en".format(lyrics_files[1]),
707 "--add-lyrics", "{}:foo:es".format(lyrics_files[2]),
708 "--add-lyrics", "{}:foo:de".format(lyrics_files[0]),
709 ])
710 assert len(audiofile.tag.lyrics) == 5
711 assert audiofile.tag.lyrics.get(u"").text == ("1" * 100)
712 assert audiofile.tag.lyrics.get(u"desc").text == ("2" * 200)
713 assert audiofile.tag.lyrics.get(u"foo", "en").text == ("2" * 200)
714 assert audiofile.tag.lyrics.get(u"foo", "es").text == ("3" * 300)
715 assert audiofile.tag.lyrics.get(u"foo", "de").text == ("1" * 100)
716
717 audiofile = eyeD3(audiofile, ["--remove-lyrics", "foo:xxx"])
718 assert len(audiofile.tag.lyrics) == 5
719
720 audiofile = eyeD3(audiofile, ["--remove-lyrics", "foo:es"])
721 assert len(audiofile.tag.lyrics) == 4
722
723 audiofile = eyeD3(audiofile, ["--remove-lyrics", "desc"])
724 assert len(audiofile.tag.lyrics) == 3
725
726 audiofile = eyeD3(audiofile, ["--remove-all-lyrics"])
727 assert len(audiofile.tag.lyrics) == 0
728
729 eyeD3(audiofile, ["--add-lyrics", "eminem.txt"], expected_retval=2)
730
731
732 @pytest.mark.coveragewhore
733 def test_all(audiofile, image, eyeD3):
734 audiofile = eyeD3(audiofile,
735 ["--artist", "Cibo Matto",
736 "--album-artist", "Cibo Matto",
737 "--album", "Viva! La Woman",
738 "--title", "Apple",
739 "--track=1", "--track-total=11",
740 "--disc-num=1", "--disc-total=1",
741 "--genre", "Pop",
742 "--release-date=1996-01-16",
743 "--orig-release-date=1996-01-16",
744 "--recording-date=1995-01-16",
745 "--encoding-date=1999-01-16",
746 "--tagging-date=1999-01-16",
747 "--comment", "From Japan",
748 "--publisher=\'Warner Brothers\'",
749 "--play-count=666",
750 "--bpm=99",
751 "--unique-file-id", "mishmash:777abc",
752 "--add-comment", "Trip Hop",
753 "--add-comment", "Quirky:Mood",
754 "--add-comment", "Kimyōna:Mood:jp",
755 "--add-comment", "Test:XXX",
756 "--add-popularity", "travis@ppbox.com:212:999",
757 "--fs-encoding=latin1",
758 "--no-config",
759 "--add-object", "{}:image/gif".format(image),
760 "--composer", "Cibo Matto",
761 ])
762
763
764 def test_removeTag_v1(audiofile, eyeD3):
765 assert audiofile.tag is None
766 audiofile = eyeD3(audiofile, ["-1", "-a", "Government Issue"])
767 assert audiofile.tag.version == id3.ID3_V1_0
768 audiofile = eyeD3(audiofile, ["--remove-v1"])
769 assert audiofile.tag is None
770
771
772 def test_removeTag_v2(audiofile, eyeD3):
773 assert audiofile.tag is None
774 audiofile = eyeD3(audiofile, ["-2", "-a", "Integrity"])
775 assert audiofile.tag.version == id3.ID3_V2_4
776 audiofile = eyeD3(audiofile, ["--remove-v2"])
777 assert audiofile.tag is None
778
779
780 def test_removeTagWithBoth_v1(audiofile, eyeD3):
781 audiofile = eyeD3(eyeD3(audiofile, ["-1", "-a", "Face Value"]),
782 ["-2", "-a", "Poison Idea"])
783 v1_view = eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1)
784 v2_view = eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2)
785 assert audiofile.tag.version == id3.ID3_V2_4
786 assert v1_view.tag.version == id3.ID3_V1_0
787 assert v2_view.tag.version == id3.ID3_V2_4
788 audiofile = eyeD3(audiofile, ["--remove-v1"])
789 assert audiofile.tag.version == id3.ID3_V2_4
790 assert eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1).tag is None
791 v2_tag = eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2).tag
792 assert v2_tag is not None
793 assert v2_tag.artist == "Poison Idea"
794
795
796 def test_removeTagWithBoth_v2(audiofile, eyeD3):
797 audiofile = eyeD3(eyeD3(audiofile, ["-1", "-a", "Face Value"]),
798 ["-2", "-a", "Poison Idea"])
799 v1_view = eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1)
800 v2_view = eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2)
801 assert audiofile.tag.version == id3.ID3_V2_4
802 assert v1_view.tag.version == id3.ID3_V1_0
803 assert v2_view.tag.version == id3.ID3_V2_4
804 audiofile = eyeD3(audiofile, ["--remove-v2"])
805 assert audiofile.tag.version == id3.ID3_V1_0
806 assert eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2).tag is None
807 v1_tag = eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1).tag
808 assert v1_tag is not None and v1_tag.artist == "Face Value"
809
810
811 def test_removeTagWithBoth_v2_withConvert(audiofile, eyeD3):
812 audiofile = eyeD3(eyeD3(audiofile, ["-1", "-a", "Face Value"]),
813 ["-2", "-a", "Poison Idea"])
814 v1_view = eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1)
815 v2_view = eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2)
816 assert audiofile.tag.version == id3.ID3_V2_4
817 assert v1_view.tag.version == id3.ID3_V1_0
818 assert v2_view.tag.version == id3.ID3_V2_4
819 audiofile = eyeD3(audiofile, ["--remove-v2", "--to-v1"])
820 assert audiofile.tag.version == id3.ID3_V1_0
821 assert eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2).tag is None
822 v1_tag = eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1).tag
823 assert v1_tag is not None and v1_tag.artist == "Face Value"
824
825
826 def test_removeTagWithBoth_v1_withConvert(audiofile, eyeD3):
827 audiofile = eyeD3(eyeD3(audiofile, ["-1", "-a", "Face Value"]),
828 ["-2", "-a", "Poison Idea"])
829 v1_view = eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1)
830 v2_view = eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2)
831 assert audiofile.tag.version == id3.ID3_V2_4
832 assert v1_view.tag.version == id3.ID3_V1_0
833 assert v2_view.tag.version == id3.ID3_V2_4
834 audiofile = eyeD3(audiofile, ["--remove-v1", "--to-v2.3"])
835 assert audiofile.tag.version == id3.ID3_V2_3
836 assert eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1).tag is None
837 v2_tag = eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2).tag
838 assert v2_tag is not None and v2_tag.artist == "Poison Idea"
839
+0
-42
src/test/test_console.py less more
0 """Tests for eyed3.utils.console module"""
1
2
3 from __future__ import unicode_literals
4 import os
5 import tempfile
6 import unittest
7 from eyed3.compat import PY2
8 from eyed3.utils.console import AnsiCodes, Fore
9
10
11 if PY2:
12 import mock
13 else:
14 from unittest import mock
15
16
17 @mock.patch('sys.stdout.isatty', new=lambda: True)
18 class AnsiCodesTC(unittest.TestCase):
19 def setUp(self):
20 AnsiCodes._USE_ANSI = False
21
22 def test_init_color_enabled(self):
23 AnsiCodes.init(True)
24 self._assert_color_enabled()
25
26 def test_init_color_disabled(self):
27 AnsiCodes.init(False)
28 self._assert_color_disabled()
29
30 @mock.patch('sys.stdout.isatty', new=lambda: False)
31 def test_init_color_enabled_not_tty(self):
32 AnsiCodes.init(False)
33 self._assert_color_disabled()
34
35 def _assert_color_enabled(self):
36 self.assertTrue(AnsiCodes._USE_ANSI)
37 self.assertEqual(Fore.GREEN, '\x1b[32m')
38
39 def _assert_color_disabled(self):
40 self.assertFalse(AnsiCodes._USE_ANSI)
41 self.assertEqual(Fore.GREEN, '')
+0
-184
src/test/test_core.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2011 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 import os
19 from pathlib import Path
20 import pytest
21 import eyed3
22 from eyed3 import core
23
24
25 def test_AudioFile_rename(audiofile):
26 orig_path = audiofile.path
27
28 # Happy path
29 audiofile.rename("Spoon")
30 assert Path(audiofile.path).exists()
31 assert not Path(orig_path).exists()
32 assert (Path(orig_path).parent /
33 "Spoon{}".format(Path(orig_path).suffix)).exists()
34
35 # File exist
36 with pytest.raises(IOError):
37 audiofile.rename("Spoon")
38
39 # Parent dir does not exist
40 with pytest.raises(IOError):
41 audiofile.rename("subdir/BloodOnTheWall")
42
43
44 def test_import_load():
45 assert eyed3.load == core.load
46
47 # eyed3.load raises IOError for non files and non-existent files
48 def test_ioerror_load():
49 # Non existent
50 with pytest.raises(IOError):
51 core.load("filedoesnotexist.txt")
52 # Non file
53 with pytest.raises(IOError):
54 core.load(os.path.abspath(os.path.curdir))
55
56 def test_none_load():
57 # File mimetypes that are not supported return None
58 assert core.load(__file__) == None
59
60 def test_AudioFile():
61 from eyed3.core import AudioFile
62 # Abstract method
63 with pytest.raises(NotImplementedError):
64 AudioFile("somefile.mp3")
65
66 class DummyAudioFile(AudioFile):
67 def _read(self):
68 pass
69
70 # precondition is that __file__ is already absolute
71 assert os.path.isabs(__file__)
72 af = DummyAudioFile(__file__)
73 # All paths are turned into absolute paths
74 assert af.path == os.path.abspath(__file__)
75
76 def test_AudioInfo():
77 from eyed3.core import AudioInfo
78 info = AudioInfo()
79 assert (info.time_secs == 0)
80 assert (info.size_bytes == 0)
81
82
83 def test_Date():
84 from eyed3.core import Date
85
86 for d in [Date(1973),
87 Date(year=1973),
88 Date.parse("1973")]:
89 assert (d.year == 1973)
90 assert (d.month == None)
91 assert (d.day == None)
92 assert (d.hour == None)
93 assert (d.minute == None)
94 assert (d.second == None)
95 assert (str(d) == "1973")
96
97 for d in [Date(1973, 3),
98 Date(year=1973, month=3),
99 Date.parse("1973-03")]:
100 assert (d.year == 1973)
101 assert (d.month == 3)
102 assert (d.day == None)
103 assert (d.hour == None)
104 assert (d.minute == None)
105 assert (d.second == None)
106 assert (str(d) == "1973-03")
107
108 for d in [Date(1973, 3, 6),
109 Date(year=1973, month=3, day=6),
110 Date.parse("1973-3-6")]:
111 assert (d.year == 1973)
112 assert (d.month == 3)
113 assert (d.day == 6)
114 assert (d.hour == None)
115 assert (d.minute == None)
116 assert (d.second == None)
117 assert (str(d) == "1973-03-06")
118
119 for d in [Date(1973, 3, 6, 23),
120 Date(year=1973, month=3, day=6, hour=23),
121 Date.parse("1973-3-6T23")]:
122 assert (d.year == 1973)
123 assert (d.month == 3)
124 assert (d.day == 6)
125 assert (d.hour == 23)
126 assert (d.minute == None)
127 assert (d.second == None)
128 assert (str(d) == "1973-03-06T23")
129
130 for d in [Date(1973, 3, 6, 23, 20),
131 Date(year=1973, month=3, day=6, hour=23, minute=20),
132 Date.parse("1973-3-6T23:20")]:
133 assert (d.year == 1973)
134 assert (d.month == 3)
135 assert (d.day == 6)
136 assert (d.hour == 23)
137 assert (d.minute == 20)
138 assert (d.second == None)
139 assert (str(d) == "1973-03-06T23:20")
140
141 for d in [Date(1973, 3, 6, 23, 20, 15),
142 Date(year=1973, month=3, day=6, hour=23, minute=20,
143 second=15),
144 Date.parse("1973-3-6T23:20:15")]:
145 assert (d.year == 1973)
146 assert (d.month == 3)
147 assert (d.day == 6)
148 assert (d.hour == 23)
149 assert (d.minute == 20)
150 assert (d.second == 15)
151 assert (str(d) == "1973-03-06T23:20:15")
152
153 with pytest.raises(ValueError):
154 Date.parse("")
155 with pytest.raises(ValueError):
156 Date.parse("ABC")
157 with pytest.raises(ValueError):
158 Date.parse("2010/1/24")
159
160 with pytest.raises(ValueError):
161 Date(2012, 0)
162 with pytest.raises(ValueError):
163 Date(2012, 1, 35)
164 with pytest.raises(ValueError):
165 Date(2012, 1, 4, -1)
166 with pytest.raises(ValueError):
167 Date(2012, 1, 4, 24)
168 with pytest.raises(ValueError):
169 Date(2012, 1, 4, 18, 60)
170 with pytest.raises(ValueError):
171 Date(2012, 1, 4, 18, 14, 61)
172
173 dt = Date(1973, 3, 6, 23, 20, 15)
174 assert not dt == None
175 dp = Date(1980, 7, 3, 10, 5, 1)
176 assert dt != dp
177 assert dt < dp
178 assert not dp < dt
179 assert None < dp
180 assert not dp < dp
181 assert dp <= dp
182
183 assert hash(dt) != hash(dp)
+0
-124
src/test/test_display_plugin.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2016 Sebastian Patschorke <physicspatschi@gmx.de>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 import sys
19 import unittest
20 import pytest
21 from eyed3.id3 import TagFile
22 from eyed3.plugins.display import *
23
24
25 class TestDisplayPlugin(unittest.TestCase):
26
27 def __init__(self, name):
28 super(TestDisplayPlugin, self).__init__(name)
29
30 def testSimpleTags(self):
31 self.file.tag.artist = u"The Artist"
32 self.file.tag.title = u"Some Song"
33 self.file.tag.composer = u"Some Composer"
34 self.__checkOutput(u"%a% - %t% - %C%", u"The Artist - Some Song - Some Composer")
35
36 def testComposer(self):
37 self.file.tag.composer = u"Bad Brains"
38 self.__checkOutput(u"%C% - %composer%", u"Bad Brains - Bad Brains")
39
40 def testCommentsTag(self):
41 self.file.tag.comments.set(u"TEXT", description=u"", lang=b"DE")
42 self.file.tag.comments.set(u"#d-tag", description=u"#l-tag", lang=b"#t-tag")
43 # Langs are chopped to 3 bytes (are are codes), so #t- is expected.
44 self.__checkOutput(u"%comments,output=#d #l #t,separation=|%", u" DE TEXT|#l-tag #t- #d-tag")
45
46 def testRepeatFunction(self):
47 self.__checkOutput(u"$repeat(*,3)", u"***")
48 self.__checkException(u"$repeat(*,three)", DisplayException)
49
50 def testNotEmptyFunction(self):
51 self.__checkOutput(u"$not-empty(foo,hello #t,nothing)", u"hello foo")
52 self.__checkOutput(u"$not-empty(,hello #t,nothing)", u"nothing")
53
54 def testNumberFormatFunction(self):
55 self.__checkOutput(u"$num(123,5)", u"00123")
56 self.__checkOutput(u"$num(123,3)", u"123")
57 self.__checkOutput(u"$num(123,0)", u"123")
58 self.__checkException(u"$num(nan,1)", DisplayException)
59 self.__checkException(u"$num(1,foo)", DisplayException)
60 self.__checkException(u"$num(1,)", DisplayException)
61
62 def __checkOutput(self, pattern, expected):
63 output = Pattern(pattern).output_for(self.file)
64 assert output == expected
65
66 def __checkException(self, pattern, exception_type):
67 with pytest.raises(exception_type):
68 Pattern(pattern).output_for(self.file)
69
70 def setUp(self):
71 import tempfile
72 with tempfile.NamedTemporaryFile() as temp:
73 temp.flush()
74 self.file = TagFile(temp.name)
75 self.file.initTag()
76
77 def tearDown(self):
78 pass
79
80
81 class TestDisplayParser(unittest.TestCase):
82
83 def __init__(self, name):
84 super(TestDisplayParser, self).__init__(name)
85
86 def testTextPattern(self):
87 pattern = Pattern(u"hello")
88 assert isinstance(pattern.sub_patterns[0], TextPattern)
89 assert len(pattern.sub_patterns) == 1
90
91 def testTagPattern(self):
92 pattern = Pattern(u"%comments,desc,lang,separation=|%")
93 assert len(pattern.sub_patterns) == 1
94 assert isinstance(pattern.sub_patterns[0], TagPattern)
95 comments_tag = pattern.sub_patterns[0]
96 assert (len(comments_tag.parameters) == 4)
97 assert comments_tag._parameter_value(u"description", None) == u"desc"
98 assert comments_tag._parameter_value(u"language", None) == u"lang"
99 assert (comments_tag._parameter_value(u"output", None) ==
100 AllCommentsTagPattern.PARAMETERS[2].default)
101 assert comments_tag._parameter_value(u"separation", None) == u"|"
102
103 def testComplexPattern(self):
104 pattern = Pattern(u"Output: $format(Artist: $not-empty(%artist%,#t,none),bold=y)")
105 assert len(pattern.sub_patterns) == 2
106 assert isinstance(pattern.sub_patterns[0], TextPattern)
107 assert isinstance(pattern.sub_patterns[1], FunctionFormatPattern)
108 text_patten = pattern.sub_patterns[1].parameters['text'].value
109 assert len(text_patten.sub_patterns) == 2
110 assert isinstance(text_patten.sub_patterns[0], TextPattern)
111 assert isinstance(text_patten.sub_patterns[1], FunctionNotEmptyPattern)
112
113 def testCompileException(self):
114 with pytest.raises(PatternCompileException):
115 Pattern(u"$bad-pattern").output_for(None)
116 with pytest.raises(PatternCompileException):
117 Pattern(u"$unknown-function()").output_for(None)
118
119 def setUp(self):
120 pass
121
122 def tearDown(self):
123 pass
+0
-22
src/test/test_factory.py less more
0 import eyed3.id3
1 import factory
2
3
4 class TagFactory(factory.Factory):
5 class Meta:
6 model = eyed3.id3.Tag
7 title = u"Track title"
8 artist = u"Artist"
9 album = u"Album"
10 album_artist = artist
11 track_num = None
12
13
14 def test_factory():
15 tag = TagFactory()
16 assert isinstance(tag, eyed3.id3.Tag)
17 assert tag.title == u"Track title"
18 assert tag.artist == u"Artist"
19 assert tag.album == u"Album"
20 assert tag.album_artist == tag.artist
21 assert tag.track_num == (None, None)
+0
-121
src/test/test_main.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2012-2015 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 import unittest
19 from eyed3 import main
20 from eyed3.compat import PY2
21 from . import RedirectStdStreams
22
23
24 class ParseCommandLineTest(unittest.TestCase):
25 def testHelpExitsSuccess(self):
26 with open("/dev/null", "w") as devnull:
27 with RedirectStdStreams(stderr=devnull):
28 for arg in ["--help", "-h"]:
29 try:
30 args, parser = main.parseCommandLine([arg])
31 except SystemExit as ex:
32 assert ex.code == 0
33
34 def testHelpOutput(self):
35 for arg in ["--help", "-h"]:
36 with RedirectStdStreams() as out:
37 try:
38 args, parser = main.parseCommandLine([arg])
39 except SystemExit as ex:
40 # __exit__ seeks and we're not there yet so...
41 out.stdout.seek(0)
42 assert out.stdout.read().startswith(u"usage:")
43 assert ex.code == 0
44
45 def testVersionExitsWithSuccess(self):
46 with open("/dev/null", "w") as devnull:
47 with RedirectStdStreams(stderr=devnull):
48 try:
49 args, parser = main.parseCommandLine(["--version"])
50 except SystemExit as ex:
51 assert ex.code == 0
52
53 def testListPluginsExitsWithSuccess(self):
54 try:
55 args, _, _ = main.parseCommandLine(["--plugins"])
56 except SystemExit as ex:
57 assert ex.code == 0
58
59 def testLoadPlugin(self):
60 from eyed3 import plugins
61 from eyed3.plugins.classic import ClassicPlugin
62 from eyed3.plugins.genres import GenreListPlugin
63
64 # XXX: in python3 the import of main is treated differently, in this
65 # case it adds confusing isinstance semantics demonstrated below
66 # where isinstance works with PY2 and does not in PY3. This is old,
67 # long before python3 but it is the closest explanantion I can find.
68 #http://mail.python.org/pipermail/python-bugs-list/2004-June/023326.html
69
70 args, _, _ = main.parseCommandLine([""])
71 if PY2:
72 assert isinstance(args.plugin, ClassicPlugin)
73 else:
74 assert args.plugin.__class__.__name__ == ClassicPlugin.__name__
75
76 args, _, _ = main.parseCommandLine(["--plugin=genres"])
77 if PY2:
78 assert isinstance(args.plugin, GenreListPlugin)
79 else:
80 assert args.plugin.__class__.__name__ == GenreListPlugin.__name__
81
82 with open("/dev/null", "w") as devnull:
83 with RedirectStdStreams(stderr=devnull):
84 try:
85 args, _ = main.parseCommandLine(["--plugin=DNE"])
86 except SystemExit as ex:
87 assert ex.code == 1
88
89 try:
90 args, _, _ = main.parseCommandLine(["--plugin"])
91 except SystemExit as ex:
92 assert ex.code == 2
93
94 def testLoggingOptions(self):
95 import logging
96 from eyed3 import log
97
98 with open("/dev/null", "w") as devnull:
99 with RedirectStdStreams(stderr=devnull):
100 try:
101 _ = main.parseCommandLine(["-l", "critical"])
102 assert log.getEffectiveLevel() == logging.CRITICAL
103
104 _ = main.parseCommandLine(["--log-level=error"])
105 assert log.getEffectiveLevel() == logging.ERROR
106
107 _ = main.parseCommandLine(["-l", "warning:NewLogger"])
108 assert (
109 logging.getLogger("NewLogger").getEffectiveLevel() ==
110 logging.WARNING
111 )
112 assert log.getEffectiveLevel() == logging.ERROR
113 except SystemExit:
114 assert not("Unexpected")
115
116 try:
117 _ = main.parseCommandLine(["--log-level=INVALID"])
118 assert not("Invalid log level, an Exception expected")
119 except SystemExit:
120 pass
+0
-54
src/test/test_plugins.py less more
0 # -*- coding: utf-8 -*-
1 ################################################################################
2 # Copyright (C) 2011 Travis Shirk <travis@pobox.com>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, see <http://www.gnu.org/licenses/>.
16 #
17 ################################################################################
18 from eyed3.plugins import *
19 from .compat import *
20
21
22 def test_load():
23 plugins = load()
24 assert "classic" in list(plugins.keys())
25 assert "genres" in list(plugins.keys())
26
27 assert load("classic") == plugins["classic"]
28 assert load("genres") == plugins["genres"]
29
30 assert (load("classic", reload=True).__class__.__name__ ==
31 plugins["classic"].__class__.__name__)
32 assert (load("genres", reload=True).__class__.__name__ ==
33 plugins["genres"].__class__.__name__)
34
35 assert load("DNE") is None
36
37 def test_Plugin():
38 import argparse
39 from eyed3.utils import FileHandler
40 class MyPlugin(Plugin):
41 pass
42
43 p = MyPlugin(argparse.ArgumentParser())
44 assert p.arg_group is not None
45
46 # In reality, this is parsed args
47 p.start("dummy_args", "dummy_config")
48 assert p.args == "dummy_args"
49 assert p.config == "dummy_config"
50
51 assert p.handleFile("f.txt") is None
52 assert p.handleDone() is None
53
+0
-32
src/test/test_stats_plugins.py less more
0 from __future__ import unicode_literals
1 import os
2 import tempfile
3 import unittest
4
5 import eyed3.id3
6 import eyed3.main
7
8 from . import RedirectStdStreams
9
10
11 class TestId3FrameRules(unittest.TestCase):
12 def test_bad_frames(self):
13 try:
14 fd, tempf = tempfile.mkstemp(suffix='.id3')
15 os.close(fd)
16 tagfile = eyed3.id3.TagFile(tempf)
17 tagfile.initTag()
18 tagfile.tag.title = 'mytitle'
19 tagfile.tag.privates.set(b'mydata', b'onwer0')
20 tagfile.tag.save()
21 args = ['--plugin', 'stats', tempf]
22 args, _, config = eyed3.main.parseCommandLine(args)
23
24 with RedirectStdStreams() as out:
25 eyed3.main.main(args, config)
26 finally:
27 os.remove(tempf)
28
29 print(out.stdout.getvalue())
30
31 self.assertIn('PRIV frames are bad', out.stdout.getvalue())
+0
-114
src/test/test_utils.py less more
0 # -*- coding: utf-8 -*-
1 import os
2 import sys
3
4 if sys.version_info[0:2] > (2, 7):
5 from unittest.mock import MagicMock, call
6 else:
7 from mock import MagicMock, call
8
9 import pytest
10
11 import eyed3.utils.console
12 from eyed3.utils import guessMimetype
13 from eyed3.utils import FileHandler, walk
14 from eyed3.utils.console import (printMsg, printWarning, printHeader, Fore,
15 WARNING_COLOR, HEADER_COLOR)
16 from . import DATA_D, RedirectStdStreams
17
18
19 @pytest.mark.skipif(not os.path.exists(DATA_D),
20 reason="test requires data files")
21 @pytest.mark.parametrize(("ext", "valid_types"),
22 [("id3", ["application/x-id3"]),
23 ("tag", ["application/x-id3"]),
24 ("aac", ["audio/x-aac", "audio/x-hx-aac-adts"]),
25 ("aiff", ["audio/x-aiff"]),
26 ("amr", ["audio/amr", "application/octet-stream"]),
27 ("au", ["audio/basic"]),
28 ("m4a", ["audio/mp4", "audio/x-m4a"]),
29 ("mka", ["video/x-matroska",
30 "application/octet-stream"]),
31 ("mp3", ["audio/mpeg"]),
32 ("mp4", ["video/mp4", "audio/x-m4a"]),
33 ("mpg", ["video/mpeg"]),
34 ("ogg", ["audio/ogg", "application/ogg"]),
35 ("ra", ["audio/x-pn-realaudio",
36 "application/vnd.rn-realmedia"]),
37 ("wav", ["audio/x-wav"]),
38 ("wma", ["audio/x-ms-wma", "video/x-ms-wma",
39 "video/x-ms-asf"])])
40 def testSampleMimeTypes(ext, valid_types):
41 guessed = guessMimetype(os.path.join(DATA_D, "sample.%s" % ext))
42 if guessed:
43 assert guessed in valid_types
44
45
46 def test_printWarning():
47 eyed3.utils.console.USE_ANSI = False
48 with RedirectStdStreams() as out:
49 printWarning("Built To Spill")
50 assert (out.stdout.read() == "Built To Spill\n")
51
52 eyed3.utils.console.USE_ANSI = True
53 with RedirectStdStreams() as out:
54 printWarning("Built To Spill")
55 assert (out.stdout.read() == "%sBuilt To Spill%s\n" % (WARNING_COLOR(),
56 Fore.RESET))
57
58
59 def test_printMsg():
60 eyed3.utils.console.USE_ANSI = False
61 with RedirectStdStreams() as out:
62 printMsg("EYEHATEGOD")
63 assert (out.stdout.read() == "EYEHATEGOD\n")
64
65 eyed3.utils.console.USE_ANSI = True
66 with RedirectStdStreams() as out:
67 printMsg("EYEHATEGOD")
68 assert (out.stdout.read() == "EYEHATEGOD\n")
69
70
71 def test_printHeader():
72 eyed3.utils.console.USE_ANSI = False
73 with RedirectStdStreams() as out:
74 printHeader("Furthur")
75 assert (out.stdout.read() == "Furthur\n")
76
77 eyed3.utils.console.USE_ANSI = True
78 with RedirectStdStreams() as out:
79 printHeader("Furthur")
80 assert (out.stdout.read() == "%sFurthur%s\n" % (HEADER_COLOR(),
81 Fore.RESET))
82
83
84 def test_walk(tmpdir):
85 root_d = tmpdir.mkdir("Root")
86 d1 = root_d.mkdir("d1")
87 f1 = (d1 / "file1")
88 f1.write_text(u"file1", "utf8")
89
90 _ = root_d.mkdir("d2")
91 d3 = root_d.mkdir("d3")
92
93 handler = MagicMock()
94 walk(handler, str(root_d))
95 handler.handleFile.assert_called_with(str(f1))
96 handler.handleDirectory.assert_called_with(str(d1), [f1.basename])
97
98 # Only dirs with files are handled, so...
99 f2 = (d3 / "Neurosis")
100 f2.write_text(u"Through Silver and Blood", "utf8")
101 f3 = (d3 / "High on Fire")
102 f3.write_text(u"Surrounded By Thieves", "utf8")
103
104 handler = MagicMock()
105 walk(handler, str(root_d))
106 handler.handleFile.assert_has_calls([call(str(f1)),
107 call(str(f3)),
108 call(str(f2)),
109 ], any_order=True)
110 handler.handleDirectory.assert_has_calls(
111 [call(str(d1), [f1.basename]),
112 call(str(d3), [f3.basename, f2.basename]),
113 ], any_order=True)
0 from io import StringIO
1 import eyed3
2 import os
3 import sys
4 import logging
5 import unittest
6
7 DATA_D = os.path.join(os.path.dirname(__file__), "data")
8
9 eyed3.log.setLevel(logging.ERROR)
10
11
12 class RedirectStdStreams(object):
13 """This class is used to capture sys.stdout and sys.stderr for tests that
14 invoke command line scripts and wish to inspect the output."""
15
16 def __init__(self, stdout=None, stderr=None, seek_on_exit=0):
17 self.stdout = stdout or StringIO()
18 self.stderr = stderr or StringIO()
19 self._seek_offset = seek_on_exit
20
21 def __enter__(self):
22 self._orig_stdout, self._orig_stderr = sys.stdout, sys.stderr
23 sys.stdout, sys.stderr = self.stdout, self.stderr
24 return self
25
26 def __exit__(self, exc_type, exc_value, traceback):
27 try:
28 for s in [self.stdout, self.stderr]:
29 s.flush()
30 if not s.isatty():
31 s.seek(self._seek_offset)
32 finally:
33 sys.stdout, sys.stderr = self._orig_stdout, self._orig_stderr
34
35
36 class ExternalDataTestCase(unittest.TestCase):
37 """Test case for external data files."""
38 def setUp(self):
39 pass
0 import shutil
1 import pytest
2 import eyed3
3 from uuid import uuid4
4 from pathlib import Path
5
6
7 DATA_D = Path(__file__).parent / "data"
8
9
10 def _tempCopy(src, dest_dir):
11 testfile = Path(str(dest_dir)) / "{}.mp3".format(uuid4())
12 shutil.copyfile(str(src), str(testfile))
13 return testfile
14
15
16 @pytest.fixture(scope="function")
17 def audiofile(tmpdir):
18 """Makes a copy of test.mp3 and loads it using eyed3.load()."""
19 if not Path(DATA_D).exists():
20 yield None
21 return
22
23 testfile = _tempCopy(DATA_D / "test.mp3", tmpdir)
24 yield eyed3.load(testfile)
25 if testfile.exists():
26 testfile.unlink()
27
28
29 @pytest.fixture(scope="function")
30 def id3tag():
31 """Returns a default-constructed eyed3.id3.Tag."""
32 from eyed3.id3 import Tag
33 return Tag()
34
35
36 @pytest.fixture(scope="function")
37 def image(tmpdir):
38 img_file = _tempCopy(DATA_D / "CypressHill3TemplesOfBoom.jpg", tmpdir)
39 return img_file
40
41
42 @pytest.fixture(scope="session")
43 def eyeD3():
44 """A fixture for running `eyeD3` default plugin.
45 `eyeD3(audiofile, args, expected_retval=0, reload_version=None)`
46 """
47 from eyed3 import main
48
49 def func(audiofile, args, expected_retval=0, reload_version=None):
50 try:
51 args, _, config = main.parseCommandLine(args + [audiofile.path])
52 retval = main.main(args, config)
53 except SystemExit as sys_exit:
54 retval = sys_exit.code
55 assert retval == expected_retval
56 return eyed3.load(audiofile.path, tag_version=reload_version)
57
58 return func
(New empty file)
0 import pytest
1 import unittest
2
3 from pathlib import Path
4 from unittest.mock import patch
5
6 import eyed3
7 from eyed3.id3 import (LATIN1_ENCODING, UTF_8_ENCODING, UTF_16_ENCODING,
8 UTF_16BE_ENCODING)
9 from eyed3.id3 import ID3_V1_0, ID3_V1_1, ID3_V2_3, ID3_V2_4
10 from eyed3.id3.frames import (Frame, TextFrame, FrameHeader, ImageFrame,
11 LanguageCodeMixin, ObjectFrame, TermsOfUseFrame,
12 DEFAULT_LANG, TOS_FID, OBJECT_FID)
13 from .. import DATA_D
14
15
16 class FrameTest(unittest.TestCase):
17 def testCtor(self):
18 f = Frame(b"ABCD")
19 assert f.id == b"ABCD"
20 assert f.header is None
21 assert f.decompressed_size == 0
22 assert f.group_id is None
23 assert f.encrypt_method is None
24 assert f.data is None
25 assert f.data_len == 0
26 assert f.encoding is None
27
28 f = Frame(b"EFGH")
29 assert f.id == b"EFGH"
30 assert f.header is None
31 assert f.decompressed_size == 0
32 assert f.group_id is None
33 assert f.encrypt_method is None
34 assert f.data is None
35 assert f.data_len == 0
36 assert f.encoding is None
37
38 def testTextDelim(self):
39 for enc in [LATIN1_ENCODING, UTF_16BE_ENCODING, UTF_16_ENCODING,
40 UTF_8_ENCODING]:
41 f = Frame(b"XXXX")
42 f.encoding = enc
43 if enc in [LATIN1_ENCODING, UTF_8_ENCODING]:
44 assert (f.text_delim == b"\x00")
45 else:
46 assert (f.text_delim == b"\x00\x00")
47
48 def testInitEncoding(self):
49 # Default encodings per version
50 for ver in [ID3_V1_0, ID3_V1_1, ID3_V2_3, ID3_V2_4]:
51 f = Frame(b"XXXX")
52 f.header = FrameHeader(f.id, ver)
53 f._initEncoding()
54 if ver[0] == 1:
55 assert (f.encoding == LATIN1_ENCODING)
56 elif ver[:2] == (2, 3):
57 assert (f.encoding == UTF_16_ENCODING)
58 else:
59 assert (f.encoding == UTF_8_ENCODING)
60
61 # Invalid encoding for a version is coerced
62 for ver in [ID3_V1_0, ID3_V1_1]:
63 for enc in [UTF_8_ENCODING, UTF_16_ENCODING, UTF_16BE_ENCODING]:
64 f = Frame(b"XXXX")
65 f.header = FrameHeader(f.id, ver)
66 f.encoding = enc
67 f._initEncoding()
68 assert (f.encoding == LATIN1_ENCODING)
69
70 for ver in [ID3_V2_3]:
71 for enc in [UTF_8_ENCODING, UTF_16BE_ENCODING]:
72 f = Frame(b"XXXX")
73 f.header = FrameHeader(f.id, ver)
74 f.encoding = enc
75 f._initEncoding()
76 assert (f.encoding == UTF_16_ENCODING)
77
78 # No coersion for v2.4
79 for ver in [ID3_V2_4]:
80 for enc in [LATIN1_ENCODING, UTF_8_ENCODING, UTF_16BE_ENCODING,
81 UTF_16_ENCODING]:
82 f = Frame(b"XXXX")
83 f.header = FrameHeader(f.id, ver)
84 f.encoding = enc
85 f._initEncoding()
86 assert (f.encoding == enc)
87
88
89 class TextFrameTest(unittest.TestCase):
90 def testCtor(self):
91 with pytest.raises(TypeError):
92 TextFrame("TCON")
93
94 f = TextFrame(b"TCON")
95 assert f.text == ""
96
97 f = TextFrame(b"TCON", "content")
98 assert f.text == "content"
99
100 def testRenderParse(self):
101 fid = b"TPE1"
102 for ver in [ID3_V2_3, ID3_V2_4]:
103 h1 = FrameHeader(fid, ver)
104 h2 = FrameHeader(fid, ver)
105 f1 = TextFrame(b"TPE1", "Ambulance LTD")
106 f1.header = h1
107 data = f1.render()
108
109 # FIXME: right here is why parse should be static
110 f2 = TextFrame(b"TIT2")
111 f2.parse(data[h1.size:], h2)
112 assert f1.id == f2.id
113 assert f1.text == f2.text
114 assert f1.encoding == f2.encoding
115
116
117 class ImageFrameTest(unittest.TestCase):
118 def testPicTypeConversions(self):
119 count = 0
120 for s in ("OTHER", "ICON", "OTHER_ICON", "FRONT_COVER", "BACK_COVER",
121 "LEAFLET", "MEDIA", "LEAD_ARTIST", "ARTIST", "CONDUCTOR",
122 "BAND", "COMPOSER", "LYRICIST", "RECORDING_LOCATION",
123 "DURING_RECORDING", "DURING_PERFORMANCE", "VIDEO",
124 "BRIGHT_COLORED_FISH", "ILLUSTRATION", "BAND_LOGO",
125 "PUBLISHER_LOGO"):
126 c = getattr(ImageFrame, s)
127 assert (ImageFrame.picTypeToString(c) == s)
128 assert (ImageFrame.stringToPicType(s) == c)
129 count += 1
130 assert (count == ImageFrame.MAX_TYPE + 1)
131
132 assert (ImageFrame.MIN_TYPE == ImageFrame.OTHER)
133 assert (ImageFrame.MAX_TYPE == ImageFrame.PUBLISHER_LOGO)
134 assert ImageFrame.picTypeToString(ImageFrame.MAX_TYPE) == \
135 "PUBLISHER_LOGO"
136 assert ImageFrame.picTypeToString(ImageFrame.MIN_TYPE) == "OTHER"
137
138 with pytest.raises(ValueError):
139 ImageFrame.picTypeToString(ImageFrame.MAX_TYPE + 1)
140 with pytest.raises(ValueError):
141 ImageFrame.picTypeToString(ImageFrame.MIN_TYPE - 1)
142
143 with pytest.raises(ValueError):
144 ImageFrame.stringToPicType("Prust")
145
146
147 def test_DateFrame():
148 from eyed3.id3.frames import DateFrame
149 from eyed3.core import Date
150
151 # Default ctor
152 df = DateFrame(b"TDRC")
153 assert df.text == ""
154 assert df.date is None
155
156 # Ctor with eyed3.core.Date arg
157 for d in [Date(2012),
158 Date(2012, 1),
159 Date(2012, 1, 4),
160 Date(2012, 1, 4, 18),
161 Date(2012, 1, 4, 18, 15),
162 Date(2012, 1, 4, 18, 15, 30),
163 ]:
164 df = DateFrame(b"TDRC", d)
165 assert df.text == str(d)
166 # Comparison is on each member, not reference ID
167 assert df.date == d
168
169 # Test ctor str arg is converted
170 for d in ["2012",
171 "2010-01",
172 "2010-01-04",
173 "2010-01-04T18",
174 "2010-01-04T06:20",
175 "2010-01-04T06:20:15",
176 "2012",
177 "2010-01",
178 "2010-01-04",
179 "2010-01-04T18",
180 "2010-01-04T06:20",
181 "2010-01-04T06:20:15",
182 ]:
183 df = DateFrame(b"TDRC", d)
184 dt = Date.parse(d)
185 assert df.text == str(dt)
186 assert df.text == str(d)
187 # Comparison is on each member, not reference ID
188 assert df.date == dt
189
190 # Technically invalid, but supported
191 for d in ["20180215"]:
192 df = DateFrame(b"TDRC", d)
193 dt = Date.parse(d)
194 assert df.text == str(dt)
195 # Comparison is on each member, not reference ID
196 assert df.date == dt
197
198 # Invalid dates
199 for d in ["1234:12"]:
200 date = DateFrame(b"TDRL")
201 date.date = d
202 assert not date.date
203
204
205 def test_compression():
206 f = open(__file__, "rb")
207 try:
208 data = f.read()
209 compressed = Frame.compress(data)
210 assert data == Frame.decompress(compressed)
211 finally:
212 f.close()
213
214
215 '''
216 FIXME:
217 def test_tag_compression(id3tag):
218 # FIXME: going to refactor FrameHeader, bbl
219 data = Path(__file__).read_text()
220 aframe = TextFrame(ARTIST_FID, text=data)
221 aframe.header = FrameHeader(ARTIST_FID)
222 import ipdb; ipdb.set_trace()
223 pass
224 '''
225
226
227 def test_encryption():
228 assert "Iceburn" == Frame.encrypt("Iceburn")
229 assert "Iceburn" == Frame.decrypt("Iceburn")
230
231
232 def test_LanguageCodeMixin():
233 with pytest.raises(TypeError):
234 LanguageCodeMixin().lang = "eng"
235
236 l = LanguageCodeMixin()
237 l.lang = b"\x80"
238 assert l.lang == b"eng"
239
240 l.lang = b""
241 assert l.lang == b""
242 l.lang = None
243 assert l.lang == b""
244
245
246 @pytest.mark.skipif(not Path(DATA_D).exists(), reason="test requires data files")
247 def test_TermsOfUseFrame(audiofile, id3tag):
248 terms = TermsOfUseFrame()
249 assert terms.id == b"USER"
250 assert terms.text == ""
251 assert terms.lang == DEFAULT_LANG
252
253 id3tag.terms_of_use = "Fucking MANDATORY!"
254 audiofile.tag = id3tag
255 audiofile.tag.save()
256 file = eyed3.load(audiofile.path)
257 assert file.tag.terms_of_use == "Fucking MANDATORY!"
258
259 id3tag.terms_of_use = "Fucking MANDATORY!"
260 audiofile.tag = id3tag
261 audiofile.tag.save()
262 file = eyed3.load(audiofile.path)
263 assert file.tag.terms_of_use == "Fucking MANDATORY!"
264
265 id3tag.terms_of_use = ("Fucking MANDATORY!", b"jib")
266 audiofile.tag = id3tag
267 audiofile.tag.save()
268 file = eyed3.load(audiofile.path)
269 assert file.tag.terms_of_use == "Fucking MANDATORY!"
270 assert file.tag.frame_set[TOS_FID][0].lang == b"jib"
271
272 id3tag.terms_of_use = ("Fucking MANDATORY!", b"en")
273 audiofile.tag = id3tag
274 audiofile.tag.save()
275 file = eyed3.load(audiofile.path)
276 assert file.tag.terms_of_use == "Fucking MANDATORY!"
277 assert file.tag.frame_set[TOS_FID][0].lang == b"en"
278
279
280 @pytest.mark.skipif(not Path(DATA_D).exists(), reason="test requires data files")
281 def test_ObjectFrame(audiofile, id3tag):
282 sixsixsix = b"\x29\x0a" * 666
283 with Path(__file__).open("rb") as fp:
284 thisfile = fp.read()
285
286 obj1 = ObjectFrame(description="Test Object", object_data=sixsixsix,
287 filename="666.txt", mime_type="text/satan")
288 obj2 = ObjectFrame(description="Test Object2", filename=str(__file__),
289 mime_type="text/python", object_data=thisfile)
290 id3tag.frame_set[OBJECT_FID] = obj1
291 id3tag.frame_set[OBJECT_FID].append(obj2)
292
293 audiofile.tag = id3tag
294 audiofile.tag.save()
295 file = eyed3.load(audiofile.path)
296 assert len(file.tag.objects) == 2
297 obj1_2 = file.tag.objects.get("Test Object")
298 assert obj1_2.mime_type == "text/satan"
299 assert obj1_2.object_data == sixsixsix
300 assert obj1_2.filename == "666.txt"
301
302 obj2_2 = file.tag.objects.get("Test Object2")
303 assert obj2_2.mime_type == "text/python"
304 assert obj2_2.object_data == thisfile
305 assert obj2_2.filename == __file__
306
307
308 @pytest.mark.skipif(not Path(DATA_D).exists(), reason="test requires data files")
309 def test_ObjectFrame_no_mimetype(audiofile, id3tag):
310 # Setting no mime-type is invalid
311 obj1 = ObjectFrame(object_data=b"Deep Purple")
312 id3tag.frame_set[OBJECT_FID] = obj1
313
314 audiofile.tag = id3tag
315 audiofile.tag.save()
316 with patch("eyed3.core.parseError") as mock:
317 file = eyed3.load(audiofile.path)
318 assert mock.call_count == 2
319
320 obj1.mime_type = "Deep"
321 audiofile.tag.save()
322 with patch("eyed3.core.parseError") as mock:
323 file = eyed3.load(audiofile.path)
324 assert mock.call_count == 1
325
326 obj1.mime_type = "Deep/Purple"
327 audiofile.tag.save()
328 with patch("eyed3.core.parseError") as mock:
329 file = eyed3.load(audiofile.path)
330 mock.assert_not_called()
331
332 obj1.object_data = b""
333 audiofile.tag.save()
334 with patch("eyed3.core.parseError") as mock:
335 file = eyed3.load(audiofile.path)
336 assert mock.call_count == 1
337 assert file
0 import unittest
1 import pytest
2 from eyed3.id3.headers import *
3 from eyed3.id3 import ID3_DEFAULT_VERSION, TagException
4
5 from io import BytesIO
6
7
8 class TestTagHeader(unittest.TestCase):
9 def testCtor(self):
10 h = TagHeader()
11 assert (h.version == ID3_DEFAULT_VERSION)
12 assert not(h.unsync)
13 assert not(h.extended)
14 assert not(h.experimental)
15 assert not(h.footer)
16 assert h.tag_size == 0
17
18 def testTagVersion(self):
19 for maj, min, rev in [(1, 0, 0), (1, 1, 0), (2, 2, 0), (2, 3, 0),
20 (2, 4, 0)]:
21 h = TagHeader((maj, min, rev))
22
23 assert (h.major_version == maj)
24 assert (h.minor_version == min)
25 assert (h.rev_version == rev)
26
27 for maj, min, rev in [(1, 0, None), (1, None, 0), (2, 5, 0), (3, 4, 0)]:
28 try:
29 h = TagHeader((maj, min, rev))
30 except ValueError:
31 pass
32 else:
33 assert not("Invalid version, expected ValueError")
34
35 def testParse(self):
36 # Incomplete headers
37 for data in [b"", b"ID3", b"ID3\x04\x00",
38 b"ID3\x02\x00\x00",
39 b"ID3\x03\x00\x00",
40 b"ID3\x04\x00\x00",
41 ]:
42 header = TagHeader()
43 found = header.parse(BytesIO(data))
44 assert not(found)
45
46 # Invalid versions
47 for data in [b"ID3\x01\x00\x00",
48 b"ID3\x05\x00\x00",
49 b"ID3\x06\x00\x00",
50 ]:
51 header = TagHeader()
52 try:
53 found = header.parse(BytesIO(data))
54 except TagException:
55 pass
56 else:
57 assert not("Expected TagException invalid version")
58
59
60 # Complete headers
61 for data in [b"ID3\x02\x00\x00",
62 b"ID3\x03\x00\x00",
63 b"ID3\x04\x00\x00",
64 ]:
65 for sz in [0, 10, 100, 1000, 2500, 5000, 7500, 10000]:
66 sz_bytes = bin2bytes(bin2synchsafe(dec2bin(sz, 32)))
67 header = TagHeader()
68 found = header.parse(BytesIO(data + sz_bytes))
69 assert (found)
70 assert header.tag_size == sz
71
72 def testRenderWithUnsyncTrue(self):
73 h = TagHeader()
74 h.unsync = True
75 with pytest.raises(NotImplementedError):
76 h.render(100)
77
78 def testRender(self):
79 h = TagHeader()
80 h.unsync = False
81 header = h.render(100)
82
83 h2 = TagHeader()
84 found = h2.parse(BytesIO(header))
85 assert not(h2.unsync)
86 assert (found)
87 assert header == h2.render(100)
88
89 h = TagHeader()
90 h.footer = True
91 h.extended = True
92 header = h.render(666)
93
94 h2 = TagHeader()
95 found = h2.parse(BytesIO(header))
96 assert (found)
97 assert not(h2.unsync)
98 assert not(h2.experimental)
99 assert h2.footer
100 assert h2.extended
101 assert (h2.tag_size == 666)
102 assert (header == h2.render(666))
103
104 class TestExtendedHeader(unittest.TestCase):
105 def testCtor(self):
106 h = ExtendedTagHeader()
107 assert (h.size == 0)
108 assert (h._flags == 0)
109 assert (h.crc is None)
110 assert (h._restrictions == 0)
111
112 assert not(h.update_bit)
113 assert not(h.crc_bit)
114 assert not(h.restrictions_bit)
115
116 def testUpdateBit(self):
117 h = ExtendedTagHeader()
118
119 h.update_bit = 1
120 assert (h.update_bit)
121 h.update_bit = 0
122 assert not(h.update_bit)
123 h.update_bit = 1
124 assert (h.update_bit)
125 h.update_bit = False
126 assert not(h.update_bit)
127 h.update_bit = True
128 assert (h.update_bit)
129
130 def testCrcBit(self):
131 h = ExtendedTagHeader()
132 h.update_bit = True
133
134 h.crc_bit = 1
135 assert (h.update_bit)
136 assert (h.crc_bit)
137 h.crc_bit = 0
138 assert (h.update_bit)
139 assert not(h.crc_bit)
140 h.crc_bit = 1
141 assert (h.update_bit)
142 assert (h.crc_bit)
143 h.crc_bit = False
144 assert (h.update_bit)
145 assert not(h.crc_bit)
146 h.crc_bit = True
147 assert (h.update_bit)
148 assert (h.crc_bit)
149
150 def testRestrictionsBit(self):
151 h = ExtendedTagHeader()
152 h.update_bit = True
153 h.crc_bit = True
154
155 h.restrictions_bit = 1
156 assert (h.update_bit)
157 assert (h.crc_bit)
158 assert (h.restrictions_bit)
159 h.restrictions_bit = 0
160 assert (h.update_bit)
161 assert (h.crc_bit)
162 assert not(h.restrictions_bit)
163 h.restrictions_bit = 1
164 assert (h.update_bit)
165 assert (h.crc_bit)
166 assert (h.restrictions_bit)
167 h.restrictions_bit = False
168 assert (h.update_bit)
169 assert (h.crc_bit)
170 assert not(h.restrictions_bit)
171 h.restrictions_bit = True
172 assert (h.update_bit)
173 assert (h.crc_bit)
174 assert (h.restrictions_bit)
175
176 h = ExtendedTagHeader()
177 h.restrictions_bit = True
178 assert (h.tag_size_restriction ==
179 ExtendedTagHeader.RESTRICT_TAG_SZ_LARGE)
180 assert (h.text_enc_restriction ==
181 ExtendedTagHeader.RESTRICT_TEXT_ENC_NONE)
182 assert (h.text_length_restriction ==
183 ExtendedTagHeader.RESTRICT_TEXT_LEN_NONE)
184 assert (h.image_enc_restriction ==
185 ExtendedTagHeader.RESTRICT_IMG_ENC_NONE)
186 assert (h.image_size_restriction ==
187 ExtendedTagHeader.RESTRICT_IMG_SZ_NONE)
188
189 h.tag_size_restriction = ExtendedTagHeader.RESTRICT_TAG_SZ_TINY
190 assert (h.tag_size_restriction ==
191 ExtendedTagHeader.RESTRICT_TAG_SZ_TINY)
192 assert (h.text_enc_restriction ==
193 ExtendedTagHeader.RESTRICT_TEXT_ENC_NONE)
194 assert (h.text_length_restriction ==
195 ExtendedTagHeader.RESTRICT_TEXT_LEN_NONE)
196 assert (h.image_enc_restriction ==
197 ExtendedTagHeader.RESTRICT_IMG_ENC_NONE)
198 assert (h.image_size_restriction ==
199 ExtendedTagHeader.RESTRICT_IMG_SZ_NONE)
200
201 h.text_enc_restriction = ExtendedTagHeader.RESTRICT_TEXT_ENC_UTF8
202 assert (h.tag_size_restriction ==
203 ExtendedTagHeader.RESTRICT_TAG_SZ_TINY)
204 assert (h.text_enc_restriction ==
205 ExtendedTagHeader.RESTRICT_TEXT_ENC_UTF8)
206 assert (h.text_length_restriction ==
207 ExtendedTagHeader.RESTRICT_TEXT_LEN_NONE)
208 assert (h.image_enc_restriction ==
209 ExtendedTagHeader.RESTRICT_IMG_ENC_NONE)
210 assert (h.image_size_restriction ==
211 ExtendedTagHeader.RESTRICT_IMG_SZ_NONE)
212
213 h.text_length_restriction = ExtendedTagHeader.RESTRICT_TEXT_LEN_30
214 assert (h.tag_size_restriction ==
215 ExtendedTagHeader.RESTRICT_TAG_SZ_TINY)
216 assert (h.text_enc_restriction ==
217 ExtendedTagHeader.RESTRICT_TEXT_ENC_UTF8)
218 assert (h.text_length_restriction ==
219 ExtendedTagHeader.RESTRICT_TEXT_LEN_30)
220 assert (h.image_enc_restriction ==
221 ExtendedTagHeader.RESTRICT_IMG_ENC_NONE)
222 assert (h.image_size_restriction ==
223 ExtendedTagHeader.RESTRICT_IMG_SZ_NONE)
224
225 h.image_enc_restriction = ExtendedTagHeader.RESTRICT_IMG_ENC_PNG_JPG
226 assert (h.tag_size_restriction ==
227 ExtendedTagHeader.RESTRICT_TAG_SZ_TINY)
228 assert (h.text_enc_restriction ==
229 ExtendedTagHeader.RESTRICT_TEXT_ENC_UTF8)
230 assert (h.text_length_restriction ==
231 ExtendedTagHeader.RESTRICT_TEXT_LEN_30)
232 assert (h.image_enc_restriction ==
233 ExtendedTagHeader.RESTRICT_IMG_ENC_PNG_JPG)
234 assert (h.image_size_restriction ==
235 ExtendedTagHeader.RESTRICT_IMG_SZ_NONE)
236
237 h.image_size_restriction = ExtendedTagHeader.RESTRICT_IMG_SZ_256
238 assert (h.tag_size_restriction ==
239 ExtendedTagHeader.RESTRICT_TAG_SZ_TINY)
240 assert (h.text_enc_restriction ==
241 ExtendedTagHeader.RESTRICT_TEXT_ENC_UTF8)
242 assert (h.text_length_restriction ==
243 ExtendedTagHeader.RESTRICT_TEXT_LEN_30)
244 assert (h.image_enc_restriction ==
245 ExtendedTagHeader.RESTRICT_IMG_ENC_PNG_JPG)
246 assert (h.image_size_restriction ==
247 ExtendedTagHeader.RESTRICT_IMG_SZ_256)
248
249 assert " 32 frames " in h.tag_size_restriction_description
250 assert " 4 KB " in h.tag_size_restriction_description
251 h.tag_size_restriction = ExtendedTagHeader.RESTRICT_TAG_SZ_LARGE
252 assert " 128 frames " in h.tag_size_restriction_description
253 h.tag_size_restriction = ExtendedTagHeader.RESTRICT_TAG_SZ_MED
254 assert " 64 frames " in h.tag_size_restriction_description
255 h.tag_size_restriction = ExtendedTagHeader.RESTRICT_TAG_SZ_SMALL
256 assert " 32 frames " in h.tag_size_restriction_description
257 assert " 40 KB " in h.tag_size_restriction_description
258
259 assert (" UTF-8" in h.text_enc_restriction_description)
260 h.text_enc_restriction = ExtendedTagHeader.RESTRICT_TEXT_ENC_NONE
261 assert ("None" == h.text_enc_restriction_description)
262
263 assert " 30 " in h.text_length_restriction_description
264 h.text_length_restriction = ExtendedTagHeader.RESTRICT_TEXT_LEN_NONE
265 assert ("None" == h.text_length_restriction_description)
266 h.text_length_restriction = ExtendedTagHeader.RESTRICT_TEXT_LEN_1024
267 assert " 1024 " in h.text_length_restriction_description
268 h.text_length_restriction = ExtendedTagHeader.RESTRICT_TEXT_LEN_128
269 assert " 128 " in h.text_length_restriction_description
270
271 assert " PNG " in h.image_enc_restriction_description
272 h.image_enc_restriction = ExtendedTagHeader.RESTRICT_IMG_ENC_NONE
273 assert ("None" == h.image_enc_restriction_description)
274
275 assert " 256x256 " in h.image_size_restriction_description
276 h.image_size_restriction = ExtendedTagHeader.RESTRICT_IMG_SZ_NONE
277 assert ("None" == h.image_size_restriction_description)
278 h.image_size_restriction = ExtendedTagHeader.RESTRICT_IMG_SZ_64
279 assert (" 64x64 pixels or smaller" in
280 h.image_size_restriction_description)
281 h.image_size_restriction = ExtendedTagHeader.RESTRICT_IMG_SZ_64_EXACT
282 assert "exactly 64x64 pixels" in h.image_size_restriction_description
283
284 def testRender(self):
285 version = (2, 4, 0)
286 dummy_data = b"\xab" * 50
287 dummy_padding_len = 1024
288
289 h = ExtendedTagHeader()
290 h.update_bit = 1
291 h.crc_bit = 1
292 h.tag_size_restriction = ExtendedTagHeader.RESTRICT_TAG_SZ_MED
293 h.text_enc_restriction = ExtendedTagHeader.RESTRICT_TEXT_ENC_UTF8
294 h.text_length_restriction = ExtendedTagHeader.RESTRICT_TEXT_LEN_128
295 h.image_enc_restriction = ExtendedTagHeader.RESTRICT_IMG_ENC_PNG_JPG
296 h.image_size_restriction = ExtendedTagHeader.RESTRICT_IMG_SZ_256
297 header = h.render(version, dummy_data, dummy_padding_len)
298
299 h2 = ExtendedTagHeader()
300 h2.parse(BytesIO(header), version)
301 assert (h2.update_bit)
302 assert (h2.crc_bit)
303 assert (h2.restrictions_bit)
304 assert (h.crc == h2.crc)
305 assert (h.tag_size_restriction == h2.tag_size_restriction)
306 assert (h.text_enc_restriction == h2.text_enc_restriction)
307 assert (h.text_length_restriction == h2.text_length_restriction)
308 assert (h.image_enc_restriction == h2.image_enc_restriction)
309 assert (h.image_size_restriction == h2.image_size_restriction)
310
311 assert h2.render(version, dummy_data, dummy_padding_len) == header
312
313 # version 2.3
314 header_23 = h.render((2,3,0), dummy_data, dummy_padding_len)
315
316 h3 = ExtendedTagHeader()
317 h3.parse(BytesIO(header_23), (2,3,0))
318 assert not(h3.update_bit)
319 assert (h3.crc_bit)
320 assert not(h3.restrictions_bit)
321 assert (h.crc == h3.crc)
322 assert (0 == h3.tag_size_restriction)
323 assert (0 == h3.text_enc_restriction)
324 assert (0 == h3.text_length_restriction)
325 assert (0 == h3.image_enc_restriction)
326 assert (0 == h3.image_size_restriction)
327
328 def testRenderCrcPadding(self):
329 version = (2, 4, 0)
330
331 h = ExtendedTagHeader()
332 h.crc_bit = 1
333 header = h.render(version, b"\x01", 0)
334
335 h2 = ExtendedTagHeader()
336 h2.parse(BytesIO(header), version)
337 assert h.crc == h2.crc
338
339 def testInvalidFlagBits(self):
340 for bad_flags in [b"\x00\x20", b"\x01\x01"]:
341 h = ExtendedTagHeader()
342 try:
343 h.parse(BytesIO(b"\x00\x00\x00\xff" + bad_flags), (2, 4, 0))
344 except TagException:
345 pass
346 else:
347 assert not("Bad ExtendedTagHeader flags, expected "
348 "TagException")
349
350 class TestFrameHeader(unittest.TestCase):
351 def testCtor(self):
352 h = FrameHeader(b"TIT2", ID3_DEFAULT_VERSION)
353 assert (h.size == 10)
354 assert (h.id == b"TIT2")
355 assert (h.data_size == 0)
356 assert (h._flags == [0] * 16)
357
358 h = FrameHeader(b"TIT2", (2, 3, 0))
359 assert (h.size == 10)
360 assert (h.id == b"TIT2")
361 assert (h.data_size == 0)
362 assert (h._flags == [0] * 16)
363
364 h = FrameHeader(b"TIT2", (2, 2, 0))
365 assert (h.size == 6)
366 assert (h.id == b"TIT2")
367 assert (h.data_size == 0)
368 assert (h._flags == [0] * 16)
369
370 def testBitMask(self):
371 for v in [(2, 2, 0), (2, 3, 0)]:
372 h = FrameHeader(b"TXXX", v)
373 assert (h.TAG_ALTER == 0)
374 assert (h.FILE_ALTER == 1)
375 assert (h.READ_ONLY == 2)
376 assert (h.COMPRESSED == 8)
377 assert (h.ENCRYPTED == 9)
378 assert (h.GROUPED == 10)
379 assert (h.UNSYNC == 14)
380 assert (h.DATA_LEN == 4)
381
382 for v in [(2, 4, 0), (1, 0, 0), (1, 1, 0)]:
383 h = FrameHeader(b"TXXX", v)
384 assert (h.TAG_ALTER == 1)
385 assert (h.FILE_ALTER == 2)
386 assert (h.READ_ONLY == 3)
387 assert (h.COMPRESSED == 12)
388 assert (h.ENCRYPTED == 13)
389 assert (h.GROUPED == 9)
390 assert (h.UNSYNC == 14)
391 assert (h.DATA_LEN == 15)
392
393 for v in [(2, 5, 0), (3, 0, 0)]:
394 try:
395 h = FrameHeader(b"TIT2", v)
396 except ValueError:
397 pass
398 else:
399 assert not("Expected a ValueError from invalid version, "
400 "but got success")
401
402 for v in [1, "yes", "no", True, 23]:
403 h = FrameHeader(b"APIC", (2, 4, 0))
404 h.tag_alter = v
405 h.file_alter = v
406 h.read_only = v
407 h.compressed = v
408 h.encrypted = v
409 h.grouped = v
410 h.unsync = v
411 h.data_length_indicator = v
412 assert (h.tag_alter == 1)
413 assert (h.file_alter == 1)
414 assert (h.read_only == 1)
415 assert (h.compressed == 1)
416 assert (h.encrypted == 1)
417 assert (h.grouped == 1)
418 assert (h.unsync == 1)
419 assert (h.data_length_indicator == 1)
420
421 for v in [0, False, None]:
422 h = FrameHeader(b"APIC", (2, 4, 0))
423 h.tag_alter = v
424 h.file_alter = v
425 h.read_only = v
426 h.compressed = v
427 h.encrypted = v
428 h.grouped = v
429 h.unsync = v
430 h.data_length_indicator = v
431 assert (h.tag_alter == 0)
432 assert (h.file_alter == 0)
433 assert (h.read_only == 0)
434 assert (h.compressed == 0)
435 assert (h.encrypted == 0)
436 assert (h.grouped == 0)
437 assert (h.unsync == 0)
438 assert (h.data_length_indicator == 0)
439
440 h1 = FrameHeader(b"APIC", (2, 3, 0))
441 h1.tag_alter = True
442 h1.grouped = True
443 h1.file_alter = 1
444 h1.encrypted = None
445 h1.compressed = 4
446 h1.data_length_indicator = 0
447 h1.read_only = 1
448 h1.unsync = 1
449
450 h2 = FrameHeader(b"APIC", (2, 4, 0))
451 assert (h2.tag_alter == 0)
452 assert (h2.grouped == 0)
453 h2.copyFlags(h1)
454 assert (h2.tag_alter)
455 assert (h2.grouped)
456 assert (h2.file_alter)
457 assert not(h2.encrypted)
458 assert (h2.compressed)
459 assert not(h2.data_length_indicator)
460 assert (h2.read_only)
461 assert (h2.unsync)
462
463 def testValidFrameId(self):
464 for id in [b"", b"a", b"tx", b"tit", b"TIT", b"Tit2", b"aPic"]:
465 assert not(FrameHeader._isValidFrameId(id))
466 for id in [b"TIT2", b"APIC", b"1234"]:
467 assert FrameHeader._isValidFrameId(id)
468
469 def testRenderWithUnsyncTrue(self):
470 h = FrameHeader(b"TIT2", ID3_DEFAULT_VERSION)
471 h.unsync = True
472 with pytest.raises(NotImplementedError):
473 h.render(100)
0 import eyed3
1 import unittest
2 import pytest
3 from pathlib import Path
4 from eyed3.id3 import *
5 from .. import DATA_D
6
7 ID3_VERSIONS = [(ID3_V1, (1, None, None), "v1.x"),
8 (ID3_V1_0, (1, 0, 0), "v1.0"),
9 (ID3_V1_1, (1, 1, 0), "v1.1"),
10 (ID3_V2, (2, None, None), "v2.x"),
11 (ID3_V2_2, (2, 2, 0), "v2.2"),
12 (ID3_V2_3, (2, 3, 0), "v2.3"),
13 (ID3_V2_4, (2, 4, 0), "v2.4"),
14 (ID3_DEFAULT_VERSION, (2, 4, 0), "v2.4"),
15 (ID3_ANY_VERSION, (1|2, None, None), "v1.x/v2.x"),
16 ]
17
18 with pytest.raises(TypeError):
19 versionToString(666)
20
21 with pytest.raises(ValueError):
22 versionToString((3, 1, 0))
23
24
25 def testEmptyGenre():
26 g = Genre()
27 assert g.id is None
28 assert g.name is None
29
30
31 def testValidGenres():
32 # Create with id
33 for i in range(genres.GENRE_MAX):
34 g = Genre()
35 g.id = i
36 assert g.id == i
37 assert g.name == genres[i]
38
39 g = Genre(id=i)
40 assert g.id == i
41 assert g.name == genres[i]
42
43 # Create with name
44 for name in [n for n in genres if n is not None and type(n) is not int]:
45 g = Genre()
46 g.name = name
47 assert g.id == genres[name]
48 assert g.name == genres[g.id]
49 assert g.name.lower() == name
50
51 g = Genre(name=name)
52 assert g.id == genres[name]
53 assert g.name.lower() == name
54
55
56 def test255Padding():
57 for i in range(GenreMap.GENRE_MAX + 1, 256):
58 assert genres[i] is None
59 with pytest.raises(KeyError):
60 genres.__getitem__(256)
61
62
63 def testCustomGenres():
64 # Genres can be created for any name, their ID is None
65 g = Genre(name="Grindcore")
66 assert g.name == "Grindcore"
67 assert g.id is None
68
69 # But when constructing with IDs they must map.
70 with pytest.raises(ValueError):
71 Genre.__call__(id=1024)
72
73
74 def testRemappedNames():
75 g = Genre(id=3, name="dance stuff")
76 assert g.id == 3
77 assert g.name == "Dance"
78
79 g = Genre(id=666, name="Funky")
80 assert g.id is None
81 assert g.name == "Funky"
82
83
84 def testGenreEq():
85 for s in ["Hardcore", "(129)Hardcore",
86 "(129)", "(0129)",
87 "129", "0129"]:
88 assert Genre.parse(s) == Genre.parse(s)
89 assert Genre.parse(s) != Genre.parse("Blues")
90
91
92 def testParseGenre():
93 test_list = ["Hardcore", "(129)Hardcore",
94 "(129)", "(0129)",
95 "129", "0129"]
96
97 # This is typically what will happen when parsing tags, a blob of text
98 # is parsed into Genre
99 for s in test_list:
100 g = Genre.parse(s)
101 assert g.name == "Hardcore"
102 assert g.id == 129
103
104 g = Genre.parse("")
105 assert g is None
106
107 g = Genre.parse("1")
108 assert g.id == 1
109 assert g.name == "Classic Rock"
110
111 g = Genre.parse("1", id3_std=False)
112 assert g.id is None
113 assert g.name == "1"
114
115
116 def testToSting():
117 assert str(Genre("Hardcore")) == "(129)Hardcore"
118 assert str(Genre("Grindcore")) == "Grindcore"
119
120
121 def testId3Versions():
122 for v in [ID3_V1, ID3_V1_0, ID3_V1_1]:
123 assert (v[0] == 1)
124
125 assert (ID3_V1_0[1] == 0)
126 assert (ID3_V1_0[2] == 0)
127 assert (ID3_V1_1[1] == 1)
128 assert (ID3_V1_1[2] == 0)
129
130 for v in [ID3_V2, ID3_V2_2, ID3_V2_3, ID3_V2_4]:
131 assert (v[0] == 2)
132
133 assert (ID3_V2_2[1] == 2)
134 assert (ID3_V2_3[1] == 3)
135 assert (ID3_V2_4[1] == 4)
136
137 assert (ID3_ANY_VERSION == (ID3_V1[0] | ID3_V2[0], None, None))
138 assert (ID3_DEFAULT_VERSION == ID3_V2_4)
139
140
141 def test_versionToString():
142 for const, tple, string in ID3_VERSIONS:
143 assert versionToString(const) == string
144
145
146 def test_isValidVersion():
147 for v, _, _ in ID3_VERSIONS:
148 assert isValidVersion(v)
149
150 for _, v, _ in ID3_VERSIONS:
151 if None in v:
152 assert not isValidVersion(v, True)
153 else:
154 assert isValidVersion(v, True)
155
156 assert not isValidVersion((3, 1, 1))
157
158
159 def testNormalizeVersion():
160 assert normalizeVersion(ID3_V1) == ID3_V1_1
161 assert normalizeVersion(ID3_V2) == ID3_V2_4
162 assert normalizeVersion(ID3_DEFAULT_VERSION) == ID3_V2_4
163 assert normalizeVersion(ID3_ANY_VERSION) == ID3_DEFAULT_VERSION
164 # Correcting the bogus
165 assert normalizeVersion((2, 2, 1)) == ID3_V2_2
166
167
168 # ID3 v2.2
169 @unittest.skipIf(not Path(DATA_D).exists(), "test requires data files")
170 def test_id3v22():
171 data_file = Path(DATA_D) / "sample-ID3v2.2.0.tag"
172 audio_file = eyed3.load(data_file)
173 assert audio_file.tag.version == (2, 2, 0)
174 assert audio_file.tag.title == "11.Portfolio Diaz.mp3"
175 assert audio_file.tag.album == "Acrobatic Tenement"
176 assert audio_file.tag.artist == "At the Drive-In"
0 import dataclasses
1 import pytest
2 from pytest import approx
3 from eyed3.id3 import ID3_V2_3, ID3_V2_4, ID3_V2_2
4 from eyed3.id3.frames import RelVolAdjFrameV23, RelVolAdjFrameV24, FrameException, FrameHeader
5
6
7 def test_default_v23():
8 f = RelVolAdjFrameV23()
9 assert f.id == b"RVAD"
10
11 f.adjustments = RelVolAdjFrameV23.VolumeAdjustments()
12 f.render()
13
14 f2 = RelVolAdjFrameV23()
15 f2.parse(f.data, f.header)
16
17 assert f.adjustments == f2.adjustments
18 assert set(dataclasses.astuple(f.adjustments)) == {0}
19
20
21 def test_RelVolAdjFrameV23_invalid_version():
22 f = RelVolAdjFrameV23()
23 f.adjustments = RelVolAdjFrameV23.VolumeAdjustments()
24 f.render()
25
26 f.header = FrameHeader(f.id, ID3_V2_4)
27 with pytest.raises(FrameException):
28 f.parse(f.data, f.header)
29
30
31 def test_RelVolAdjFrameV23_outofbounds():
32 f = RelVolAdjFrameV23()
33 f.adjustments = RelVolAdjFrameV23.VolumeAdjustments()
34 with pytest.raises(ValueError):
35 f.adjustments.front_right = 2**16 + 1
36 f.render()
37 with pytest.raises(ValueError):
38 f.adjustments.front_right = -(2**16) - 1
39 f.render()
40
41 f.adjustments.front_right = 2**16
42 assert f.render()
43
44 f2 = RelVolAdjFrameV23()
45 data = bytearray(f.data)
46 data[1] = 32
47 f.data = bytes(data)
48 with pytest.raises(FrameException):
49 f2.parse(f.data, f.header)
50
51
52 def test_v23_supported():
53 f = RelVolAdjFrameV23()
54 f.adjustments = RelVolAdjFrameV23.VolumeAdjustments(
55 front_right=-10, front_left=2, front_right_peak=15, front_left_peak=15,
56 back_right=54, back_left=-24, back_right_peak=100, back_left_peak=101,
57 front_center=10, front_center_peak=15,
58 bass=-666, bass_peak=5000,
59 )
60 assert f.adjustments.has_front_channel
61 assert f.adjustments.has_back_channel
62 assert f.adjustments.has_front_channel
63 assert f.adjustments.has_bass_channel
64 assert not f.adjustments.has_master_channel
65 assert not f.adjustments.has_other_channel
66 assert not f.adjustments.has_back_center_channel
67
68 f.render()
69
70 f2 = RelVolAdjFrameV23()
71 f2.parse(f.data, f.header)
72
73 assert f.adjustments == f2.adjustments
74
75
76 def test_v23_unsupported():
77 f = RelVolAdjFrameV23()
78 f.adjustments = RelVolAdjFrameV23.VolumeAdjustments(
79 master=999, master_peak=999, other=333, other_peak=333, back_center=-5, back_center_peak=1,
80 front_right=10, front_left=-2, front_right_peak=15, front_left_peak=15,
81 back_right=-54, back_left=-24, back_right_peak=100, back_left_peak=101,
82 front_center=10, front_center_peak=15,
83 bass=666, bass_peak=5000,
84 )
85 assert f.adjustments.has_front_channel
86 assert f.adjustments.has_back_channel
87 assert f.adjustments.has_front_channel
88 assert f.adjustments.has_bass_channel
89 assert f.adjustments.has_master_channel
90 assert f.adjustments.has_other_channel
91 assert f.adjustments.has_back_center_channel
92
93 f.render()
94
95 f2 = RelVolAdjFrameV23()
96 f2.parse(f.data, f.header)
97
98 assert f2.adjustments.has_front_channel
99 assert f2.adjustments.has_back_channel
100 assert f2.adjustments.has_front_channel
101 assert f2.adjustments.has_bass_channel
102 assert not f2.adjustments.has_master_channel
103 assert not f2.adjustments.has_other_channel
104 assert not f2.adjustments.has_back_center_channel
105
106 f.adjustments.master = f.adjustments.master_peak = 0
107 f.adjustments.other = f.adjustments.other_peak = 0
108 f.adjustments.back_center = f.adjustments.back_center_peak = 0
109 assert f.adjustments == f2.adjustments
110
111
112 def test_v23_bounds():
113 f = RelVolAdjFrameV23()
114 adjustments = dataclasses.asdict(RelVolAdjFrameV23.VolumeAdjustments())
115
116 for a in adjustments.keys():
117 values = dict(adjustments)
118 for value, raises in [
119 (65537, True), (-65537, True),
120 (65536, False), (-65536, False),
121 (32769, False), (-32768, False),
122 (777, False), (-999, False),
123 (0, False), (-0, False),
124 ]:
125 values[a] = value
126 if raises:
127 with pytest.raises(ValueError):
128 f.adjustments = RelVolAdjFrameV23.VolumeAdjustments(**values)
129 f.render()
130 else:
131 f.adjustments = RelVolAdjFrameV23.VolumeAdjustments(**values)
132 f.render()
133
134 assert dataclasses.asdict(f.adjustments)[a] == value
135
136
137 def test_v23_optionals():
138 f = RelVolAdjFrameV23()
139 f.adjustments = RelVolAdjFrameV23.VolumeAdjustments(
140 front_right=-10, front_left=2, front_right_peak=0, front_left_peak=0,
141 )
142 f.render()
143 assert len(f.data) == 10
144
145 f = RelVolAdjFrameV23()
146 f.adjustments = RelVolAdjFrameV23.VolumeAdjustments(
147 front_right=-10, front_left=2, front_right_peak=0, front_left_peak=0,
148 bass=-666, bass_peak=5000,
149 )
150 f.render()
151 assert len(f.data) == 26
152
153 f = RelVolAdjFrameV23()
154 f.adjustments = RelVolAdjFrameV23.VolumeAdjustments(
155 front_right=-10, front_left=2, front_right_peak=0, front_left_peak=0,
156 back_right=54, back_left=-24, back_right_peak=100, back_left_peak=101,
157 front_center=10, front_center_peak=15,
158 )
159 f.render()
160 assert len(f.data) == 22
161
162 f = RelVolAdjFrameV23()
163 f.adjustments = RelVolAdjFrameV23.VolumeAdjustments(
164 front_right=-10, front_left=2, front_right_peak=0, front_left_peak=0,
165 back_right=54, back_left=-24, back_right_peak=100, back_left_peak=101,
166 )
167 f.render()
168 assert len(f.data) == 18
169
170
171 def test_default_v24():
172 f = RelVolAdjFrameV24()
173 assert f.id == b"RVA2"
174
175 f.channel_type = RelVolAdjFrameV24.CHANNEL_TYPE_MASTER
176 f.adjustment = -6.3
177 f.peak = 666
178 f.render()
179
180 f2 = RelVolAdjFrameV24()
181 f2.parse(f.data, f.header)
182 assert f.adjustment == approx(-6.3)
183 assert f2.peak == 666
184
185
186 def test_RVAD_RVA2(audiofile):
187 # RVAD -> *RVA2
188 audiofile.initTag(version=ID3_V2_3)
189 audiofile.tag.frame_set[b"RVAD"] = RelVolAdjFrameV23()
190 assert audiofile.tag.frame_set[b"RVAD"][0].adjustments is None
191 adj = RelVolAdjFrameV23.VolumeAdjustments(front_left=20, front_right=19,
192 back_left=-1, bass_peak=1024)
193 audiofile.tag.frame_set[b"RVAD"][0].adjustments = adj
194
195 # Convert to RVA2
196 audiofile.tag.version = ID3_V2_4
197 rva2_frames = {frame.channel_type: frame for frame in audiofile.tag.frame_set[b"RVA2"]}
198 assert len(rva2_frames) == 4
199 assert set(rva2_frames.keys()) == {RelVolAdjFrameV24.CHANNEL_TYPE_FRONT_LEFT,
200 RelVolAdjFrameV24.CHANNEL_TYPE_FRONT_RIGHT,
201 RelVolAdjFrameV24.CHANNEL_TYPE_BACK_LEFT,
202 RelVolAdjFrameV24.CHANNEL_TYPE_BASS}
203 assert rva2_frames[RelVolAdjFrameV24.CHANNEL_TYPE_FRONT_RIGHT].adjustment == approx(0.037109375)
204 assert rva2_frames[RelVolAdjFrameV24.CHANNEL_TYPE_FRONT_LEFT].adjustment == approx(0.0390625)
205 assert rva2_frames[RelVolAdjFrameV24.CHANNEL_TYPE_BACK_LEFT].adjustment == approx(-0.001953125)
206 assert rva2_frames[RelVolAdjFrameV24.CHANNEL_TYPE_BASS].adjustment == 0
207 assert rva2_frames[RelVolAdjFrameV24.CHANNEL_TYPE_BASS].peak == 1024
208
209 # RVA2 --> RVAD
210 audiofile.initTag(version=ID3_V2_4)
211 assert len(audiofile.tag.frame_set) == 0
212 for frame in rva2_frames.values():
213 if b"RVA2" not in audiofile.tag.frame_set:
214 audiofile.tag.frame_set[b"RVA2"] = frame
215 else:
216 audiofile.tag.frame_set[b"RVA2"].append(frame)
217 assert len(audiofile.tag.frame_set) == 1
218 assert len(audiofile.tag.frame_set[b"RVA2"]) == 4
219
220 audiofile.tag.version = ID3_V2_3
221 assert len(audiofile.tag.frame_set) == 1
222 assert len(audiofile.tag.frame_set[b"RVAD"]) == 1
223 assert audiofile.tag.frame_set[b"RVAD"][0].adjustments == \
224 RelVolAdjFrameV23.VolumeAdjustments(front_left=20, front_right=19,back_left=-1,
225 bass_peak=1024)
226
227
228 def test_RelVolAdjFrameV24_channel_type():
229 for valid in range(9):
230 RelVolAdjFrameV24().channel_type = valid
231 for invalid in (-1, 9):
232 with pytest.raises(ValueError):
233 RelVolAdjFrameV24().channel_type = invalid
234
235
236 def test_RelVolAdjFrameV24_render_invalid_peak():
237 rva2 = RelVolAdjFrameV24()
238 rva2.peak = 2**32 - 1
239 assert rva2.render()
240
241 rva2.peak = 2**32
242 with pytest.raises(ValueError):
243 rva2.render()
244 rva2.peak = 2**64
245 with pytest.raises(ValueError):
246 rva2.render()
0 import os
1 import pytest
2 import unittest
3
4 import deprecation
5
6 import eyed3
7 from eyed3.core import Date
8 from eyed3.id3 import frames
9 from eyed3.id3 import Tag, ID3_DEFAULT_VERSION, ID3_V2_3, ID3_V2_4
10 from .. import DATA_D
11
12
13 def testTagImport():
14 import eyed3.id3.tag
15 assert eyed3.id3.Tag == eyed3.id3.tag.Tag
16
17
18 def testTagConstructor():
19 t = Tag()
20 assert t.file_info is None
21 assert t.header is not None
22 assert t.extended_header is not None
23 assert t.frame_set is not None
24 assert len(t.frame_set) == 0
25
26
27 def testFileInfoConstructor():
28 from eyed3.id3.tag import FileInfo
29
30 # Both bytes and unicode input file names must be accepted and the former
31 # must be converted to unicode.
32 for name in [__file__, str(__file__)]:
33 fi = FileInfo(name)
34 assert type(fi.name) is str
35 assert name == str(name)
36 assert fi.tag_size == 0
37
38
39 def testTagMainProps():
40 tag = Tag()
41
42 # No version yet
43 assert tag.version == ID3_DEFAULT_VERSION
44 assert not(tag.isV1())
45 assert tag.isV2()
46
47 assert tag.artist is None
48 tag.artist = "Autolux"
49 assert tag.artist == "Autolux"
50 assert len(tag.frame_set) == 1
51
52 tag.artist = ""
53 assert len(tag.frame_set) == 0
54 tag.artist = "Autolux"
55
56 assert tag.album is None
57 tag.album = "Future Perfect"
58 assert tag.album == "Future Perfect"
59
60 assert tag.album_artist is None
61 tag.album_artist = "Various Artists"
62 assert (tag.album_artist == "Various Artists")
63
64 assert (tag.title is None)
65 tag.title = "Robots in the Garden"
66 assert (tag.title == "Robots in the Garden")
67
68 assert (tag.track_num == (None, None))
69 tag.track_num = 7
70 assert (tag.track_num == (7, None))
71 tag.track_num = (7, None)
72 assert (tag.track_num == (7, None))
73 tag.track_num = (7, 15)
74 assert (tag.frame_set[frames.TRACKNUM_FID][0].text == "07/15")
75 assert (tag.track_num == (7, 15))
76 tag.track_num = (7, 150)
77 assert (tag.frame_set[frames.TRACKNUM_FID][0].text == "007/150")
78 assert (tag.track_num == (7, 150))
79 tag.track_num = (1, 7)
80 assert (tag.frame_set[frames.TRACKNUM_FID][0].text == "01/07")
81 assert (tag.track_num == (1, 7))
82 tag.track_num = None
83 assert (tag.track_num == (None, None))
84 tag.track_num = None, None
85
86
87 def testTagDates():
88 tag = Tag()
89 tag.release_date = 2004
90 assert tag.release_date == Date(2004)
91
92 tag.release_date = None
93 assert tag.release_date is None
94
95 tag = Tag()
96 for date in [Date(2002), Date(2002, 11, 26), Date(2002, 11, 26),
97 Date(2002, 11, 26, 4), Date(2002, 11, 26, 4, 20),
98 Date(2002, 11, 26, 4, 20), Date(2002, 11, 26, 4, 20, 10)]:
99
100 tag.encoding_date = date
101 assert (tag.encoding_date == date)
102 tag.encoding_date = str(date)
103 assert (tag.encoding_date == date)
104
105 tag.release_date = date
106 assert (tag.release_date == date)
107 tag.release_date = str(date)
108 assert (tag.release_date == date)
109
110 tag.original_release_date = date
111 assert (tag.original_release_date == date)
112 tag.original_release_date = str(date)
113 assert (tag.original_release_date == date)
114
115 tag.recording_date = date
116 assert (tag.recording_date == date)
117 tag.recording_date = str(date)
118 assert (tag.recording_date == date)
119
120 tag.tagging_date = date
121 assert (tag.tagging_date == date)
122 tag.tagging_date = str(date)
123 assert (tag.tagging_date == date)
124
125 try:
126 tag._setDate(b"TDRL", 2.4)
127 except TypeError:
128 pass # expected
129 else:
130 assert not("Invalid date type, expected TypeError")
131
132
133 def testTagComments():
134 tag = Tag()
135 for c in tag.comments:
136 assert not("Expected not to be here")
137
138 # Adds
139 with pytest.raises(TypeError):
140 tag.comments.set(b"bold")
141 with pytest.raises(TypeError):
142 tag.comments.set("bold", b"search")
143
144 tag.comments.set("Always Try", "")
145 assert (len(tag.comments) == 1)
146 c = tag.comments[0]
147 assert (c.description == "")
148 assert (c.text == "Always Try")
149 assert (c.lang == b"eng")
150
151 tag.comments.set("Speak Out", "Bold")
152 assert (len(tag.comments) == 2)
153 c = tag.comments[1]
154 assert (c.description == "Bold")
155 assert (c.text == "Speak Out")
156 assert (c.lang == b"eng")
157
158 tag.comments.set("K Town Mosh Crew", "Crippled Youth", b"sxe")
159 assert (len(tag.comments) == 3)
160 c = tag.comments[2]
161 assert (c.description == "Crippled Youth")
162 assert (c.text == "K Town Mosh Crew")
163 assert (c.lang == b"sxe")
164
165 # Lang is different, new frame
166 tag.comments.set("K Town Mosh Crew", "Crippled Youth", b"eng")
167 assert (len(tag.comments) == 4)
168 c = tag.comments[3]
169 assert (c.description == "Crippled Youth")
170 assert (c.text == "K Town Mosh Crew")
171 assert (c.lang == b"eng")
172
173 # Gets
174 assert (tag.comments.get("", "fre") is None)
175 assert (tag.comments.get("Crippled Youth", b"esp") is None)
176
177 c = tag.comments.get("")
178 assert c
179 assert (c.description == "")
180 assert (c.text == "Always Try")
181 assert (c.lang == b"eng")
182
183 assert tag.comments.get("Bold") is not None
184 assert tag.comments.get("Bold", b"eng") is not None
185 assert tag.comments.get("Crippled Youth", b"eng") is not None
186 assert tag.comments.get("Crippled Youth", b"sxe") is not None
187
188 assert (len(tag.comments) == 4)
189
190 # Iterate
191 count = 0
192 for c in tag.comments:
193 count += 1
194 assert count == 4
195
196 # Index access
197 assert tag.comments[0]
198 assert tag.comments[1]
199 assert tag.comments[2]
200 assert tag.comments[3]
201
202 try:
203 c = tag.comments[4]
204 except IndexError:
205 pass # expected
206 else:
207 assert not("Expected IndexError, but got success")
208
209 # Removal
210 with pytest.raises(TypeError):
211 tag.comments.remove(b"not unicode")
212 assert (tag.comments.remove("foobazz") is None)
213
214 c = tag.comments.get("Bold")
215 assert c is not None
216 c2 = tag.comments.remove("Bold")
217 assert (c == c2)
218 assert (len(tag.comments) == 3)
219
220 c = tag.comments.get("Crippled Youth", b"eng")
221 assert c is not None
222 c2 = tag.comments.remove("Crippled Youth", b"eng")
223 assert (c == c2)
224 assert (len(tag.comments) == 2)
225
226 assert (tag.comments.remove("Crippled Youth", b"eng") is None)
227 assert (len(tag.comments) == 2)
228
229 assert (tag.comments.get("") == tag.comments.remove(""))
230 assert (len(tag.comments) == 1)
231
232 assert (tag.comments.get("Crippled Youth", b"sxe") ==
233 tag.comments.remove("Crippled Youth", b"sxe"))
234 assert (len(tag.comments) == 0)
235
236 # Index Error when there are no comments
237 try:
238 c = tag.comments[0]
239 except IndexError:
240 pass # expected
241 else:
242 assert not("Expected IndexError, but got success")
243
244 # Replacing frames thru add and frame object preservation
245 tag = Tag()
246 c1 = tag.comments.set("Snoop", "Dog", b"rap")
247 assert tag.comments.get("Dog", b"rap").text == "Snoop"
248 c1.text = "Lollipop"
249 assert tag.comments.get("Dog", b"rap").text == "Lollipop"
250 # now thru add
251 c2 = tag.comments.set("Doggy", "Dog", b"rap")
252 assert id(c1) == id(c2)
253 assert tag.comments.get("Dog", b"rap").text == "Doggy"
254
255
256 def testTagBPM():
257 tag = Tag()
258 assert (tag.bpm is None)
259
260 tag.bpm = 150
261 assert (tag.bpm == 150)
262 assert (tag.frame_set[b"TBPM"])
263
264 tag.bpm = 180
265 assert (tag.bpm == 180)
266 assert (tag.frame_set[b"TBPM"])
267 assert (len(tag.frame_set[b"TBPM"]) == 1)
268
269 tag.bpm = 190.5
270 assert type(tag.bpm) is int
271 assert tag.bpm == 191
272 assert len(tag.frame_set[b"TBPM"]) == 1
273
274
275 def testTagPlayCount():
276 tag = Tag()
277 assert (tag.play_count is None)
278
279 tag.play_count = 0
280 assert tag.play_count == 0
281 tag.play_count = 1
282 assert tag.play_count == 1
283 tag.play_count += 1
284 assert tag.play_count == 2
285 tag.play_count -= 1
286 assert tag.play_count == 1
287 tag.play_count *= 5
288 assert tag.play_count == 5
289
290 tag.play_count = None
291 assert tag.play_count is None
292
293 try:
294 tag.play_count = -1
295 except ValueError:
296 pass # expected
297 else:
298 assert not("Invalid play count, expected ValueError")
299
300
301 def testTagPublisher():
302 t = Tag()
303 assert (t.publisher is None)
304
305 try:
306 t.publisher = b"not unicode"
307 except TypeError:
308 pass #expected
309 else:
310 assert not("Expected TypeError when setting non-unicode publisher")
311
312 t.publisher = "Dischord"
313 assert t.publisher == "Dischord"
314 t.publisher = "Infinity Cat"
315 assert t.publisher == "Infinity Cat"
316
317 t.publisher = None
318 assert t.publisher is None
319
320
321 def testTagCdId():
322 tag = Tag()
323 assert tag.cd_id is None
324
325 tag.cd_id = b"\x01\x02"
326 assert tag.cd_id == b"\x01\x02"
327
328 tag.cd_id = b"\xff" * 804
329 assert tag.cd_id == b"\xff" * 804
330
331 try:
332 tag.cd_id = b"\x00" * 805
333 except ValueError:
334 pass # expected
335 else:
336 assert not("CD id is too long, expected ValueError")
337
338
339 def testTagImages():
340 from eyed3.id3.frames import ImageFrame
341
342 tag = Tag()
343
344 # No images
345 assert len(tag.images) == 0
346 for i in tag.images:
347 assert not("Expected no images")
348 try:
349 img = tag.images[0]
350 except IndexError:
351 pass #expected
352 else:
353 assert not("Expected IndexError for no images")
354 assert (tag.images.get("") is None)
355
356 # Image types must be within range
357 for i in range(ImageFrame.MIN_TYPE, ImageFrame.MAX_TYPE):
358 tag.images.set(i, b"\xff", b"img")
359 for i in (ImageFrame.MIN_TYPE - 1, ImageFrame.MAX_TYPE + 1):
360 try:
361 tag.images.set(i, b"\xff", b"img")
362 except ValueError:
363 pass # expected
364 else:
365 assert not("Expected ValueError for invalid picture type")
366
367 tag = Tag()
368 tag.images.set(ImageFrame.FRONT_COVER, b"\xab\xcd", b"img/gif")
369 assert (len(tag.images) == 1)
370 assert (tag.images[0].description == "")
371 assert (tag.images[0].picture_type == ImageFrame.FRONT_COVER)
372 assert (tag.images[0].image_data == b"\xab\xcd")
373 assert (tag.images[0].mime_type == "img/gif")
374 assert (tag.images[0]._mime_type == b"img/gif")
375 assert (tag.images[0].image_url is None)
376
377 assert (tag.images.get("").description == "")
378 assert (tag.images.get("").picture_type == ImageFrame.FRONT_COVER)
379 assert (tag.images.get("").image_data == b"\xab\xcd")
380 assert (tag.images.get("").mime_type == "img/gif")
381 assert (tag.images.get("")._mime_type == b"img/gif")
382 assert (tag.images.get("").image_url is None)
383
384 tag.images.set(ImageFrame.FRONT_COVER, b"\xdc\xba", b"img/gif",
385 "Different")
386 assert len(tag.images) == 2
387 assert tag.images[1].description == "Different"
388 assert tag.images[1].picture_type == ImageFrame.FRONT_COVER
389 assert tag.images[1].image_data == b"\xdc\xba"
390 assert tag.images[1].mime_type == "img/gif"
391 assert tag.images[1]._mime_type == b"img/gif"
392 assert tag.images[1].image_url is None
393
394 assert (tag.images.get("Different").description == "Different")
395 assert (tag.images.get("Different").picture_type == ImageFrame.FRONT_COVER)
396 assert (tag.images.get("Different").image_data == b"\xdc\xba")
397 assert (tag.images.get("Different").mime_type == "img/gif")
398 assert (tag.images.get("Different")._mime_type == b"img/gif")
399 assert (tag.images.get("Different").image_url is None)
400
401 # This is an update (same description)
402 tag.images.set(ImageFrame.BACK_COVER, b"\xff\xef", b"img/jpg", "Different")
403 assert (len(tag.images) == 2)
404 assert (tag.images[1].description == "Different")
405 assert (tag.images[1].picture_type == ImageFrame.BACK_COVER)
406 assert (tag.images[1].image_data == b"\xff\xef")
407 assert (tag.images[1].mime_type == "img/jpg")
408 assert (tag.images[1].image_url is None)
409
410 assert (tag.images.get("Different").description == "Different")
411 assert (tag.images.get("Different").picture_type == ImageFrame.BACK_COVER)
412 assert (tag.images.get("Different").image_data == b"\xff\xef")
413 assert (tag.images.get("Different").mime_type == "img/jpg")
414 assert (tag.images.get("Different").image_url is None)
415
416 count = 0
417 for img in tag.images:
418 count += 1
419 assert count == 2
420
421 # Remove
422 img = tag.images.remove("")
423 assert (img.description == "")
424 assert (img.picture_type == ImageFrame.FRONT_COVER)
425 assert (img.image_data == b"\xab\xcd")
426 assert (img.mime_type == "img/gif")
427 assert (img.image_url is None)
428 assert (len(tag.images) == 1)
429
430 img = tag.images.remove("Different")
431 assert img.description == "Different"
432 assert img.picture_type == ImageFrame.BACK_COVER
433 assert img.image_data == b"\xff\xef"
434 assert img.mime_type == "img/jpg"
435 assert img.image_url is None
436 assert len(tag.images) == 0
437
438 assert (tag.images.remove("Lundqvist") is None)
439
440 # Unicode enforcement
441 with pytest.raises(TypeError):
442 tag.images.get(b"not Unicode")
443 with pytest.raises(TypeError):
444 tag.images.set(ImageFrame.ICON, "\xff", "img", b"not Unicode")
445 with pytest.raises(TypeError):
446 tag.images.remove(b"not Unicode")
447
448 # Image URL
449 tag = Tag()
450 tag.images.set(ImageFrame.BACK_COVER, None, None, "A URL",
451 img_url=b"http://www.tumblr.com/tagged/ty-segall")
452 img = tag.images.get("A URL")
453 assert img is not None
454 assert (img.image_data is None)
455 assert (img.image_url == b"http://www.tumblr.com/tagged/ty-segall")
456 assert (img.mime_type == "-->")
457 assert (img._mime_type == b"-->")
458
459 # Unicode mime-type in, converted to bytes
460 tag = Tag()
461 tag.images.set(ImageFrame.BACK_COVER, b"\x00", "img/jpg")
462 img = tag.images[0]
463 assert isinstance(img._mime_type, bytes)
464 img.mime_type = ""
465 assert isinstance(img._mime_type, bytes)
466 img.mime_type = None
467 assert isinstance(img._mime_type, bytes)
468 assert img.mime_type == ""
469
470
471 def testTagLyrics():
472 tag = Tag()
473 for c in tag.lyrics:
474 assert not("Expected not to be here")
475
476 # Adds
477 with pytest.raises(TypeError):
478 tag.lyrics.set(b"bold")
479 with pytest.raises(TypeError):
480 tag.lyrics.set("bold", b"search")
481
482 tag.lyrics.set("Always Try", "")
483 assert (len(tag.lyrics) == 1)
484 c = tag.lyrics[0]
485 assert (c.description == "")
486 assert (c.text == "Always Try")
487 assert (c.lang == b"eng")
488
489 tag.lyrics.set("Speak Out", "Bold")
490 assert (len(tag.lyrics) == 2)
491 c = tag.lyrics[1]
492 assert (c.description == "Bold")
493 assert (c.text == "Speak Out")
494 assert (c.lang == b"eng")
495
496 tag.lyrics.set("K Town Mosh Crew", "Crippled Youth", b"sxe")
497 assert (len(tag.lyrics) == 3)
498 c = tag.lyrics[2]
499 assert (c.description == "Crippled Youth")
500 assert (c.text == "K Town Mosh Crew")
501 assert (c.lang == b"sxe")
502
503 # Lang is different, new frame
504 tag.lyrics.set("K Town Mosh Crew", "Crippled Youth", b"eng")
505 assert (len(tag.lyrics) == 4)
506 c = tag.lyrics[3]
507 assert (c.description == "Crippled Youth")
508 assert (c.text == "K Town Mosh Crew")
509 assert (c.lang == b"eng")
510
511 # Gets
512 assert (tag.lyrics.get("", b"fre") is None)
513 assert (tag.lyrics.get("Crippled Youth", b"esp") is None)
514
515 c = tag.lyrics.get("")
516 assert (c)
517 assert (c.description == "")
518 assert (c.text == "Always Try")
519 assert (c.lang == b"eng")
520
521 assert tag.lyrics.get("Bold") is not None
522 assert tag.lyrics.get("Bold", b"eng") is not None
523 assert tag.lyrics.get("Crippled Youth", b"eng") is not None
524 assert tag.lyrics.get("Crippled Youth", b"sxe") is not None
525
526 assert (len(tag.lyrics) == 4)
527
528 # Iterate
529 count = 0
530 for c in tag.lyrics:
531 count += 1
532 assert (count == 4)
533
534 # Index access
535 assert (tag.lyrics[0])
536 assert (tag.lyrics[1])
537 assert (tag.lyrics[2])
538 assert (tag.lyrics[3])
539
540 try:
541 c = tag.lyrics[4]
542 except IndexError:
543 pass # expected
544 else:
545 assert not("Expected IndexError, but got success")
546
547 # Removal
548 with pytest.raises(TypeError):
549 tag.lyrics.remove(b"not unicode")
550 assert tag.lyrics.remove("foobazz") is None
551
552 c = tag.lyrics.get("Bold")
553 assert c is not None
554 c2 = tag.lyrics.remove("Bold")
555 assert c == c2
556 assert len(tag.lyrics) == 3
557
558 c = tag.lyrics.get("Crippled Youth", b"eng")
559 assert c is not None
560 c2 = tag.lyrics.remove("Crippled Youth", b"eng")
561 assert c == c2
562 assert len(tag.lyrics) == 2
563
564 assert tag.lyrics.remove("Crippled Youth", b"eng") is None
565 assert len(tag.lyrics) == 2
566
567 assert tag.lyrics.get("") == tag.lyrics.remove("")
568 assert len(tag.lyrics) == 1
569
570 assert (tag.lyrics.get("Crippled Youth", b"sxe") ==
571 tag.lyrics.remove("Crippled Youth", b"sxe"))
572 assert len(tag.lyrics) == 0
573
574 # Index Error when there are no lyrics
575 try:
576 c = tag.lyrics[0]
577 except IndexError:
578 pass # expected
579 else:
580 assert not("Expected IndexError, but got success")
581
582
583 def testTagObjects():
584 tag = Tag()
585
586 # No objects
587 assert len(tag.objects) == 0
588 for i in tag.objects:
589 assert not("Expected no objects")
590 try:
591 img = tag.objects[0]
592 except IndexError:
593 pass #expected
594 else:
595 assert not("Expected IndexError for no objects")
596 assert (tag.objects.get("") is None)
597
598 tag = Tag()
599 tag.objects.set(b"\xab\xcd", b"img/gif")
600 assert (len(tag.objects) == 1)
601 assert (tag.objects[0].description == "")
602 assert (tag.objects[0].filename == "")
603 assert (tag.objects[0].object_data == b"\xab\xcd")
604 assert (tag.objects[0]._mime_type == b"img/gif")
605 assert (tag.objects[0].mime_type == "img/gif")
606
607 assert (tag.objects.get("").description == "")
608 assert (tag.objects.get("").filename == "")
609 assert (tag.objects.get("").object_data == b"\xab\xcd")
610 assert (tag.objects.get("").mime_type == "img/gif")
611
612 tag.objects.set(b"\xdc\xba", b"img/gif", "Different")
613 assert (len(tag.objects) == 2)
614 assert (tag.objects[1].description == "Different")
615 assert (tag.objects[1].filename == "")
616 assert (tag.objects[1].object_data == b"\xdc\xba")
617 assert (tag.objects[1]._mime_type == b"img/gif")
618 assert (tag.objects[1].mime_type == "img/gif")
619
620 assert (tag.objects.get("Different").description == "Different")
621 assert (tag.objects.get("Different").filename == "")
622 assert (tag.objects.get("Different").object_data == b"\xdc\xba")
623 assert (tag.objects.get("Different").mime_type == "img/gif")
624 assert (tag.objects.get("Different")._mime_type == b"img/gif")
625
626 # This is an update (same description)
627 tag.objects.set(b"\xff\xef", b"img/jpg", "Different",
628 "example_filename.XXX")
629 assert (len(tag.objects) == 2)
630 assert (tag.objects[1].description == "Different")
631 assert (tag.objects[1].filename == "example_filename.XXX")
632 assert (tag.objects[1].object_data == b"\xff\xef")
633 assert (tag.objects[1].mime_type == "img/jpg")
634
635 assert (tag.objects.get("Different").description == "Different")
636 assert (tag.objects.get("Different").filename == "example_filename.XXX")
637 assert (tag.objects.get("Different").object_data == b"\xff\xef")
638 assert (tag.objects.get("Different").mime_type == "img/jpg")
639
640 count = 0
641 for obj in tag.objects:
642 count += 1
643 assert (count == 2)
644
645 # Remove
646 obj = tag.objects.remove("")
647 assert (obj.description == "")
648 assert (obj.filename == "")
649 assert (obj.object_data == b"\xab\xcd")
650 assert (obj.mime_type == "img/gif")
651 assert (len(tag.objects) == 1)
652
653 obj = tag.objects.remove("Different")
654 assert (obj.description == "Different")
655 assert (obj.filename == "example_filename.XXX")
656 assert (obj.object_data == b"\xff\xef")
657 assert (obj.mime_type == "img/jpg")
658 assert (obj._mime_type == b"img/jpg")
659 assert (len(tag.objects) == 0)
660
661 assert (tag.objects.remove("Dubinsky") is None)
662
663 # Unicode enforcement
664 with pytest.raises(TypeError):
665 tag.objects.get(b"not Unicode")
666 with pytest.raises(TypeError):
667 tag.objects.set("\xff", "img", b"not Unicode")
668 with pytest.raises(TypeError):
669 tag.objects.set("\xff", "img", "Unicode", b"not unicode")
670 with pytest.raises(TypeError):
671 tag.objects.remove(b"not Unicode")
672
673
674 def testTagPrivates():
675 tag = Tag()
676
677 # No private frames
678 assert len(tag.privates) == 0
679 for i in tag.privates:
680 assert not("Expected no privates")
681 try:
682 img = tag.privates[0]
683 except IndexError:
684 pass #expected
685 else:
686 assert not("Expected IndexError for no privates")
687 assert (tag.privates.get(b"") is None)
688
689 tag = Tag()
690 tag.privates.set(b"\xab\xcd", b"owner1")
691 assert (len(tag.privates) == 1)
692 assert (tag.privates[0].owner_id == b"owner1")
693 assert (tag.privates[0].owner_data == b"\xab\xcd")
694
695 assert (tag.privates.get(b"owner1").owner_id == b"owner1")
696 assert (tag.privates.get(b"owner1").owner_data == b"\xab\xcd")
697
698 tag.privates.set(b"\xba\xdc", b"owner2")
699 assert (len(tag.privates) == 2)
700 assert (tag.privates[1].owner_id == b"owner2")
701 assert (tag.privates[1].owner_data == b"\xba\xdc")
702
703 assert (tag.privates.get(b"owner2").owner_id == b"owner2")
704 assert (tag.privates.get(b"owner2").owner_data == b"\xba\xdc")
705
706
707 # This is an update (same description)
708 tag.privates.set(b"\x00\x00\x00", b"owner1")
709 assert (len(tag.privates) == 2)
710 assert (tag.privates[0].owner_id == b"owner1")
711 assert (tag.privates[0].owner_data == b"\x00\x00\x00")
712
713 assert (tag.privates.get(b"owner1").owner_id == b"owner1")
714 assert (tag.privates.get(b"owner1").owner_data == b"\x00\x00\x00")
715
716 count = 0
717 for f in tag.privates:
718 count += 1
719 assert (count == 2)
720
721 # Remove
722 priv = tag.privates.remove(b"owner1")
723 assert (priv.owner_id == b"owner1")
724 assert (priv.owner_data == b"\x00\x00\x00")
725 assert (len(tag.privates) == 1)
726
727 priv = tag.privates.remove(b"owner2")
728 assert (priv.owner_id == b"owner2")
729 assert (priv.owner_data == b"\xba\xdc")
730 assert (len(tag.privates) == 0)
731
732 assert tag.objects.remove("Callahan") is None
733
734
735 def testTagDiscNum():
736 tag = Tag()
737
738 assert (tag.disc_num == (None, None))
739 tag.disc_num = 7
740 assert (tag.disc_num == (7, None))
741 tag.disc_num = (7, None)
742 assert (tag.disc_num == (7, None))
743 tag.disc_num = (7, 15)
744 assert (tag.frame_set[frames.DISCNUM_FID][0].text == "07/15")
745 assert (tag.disc_num == (7, 15))
746 tag.disc_num = (7, 150)
747 assert (tag.frame_set[frames.DISCNUM_FID][0].text == "007/150")
748 assert (tag.disc_num == (7, 150))
749 tag.disc_num = (1, 7)
750 assert (tag.frame_set[frames.DISCNUM_FID][0].text == "01/07")
751 assert (tag.disc_num == (1, 7))
752 tag.disc_num = None
753 assert (tag.disc_num == (None, None))
754 tag.disc_num = None, None
755
756
757 def testTagGenre():
758 from eyed3.id3 import Genre
759
760 tag = Tag()
761
762 assert (tag.genre is None)
763
764 try:
765 tag.genre = b"Not Unicode"
766 except TypeError:
767 pass # expected
768 else:
769 assert not "Non unicode genre, expected TypeError"
770
771 gobj = Genre("Hardcore")
772
773 tag.genre = "Hardcore"
774 assert (tag.genre.name == "Hardcore")
775 assert (tag.genre == gobj)
776
777 tag.genre = 130
778 assert tag.genre.id == 130
779 assert tag.genre.name == "Terror"
780
781 tag.genre = 0
782 assert tag.genre.id == 0
783 assert tag.genre.name == "Blues"
784
785 tag.genre = None
786 assert tag.genre is None
787 assert tag.frame_set[b"TCON"] is None
788
789
790 def testTagUserTextFrames():
791 tag = Tag()
792
793 assert (len(tag.user_text_frames) == 0)
794 utf1 = tag.user_text_frames.set("Custom content")
795 assert (tag.user_text_frames.get("").text == "Custom content")
796
797 utf2 = tag.user_text_frames.set("Content custom", "Desc1")
798 assert (tag.user_text_frames.get("Desc1").text == "Content custom")
799
800 assert (len(tag.user_text_frames) == 2)
801
802 utf3 = tag.user_text_frames.set("New content", "")
803 assert (tag.user_text_frames.get("").text == "New content")
804 assert (len(tag.user_text_frames) == 2)
805 assert (id(utf1) == id(utf3))
806
807 assert (tag.user_text_frames[0] == utf1)
808 assert (tag.user_text_frames[1] == utf2)
809 assert (tag.user_text_frames.get("") == utf1)
810 assert (tag.user_text_frames.get("Desc1") == utf2)
811
812 tag.user_text_frames.remove("")
813 assert (len(tag.user_text_frames) == 1)
814 tag.user_text_frames.remove("Desc1")
815 assert (len(tag.user_text_frames) == 0)
816
817 tag.user_text_frames.set("Foobazz", "Desc2")
818 assert (len(tag.user_text_frames) == 1)
819
820
821 def testTagUrls():
822 tag = Tag()
823 url = "http://example.com/"
824 url2 = "http://sample.com/"
825
826 tag.commercial_url = url
827 assert (tag.commercial_url == url)
828 tag.commercial_url = url2
829 assert (tag.commercial_url == url2)
830 tag.commercial_url = None
831 assert (tag.commercial_url is None)
832
833 tag.copyright_url = url
834 assert (tag.copyright_url == url)
835 tag.copyright_url = url2
836 assert (tag.copyright_url == url2)
837 tag.copyright_url = None
838 assert (tag.copyright_url is None)
839
840 tag.audio_file_url = url
841 assert (tag.audio_file_url == url)
842 tag.audio_file_url = url2
843 assert (tag.audio_file_url == url2)
844 tag.audio_file_url = None
845 assert (tag.audio_file_url is None)
846
847 tag.audio_source_url = url
848 assert (tag.audio_source_url == url)
849 tag.audio_source_url = url2
850 assert (tag.audio_source_url == url2)
851 tag.audio_source_url = None
852 assert (tag.audio_source_url is None)
853
854 tag.artist_url = url
855 assert (tag.artist_url == url)
856 tag.artist_url = url2
857 assert (tag.artist_url == url2)
858 tag.artist_url = None
859 assert (tag.artist_url is None)
860
861 tag.internet_radio_url = url
862 assert (tag.internet_radio_url == url)
863 tag.internet_radio_url = url2
864 assert (tag.internet_radio_url == url2)
865 tag.internet_radio_url = None
866 assert (tag.internet_radio_url is None)
867
868 tag.payment_url = url
869 assert (tag.payment_url == url)
870 tag.payment_url = url2
871 assert (tag.payment_url == url2)
872 tag.payment_url = None
873 assert (tag.payment_url is None)
874
875 tag.publisher_url = url
876 assert (tag.publisher_url == url)
877 tag.publisher_url = url2
878 assert (tag.publisher_url == url2)
879 tag.publisher_url = None
880 assert (tag.publisher_url is None)
881
882 # Frame ID enforcement
883 with pytest.raises(ValueError):
884 tag._setUrlFrame("WDDD", "url")
885 with pytest.raises(ValueError):
886 tag._getUrlFrame("WDDD")
887
888
889 def testTagUniqIds():
890 tag = Tag()
891
892 assert (len(tag.unique_file_ids) == 0)
893
894 tag.unique_file_ids.set(b"http://music.com/12354", b"test")
895 tag.unique_file_ids.set(b"1234", b"http://eyed3.nicfit.net")
896 assert tag.unique_file_ids.get(b"test").uniq_id == b"http://music.com/12354"
897 assert (tag.unique_file_ids.get(b"http://eyed3.nicfit.net").uniq_id ==
898 b"1234")
899
900 assert len(tag.unique_file_ids) == 2
901 tag.unique_file_ids.remove(b"test")
902 assert len(tag.unique_file_ids) == 1
903
904 tag.unique_file_ids.set(b"4321", b"http://eyed3.nicfit.net")
905 assert len(tag.unique_file_ids) == 1
906 assert (tag.unique_file_ids.get(b"http://eyed3.nicfit.net").uniq_id ==
907 b"4321")
908
909 tag.unique_file_ids.set("1111", "")
910 assert len(tag.unique_file_ids) == 2
911
912
913 def testTagUniqIdsUnicode():
914 tag = Tag()
915
916 assert (len(tag.unique_file_ids) == 0)
917
918 tag.unique_file_ids.set("http://music.com/12354", "test")
919 tag.unique_file_ids.set("1234", "http://eyed3.nicfit.net")
920 assert tag.unique_file_ids.get("test").uniq_id == b"http://music.com/12354"
921 assert (tag.unique_file_ids.get("http://eyed3.nicfit.net").uniq_id == b"1234")
922
923 assert len(tag.unique_file_ids) == 2
924 tag.unique_file_ids.remove("test")
925 assert len(tag.unique_file_ids) == 1
926
927 tag.unique_file_ids.set("4321", "http://eyed3.nicfit.net")
928 assert len(tag.unique_file_ids) == 1
929 assert (tag.unique_file_ids.get("http://eyed3.nicfit.net").uniq_id == b"4321")
930
931 def testTagUserUrls():
932 tag = Tag()
933
934 assert (len(tag.user_url_frames) == 0)
935 uuf1 = tag.user_url_frames.set(b"http://yo.yo.com/")
936 assert (tag.user_url_frames.get("").url == "http://yo.yo.com/")
937
938 utf2 = tag.user_url_frames.set("http://run.dmc.org", "URL")
939 assert (tag.user_url_frames.get("URL").url == "http://run.dmc.org")
940
941 assert len(tag.user_url_frames) == 2
942
943 utf3 = tag.user_url_frames.set(b"http://my.adidas.com", "")
944 assert (tag.user_url_frames.get("").url == "http://my.adidas.com")
945 assert (len(tag.user_url_frames) == 2)
946 assert (id(uuf1) == id(utf3))
947
948 assert (tag.user_url_frames[0] == uuf1)
949 assert (tag.user_url_frames[1] == utf2)
950 assert (tag.user_url_frames.get("") == uuf1)
951 assert (tag.user_url_frames.get("URL") == utf2)
952
953 tag.user_url_frames.remove("")
954 assert (len(tag.user_url_frames) == 1)
955 tag.user_url_frames.remove("URL")
956 assert (len(tag.user_url_frames) == 0)
957
958 tag.user_url_frames.set("Foobazz", "Desc2")
959 assert (len(tag.user_url_frames) == 1)
960
961
962 def testSortOrderConversions():
963 test_file = "/tmp/soconvert.id3"
964
965 tag = Tag()
966 # 2.3 frames to 2.4
967 for fid in [b"XSOA", b"XSOP", b"XSOT"]:
968 frame = frames.TextFrame(fid)
969 frame.text = fid.decode("ascii")
970 tag.frame_set[fid] = frame
971 try:
972 tag.save(test_file) # v2.4 is the default
973 tag = eyed3.load(test_file).tag
974 assert (tag.version == ID3_V2_4)
975 assert (len(tag.frame_set) == 3)
976 del tag.frame_set[b"TSOA"]
977 del tag.frame_set[b"TSOP"]
978 del tag.frame_set[b"TSOT"]
979 assert (len(tag.frame_set) == 0)
980 finally:
981 os.remove(test_file)
982
983 tag = Tag()
984 # 2.4 frames to 2.3
985 for fid in [b"TSOA", b"TSOP", b"TSOT"]:
986 frame = frames.TextFrame(fid)
987 frame.text = str(fid)
988 tag.frame_set[fid] = frame
989 try:
990 tag.save(test_file, version=eyed3.id3.ID3_V2_3)
991 tag = eyed3.load(test_file).tag
992 assert (tag.version == ID3_V2_3)
993 assert (len(tag.frame_set) == 3)
994 del tag.frame_set[b"XSOA"]
995 del tag.frame_set[b"XSOP"]
996 del tag.frame_set[b"XSOT"]
997 assert (len(tag.frame_set) == 0)
998 finally:
999 os.remove(test_file)
1000
1001
1002 def test_XDOR_TDOR_Conversions():
1003 test_file = "/tmp/xdortdrc.id3"
1004
1005 tag = Tag()
1006 # 2.3 frames to 2.4
1007 frame = frames.DateFrame(b"XDOR", "1990-06-24")
1008 tag.frame_set[b"XDOR"] = frame
1009 try:
1010 tag.save(test_file) # v2.4 is the default
1011 tag = eyed3.load(test_file).tag
1012 assert tag.version == ID3_V2_4
1013 assert len(tag.frame_set) == 1
1014 del tag.frame_set[b"TDOR"]
1015 assert len(tag.frame_set) == 0
1016 finally:
1017 os.remove(test_file)
1018
1019 tag = Tag()
1020 # 2.4 frames to 2.3
1021 frame = frames.DateFrame(b"TDRC", "2012-10-21")
1022 tag.frame_set[frame.id] = frame
1023 try:
1024 tag.save(test_file, version=eyed3.id3.ID3_V2_3)
1025 tag = eyed3.load(test_file).tag
1026 assert tag.version == ID3_V2_3
1027 assert len(tag.frame_set) == 2
1028 del tag.frame_set[b"TYER"]
1029 del tag.frame_set[b"TDAT"]
1030 assert len(tag.frame_set) == 0
1031 finally:
1032 os.remove(test_file)
1033
1034
1035 def test_TSST_Conversions():
1036 test_file = "/tmp/tsst.id3"
1037
1038 tag = Tag()
1039 # 2.4 TSST to 2.3 TIT3
1040 tag.frame_set.setTextFrame(b"TSST", "Subtitle")
1041 try:
1042 tag.save(test_file) # v2.4 is the default
1043 tag = eyed3.load(test_file).tag
1044 assert tag.version == ID3_V2_4
1045 assert len(tag.frame_set) == 1
1046 del tag.frame_set[b"TSST"]
1047 assert len(tag.frame_set) == 0
1048
1049 tag.frame_set.setTextFrame(b"TSST", "Subtitle")
1050 tag.save(test_file, version=eyed3.id3.ID3_V2_3)
1051 tag = eyed3.load(test_file).tag
1052 assert b"TXXX" in tag.frame_set
1053 txxx = tag.frame_set[b"TXXX"][0]
1054 assert txxx.text == "Subtitle"
1055 assert txxx.description == "Subtitle (converted)"
1056
1057 finally:
1058 os.remove(test_file)
1059
1060
1061 @unittest.skipIf(not os.path.exists(DATA_D), "test requires data files")
1062 def testChapterExampleTag():
1063 tag = eyed3.load(os.path.join(DATA_D, "id3_chapters_example.mp3")).tag
1064
1065 assert len(tag.table_of_contents) == 1
1066 toc = list(tag.table_of_contents)[0]
1067
1068 assert id(toc) == id(tag.table_of_contents.get(toc.element_id))
1069
1070 assert toc.element_id == b"toc1"
1071 assert toc.description is None
1072 assert toc.toplevel
1073 assert toc.ordered
1074 assert toc.child_ids == [b'ch1', b'ch2', b'ch3']
1075
1076 assert tag.chapters.get(b"ch1").title == "start"
1077 assert tag.chapters.get(b"ch1").subtitle is None
1078 assert tag.chapters.get(b"ch1").user_url is None
1079 assert tag.chapters.get(b"ch1").times == (0, 5000)
1080 assert tag.chapters.get(b"ch1").offsets == (None, None)
1081 assert len(tag.chapters.get(b"ch1").sub_frames) == 1
1082
1083 assert tag.chapters.get(b"ch2").title == "5 seconds"
1084 assert tag.chapters.get(b"ch2").subtitle is None
1085 assert tag.chapters.get(b"ch2").user_url is None
1086 assert tag.chapters.get(b"ch2").times == (5000, 10000)
1087 assert tag.chapters.get(b"ch2").offsets == (None, None)
1088 assert len(tag.chapters.get(b"ch2").sub_frames) == 1
1089
1090 assert tag.chapters.get(b"ch3").title == "10 seconds"
1091 assert tag.chapters.get(b"ch3").subtitle is None
1092 assert tag.chapters.get(b"ch3").user_url is None
1093 assert tag.chapters.get(b"ch3").times == (10000, 15000)
1094 assert tag.chapters.get(b"ch3").offsets == (None, None)
1095 assert len(tag.chapters.get(b"ch3").sub_frames) == 1
1096
1097
1098 def testTableOfContents():
1099 test_file = "/tmp/toc.id3"
1100 t = Tag()
1101
1102 assert (len(t.table_of_contents) == 0)
1103
1104 toc_main = t.table_of_contents.set(b"main", toplevel=True,
1105 child_ids=[b"c1", b"c2", b"c3", b"c4"],
1106 description="Table of Conents")
1107 assert toc_main is not None
1108 assert (len(t.table_of_contents) == 1)
1109
1110 toc_dc = t.table_of_contents.set(b"director-cut", toplevel=False,
1111 ordered=False,
1112 child_ids=[b"d3", b"d1", b"d2"])
1113 assert toc_dc is not None
1114 assert (len(t.table_of_contents) == 2)
1115
1116 toc_dummy = t.table_of_contents.set(b"test")
1117 assert (len(t.table_of_contents) == 3)
1118 t.table_of_contents.remove(toc_dummy.element_id)
1119 assert (len(t.table_of_contents) == 2)
1120
1121 t.save(test_file)
1122 try:
1123 t2 = eyed3.load(test_file).tag
1124 finally:
1125 os.remove(test_file)
1126
1127 assert len(t.table_of_contents) == 2
1128
1129 assert t2.table_of_contents.get(b"main").toplevel
1130 assert t2.table_of_contents.get(b"main").ordered
1131 assert t2.table_of_contents.get(b"main").description == toc_main.description
1132 assert t2.table_of_contents.get(b"main").child_ids == toc_main.child_ids
1133
1134 assert (t2.table_of_contents.get(b"director-cut").toplevel ==
1135 toc_dc.toplevel)
1136 assert not t2.table_of_contents.get(b"director-cut").ordered
1137 assert (t2.table_of_contents.get(b"director-cut").description ==
1138 toc_dc.description)
1139 assert (t2.table_of_contents.get(b"director-cut").child_ids ==
1140 toc_dc.child_ids)
1141
1142
1143 def testChapters():
1144 test_file = "/tmp/chapters.id3"
1145 t = Tag()
1146
1147 ch1 = t.chapters.set(b"c1", (0, 200))
1148 ch2 = t.chapters.set(b"c2", (200, 300))
1149 ch3 = t.chapters.set(b"c3", (300, 375))
1150 ch4 = t.chapters.set(b"c4", (375, 600))
1151
1152 assert len(t.chapters) == 4
1153
1154 for i, c in enumerate(iter(t.chapters), 1):
1155 if i != 2:
1156 c.title = "Chapter %d" % i
1157 c.subtitle = "Subtitle %d" % i
1158 c.user_url = "http://example.com/%d" % i
1159
1160 t.save(test_file)
1161
1162 try:
1163 t2 = eyed3.load(test_file).tag
1164 finally:
1165 os.remove(test_file)
1166
1167 assert len(t2.chapters) == 4
1168 for i in range(1, 5):
1169 c = t2.chapters.get(str("c%d" % i).encode("latin1"))
1170 if i == 2:
1171 assert c.title is None
1172 assert c.subtitle is None
1173 assert c.user_url is None
1174 else:
1175 assert c.title == "Chapter %d" % i
1176 assert c.subtitle == "Subtitle %d" % i
1177 assert c.user_url == "http://example.com/%d" % i
1178
1179
1180 def testReadOnly():
1181 assert not(Tag.read_only)
1182
1183 t = Tag()
1184 assert not(t.read_only)
1185
1186 t.read_only = True
1187 with pytest.raises(RuntimeError):
1188 t.save()
1189 with pytest.raises(RuntimeError):
1190 t._saveV1Tag(None)
1191 with pytest.raises(RuntimeError):
1192 t._saveV2Tag(None, None, None)
1193
1194
1195 def testSetNumExceptions():
1196 t = Tag()
1197 with pytest.raises(ValueError) as ex:
1198 t.track_num = (1, 2, 3)
1199
1200
1201 @deprecation.fail_if_not_removed
1202 def testNonStdGenre():
1203 t = Tag()
1204 t.non_std_genre = "Black Lips"
1205 assert t.genre.id is None
1206 assert t.genre.name == "Black Lips"
1207
1208
1209 def testNumStringConvert():
1210 t = Tag()
1211
1212 t.track_num = "1"
1213 assert t.track_num == (1, None)
1214
1215 t.disc_num = ("2", "6")
1216 assert t.disc_num == (2, 6)
1217
1218
1219 def testReleaseDate_v23_v24():
1220 """v23 does not have release date, only original release date."""
1221 date = Date.parse("1980-07-03")
1222 date2 = Date.parse("1926-07-05")
1223 year = Date(1966)
1224
1225 tag = Tag()
1226 assert tag.version == ID3_DEFAULT_VERSION
1227
1228 tag.version = ID3_V2_3
1229 assert tag.version == ID3_V2_3
1230
1231 # Setting release date sets original release date
1232 # v2.3 TORY get the year, XDOR get the full date; getter prefers XDOR
1233 tag.release_date = "2020-03-08"
1234 assert b"TORY" in tag.frame_set
1235 assert b"XDOR" in tag.frame_set
1236 assert tag.release_date == Date.parse("2020-03-08")
1237 assert tag.original_release_date == Date(year=2020, month=3, day=8)
1238
1239 # Setting original release date sets release date
1240 tag.original_release_date = year
1241 assert tag.original_release_date == Date(1966)
1242 assert tag.release_date == Date.parse("1966")
1243 assert b"TORY" in tag.frame_set
1244 # Year only value should clean up XDOR
1245 assert b"XDOR" not in tag.frame_set
1246
1247 # Version convert to 2.4 converts original release date only
1248 tag.release_date = date
1249 assert b"TORY" in tag.frame_set
1250 assert b"XDOR" in tag.frame_set
1251 assert tag.original_release_date == date
1252 assert tag.release_date == date
1253 tag.version = ID3_V2_4
1254 assert tag.original_release_date == date
1255 assert tag.release_date is None
1256
1257 # v2.4 has both date types
1258 tag.release_date = date2
1259 assert tag.original_release_date == date
1260 assert tag.release_date == date2
1261 assert b"TORY" not in tag.frame_set
1262 assert b"XDOR" not in tag.frame_set
1263
1264 # Convert back to 2.3 loses release date, only the year is copied to TORY
1265 tag.version = ID3_V2_3
1266 assert b"TORY" in tag.frame_set
1267 assert b"XDOR" in tag.frame_set
1268 assert tag.original_release_date == date
1269 assert tag.release_date == Date.parse(str(date))
(New empty file)
0 """
1 Test functions and data by Jason Penney.
2 https://bitbucket.org/nicfit/eyed3/issue/32/mp3audioinfotime_secs-incorrect-for-mpeg2
3
4 To test individual files use:::
5
6 python -m test.mp3.test_infos <file>
7 """
8 import eyed3
9 import sys
10 import os
11 from decimal import Decimal
12 from .. import DATA_D, unittest
13
14
15 def _do_test(reported, expected):
16 if reported != expected:
17 return (False, "eyed3 reported %s (expected %s)" %
18 (str(reported), str(expected)))
19 return (True, '')
20
21
22 def _translate_mode(mode):
23 if mode == 'simple':
24 return 'Stereo'
25 if mode == 'mono':
26 return 'Mono'
27 if mode == 'joint' or mode == 'force':
28 return 'Joint stereo'
29 if mode == 'dual-mono':
30 return 'Dual channel stereo'
31 raise RuntimeError("unknown mode: %s" % mode)
32
33
34 def _test_file(pth):
35 errors = []
36 info = os.path.splitext(os.path.basename(pth))[0].split(' ')
37 fil = eyed3.load(pth)
38
39 tests = [
40 ('mpeg_version', Decimal(str(fil.info.mp3_header.version)),
41 Decimal(info[0][-3:])),
42 ('sample_freq', Decimal(str(fil.info.mp3_header.sample_freq))/1000,
43 Decimal(info[1][:-3])),
44 ('vbr', fil.info.bit_rate[0], bool(info[2] == '__vbr__')),
45 ('stereo_mode', fil.info.mode, _translate_mode(info[3])),
46 ('duration', round(fil.info.time_secs), 10),
47
48 ]
49
50 if info[2] != '__vbr__':
51 tests.append(('bit_rate', fil.info.bit_rate[1], int(info[2][:-4])))
52
53 for test, reported, expected in tests:
54 (passed, msg) = _do_test(reported, expected)
55 if not passed:
56 errors.append("%s: %s" % (test, msg))
57
58 print("%s: %s" % (os.path.basename(pth), 'FAIL' if errors else 'ok'))
59 for err in errors:
60 print(" %s" % err)
61
62 return errors
63
64
65 @unittest.skipIf(not os.path.exists(DATA_D), "test requires data files")
66 def test_mp3_infos(do_assert=True):
67 data_d = os.path.join(DATA_D, "mp3_samples")
68 mp3s = sorted([f for f in os.listdir(data_d) if f.endswith(".mp3")])
69
70 for mp3_file in mp3s:
71 errors = _test_file(os.path.join(data_d, mp3_file))
72 if do_assert:
73 assert(len(errors) == 0)
74
75 if __name__ == "__main__":
76 if len(sys.argv) < 2:
77 test_mp3_infos(do_assert=False)
78 else:
79 for mp3_file in sys.argv[1:]:
80 errors = _test_file(mp3_file)
0 import os
1 import unittest
2 import deprecation
3
4 from io import BytesIO
5 from .. import DATA_D
6
7 import eyed3
8
9
10 def testvalidHeader():
11 from eyed3.mp3.headers import isValidHeader
12
13 # False sync, the layer is invalid
14 assert not isValidHeader(0xffe00000)
15 # False sync, bitrate is invalid
16 assert not isValidHeader(0xffe20000)
17 assert not isValidHeader(0xffe20001)
18 assert not isValidHeader(0xffe2000f)
19 # False sync, sample rate is invalid
20 assert not isValidHeader(0xffe21c34)
21 assert not isValidHeader(0xffe21c54)
22 # False sync, version is invalid
23 assert not isValidHeader(0xffea0000)
24 assert not isValidHeader(0xffea0001)
25 assert not isValidHeader(0xffeb0001)
26 assert not isValidHeader(0xffec0001)
27
28
29 assert not isValidHeader(0)
30 assert not isValidHeader(0xffffffff)
31 assert not isValidHeader(0xffe0ffff)
32 assert not isValidHeader(0xffe00000)
33 assert not isValidHeader(0xfffb0000)
34
35 assert isValidHeader(0xfffb9064)
36 assert isValidHeader(0xfffb9074)
37 assert isValidHeader(0xfffb900c)
38 assert isValidHeader(0xfffb1900)
39 assert isValidHeader(0xfffbd204)
40 assert isValidHeader(0xfffba040)
41 assert isValidHeader(0xfffba004)
42 assert isValidHeader(0xfffb83eb)
43 assert isValidHeader(0xfffb7050)
44 assert isValidHeader(0xfffb32c0)
45
46
47 def testFindHeader():
48 from eyed3.mp3.headers import findHeader
49
50 # No header
51 buffer = BytesIO(b'\x00' * 1024)
52 (offset, header_int, header_bytes) = findHeader(buffer, 0)
53 assert header_int is None
54
55 # Valid header
56 buffer = BytesIO(b'\x11\x12\x23' * 1024 + b"\xff\xfb\x90\x64" +
57 b"\x00" * 1024)
58 (offset, header_int, header_bytes) = findHeader(buffer, 0)
59 assert header_int == 0xfffb9064
60
61 # Same thing with a false sync in the mix
62 buffer = BytesIO(b'\x11\x12\x23' * 1024 +
63 b"\x11" * 100 +
64 b"\xff\xea\x00\x00" + # false sync
65 b"\x22" * 100 +
66 b"\xff\xe2\x1c\x34" + # false sync
67 b"\xee" * 100 +
68 b"\xff\xfb\x90\x64" +
69 b"\x00" * 1024)
70 (offset, header_int, header_bytes) = findHeader(buffer, 0)
71 assert header_int == 0xfffb9064
72
73
74 @unittest.skipIf(not os.path.exists(DATA_D), "test requires data files")
75 def testBasicVbrMp3():
76 audio_file = eyed3.load(os.path.join(DATA_D, "notag-vbr.mp3"))
77 assert isinstance(audio_file, eyed3.mp3.Mp3AudioFile)
78
79 assert audio_file.info is not None
80 assert round(audio_file.info.time_secs) == 262
81 assert audio_file.info.size_bytes == 6272220
82 # Variable bit rate, ~191
83 assert audio_file.info.bit_rate[0] == True
84 assert audio_file.info.bit_rate[1] == 191
85 assert audio_file.info.bit_rate_str == "~191 kb/s"
86
87 assert audio_file.info.mode == "Joint stereo"
88 assert audio_file.info.sample_freq == 44100
89
90 assert audio_file.info.mp3_header is not None
91 assert audio_file.info.mp3_header.version == 1.0
92 assert audio_file.info.mp3_header.layer == 3
93
94 assert audio_file.info.xing_header is not None
95 assert audio_file.info.lame_tag is not None
96 assert audio_file.info.vbri_header is None
97 assert audio_file.tag is None
98
99
100 @deprecation.fail_if_not_removed
101 def test_compute_time_from_frame_deprecation():
102 from eyed3.mp3.headers import compute_time_per_frame
103
104 compute_time_per_frame(None)
105
0 import eyed3
1
2
3 def testLocale():
4 assert eyed3.LOCAL_ENCODING
5 assert eyed3.LOCAL_ENCODING != "ANSI_X3.4-1968"
6
7 assert eyed3.LOCAL_FS_ENCODING
8
9 def testException():
10
11 ex = eyed3.Error()
12 assert isinstance(ex, Exception)
13
14 msg = "this is a test"
15 ex = eyed3.Error(msg)
16 assert ex.message == msg
17 assert ex.args == (msg,)
18
19 ex = eyed3.Error(msg, 1, 2)
20 assert ex.message == msg
21 assert ex.args == (msg, 1, 2)
22
23
24 def test_log():
25 from eyed3 import log
26 assert log is not None
27
28 log.verbose("Hiya from Dr. Know")
0 import pytest
1 from eyed3.utils.binfuncs import *
2
3
4 def test_bytes2bin():
5 # test ones and zeros, sz==8
6 for i in range(1, 11):
7 zeros = bytes2bin(b"\x00" * i)
8 ones = bytes2bin(b"\xFF" * i)
9 assert len(zeros) == (8 * i) and len(zeros) == len(ones)
10 for i in range(len(zeros)):
11 assert zeros[i] == 0
12 assert ones[i] == 1
13
14 # test 'sz' bounds checking
15 with pytest.raises(ValueError):
16 bytes2bin(b"a", -1)
17 with pytest.raises(ValueError):
18 bytes2bin(b"a", 0)
19 with pytest.raises(ValueError):
20 bytes2bin(b"a", 9)
21
22 # Test 'sz'
23 for sz in range(1, 9):
24 res = bytes2bin(b"\x00\xFF", sz=sz)
25 assert len(res) == 2 * sz
26 assert res[:sz] == [0] * sz
27 assert res[sz:] == [1] * sz
28
29
30 def test_bin2bytes():
31 res = bin2bytes([0])
32 assert len(res) == 1
33 assert ord(res) == 0
34
35 res = bin2bytes([1] * 8)
36 assert len(res) == 1
37 assert ord(res) == 255
38
39
40 def test_bin2dec():
41 assert bin2dec([1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0]) == 2730
42
43
44 def test_bytes2dec():
45 assert bytes2dec(b"\x00\x11\x22\x33") == 1122867
46
47
48 def test_dec2bin():
49 assert dec2bin(3036790792) == [1, 0, 1, 1, 0, 1, 0, 1,
50 0, 0, 0, 0, 0, 0, 0, 1,
51 1, 1, 0, 0, 0, 0, 0, 0,
52 0, 0, 0, 0, 1, 0, 0, 0]
53 assert dec2bin(1, p=8) == [0, 0, 0, 0, 0, 0, 0, 1]
54
55
56 def test_dec2bytes():
57 assert dec2bytes(ord(b"a")) == b"\x61"
58
59
60 def test_bin2syncsafe():
61 with pytest.raises(ValueError):
62 bin2synchsafe(bytes2bin(b"\xff\xff\xff\xff"))
63 with pytest.raises(ValueError):
64 bin2synchsafe([0] * 33)
65 assert bin2synchsafe([1] * 7) == [1] * 7
66 assert bin2synchsafe(dec2bin(255)) == [0, 0, 0, 0, 0, 0, 0, 0,
67 0, 0, 0, 0, 0, 0, 0, 0,
68 0, 0, 0, 0, 0, 0, 0, 1,
69 0, 1, 1, 1, 1, 1, 1, 1]
0 import os
1 import shutil
2 import tempfile
3 import unittest
4 from pathlib import Path
5
6 import pytest
7 import eyed3
8 from eyed3 import main, id3, core, utils
9 from . import DATA_D, RedirectStdStreams
10
11
12 def testPluginOption():
13 for arg in ["--help", "-h"]:
14 # When help is requested and no plugin is specified, use default
15 with RedirectStdStreams() as out:
16 try:
17 args, _, config = main.parseCommandLine([arg])
18 except SystemExit as ex:
19 assert ex.code == 0
20 out.stdout.seek(0)
21 sout = out.stdout.read()
22 assert sout.find("Plugin options:\n Classic eyeD3") != -1
23
24 # When help is requested and all default plugin names are specified
25 for plugin_name in ["classic"]:
26 for args in [["--plugin=%s" % plugin_name, "--help"]]:
27 with RedirectStdStreams() as out:
28 try:
29 args, _, config = main.parseCommandLine(args)
30 except SystemExit as ex:
31 assert ex.code == 0
32 out.stdout.seek(0)
33 sout = out.stdout.read()
34 assert sout.find("Plugin options:\n Classic eyeD3") != -1
35
36
37 @unittest.skipIf(not Path(DATA_D).exists(), "test requires data files")
38 def testReadEmptyMp3():
39 with RedirectStdStreams() as out:
40 args, _, config = main.parseCommandLine([os.path.join(DATA_D,
41 "test.mp3")])
42 retval = main.main(args, config)
43 assert retval == 0
44 assert out.stderr.read().find("No ID3 v1.x/v2.x tag found") != -1
45
46
47 class TestDefaultPlugin(unittest.TestCase):
48 def __init__(self, name):
49 super(TestDefaultPlugin, self).__init__(name)
50 self.orig_test_file = "%s/test.mp3" % DATA_D
51 self.test_file = "/tmp/test.mp3"
52 fd, self.test_file = tempfile.mkstemp(suffix=".mp3")
53 os.close(fd)
54
55 @unittest.skipIf(not os.path.exists(DATA_D), "test requires data files")
56 def setUp(self):
57 shutil.copy(self.orig_test_file, self.test_file)
58
59 def tearDown(self):
60 # TODO: could remove the tag and compare audio file to original
61 os.remove(self.test_file)
62
63 @staticmethod
64 def _addVersionOpt(version, opts):
65 if version == id3.ID3_DEFAULT_VERSION:
66 return
67
68 if version[0] == 1:
69 opts.append("--to-v1.1")
70 elif version[:2] == (2, 3):
71 opts.append("--to-v2.3")
72 elif version[:2] == (2, 4):
73 opts.append("--to-v2.4")
74 else:
75 assert not "Unhandled version"
76
77 def testNewTagArtist(self, version=id3.ID3_DEFAULT_VERSION):
78 for opts in [ ["-a", "The Cramps", self.test_file],
79 ["--artist=The Cramps", self.test_file] ]:
80 self._addVersionOpt(version, opts)
81
82 with RedirectStdStreams() as out:
83 args, _, config = main.parseCommandLine(opts)
84 retval = main.main(args, config)
85 assert retval == 0
86
87 af = eyed3.load(self.test_file)
88 assert af is not None
89 assert af.tag is not None
90 assert af.tag.artist == "The Cramps"
91
92 def testNewTagComposer(self, version=id3.ID3_DEFAULT_VERSION):
93 for opts in [ ["--composer=H.R.", self.test_file] ]:
94 self._addVersionOpt(version, opts)
95
96 with RedirectStdStreams() as out:
97 args, _, config = main.parseCommandLine(opts)
98 retval = main.main(args, config)
99 assert retval == 0
100
101 af = eyed3.load(self.test_file)
102 assert af is not None
103 assert af.tag is not None
104 assert af.tag.composer == "H.R."
105
106 def testNewTagAlbum(self, version=id3.ID3_DEFAULT_VERSION):
107 for opts in [ ["-A", "Psychedelic Jungle", self.test_file],
108 ["--album=Psychedelic Jungle", self.test_file] ]:
109 self._addVersionOpt(version, opts)
110
111 with RedirectStdStreams() as out:
112 args, _, config = main.parseCommandLine(opts)
113 retval = main.main(args, config)
114 assert (retval == 0)
115
116 af = eyed3.load(self.test_file)
117 assert (af is not None)
118 assert (af.tag is not None)
119 assert (af.tag.album == "Psychedelic Jungle")
120
121 def testNewTagAlbumArtist(self, version=id3.ID3_DEFAULT_VERSION):
122 for opts in [ ["-b", "Various Artists", self.test_file],
123 ["--album-artist=Various Artists", self.test_file] ]:
124 self._addVersionOpt(version, opts)
125
126 with RedirectStdStreams() as out:
127 args, _, config = main.parseCommandLine(opts)
128 retval = main.main(args, config)
129 assert (retval == 0)
130
131 af = eyed3.load(self.test_file)
132 assert af is not None
133 assert af.tag is not None
134 assert af.tag.album_artist == "Various Artists"
135
136 def testNewTagTitle(self, version=id3.ID3_DEFAULT_VERSION):
137 for opts in [ ["-t", "Green Door", self.test_file],
138 ["--title=Green Door", self.test_file] ]:
139 self._addVersionOpt(version, opts)
140
141 with RedirectStdStreams() as out:
142 args, _, config = main.parseCommandLine(opts)
143 retval = main.main(args, config)
144 assert (retval == 0)
145
146 af = eyed3.load(self.test_file)
147 assert (af is not None)
148 assert (af.tag is not None)
149 assert (af.tag.title == "Green Door")
150
151 def testNewTagTrackNum(self, version=id3.ID3_DEFAULT_VERSION):
152 for opts in [ ["-n", "14", self.test_file],
153 ["--track=14", self.test_file] ]:
154 self._addVersionOpt(version, opts)
155
156 with RedirectStdStreams() as out:
157 args, _, config = main.parseCommandLine(opts)
158 retval = main.main(args, config)
159 assert (retval == 0)
160
161 af = eyed3.load(self.test_file)
162 assert (af is not None)
163 assert (af.tag is not None)
164 assert (af.tag.track_num[0] == 14)
165
166 def testNewTagTrackNumInvalid(self):
167 for opts in [ ["-n", "abc", self.test_file],
168 ["--track=-14", self.test_file]
169 ]:
170
171 with RedirectStdStreams() as out:
172 try:
173 args, _, config = main.parseCommandLine(opts)
174 except SystemExit as ex:
175 assert ex.code != 0
176 else:
177 assert not("Should not have gotten here")
178
179 def testNewTagTrackTotal(self, version=id3.ID3_DEFAULT_VERSION):
180 if version[0] == 1:
181 # No support for this in v1.x
182 return
183
184 for opts in [ ["-N", "14", self.test_file],
185 ["--track-total=14", self.test_file] ]:
186 self._addVersionOpt(version, opts)
187
188 with RedirectStdStreams() as out:
189 args, _, config = main.parseCommandLine(opts)
190 retval = main.main(args, config)
191 assert (retval == 0)
192
193 af = eyed3.load(self.test_file)
194 assert (af is not None)
195 assert (af.tag is not None)
196 assert (af.tag.track_num[1] == 14)
197
198 def testNewTagGenre(self, version=id3.ID3_DEFAULT_VERSION):
199 for opts in [ ["-G", "Rock", self.test_file],
200 ["--genre=Rock", self.test_file] ]:
201 self._addVersionOpt(version, opts)
202
203 with RedirectStdStreams() as out:
204 args, _, config = main.parseCommandLine(opts)
205 retval = main.main(args, config)
206 assert (retval == 0)
207
208 af = eyed3.load(self.test_file)
209 assert (af is not None)
210 assert (af.tag is not None)
211 assert (af.tag.genre.name == "Rock")
212 assert (af.tag.genre.id == 17)
213
214 def testNewTagNonStdGenre(self, version=id3.ID3_DEFAULT_VERSION):
215 for opts in (("-G", "108", "--non-std-genre", self.test_file),
216 ("--genre=108", "--non-std-genre", self.test_file)):
217 self._addVersionOpt(version, opts)
218
219 with RedirectStdStreams() as out:
220 args, _, config = main.parseCommandLine(opts)
221 retval = main.main(args, config)
222 assert retval == 0
223
224 af = eyed3.load(self.test_file)
225 assert af.tag.non_std_genre.name == "108"
226 assert af.tag.non_std_genre.id is None
227
228 def testNewTagYear(self, version=id3.ID3_DEFAULT_VERSION):
229 for opts in [ ["-Y", "1981", self.test_file],
230 ["--release-year=1981", self.test_file] ]:
231 self._addVersionOpt(version, opts)
232
233 with RedirectStdStreams() as out:
234 args, _, config = main.parseCommandLine(opts)
235 retval = main.main(args, config)
236 assert (retval == 0)
237
238 af = eyed3.load(self.test_file)
239 assert (af is not None)
240 assert (af.tag is not None)
241 if version == id3.ID3_V2_3:
242 assert (af.tag.original_release_date.year == 1981)
243 else:
244 assert (af.tag.release_date.year == 1981)
245
246 def testNewTagReleaseDate(self, version=id3.ID3_DEFAULT_VERSION):
247 for date in ["1981", "1981-03-06", "1981-03"]:
248 orig_date = core.Date.parse(date)
249
250 for opts in [ ["--release-date=%s" % str(date), self.test_file] ]:
251 self._addVersionOpt(version, opts)
252
253 with RedirectStdStreams() as out:
254 args, _, config = main.parseCommandLine(opts)
255 retval = main.main(args, config)
256 assert (retval == 0)
257
258 af = eyed3.load(self.test_file)
259 assert (af is not None)
260 assert (af.tag is not None)
261 assert (af.tag.release_date == orig_date)
262
263 def testNewTagOrigRelease(self, version=id3.ID3_DEFAULT_VERSION):
264 for opts in [ ["--orig-release-date=1981", self.test_file] ]:
265 self._addVersionOpt(version, opts)
266
267 with RedirectStdStreams() as out:
268 args, _, config = main.parseCommandLine(opts)
269 retval = main.main(args, config)
270 assert (retval == 0)
271
272 af = eyed3.load(self.test_file)
273 assert (af is not None)
274 assert (af.tag is not None)
275 assert (af.tag.original_release_date.year == 1981)
276
277 def testNewTagRecordingDate(self, version=id3.ID3_DEFAULT_VERSION):
278 for opts in [ ["--recording-date=1993-10-30", self.test_file] ]:
279 self._addVersionOpt(version, opts)
280
281 with RedirectStdStreams() as out:
282 args, _, config = main.parseCommandLine(opts)
283 retval = main.main(args, config)
284 assert (retval == 0)
285
286 af = eyed3.load(self.test_file)
287 assert (af is not None)
288 assert (af.tag is not None)
289 assert (af.tag.recording_date.year == 1993)
290 assert (af.tag.recording_date.month == 10)
291 assert (af.tag.recording_date.day == 30)
292
293 def testNewTagEncodingDate(self, version=id3.ID3_DEFAULT_VERSION):
294 for opts in [ ["--encoding-date=2012-10-23T20:22", self.test_file] ]:
295 self._addVersionOpt(version, opts)
296
297 with RedirectStdStreams() as out:
298 args, _, config = main.parseCommandLine(opts)
299 retval = main.main(args, config)
300 assert (retval == 0)
301
302 af = eyed3.load(self.test_file)
303 assert (af is not None)
304 assert (af.tag is not None)
305 assert (af.tag.encoding_date.year == 2012)
306 assert (af.tag.encoding_date.month == 10)
307 assert (af.tag.encoding_date.day == 23)
308 assert (af.tag.encoding_date.hour == 20)
309 assert (af.tag.encoding_date.minute == 22)
310
311 def testNewTagTaggingDate(self, version=id3.ID3_DEFAULT_VERSION):
312 for opts in [ ["--tagging-date=2012-10-23T20:22", self.test_file] ]:
313 self._addVersionOpt(version, opts)
314
315 with RedirectStdStreams() as out:
316 args, _, config = main.parseCommandLine(opts)
317 retval = main.main(args, config)
318 assert (retval == 0)
319
320 af = eyed3.load(self.test_file)
321 assert (af is not None)
322 assert (af.tag is not None)
323 assert (af.tag.tagging_date.year == 2012)
324 assert (af.tag.tagging_date.month == 10)
325 assert (af.tag.tagging_date.day == 23)
326 assert (af.tag.tagging_date.hour == 20)
327 assert (af.tag.tagging_date.minute == 22)
328
329 def testNewTagPlayCount(self):
330 for expected, opts in [ (0, ["--play-count=0", self.test_file]),
331 (1, ["--play-count=+1", self.test_file]),
332 (6, ["--play-count=+5", self.test_file]),
333 (7, ["--play-count=7", self.test_file]),
334 (10000, ["--play-count=10000", self.test_file]),
335 ]:
336
337 with RedirectStdStreams() as out:
338 args, _, config = main.parseCommandLine(opts)
339 retval = main.main(args, config)
340 assert (retval == 0)
341
342 af = eyed3.load(self.test_file)
343 assert (af is not None)
344 assert (af.tag is not None)
345 assert (af.tag.play_count == expected)
346
347 def testNewTagPlayCountInvalid(self):
348 for expected, opts in [ (0, ["--play-count=", self.test_file]),
349 (0, ["--play-count=-24", self.test_file]),
350 (0, ["--play-count=+", self.test_file]),
351 (0, ["--play-count=abc", self.test_file]),
352 (0, ["--play-count=False", self.test_file]),
353 ]:
354
355 with RedirectStdStreams() as out:
356 try:
357 args, _, config = main.parseCommandLine(opts)
358 except SystemExit as ex:
359 assert ex.code != 0
360 else:
361 assert not("Should not have gotten here")
362
363 def testNewTagBpm(self):
364 for expected, opts in [ (1, ["--bpm=1", self.test_file]),
365 (180, ["--bpm=180", self.test_file]),
366 (117, ["--bpm", "116.7", self.test_file]),
367 (116, ["--bpm", "116.4", self.test_file]),
368 ]:
369
370 with RedirectStdStreams() as out:
371 args, _, config = main.parseCommandLine(opts)
372 retval = main.main(args, config)
373 assert (retval == 0)
374
375 af = eyed3.load(self.test_file)
376 assert (af is not None)
377 assert (af.tag is not None)
378 assert (af.tag.bpm == expected)
379
380 def testNewTagBpmInvalid(self):
381 for expected, opts in [ (0, ["--bpm=", self.test_file]),
382 (0, ["--bpm=-24", self.test_file]),
383 (0, ["--bpm=+", self.test_file]),
384 (0, ["--bpm=abc", self.test_file]),
385 (0, ["--bpm", "=180", self.test_file]),
386 ]:
387
388 with RedirectStdStreams() as out:
389 try:
390 args, _, config = main.parseCommandLine(opts)
391 except SystemExit as ex:
392 assert ex.code != 0
393 else:
394 assert not("Should not have gotten here")
395
396 def testNewTagPublisher(self):
397 for expected, opts in [
398 ("SST", ["--publisher", "SST", self.test_file]),
399 ("Dischord", ["--publisher=Dischord", self.test_file]),
400 ]:
401
402 with RedirectStdStreams() as out:
403 args, _, config = main.parseCommandLine(opts)
404 retval = main.main(args, config)
405 assert (retval == 0)
406
407 af = eyed3.load(self.test_file)
408 assert (af is not None)
409 assert (af.tag is not None)
410 assert (af.tag.publisher == expected)
411
412 def testUniqueFileId_1(self):
413 with RedirectStdStreams() as out:
414 assert out
415 args, _, config = main.parseCommandLine(["--unique-file-id", "Travis:Me",
416 self.test_file])
417 retval = main.main(args, config)
418 assert retval == 0
419
420 af = eyed3.load(self.test_file)
421 assert len(af.tag.unique_file_ids) == 1
422 assert af.tag.unique_file_ids.get(b"Travis").uniq_id == b"Me"
423
424 def testUniqueFileId_dup(self):
425 with RedirectStdStreams() as out:
426 assert out
427 args, _, config = \
428 main.parseCommandLine(["--unique-file-id", "Travis:Me",
429 "--unique-file-id=Travis:Me",
430 self.test_file])
431 retval = main.main(args, config)
432 assert retval == 0
433
434 af = eyed3.load(self.test_file)
435 assert len(af.tag.unique_file_ids) == 1
436 assert af.tag.unique_file_ids.get(b"Travis").uniq_id == b"Me"
437
438 def testUniqueFileId_N(self):
439 # Add 3
440 with RedirectStdStreams() as out:
441 assert out
442 args, _, config = \
443 main.parseCommandLine(["--unique-file-id", "Travis:Me",
444 "--unique-file-id=Engine:Kid",
445 "--unique-file-id", "Owner:Kid",
446 self.test_file])
447 retval = main.main(args, config)
448 assert retval == 0
449
450 af = eyed3.load(self.test_file)
451 assert len(af.tag.unique_file_ids) == 3
452 assert af.tag.unique_file_ids.get("Travis").uniq_id == b"Me"
453 assert af.tag.unique_file_ids.get("Engine").uniq_id == b"Kid"
454 assert af.tag.unique_file_ids.get(b"Owner").uniq_id == b"Kid"
455
456 # Remove 2
457 with RedirectStdStreams() as out:
458 assert out
459 args, _, config = \
460 main.parseCommandLine(["--unique-file-id", "Travis:",
461 "--unique-file-id=Engine:",
462 "--unique-file-id", "Owner:Kid",
463 self.test_file])
464 retval = main.main(args, config)
465 assert retval == 0
466
467 af = eyed3.load(self.test_file)
468 assert len(af.tag.unique_file_ids) == 1
469
470 # Remove not found ID
471 with RedirectStdStreams() as out:
472 args, _, config = \
473 main.parseCommandLine(["--unique-file-id", "Travis:",
474 self.test_file])
475 retval = main.main(args, config)
476 assert retval == 0
477
478 sout = out.stdout.read()
479 assert "Unique file ID 'Travis' not found" in sout
480
481 af = eyed3.load(self.test_file)
482 assert len(af.tag.unique_file_ids) == 1
483
484 # TODO:
485 # --text-frame, --user-text-frame
486 # --url-frame, --user-user-frame
487 # --add-image, --remove-image, --remove-all-images, --write-images
488 # etc.
489 # --rename, --force-update, -1, -2, --exclude
490
491 def testNewTagSimpleComment(self, version=id3.ID3_DEFAULT_VERSION):
492 if version[0] == 1:
493 # No support for this in v1.x
494 return
495
496 for opts in [ ["-c", "Starlette", self.test_file],
497 ["--comment=Starlette", self.test_file] ]:
498 self._addVersionOpt(version, opts)
499
500 with RedirectStdStreams() as out:
501 args, _, config = main.parseCommandLine(opts)
502 retval = main.main(args, config)
503 assert (retval == 0)
504
505 af = eyed3.load(self.test_file)
506 assert (af is not None)
507 assert (af.tag is not None)
508 assert (af.tag.comments[0].text == "Starlette")
509 assert (af.tag.comments[0].description == "")
510
511 def testAddRemoveComment(self, version=id3.ID3_DEFAULT_VERSION):
512 if version[0] == 1:
513 # No support for this in v1.x
514 return
515
516 comment = "Why can't I be you?"
517 for i, (c, d, l) in enumerate([(comment, "c0", None),
518 (comment, "c1", None),
519 (comment, "c2", 'eng'),
520 ("¿Por qué no puedo ser tú ?", "c2",
521 'esp'),
522 ]):
523
524 darg = ":{}".format(d) if d else ""
525 larg = ":{}".format(l) if l else ""
526 opts = ["--add-comment={c}{darg}{larg}".format(**locals()),
527 self.test_file]
528
529 self._addVersionOpt(version, opts)
530
531 with RedirectStdStreams() as out:
532 args, _, config = main.parseCommandLine(opts)
533 retval = main.main(args, config)
534 assert (retval == 0)
535
536 af = eyed3.load(self.test_file)
537 assert (af is not None)
538 assert (af.tag is not None)
539
540 tag_comment = af.tag.comments.get(d or "",
541 lang=utils.b(l if l else "eng"))
542 assert (tag_comment.text == c)
543 assert (tag_comment.description == d or "")
544 assert (tag_comment.lang == utils.b(l if l else "eng"))
545
546 for d, l in [("c0", None),
547 ("c1", None),
548 ("c2", "eng"),
549 ("c2", "esp"),
550 ]:
551
552 larg = ":{}".format(l) if l else ""
553 opts = ["--remove-comment={d}{larg}".format(**locals()),
554 self.test_file]
555 self._addVersionOpt(version, opts)
556
557 with RedirectStdStreams() as out:
558 args, _, config = main.parseCommandLine(opts)
559 retval = main.main(args, config)
560 assert (retval == 0)
561
562 af = eyed3.load(self.test_file)
563 tag_comment = af.tag.comments.get(d,
564 lang=utils.b(l if l else "eng"))
565 assert tag_comment is None
566
567 assert (len(af.tag.comments) == 0)
568
569 def testRemoveAllComments(self, version=id3.ID3_DEFAULT_VERSION):
570 if version[0] == 1:
571 # No support for this in v1.x
572 return
573
574 comment = "Why can't I be you?"
575 for i, (c, d, l) in enumerate([(comment, "c0", None),
576 (comment, "c1", None),
577 (comment, "c2", 'eng'),
578 ("¿Por qué no puedo ser tú ?", "c2",
579 'esp'),
580 (comment, "c4", "ger"),
581 (comment, "c4", "rus"),
582 (comment, "c5", "rus"),
583 ]):
584
585 darg = ":{}".format(d) if d else ""
586 larg = ":{}".format(l) if l else ""
587 opts = ["--add-comment={c}{darg}{larg}".format(**locals()),
588 self.test_file]
589
590 self._addVersionOpt(version, opts)
591
592 with RedirectStdStreams() as out:
593 args, _, config = main.parseCommandLine(opts)
594 retval = main.main(args, config)
595 assert (retval == 0)
596
597 af = eyed3.load(self.test_file)
598 assert (af is not None)
599 assert (af.tag is not None)
600
601 tag_comment = af.tag.comments.get(d or "",
602 lang=utils.b(l if l else "eng"))
603 assert (tag_comment.text == c)
604 assert (tag_comment.description == d or "")
605 assert (tag_comment.lang == utils.b(l if l else "eng"))
606
607 opts = ["--remove-all-comments", self.test_file]
608 self._addVersionOpt(version, opts)
609
610 with RedirectStdStreams() as out:
611 args, _, config = main.parseCommandLine(opts)
612 retval = main.main(args, config)
613 assert (retval == 0)
614
615 af = eyed3.load(self.test_file)
616 assert (len(af.tag.comments) == 0)
617
618 def testAddRemoveLyrics(self, version=id3.ID3_DEFAULT_VERSION):
619 if version[0] == 1:
620 # No support for this in v1.x
621 return
622
623 comment = "Why can't I be you?"
624 for i, (c, d, l) in enumerate([(comment, "c0", None),
625 (comment, "c1", None),
626 (comment, "c2", 'eng'),
627 ("¿Por qué no puedo ser tú ?", "c2",
628 'esp'),
629 ]):
630
631 darg = ":{}".format(d) if d else ""
632 larg = ":{}".format(l) if l else ""
633 opts = ["--add-comment={c}{darg}{larg}".format(**locals()),
634 self.test_file]
635
636 self._addVersionOpt(version, opts)
637
638 with RedirectStdStreams() as out:
639 args, _, config = main.parseCommandLine(opts)
640 retval = main.main(args, config)
641 assert (retval == 0)
642
643 af = eyed3.load(self.test_file)
644 assert (af is not None)
645 assert (af.tag is not None)
646
647 tag_comment = af.tag.comments.get(d or "",
648 lang=utils.b(l if l else "eng"))
649 assert (tag_comment.text == c)
650 assert (tag_comment.description == d or "")
651 assert (tag_comment.lang == utils.b(l if l else "eng"))
652
653 for d, l in [("c0", None),
654 ("c1", None),
655 ("c2", "eng"),
656 ("c2", "esp"),
657 ]:
658
659 larg = ":{}".format(l) if l else ""
660 opts = ["--remove-comment={d}{larg}".format(**locals()),
661 self.test_file]
662 self._addVersionOpt(version, opts)
663
664 with RedirectStdStreams() as out:
665 args, _, config = main.parseCommandLine(opts)
666 retval = main.main(args, config)
667 assert (retval == 0)
668
669 af = eyed3.load(self.test_file)
670 tag_comment = af.tag.comments.get(d,
671 lang=utils.b(l if l else "eng"))
672 assert tag_comment is None
673
674 assert (len(af.tag.comments) == 0)
675
676 def testNewTagAll(self, version=id3.ID3_DEFAULT_VERSION):
677 self.testNewTagArtist(version)
678 self.testNewTagAlbum(version)
679 self.testNewTagTitle(version)
680 self.testNewTagTrackNum(version)
681 self.testNewTagTrackTotal(version)
682 self.testNewTagGenre(version)
683 self.testNewTagYear(version)
684 self.testNewTagSimpleComment(version)
685
686 af = eyed3.load(self.test_file)
687 assert (af.tag.artist == "The Cramps")
688 assert (af.tag.album == "Psychedelic Jungle")
689 assert (af.tag.title == "Green Door")
690 assert (af.tag.track_num == (14, 14 if version[0] != 1 else None))
691 assert ((af.tag.genre.name, af.tag.genre.id) == ("Rock", 17))
692 if version == id3.ID3_V2_3:
693 assert (af.tag.original_release_date.year == 1981)
694 else:
695 assert (af.tag.release_date.year == 1981)
696
697 if version[0] != 1:
698 assert (af.tag.comments[0].text == "Starlette")
699 assert (af.tag.comments[0].description == "")
700
701 assert (af.tag.version == version)
702
703 def testNewTagAllVersion1(self):
704 self.testNewTagAll(version=id3.ID3_V1_1)
705
706 def testNewTagAllVersion2_3(self):
707 self.testNewTagAll(version=id3.ID3_V2_3)
708
709 def testNewTagAllVersion2_4(self):
710 self.testNewTagAll(version=id3.ID3_V2_4)
711
712
713 ## XXX: newer pytest test below.
714
715
716 @pytest.mark.skipif(not Path(DATA_D).exists(), reason="test requires data files")
717 def test_lyrics(audiofile, tmpdir, eyeD3):
718 lyrics_files = []
719 for i in range(1, 4):
720 lfile = tmpdir / "lryics{:d}".format(i)
721 lfile.write_text((str(i) * (100 * i)), "utf8")
722 lyrics_files.append(lfile)
723
724 audiofile = eyeD3(audiofile,
725 ["--add-lyrics", "{}".format(lyrics_files[0]),
726 "--add-lyrics", "{}:desc".format(lyrics_files[1]),
727 "--add-lyrics", "{}:foo:en".format(lyrics_files[1]),
728 "--add-lyrics", "{}:foo:es".format(lyrics_files[2]),
729 "--add-lyrics", "{}:foo:de".format(lyrics_files[0]),
730 ])
731 assert len(audiofile.tag.lyrics) == 5
732 assert audiofile.tag.lyrics.get("").text == ("1" * 100)
733 assert audiofile.tag.lyrics.get("desc").text == ("2" * 200)
734 assert audiofile.tag.lyrics.get("foo", "en").text == ("2" * 200)
735 assert audiofile.tag.lyrics.get("foo", "es").text == ("3" * 300)
736 assert audiofile.tag.lyrics.get("foo", "de").text == ("1" * 100)
737
738 audiofile = eyeD3(audiofile, ["--remove-lyrics", "foo:xxx"])
739 assert len(audiofile.tag.lyrics) == 5
740
741 audiofile = eyeD3(audiofile, ["--remove-lyrics", "foo:es"])
742 assert len(audiofile.tag.lyrics) == 4
743
744 audiofile = eyeD3(audiofile, ["--remove-lyrics", "desc"])
745 assert len(audiofile.tag.lyrics) == 3
746
747 audiofile = eyeD3(audiofile, ["--remove-all-lyrics"])
748 assert len(audiofile.tag.lyrics) == 0
749
750 eyeD3(audiofile, ["--add-lyrics", "eminem.txt"], expected_retval=2)
751
752
753 @pytest.mark.skipif(not Path(DATA_D).exists(), reason="test requires data files")
754 def test_all(audiofile, image, eyeD3):
755 audiofile = eyeD3(audiofile,
756 ["--artist", "Cibo Matto",
757 "--album-artist", "Cibo Matto",
758 "--album", "Viva! La Woman",
759 "--title", "Apple",
760 "--track=1", "--track-total=11",
761 "--disc-num=1", "--disc-total=1",
762 "--genre", "Pop",
763 "--release-date=1996-01-16",
764 "--orig-release-date=1996-01-16",
765 "--recording-date=1995-01-16",
766 "--encoding-date=1999-01-16",
767 "--tagging-date=1999-01-16",
768 "--comment", "From Japan",
769 "--publisher=\'Warner Brothers\'",
770 "--play-count=666",
771 "--bpm=99",
772 "--unique-file-id", "mishmash:777abc",
773 "--add-comment", "Trip Hop",
774 "--add-comment", "Quirky:Mood",
775 "--add-comment", "Kimyōna:Mood:jp",
776 "--add-comment", "Test:XXX",
777 "--add-popularity", "travis@ppbox.com:212:999",
778 "--fs-encoding=latin1",
779 "--no-config",
780 "--add-object", "{}:image/gif".format(image),
781 "--composer", "Cibo Matto",
782 ])
783 assert audiofile
784
785
786 @pytest.mark.skipif(not Path(DATA_D).exists(), reason="test requires data files")
787 def test_removeTag_v1(audiofile, eyeD3):
788 assert audiofile.tag is None
789 audiofile = eyeD3(audiofile, ["-1", "-a", "Government Issue"])
790 assert audiofile.tag.version == id3.ID3_V1_0
791 audiofile = eyeD3(audiofile, ["--remove-v1"])
792 assert audiofile.tag is None
793
794
795 @pytest.mark.skipif(not Path(DATA_D).exists(), reason="test requires data files")
796 def test_removeTag_v2(audiofile, eyeD3):
797 assert audiofile.tag is None
798 audiofile = eyeD3(audiofile, ["-2", "-a", "Integrity"])
799 assert audiofile.tag.version == id3.ID3_V2_4
800 audiofile = eyeD3(audiofile, ["--remove-v2"])
801 assert audiofile.tag is None
802
803
804 @pytest.mark.skipif(not Path(DATA_D).exists(), reason="test requires data files")
805 def test_removeTagWithBoth_v1(audiofile, eyeD3):
806 audiofile = eyeD3(eyeD3(audiofile, ["-1", "-a", "Face Value"]),
807 ["-2", "-a", "Poison Idea"])
808 v1_view = eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1)
809 v2_view = eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2)
810 assert audiofile.tag.version == id3.ID3_V2_4
811 assert v1_view.tag.version == id3.ID3_V1_0
812 assert v2_view.tag.version == id3.ID3_V2_4
813 audiofile = eyeD3(audiofile, ["--remove-v1"])
814 assert audiofile.tag.version == id3.ID3_V2_4
815 assert eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1).tag is None
816 v2_tag = eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2).tag
817 assert v2_tag is not None
818 assert v2_tag.artist == "Poison Idea"
819
820
821 @pytest.mark.skipif(not Path(DATA_D).exists(), reason="test requires data files")
822 def test_removeTagWithBoth_v2(audiofile, eyeD3):
823 audiofile = eyeD3(eyeD3(audiofile, ["-1", "-a", "Face Value"]),
824 ["-2", "-a", "Poison Idea"])
825 v1_view = eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1)
826 v2_view = eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2)
827 assert audiofile.tag.version == id3.ID3_V2_4
828 assert v1_view.tag.version == id3.ID3_V1_0
829 assert v2_view.tag.version == id3.ID3_V2_4
830 audiofile = eyeD3(audiofile, ["--remove-v2"])
831 assert audiofile.tag.version == id3.ID3_V1_0
832 assert eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2).tag is None
833 v1_tag = eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1).tag
834 assert v1_tag is not None and v1_tag.artist == "Face Value"
835
836
837 @pytest.mark.skipif(not Path(DATA_D).exists(), reason="test requires data files")
838 def test_removeTagWithBoth_v2_withConvert(audiofile, eyeD3):
839 audiofile = eyeD3(eyeD3(audiofile, ["-1", "-a", "Face Value"]),
840 ["-2", "-a", "Poison Idea"])
841 v1_view = eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1)
842 v2_view = eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2)
843 assert audiofile.tag.version == id3.ID3_V2_4
844 assert v1_view.tag.version == id3.ID3_V1_0
845 assert v2_view.tag.version == id3.ID3_V2_4
846 audiofile = eyeD3(audiofile, ["--remove-v2", "--to-v1"])
847 assert audiofile.tag.version == id3.ID3_V1_0
848 assert eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2).tag is None
849 v1_tag = eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1).tag
850 assert v1_tag is not None and v1_tag.artist == "Face Value"
851
852
853 @pytest.mark.skipif(not Path(DATA_D).exists(), reason="test requires data files")
854 def test_removeTagWithBoth_v1_withConvert(audiofile, eyeD3):
855 audiofile = eyeD3(eyeD3(audiofile, ["-1", "-a", "Face Value"]),
856 ["-2", "-a", "Poison Idea"])
857 v1_view = eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1)
858 v2_view = eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2)
859 assert audiofile.tag.version == id3.ID3_V2_4
860 assert v1_view.tag.version == id3.ID3_V1_0
861 assert v2_view.tag.version == id3.ID3_V2_4
862 audiofile = eyeD3(audiofile, ["--remove-v1", "--to-v2.3"])
863 assert audiofile.tag.version == id3.ID3_V2_3
864 assert eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1).tag is None
865 v2_tag = eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2).tag
866 assert v2_tag is not None and v2_tag.artist == "Poison Idea"
867
868
869 def test_clearGenre(audiofile, eyeD3):
870 audiofile = eyeD3(audiofile, ["--genre=Rock"])
871 assert audiofile.tag.genre.name, audiofile.tag.genre.name == ("Rock", 17)
872 audiofile = eyeD3(audiofile, ["--genre", ""])
873 assert audiofile.tag.genre is None
0 """Tests for eyed3.utils.console module"""
1 import unittest
2 from unittest import mock
3 from eyed3.utils.console import AnsiCodes, Fore
4
5
6 @mock.patch('sys.stdout.isatty', new=lambda: True)
7 class AnsiCodesTC(unittest.TestCase):
8 def setUp(self):
9 AnsiCodes._USE_ANSI = False
10
11 def test_init_color_enabled(self):
12 AnsiCodes.init(True)
13 self._assert_color_enabled()
14
15 def test_init_color_disabled(self):
16 AnsiCodes.init(False)
17 self._assert_color_disabled()
18
19 @mock.patch('sys.stdout.isatty', new=lambda: False)
20 def test_init_color_enabled_not_tty(self):
21 AnsiCodes.init(False)
22 self._assert_color_disabled()
23
24 def _assert_color_enabled(self):
25 self.assertTrue(AnsiCodes._USE_ANSI)
26 self.assertEqual(Fore.GREEN, '\x1b[32m')
27
28 def _assert_color_disabled(self):
29 self.assertFalse(AnsiCodes._USE_ANSI)
30 self.assertEqual(Fore.GREEN, '')
0 import os
1 from pathlib import Path
2 import pytest
3 import eyed3
4 from eyed3 import core
5 from . import DATA_D
6
7
8 @pytest.mark.skipif(not Path(DATA_D).exists(), reason="test requires data files")
9 def test_AudioFile_rename(audiofile):
10 orig_path = audiofile.path
11
12 # Happy path
13 audiofile.rename("Spoon")
14 assert Path(audiofile.path).exists()
15 assert not Path(orig_path).exists()
16 assert (Path(orig_path).parent / "Spoon{}".format(Path(orig_path).suffix)).exists()
17
18 # File exist
19 with pytest.raises(IOError):
20 audiofile.rename("Spoon")
21
22 # Parent dir does not exist
23 with pytest.raises(IOError):
24 audiofile.rename("subdir/BloodOnTheWall")
25
26
27 def test_import_load():
28 assert eyed3.load == core.load
29
30
31 # eyed3.load raises IOError for non files and non-existent files
32 def test_ioerror_load():
33 # Non existent
34 with pytest.raises(IOError):
35 core.load("filedoesnotexist.txt")
36 # Non file
37 with pytest.raises(IOError):
38 core.load(os.path.abspath(os.path.curdir))
39
40
41 def test_none_load():
42 # File mimetypes that are not supported return None
43 assert core.load(__file__) is None
44
45
46 def test_AudioFile():
47 from eyed3.core import AudioFile
48 # Abstract method
49 with pytest.raises(NotImplementedError):
50 AudioFile("somefile.mp3")
51
52 class DummyAudioFile(AudioFile):
53 def _read(self):
54 pass
55
56 # precondition is that __file__ is already absolute
57 assert os.path.isabs(__file__)
58 af = DummyAudioFile(__file__)
59 # All paths are turned into absolute paths
60 assert str(af.path) == os.path.abspath(__file__)
61
62
63 def test_AudioInfo():
64 from eyed3.core import AudioInfo
65 info = AudioInfo()
66 assert (info.time_secs == 0)
67 assert (info.size_bytes == 0)
68
69
70 def test_Date():
71 from eyed3.core import Date
72
73 for d in [Date(1965),
74 Date(year=1965),
75 Date.parse("1965")]:
76 assert d.year == 1965
77 assert d.month is None
78 assert d.day is None
79 assert d.hour is None
80 assert d.minute is None
81 assert d.second is None
82 assert str(d) == "1965"
83
84 for d in [Date(1965, 3),
85 Date(year=1965, month=3),
86 Date.parse("1965-03")]:
87 assert d.year == 1965
88 assert d.month == 3
89 assert d.day is None
90 assert d.hour is None
91 assert d.minute is None
92 assert d.second is None
93 assert str(d) == "1965-03"
94
95 for d in [Date(1965, 3, 6),
96 Date(year=1965, month=3, day=6),
97 Date.parse("1965-3-6")]:
98 assert d.year == 1965
99 assert d.month == 3
100 assert d.day == 6
101 assert d.hour is None
102 assert d.minute is None
103 assert d.second is None
104 assert (str(d) == "1965-03-06")
105
106 for d in [Date(1965, 3, 6, 23),
107 Date(year=1965, month=3, day=6, hour=23),
108 Date.parse("1965-3-6T23")]:
109 assert d.year == 1965
110 assert d.month == 3
111 assert d.day == 6
112 assert d.hour == 23
113 assert d.minute is None
114 assert d.second is None
115 assert str(d) == "1965-03-06T23"
116
117 for d in [Date(1965, 3, 6, 23, 20),
118 Date(year=1965, month=3, day=6, hour=23, minute=20),
119 Date.parse("1965-3-6T23:20")]:
120 assert d.year == 1965
121 assert d.month == 3
122 assert d.day == 6
123 assert d.hour == 23
124 assert d.minute == 20
125 assert d.second is None
126 assert str(d) == "1965-03-06T23:20"
127
128 for d in [Date(1965, 3, 6, 23, 20, 15),
129 Date(year=1965, month=3, day=6, hour=23, minute=20,
130 second=15),
131 Date.parse("1965-3-6T23:20:15")]:
132 assert d.year == 1965
133 assert d.month == 3
134 assert d.day == 6
135 assert d.hour == 23
136 assert d.minute == 20
137 assert d.second == 15
138 assert str(d) == "1965-03-06T23:20:15"
139
140 with pytest.raises(ValueError):
141 Date.parse("")
142 with pytest.raises(ValueError):
143 Date.parse("ABC")
144 with pytest.raises(ValueError):
145 Date.parse("2010/1/24")
146
147 with pytest.raises(ValueError):
148 Date(2012, 0)
149 with pytest.raises(ValueError):
150 Date(2012, 1, 35)
151 with pytest.raises(ValueError):
152 Date(2012, 1, 4, -1)
153 with pytest.raises(ValueError):
154 Date(2012, 1, 4, 24)
155 with pytest.raises(ValueError):
156 Date(2012, 1, 4, 18, 60)
157 with pytest.raises(ValueError):
158 Date(2012, 1, 4, 18, 14, 61)
159
160 dt = Date(1965, 3, 6, 23, 20, 15)
161 dp = Date(1980, 7, 3, 10, 5, 1)
162 assert dt != dp
163 assert dt < dp
164 assert not dp < dt
165 assert None < dp
166 assert not dp < dp
167 assert dp <= dp
168
169 assert hash(dt) != hash(dp)
0 import unittest
1 import pytest
2 from eyed3.id3 import TagFile
3 from eyed3.plugins.display import *
4
5
6 class TestDisplayPlugin(unittest.TestCase):
7
8 def __init__(self, name):
9 super(TestDisplayPlugin, self).__init__(name)
10
11 def testSimpleTags(self):
12 self.file.tag.artist = "The Artist"
13 self.file.tag.title = "Some Song"
14 self.file.tag.composer = "Some Composer"
15 self.__checkOutput("%a% - %t% - %C%", "The Artist - Some Song - Some Composer")
16
17 def testComposer(self):
18 self.file.tag.composer = "Bad Brains"
19 self.__checkOutput("%C% - %composer%", "Bad Brains - Bad Brains")
20
21 def testCommentsTag(self):
22 self.file.tag.comments.set("TEXT", description="", lang=b"DE")
23 self.file.tag.comments.set("#d-tag", description="#l-tag", lang=b"#t-tag")
24 # Langs are chopped to 3 bytes (are are codes), so #t- is expected.
25 self.__checkOutput("%comments,output=#d #l #t,separation=|%", " DE TEXT|#l-tag #t- #d-tag")
26
27 def testRepeatFunction(self):
28 self.__checkOutput("$repeat(*,3)", "***")
29 self.__checkException("$repeat(*,three)", DisplayException)
30
31 def testNotEmptyFunction(self):
32 self.__checkOutput("$not-empty(foo,hello #t,nothing)", "hello foo")
33 self.__checkOutput("$not-empty(,hello #t,nothing)", "nothing")
34
35 def testNumberFormatFunction(self):
36 self.__checkOutput("$num(123,5)", "00123")
37 self.__checkOutput("$num(123,3)", "123")
38 self.__checkOutput("$num(123,0)", "123")
39 self.__checkException("$num(nan,1)", DisplayException)
40 self.__checkException("$num(1,foo)", DisplayException)
41 self.__checkException("$num(1,)", DisplayException)
42
43 def __checkOutput(self, pattern, expected):
44 output = Pattern(pattern).output_for(self.file)
45 assert output == expected
46
47 def __checkException(self, pattern, exception_type):
48 with pytest.raises(exception_type):
49 Pattern(pattern).output_for(self.file)
50
51 def setUp(self):
52 import tempfile
53 with tempfile.NamedTemporaryFile() as temp:
54 temp.flush()
55 self.file = TagFile(temp.name)
56 self.file.initTag()
57
58 def tearDown(self):
59 pass
60
61
62 class TestDisplayParser(unittest.TestCase):
63
64 def __init__(self, name):
65 super(TestDisplayParser, self).__init__(name)
66
67 def testTextPattern(self):
68 pattern = Pattern("hello")
69 assert isinstance(pattern.sub_patterns[0], TextPattern)
70 assert len(pattern.sub_patterns) == 1
71
72 def testTagPattern(self):
73 pattern = Pattern("%comments,desc,lang,separation=|%")
74 assert len(pattern.sub_patterns) == 1
75 assert isinstance(pattern.sub_patterns[0], TagPattern)
76 comments_tag = pattern.sub_patterns[0]
77 assert (len(comments_tag.parameters) == 4)
78 assert comments_tag._parameter_value("description", None) == "desc"
79 assert comments_tag._parameter_value("language", None) == "lang"
80 assert (comments_tag._parameter_value("output", None) ==
81 AllCommentsTagPattern.PARAMETERS[2].default)
82 assert comments_tag._parameter_value("separation", None) == "|"
83
84 def testComplexPattern(self):
85 pattern = Pattern("Output: $format(Artist: $not-empty(%artist%,#t,none),bold=y)")
86 assert len(pattern.sub_patterns) == 2
87 assert isinstance(pattern.sub_patterns[0], TextPattern)
88 assert isinstance(pattern.sub_patterns[1], FunctionFormatPattern)
89 text_patten = pattern.sub_patterns[1].parameters['text'].value
90 assert len(text_patten.sub_patterns) == 2
91 assert isinstance(text_patten.sub_patterns[0], TextPattern)
92 assert isinstance(text_patten.sub_patterns[1], FunctionNotEmptyPattern)
93
94 def testCompileException(self):
95 with pytest.raises(PatternCompileException):
96 Pattern("$bad-pattern").output_for(None)
97 with pytest.raises(PatternCompileException):
98 Pattern("$unknown-function()").output_for(None)
99
100 def setUp(self):
101 pass
102
103 def tearDown(self):
104 pass
0 import eyed3.id3
1 import factory
2
3
4 class TagFactory(factory.Factory):
5 class Meta:
6 model = eyed3.id3.Tag
7 title = u"Track title"
8 artist = u"Artist"
9 album = u"Album"
10 album_artist = artist
11 track_num = None
12
13
14 def test_factory():
15 tag = TagFactory()
16 assert isinstance(tag, eyed3.id3.Tag)
17 assert tag.title == u"Track title"
18 assert tag.artist == u"Artist"
19 assert tag.album == u"Album"
20 assert tag.album_artist == tag.artist
21 assert tag.track_num == (None, None)
0 from pathlib import Path
1 import pytest
2 import eyed3
3 from eyed3.id3 import Tag, ID3_V2_3, ID3_V2_4
4 from . import DATA_D
5
6
7 @pytest.mark.skipif(not Path(DATA_D).exists(), reason="test requires data files")
8 def testIssue76(audiofile):
9 """
10 Writing lyrics deletes TSOP tag (ARTISTSORT)
11 https://github.com/nicfit/eyeD3/issues/76
12 """
13 tag = audiofile.initTag(ID3_V2_4)
14 tag.setTextFrame("TPE1", "Confederacy of Ruined Lives")
15 tag.setTextFrame("TPE2", "Take as needed for pain")
16 tag.setTextFrame("TSOP", "In the name of suffering")
17 tag.setTextFrame("TSO2", "Dope sick")
18 tag.save()
19
20 audiofile = eyed3.load(audiofile.path)
21 tag = audiofile.tag
22 assert (set(tag.frame_set.keys()) ==
23 set([b"TPE1", b"TPE2", b"TSOP", b"TSO2"]))
24 assert tag.getTextFrame("TSO2") == "Dope sick"
25 assert tag.getTextFrame("TSOP") == "In the name of suffering"
26 assert tag.getTextFrame("TPE2") == "Take as needed for pain"
27 assert tag.getTextFrame("TPE1") == "Confederacy of Ruined Lives"
28
29 audiofile.tag.lyrics.set("some lyrics")
30 audiofile = eyed3.load(audiofile.path)
31 tag = audiofile.tag
32 assert (set(tag.frame_set.keys()) ==
33 set([b"TPE1", b"TPE2", b"TSOP", b"TSO2"]))
34 assert tag.getTextFrame("TSO2") == "Dope sick"
35 assert tag.getTextFrame("TSOP") == "In the name of suffering"
36 assert tag.getTextFrame("TPE2") == "Take as needed for pain"
37 assert tag.getTextFrame("TPE1") == "Confederacy of Ruined Lives"
38
39 # Convert to v2.3 and verify conversions
40 tag.save(version=ID3_V2_3)
41 audiofile = eyed3.load(audiofile.path)
42 tag = audiofile.tag
43 assert (set(tag.frame_set.keys()) ==
44 set([b"TPE1", b"TPE2", b"XSOP", b"TSO2"]))
45 assert tag.getTextFrame("TSO2") == "Dope sick"
46 assert tag.getTextFrame("TPE2") == "Take as needed for pain"
47 assert tag.getTextFrame("TPE1") == "Confederacy of Ruined Lives"
48 assert tag.frame_set[b"XSOP"][0].text == "In the name of suffering"
49
50 # Convert to v2.4 and verify conversions
51 tag.save(version=ID3_V2_4)
52 audiofile = eyed3.load(audiofile.path)
53 tag = audiofile.tag
54 assert (set(tag.frame_set.keys()) ==
55 set([b"TPE1", b"TPE2", b"TSOP", b"TSO2"]))
56 assert tag.getTextFrame("TSO2") == "Dope sick"
57 assert tag.getTextFrame("TPE2") == "Take as needed for pain"
58 assert tag.getTextFrame("TPE1") == "Confederacy of Ruined Lives"
59 assert tag.getTextFrame("TSOP") == "In the name of suffering"
60
61
62 def test_issue382_genres(audiofile):
63 """Tags always written in v2.3 format, always including ID.
64 https://github.com/nicfit/eyeD3/issues/382
65 """
66 tag = Tag()
67 tag.genre = "Dubstep"
68 assert tag.genre.id == 189
69 assert tag.genre.name == "Dubstep"
70
71 audiofile.tag = tag
72 tag.save()
73
74 new_audiofile = eyed3.load(audiofile.path)
75 # Prior versions would be `(189)Dubstep`, now no index.
76 assert new_audiofile.tag.frame_set[b"TCON"][0].text == "Dubstep"
0 import unittest
1 from pathlib import Path
2
3 from eyed3 import main
4 from . import DATA_D, RedirectStdStreams
5
6
7 @unittest.skipIf(not Path(DATA_D).exists(), "test requires data files")
8 def testLameInfoPlugin():
9 test_file = Path(DATA_D) / "mp3_samples/mpeg2.5 12.000kHz __vbr__ simple.mp3"
10 with RedirectStdStreams() as plugin_out:
11 args, _, config = main.parseCommandLine(["-P", "lameinfo", str(test_file)])
12 retval = main.main(args, config)
13 assert retval == 0
14
15 stdout = plugin_out.stdout.read()
16 assert stdout[stdout.index("Encoder Version"):].strip() == \
17 """
18 Encoder Version : LAME3.99r
19 LAME Tag Revision : 0
20 VBR Method : Variable Bitrate method2 (mtrh)
21 Lowpass Filter : 6000
22 Radio Replay Gain : 12.2 dB (Set automatically)
23 Encoding Flags : --nspsytune --nssafejoint
24 ATH Type : 5
25 Bitrate (Minimum) : 8
26 Encoder Delay : 576 samples
27 Encoder Padding : 960 samples
28 Noise Shaping : 1
29 Stereo Mode : Stereo
30 Unwise Settings : False
31 Sample Frequency : 44.1 kHz
32 MP3 Gain : 0 (+0.0 dB)
33 Preset : V0
34 Surround Info : None
35 Music Length : 67.88 KB
36 Music CRC-16 : 8707
37 LAME Tag CRC-16 : 0000
38 """.strip()
39
40
41 @unittest.skipIf(not Path(DATA_D).exists(), "test requires data files")
42 def testLameInfoPlugin_None():
43 test_file = Path(DATA_D) / "test.mp3"
44 with RedirectStdStreams() as plugin_out:
45 args, _, config = main.parseCommandLine(["-P", "lameinfo", str(test_file)])
46 retval = main.main(args, config)
47 assert retval == 0
48
49 stdout = plugin_out.stdout.read()
50 assert stdout[stdout.index("--\n") + 3:].strip() == "No LAME Tag"
51
0 import unittest
1 import deprecation
2 from eyed3 import main
3 from . import RedirectStdStreams
4
5
6 def testHelpExitsSuccess():
7 with open("/dev/null", "w") as devnull:
8 with RedirectStdStreams(stderr=devnull):
9 for arg in ["--help", "-h"]:
10 try:
11 args, parser = main.parseCommandLine([arg])
12 except SystemExit as ex:
13 assert ex.code == 0
14
15
16 def testHelpOutput():
17 for arg in ["--help", "-h"]:
18 with RedirectStdStreams() as out:
19 try:
20 args, parser = main.parseCommandLine([arg])
21 except SystemExit as ex:
22 # __exit__ seeks and we're not there yet so...
23 out.stdout.seek(0)
24 assert out.stdout.read().startswith(u"usage:")
25 assert ex.code == 0
26
27
28 def testVersionExitsWithSuccess():
29 with open("/dev/null", "w") as devnull:
30 with RedirectStdStreams(stderr=devnull):
31 try:
32 args, parser = main.parseCommandLine(["--version"])
33 except SystemExit as ex:
34 assert ex.code == 0
35
36
37 def testListPluginsExitsWithSuccess():
38 try:
39 args, _, _ = main.parseCommandLine(["--plugins"])
40 except SystemExit as ex:
41 assert ex.code == 0
42
43
44 def testLoadPlugin():
45 from eyed3.plugins.classic import ClassicPlugin
46 from eyed3.plugins.genres import GenreListPlugin
47
48 args, _, _ = main.parseCommandLine([""])
49 assert args.plugin.__class__.__name__ == ClassicPlugin.__name__
50
51 args, _, _ = main.parseCommandLine(["--plugin=genres"])
52 assert args.plugin.__class__.__name__ == GenreListPlugin.__name__
53
54 with open("/dev/null", "w") as devnull:
55 with RedirectStdStreams(stderr=devnull):
56 try:
57 args, _ = main.parseCommandLine(["--plugin=DNE"])
58 except SystemExit as ex:
59 assert ex.code == 1
60
61 try:
62 args, _, _ = main.parseCommandLine(["--plugin"])
63 except SystemExit as ex:
64 assert ex.code == 2
65
66
67 def testLoggingOptions():
68 import logging
69 from eyed3 import log
70
71 with open("/dev/null", "w") as devnull:
72 with RedirectStdStreams(stderr=devnull):
73 try:
74 _ = main.parseCommandLine(["-l", "critical"])
75 assert log.getEffectiveLevel() == logging.CRITICAL
76
77 _ = main.parseCommandLine(["--log-level=error"])
78 assert log.getEffectiveLevel() == logging.ERROR
79
80 _ = main.parseCommandLine(["-l", "warning:NewLogger"])
81 assert (
82 logging.getLogger("NewLogger").getEffectiveLevel() ==
83 logging.WARNING
84 )
85 assert log.getEffectiveLevel() == logging.ERROR
86 except SystemExit:
87 assert not "Unexpected"
88
89 try:
90 _ = main.parseCommandLine(["--log-level=INVALID"])
91 assert not "Invalid log level, an Exception expected"
92 except SystemExit:
93 pass
94
95
96 @deprecation.fail_if_not_removed
97 def testConfigFileDeprecation():
98 main._deprecatedConfigFileCheck(None)
0 import os
1 import pytest
2 import deprecation
3
4 from eyed3 import utils, mimetype
5 from . import DATA_D
6
7
8 mime_test_params = [("id3", ["application/x-id3"]),
9 ("tag", ["application/x-id3"]),
10 ("mka", ["video/x-matroska", "application/octet-stream"]),
11 ("mp3", ["audio/mpeg"]),
12 ("ogg", ["audio/ogg", "application/ogg"]),
13 ("wav", ["audio/x-wav"]),
14 ("wma", ["audio/x-ms-wma", "video/x-ms-wma", "video/x-ms-asf",
15 "video/x-ms-wmv"]),
16 ]
17
18
19 @pytest.mark.skipif(not os.path.exists(DATA_D), reason="test requires data files")
20 @deprecation.fail_if_not_removed
21 def testSampleMimeTypesUtils():
22 for ext, valid_types in mime_test_params:
23 guessed = utils.guessMimetype(os.path.join(DATA_D, f"sample.%s" % ext))
24 assert guessed in valid_types
25
26
27 @pytest.mark.skipif(not os.path.exists(DATA_D), reason="test requires data files")
28 @pytest.mark.parametrize(("ext", "valid_types"), mime_test_params)
29 def testSampleMimeTypes(ext, valid_types):
30 guessed = mimetype.guessMimetype(os.path.join(DATA_D, "sample.%s" % ext))
31 assert guessed in valid_types
32
0 from eyed3.plugins import *
1
2
3 def test_load():
4 plugins = load()
5 assert "classic" in list(plugins.keys())
6 assert "genres" in list(plugins.keys())
7
8 assert load("classic") == plugins["classic"]
9 assert load("genres") == plugins["genres"]
10
11 assert (load("classic", reload=True).__class__.__name__ ==
12 plugins["classic"].__class__.__name__)
13 assert (load("genres", reload=True).__class__.__name__ ==
14 plugins["genres"].__class__.__name__)
15
16 assert load("DNE") is None
17
18 def test_Plugin():
19 import argparse
20 class MyPlugin(Plugin):
21 pass
22
23 p = MyPlugin(argparse.ArgumentParser())
24 assert p.arg_group is not None
25
26 # In reality, this is parsed args
27 p.start("dummy_args", "dummy_config")
28 assert p.args == "dummy_args"
29 assert p.config == "dummy_config"
30
31 assert p.handleFile("f.txt") is None
32 assert p.handleDone() is None
33
0 import os
1 import tempfile
2 import unittest
3
4 import eyed3.id3
5 import eyed3.main
6
7 from . import RedirectStdStreams
8
9
10 class TestId3FrameRules(unittest.TestCase):
11 def test_bad_frames(self):
12 try:
13 fd, tempf = tempfile.mkstemp(suffix='.id3')
14 os.close(fd)
15 tagfile = eyed3.id3.TagFile(tempf)
16 tagfile.initTag()
17 tagfile.tag.title = 'mytitle'
18 tagfile.tag.privates.set(b'mydata', b'onwer0')
19 tagfile.tag.save()
20 args = ['--plugin', 'stats', tempf]
21 args, _, config = eyed3.main.parseCommandLine(args)
22
23 with RedirectStdStreams() as out:
24 eyed3.main.main(args, config)
25 finally:
26 os.remove(tempf)
27
28 print(out.stdout.getvalue())
29
30 self.assertIn('PRIV frames are bad', out.stdout.getvalue())
0 from unittest.mock import MagicMock, call
1
2 import eyed3.utils.console
3 from eyed3.utils import walk
4 from eyed3.utils.console import (
5 printMsg, printWarning, printHeader, Fore, WARNING_COLOR, HEADER_COLOR
6 )
7 from . import RedirectStdStreams
8
9
10 def test_printWarning():
11 eyed3.utils.console.USE_ANSI = False
12 with RedirectStdStreams() as out:
13 printWarning("Built To Spill")
14 assert (out.stdout.read() == "Built To Spill\n")
15
16 eyed3.utils.console.USE_ANSI = True
17 with RedirectStdStreams() as out:
18 printWarning("Built To Spill")
19 assert (out.stdout.read() == "%sBuilt To Spill%s\n" % (WARNING_COLOR(),
20 Fore.RESET))
21
22
23 def test_printMsg():
24 eyed3.utils.console.USE_ANSI = False
25 with RedirectStdStreams() as out:
26 printMsg("EYEHATEGOD")
27 assert (out.stdout.read() == "EYEHATEGOD\n")
28
29 eyed3.utils.console.USE_ANSI = True
30 with RedirectStdStreams() as out:
31 printMsg("EYEHATEGOD")
32 assert (out.stdout.read() == "EYEHATEGOD\n")
33
34
35 def test_printHeader():
36 eyed3.utils.console.USE_ANSI = False
37 with RedirectStdStreams() as out:
38 printHeader("Furthur")
39 assert (out.stdout.read() == "Furthur\n")
40
41 eyed3.utils.console.USE_ANSI = True
42 with RedirectStdStreams() as out:
43 printHeader("Furthur")
44 assert (out.stdout.read() == "%sFurthur%s\n" % (HEADER_COLOR(),
45 Fore.RESET))
46
47
48 def test_walk_recursive(tmpdir):
49 root_d = tmpdir.mkdir("Root")
50 d1 = root_d.mkdir("d1")
51 f1 = d1 / "file1"
52 f1.write_text("file1", "utf8")
53
54 _ = root_d.mkdir("d2")
55 d3 = root_d.mkdir("d3")
56
57 handler = MagicMock()
58 walk(handler, str(root_d), recursive=True)
59 handler.handleFile.assert_called_with(str(f1))
60 handler.handleDirectory.assert_called_with(str(d1), [f1.basename])
61
62 # Only dirs with files are handled, so...
63 f2 = d3 / "Neurosis"
64 f2.write_text("Through Silver and Blood", "utf8")
65 f3 = d3 / "High on Fire"
66 f3.write_text("Surrounded By Thieves", "utf8")
67
68 d4 = d3.mkdir("d4")
69 f4 = d4 / "Cross Rot"
70 f4.write_text("VII", "utf8")
71
72 handler = MagicMock()
73 walk(handler, str(root_d), recursive=True)
74 handler.handleFile.assert_has_calls([call(str(f1)),
75 call(str(f3)),
76 call(str(f2)),
77 call(str(f4)),
78 ], any_order=True)
79 handler.handleDirectory.assert_has_calls(
80 [call(str(d1), [f1.basename]),
81 call(str(d3), [f3.basename, f2.basename]),
82 call(str(d4), [f4.basename]),
83 ], any_order=True)
84
85
86 def test_walk(tmpdir):
87 root_d = tmpdir.mkdir("Root")
88 d1 = root_d.mkdir("d1")
89 f1 = d1 / "file1"
90 f1.write_text("file1", "utf8")
91
92 _ = root_d.mkdir("d2")
93 d3 = root_d.mkdir("d3")
94
95 f2 = d3 / "Neurosis"
96 f2.write_text("Through Silver and Blood", "utf8")
97 f3 = d3 / "High on Fire"
98 f3.write_text("Surrounded By Thieves", "utf8")
99
100 d4 = d3.mkdir("d4")
101 f4 = d4 / "Cross Rot"
102 f4.write_text("VII", "utf8")
103
104 handler = MagicMock()
105 walk(handler, str(root_d))
106 handler.handleFile.assert_not_called()
107 handler.handleDirectory.assert_not_called()
108
109 handler = MagicMock()
110 walk(handler, str(root_d / "d1"), recursive=True)
111 handler.handleFile.assert_called_with(str(f1))
112 handler.handleDirectory.assert_called_with(str(d1), [f1.basename])
113
114 handler = MagicMock()
115 walk(handler, str(root_d / "d3"))
116 handler.handleFile.assert_has_calls([call(str(f3)), call(str(f2))], any_order=True)
117 handler.handleDirectory.assert_has_calls([call(str(d3), [f3.basename, f2.basename])],
118 any_order=True)
00 [tox]
1 envlist = clean, py27, pypy, py34, py35, py36, py37, pypy3, report
2
3 [testenv:clean]
4 commands = coverage erase
1 envlist = py{39,38,37,36},pypy3
52
63 [testenv]
7 commands = coverage run --rcfile=setup.cfg --source ./src/eyed3 --append -m \
8 pytest {posargs:--verbose ./src/test}
9 deps =
10 -r{toxinidir}/requirements.txt
11 -r{toxinidir}/requirements/test.txt
12
13 [testenv:report]
14 commands =
15 coverage report --rcfile=setup.cfg
16 coverage html --rcfile=setup.cfg
4 deps = .[test]
5 .[display-plugin]
6 commands = make lint test PYTEST_ARGS={posargs}