eyeD3-0.8.4/ 0000755 0001750 0001750 00000000000 13203726215 013337 5 ustar travis travis 0000000 0000000 eyeD3-0.8.4/Makefile 0000644 0001750 0001750 00000020317 13203725473 015007 0 ustar travis travis 0000000 0000000 .PHONY: help build test clean dist install coverage pre-release release \
docs clean-docs lint tags coverage-view changelog \
clean-pyc clean-build clean-patch clean-local clean-test-data \
test-all test-data build-release freeze-release tag-release \
pypi-release web-release github-release cookiecutter requirements
SRC_DIRS = ./src/eyed3
TEST_DIR = ./src/test
NAME ?= Travis Shirk
EMAIL ?= travis@pobox.com
GITHUB_USER ?= nicfit
GITHUB_REPO ?= eyeD3
PYPI_REPO = pypitest
PROJECT_NAME = $(shell python setup.py --name 2> /dev/null)
VERSION = $(shell python setup.py --version 2> /dev/null)
RELEASE_NAME = $(shell python setup.py --release-name 2> /dev/null)
CHANGELOG = HISTORY.rst
CHANGELOG_HEADER = v${VERSION} ($(shell date --iso-8601))$(if ${RELEASE_NAME}, : ${RELEASE_NAME},)
TEST_DATA = eyeD3-test-data
TEST_DATA_FILE = ${TEST_DATA}.tgz
TEST_DATA_DIR ?= $(shell pwd)/src/test
help:
@echo "test - run tests quickly with the default Python"
@echo "docs - generate Sphinx HTML documentation, including API docs"
@echo "clean - remove all build, test, coverage and Python artifacts"
@echo "clean-build - remove build artifacts"
@echo "clean-pyc - remove Python file artifacts"
@echo "clean-test - remove test and coverage artifacts"
@echo "clean-docs - remove autogenerating doc artifacts"
@echo "clean-patch - remove patch artifacts (.rej, .orig)"
@echo "build - byte-compile python files and generate other build objects"
@echo "lint - check style with flake8"
@echo "test - run tests quickly with the default Python"
@echo "test-all - run tests on every Python version with tox"
@echo "coverage - check code coverage quickly with the default Python"
@echo "test-all - run tests on various Python versions with tox"
@echo "release - package and upload a release"
@echo " PYPI_REPO=[pypitest]|pypi"
@echo "pre-release - check repo and show version, generate changelog, etc."
@echo "dist - package"
@echo "install - install the package to the active Python's site-packages"
@echo "build - build package source files"
@echo ""
@echo "Options:"
@echo "TEST_PDB - If defined PDB options are added when 'pytest' is invoked"
@echo "BROWSER - HTML viewer used by docs-view/coverage-view"
@echo "CC_MERGE - Set to no to disable cookiecutter merging."
@echo "CC_OPTS - OVerrided the default options (--no-input) with your own."
build:
python setup.py build
clean: clean-local clean-build clean-pyc clean-test clean-patch clean-docs
clean-local:
-rm tags
-rm all.id3 example.id3
clean-build:
rm -fr build/
rm -fr dist/
rm -fr .eggs/
find . -name '*.egg-info' -exec rm -fr {} +
find . -name '*.egg' -exec rm -f {} +
clean-pyc:
find . -name '*.pyc' -exec rm -f {} +
find . -name '*.pyo' -exec rm -f {} +
find . -name '*~' -exec rm -f {} +
find . -name '__pycache__' -exec rm -fr {} +
clean-test:
rm -fr .tox/
rm -f .coverage
clean-patch:
find . -name '*.rej' -exec rm -f '{}' \;
find . -name '*.orig' -exec rm -f '{}' \;
lint:
flake8 $(SRC_DIRS)
_PYTEST_OPTS=
ifdef TEST_PDB
_PDB_OPTS=--pdb -s
endif
test:
pytest $(_PYTEST_OPTS) $(_PDB_OPTS) ${TEST_DIR}
test-all:
tox
test-most:
tox -e py27,py36
test-data:
# Move these to eyed3.nicfit.net
test -f ${TEST_DATA_DIR}/${TEST_DATA_FILE} || \
wget --quiet "http://nicfit.net/files/${TEST_DATA_FILE}" \
-O ${TEST_DATA_DIR}/${TEST_DATA_FILE}
tar xzf ${TEST_DATA_DIR}/${TEST_DATA_FILE} -C ${TEST_DATA_DIR}
cd src/test && rm -f ./data && ln -s ${TEST_DATA_DIR}/${TEST_DATA} ./data
clean-test-data:
-rm src/test/data
-rm src/test/${TEST_DATA_FILE}
pkg-test-data:
tar czf ./build/${TEST_DATA_FILE} -C ./src/test ./eyeD3-test-data
coverage:
pytest --cov=./src/eyed3 \
--cov-report=html --cov-report term \
--cov-config=setup.cfg ${TEST_DIR}
coverage-view: coverage
${BROWSER} build/tests/coverage/index.html;\
docs:
rm -f docs/eyed3.rst
rm -f docs/modules.rst
sphinx-apidoc -o docs/ ${SRC_DIRS}
$(MAKE) -C docs clean
etc/mycog.py
$(MAKE) -C docs html
-rm example.id3
docs-view: docs
$(BROWSER) docs/_build/html/index.html
clean-docs:
$(MAKE) -C docs clean
-rm README.html
pre-release: lint test changelog requirements
@# Keep docs off pre-release target list, else it is pruned during 'release' but
@# after a clean.
@$(MAKE) docs
@echo "VERSION: $(VERSION)"
$(eval RELEASE_TAG = v${VERSION})
@echo "RELEASE_TAG: $(RELEASE_TAG)"
@echo "RELEASE_NAME: $(RELEASE_NAME)"
check-manifest
@if git tag -l | grep -x ${RELEASE_TAG} > /dev/null; then \
echo "Version tag '${RELEASE_TAG}' already exists!"; \
false; \
fi
IFS=$$'\n';\
for auth in `git authors --list | sed 's/.* <\(.*\)>/\1/'`; do \
echo "Checking $$auth...";\
grep "$$auth" AUTHORS.rst || echo "* $$auth" >> AUTHORS.rst;\
done
@test -n "${GITHUB_USER}" || (echo "GITHUB_USER not set, needed for github" && false)
@test -n "${GITHUB_TOKEN}" || (echo "GITHUB_TOKEN not set, needed for github" && false)
@github-release --version # Just a exe existence check
@git status -s -b
requirements:
nicfit requirements
pip-compile -U requirements.txt -o ./requirements.txt
changelog:
last=`git tag -l --sort=version:refname | grep '^v[0-9]' | tail -n1`;\
if ! grep "${CHANGELOG_HEADER}" ${CHANGELOG} > /dev/null; then \
rm -f ${CHANGELOG}.new; \
if test -n "$$last"; then \
gitchangelog --author-format=email \
--omit-author="travis@pobox.com" $${last}..HEAD |\
sed "s|^%%version%% .*|${CHANGELOG_HEADER}|" |\
sed '/^.. :changelog:/ r/dev/stdin' ${CHANGELOG} \
> ${CHANGELOG}.new; \
else \
cat ${CHANGELOG} |\
sed "s/^%%version%% .*/${CHANGELOG_HEADER}/" \
> ${CHANGELOG}.new;\
fi; \
mv ${CHANGELOG}.new ${CHANGELOG}; \
fi
build-release: test-most dist
freeze-release:
@(git diff --quiet && git diff --quiet --staged) || \
(printf "\n!!! Working repo has uncommited/unstaged changes. !!!\n" && \
printf "\nCommit and try again.\n" && false)
tag-release:
git tag -a $(RELEASE_TAG) -m "Release $(RELEASE_TAG)"
git push --tags origin
release: pre-release freeze-release build-release tag-release upload-release
github-release:
name="${RELEASE_TAG}"; \
if test -n "${RELEASE_NAME}"; then \
name="${RELEASE_TAG} (${RELEASE_NAME})"; \
fi; \
prerelease=""; \
if echo "${RELEASE_TAG}" | grep '[^v0-9\.]'; then \
prerelease="--pre-release"; \
fi; \
echo "NAME: $$name"; \
echo "PRERELEASE: $$prerelease"; \
github-release --verbose release --user "${GITHUB_USER}" \
--repo ${GITHUB_REPO} --tag ${RELEASE_TAG} \
--name "$${name}" $${prerelease}
for file in $$(find dist -type f -exec basename {} \;) ; do \
echo "Uploading: $$file"; \
github-release upload --user "${GITHUB_USER}" --repo ${GITHUB_REPO} \
--tag ${RELEASE_TAG} --name $${file} --file dist/$${file}; \
done
web-release:
for f in `find dist -type f`; do \
scp -P444 $$f eyed3.nicfit.net:eyeD3-releases/`basename $$f`; \
done
upload-release: github-release pypi-release web-release
pypi-release:
for f in `find dist -type f -name ${PROJECT_NAME}-${VERSION}.tar.gz \
-o -name \*.egg -o -name \*.whl`; do \
if test -f $$f ; then \
twine upload -r ${PYPI_REPO} --skip-existing $$f ; \
fi \
done
sdist: build
python setup.py sdist --formats=gztar,zip
python setup.py bdist_egg
python setup.py bdist_wheel
dist: clean sdist docs
cd docs/_build && \
tar czvf ../../dist/${PROJECT_NAME}-${VERSION}_docs.tar.gz html
@# The cd dist keeps the dist/ prefix out of the md5sum files
cd dist && \
for f in $$(ls); do \
md5sum $${f} > $${f}.md5; \
done
ls -l dist
install: clean
python setup.py install
tags:
ctags -R ${SRC_DIRS}
README.html: README.rst
rst2html5.py README.rst >| README.html
if test -n "${BROWSER}"; then \
${BROWSER} README.html;\
fi
CC_MERGE ?= yes
CC_OPTS ?= --no-input
GIT_COMMIT_HOOK = .git/hooks/commit-msg
cookiecutter:
tmp_d=`mktemp -d`; cc_d=$$tmp_d/eyeD3; \
if test "${CC_MERGE}" == "no"; then \
nicfit cookiecutter ${CC_OPTS} "$${tmp_d}"; \
git -C "$$cc_d" diff; \
git -C "$$cc_d" status -s -b; \
else \
nicfit cookiecutter --merge ${CC_OPTS} "$${tmp_d}" \
--extra-merge ${GIT_COMMIT_HOOK} ${GIT_COMMIT_HOOK};\
fi; \
rm -rf $$tmp_d
eyeD3-0.8.4/requirements.txt 0000644 0001750 0001750 00000000305 13203726152 016621 0 ustar travis travis 0000000 0000000 #
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile --output-file ./requirements.txt requirements.txt
#
grako==3.99.9
pathlib==1.0.1
python-magic==0.4.13
six==1.11.0
eyeD3-0.8.4/setup.cfg 0000644 0001750 0001750 00000000464 13203726215 015164 0 ustar travis travis 0000000 0000000 [wheel]
universal = 1
[flake8]
max-line-length = 80
statistics = 1
ignore = E121,E124,E126,E127,E128,E131,E266
[aliases]
test = pytest
[html]
directory = build/tests/coverage
[run]
omit = /tmp/*
[tool:pytest]
addopts = --verbose
[metadata]
license_file = LICENSE
[egg_info]
tag_build =
tag_date = 0
eyeD3-0.8.4/LICENSE 0000644 0001750 0001750 00000104514 13061344514 014351 0 ustar travis travis 0000000 0000000
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Copyright (C)
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
.
eyeD3-0.8.4/README.rst 0000644 0001750 0001750 00000007053 13153052736 015037 0 ustar travis travis 0000000 0000000 Status
------
.. image:: https://img.shields.io/pypi/v/eyeD3.svg
:target: https://pypi.python.org/pypi/eyeD3/
:alt: Latest Version
.. image:: https://img.shields.io/pypi/status/eyeD3.svg
:target: https://pypi.python.org/pypi/eyeD3/
:alt: Project Status
.. image:: https://travis-ci.org/nicfit/eyeD3.svg?branch=master
:target: https://travis-ci.org/nicfit/eyeD3
:alt: Build Status
.. image:: https://img.shields.io/pypi/l/eyeD3.svg
:target: https://pypi.python.org/pypi/eyeD3/
:alt: License
.. image:: https://img.shields.io/pypi/pyversions/eyeD3.svg
:target: https://pypi.python.org/pypi/eyeD3/
:alt: Supported Python versions
.. image:: https://coveralls.io/repos/nicfit/eyeD3/badge.svg
:target: https://coveralls.io/r/nicfit/eyeD3
:alt: Coverage Status
About
-----
eyeD3_ is a Python tool for working with audio files, specifically MP3 files
containing ID3_ metadata (i.e. song info).
It provides a command-line tool (``eyeD3``) and a Python library
(``import eyed3``) that can be used to write your own applications or
plugins that are callable from the command-line tool.
For example, to set some song information in an mp3 file called
``song.mp3``::
$ eyeD3 -a Integrity -A "Humanity Is The Devil" -t "Hollow" -n 2 song.mp3
With this command we've set the artist (``-a/--artist``), album
(``-A/--album``), title (``-t/--title``), and track number
(``-n/--track-num``) properties in the ID3 tag of the file. This is the
standard interface that eyeD3 has always had in the past, therefore it
is also the default plugin when no other is specified.
The results of this command can be seen by running the ``eyeD3`` with no
options.
::
$ eyeD3 song.mp3
song.mp3 [ 3.06 MB ]
-------------------------------------------------------------------------
ID3 v2.4:
title: Hollow
artist: Integrity
album: Humanity Is The Devil
album artist: None
track: 2
-------------------------------------------------------------------------
The same can be accomplished using Python.
::
import eyed3
audiofile = eyed3.load("song.mp3")
audiofile.tag.artist = u"Integrity"
audiofile.tag.album = u"Humanity Is The Devil"
audiofile.tag.album_artist = u"Integrity"
audiofile.tag.title = u"Hollow"
audiofile.tag.track_num = 2
audiofile.tag.save()
eyeD3_ is written and maintained by `Travis Shirk`_ and is licensed under
version 3 of the GPL_.
Features
--------
* Python package for writing application and/or plugins.
* Command-line tool driver script that supports plugins.
viewer/editor interface.
* Easy editing/viewing of audio metadata from the command-line, using the
'classic' plugin.
* Support for ID3 versions 1.x, 2.2 (read-only), 2.3, and 2.4.
* Support for the MP3 audio format exposing details such as play time, bit
rate, sampling frequency, etc.
* Abstract design allowing future support for different audio formats and
metadata containers.
Get Started
-----------
Python 2.7, >= 3.3 is required.
For `installation instructions`_ or more complete `documentation`_ see
http://eyeD3.nicfit.net/
Please post feedback and/or defects on the `issue tracker`_, or `mailing list`_.
.. _eyeD3: http://eyeD3.nicfit.net/
.. _Travis Shirk: travis@pobox.com
.. _issue tracker: https://bitbucket.org/nicfit/eyed3/issues?status=new&status=open
.. _mailing list: https://groups.google.com/forum/?fromgroups#!forum/eyed3-users
.. _installation instructions: http://eyeD3.nicfit.net/index.html#installation
.. _documentation: http://eyeD3.nicfit.net/index.html#documentation
.. _GPL: http://www.gnu.org/licenses/gpl-2.0.html
.. _ID3: http://id3.org/
eyeD3-0.8.4/CONTRIBUTING.rst 0000644 0001750 0001750 00000006042 13153052736 016006 0 ustar travis travis 0000000 0000000 ============
Contributing
============
Contributions are welcome, and they are greatly appreciated! Every
little bit helps, and credit will always be given.
You can contribute in many ways:
Types of Contributions
----------------------
Report Bugs
~~~~~~~~~~~
Report bugs at https://github.com/nicfit/eyeD3/issues.
If you are reporting a bug, please include:
* Your operating system name and version.
* Any details about your local setup that might be helpful in troubleshooting.
* Detailed steps to reproduce the bug.
Fix Bugs
~~~~~~~~
Look through the GitHub issues for bugs. Anything tagged with "bug"
is open to whoever wants to implement it.
Implement Features
~~~~~~~~~~~~~~~~~~
Look through the GitHub issues for features. Anything tagged with
"feature" is open to whoever wants to implement it.
Write Documentation
~~~~~~~~~~~~~~~~~~~
eyeD3 could always use more documentation, whether as
part of the official eyeD3 docs, in docstrings, or
even on the web in blog posts, articles, and such.
Submit Feedback
~~~~~~~~~~~~~~~
The best way to send feedback is to file an issue at
https://github.com/nicfit/eyeD3/issues.
If you are proposing a feature:
* Explain in detail how it would work.
* Keep the scope as narrow as possible, to make it easier to implement.
* Remember that this is a volunteer-driven project, and that contributions
are welcome :)
Get Started!
------------
Ready to contribute? Here's how to set up eyeD3 for
local development.
1. Fork the `eyeD3` repo on GitHub.
2. Clone your fork locally::
$ git clone git@github.com:your_name_here/eyeD3.git
3. Install your local copy into a virtualenv. Assuming you have
virtualenvwrapper installed, this is how you set up your fork for local
development::
$ mkvirtualenv eyed3
$ cd eyed3/
$ python setup.py develop
4. Create a branch for local development::
$ git checkout -b name-of-your-bugfix-or-feature
Now you can make your changes locally.
5. When you're done making changes, check that your changes pass flake8 and the
tests, including testing other Python versions with tox:
.. code-block:: bash
$ make lint
$ make test
$ make test-all # Optional, requires multiple versions of Python
To get flake8 and tox, just pip install them into your virtualenv.
6. Commit your changes and push your branch to GitHub.::
$ git add .
$ git commit -m "Your detailed description of your changes."
$ git push origin name-of-your-bugfix-or-feature
7. Submit a pull request through the GitHub website.
Pull Request Guidelines
-----------------------
Before you submit a pull request, check that it meets these guidelines:
1. The pull request should include tests.
2. If the pull request adds functionality, the docs should be updated. Put
your new functionality into a function with a docstring, and add the
feature to the list in README.rst.
3. The pull request should work for Python 2.7, and 3.3, 3.4, 3.5, and for PyPy. Check
https://travis-ci.org/nicfit/eyeD3/pulls
and make sure that the tests pass for all supported Python versions.
eyeD3-0.8.4/src/ 0000755 0001750 0001750 00000000000 13203726215 014126 5 ustar travis travis 0000000 0000000 eyeD3-0.8.4/src/test/ 0000755 0001750 0001750 00000000000 13203726215 015105 5 ustar travis travis 0000000 0000000 eyeD3-0.8.4/src/test/mp3/ 0000755 0001750 0001750 00000000000 13203726215 015604 5 ustar travis travis 0000000 0000000 eyeD3-0.8.4/src/test/mp3/test_mp3.py 0000644 0001750 0001750 00000007761 13161501620 017721 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2009 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
import unittest
import os
from io import BytesIO
from .. import DATA_D
import eyed3
def testvalidHeader():
from eyed3.mp3.headers import isValidHeader
# False sync, the layer is invalid
assert not isValidHeader(0xffe00000)
# False sync, bitrate is invalid
assert not isValidHeader(0xffe20000)
assert not isValidHeader(0xffe20001)
assert not isValidHeader(0xffe2000f)
# False sync, sample rate is invalid
assert not isValidHeader(0xffe21c34)
assert not isValidHeader(0xffe21c54)
# False sync, version is invalid
assert not isValidHeader(0xffea0000)
assert not isValidHeader(0xffea0001)
assert not isValidHeader(0xffeb0001)
assert not isValidHeader(0xffec0001)
assert not isValidHeader(0)
assert not isValidHeader(0xffffffff)
assert not isValidHeader(0xffe0ffff)
assert not isValidHeader(0xffe00000)
assert not isValidHeader(0xfffb0000)
assert isValidHeader(0xfffb9064)
assert isValidHeader(0xfffb9074)
assert isValidHeader(0xfffb900c)
assert isValidHeader(0xfffb1900)
assert isValidHeader(0xfffbd204)
assert isValidHeader(0xfffba040)
assert isValidHeader(0xfffba004)
assert isValidHeader(0xfffb83eb)
assert isValidHeader(0xfffb7050)
assert isValidHeader(0xfffb32c0)
def testFindHeader():
from eyed3.mp3.headers import findHeader
# No header
buffer = BytesIO(b'\x00' * 1024)
(offset, header_int, header_bytes) = findHeader(buffer, 0)
assert header_int is None
# Valid header
buffer = BytesIO(b'\x11\x12\x23' * 1024 + b"\xff\xfb\x90\x64" +
b"\x00" * 1024)
(offset, header_int, header_bytes) = findHeader(buffer, 0)
assert header_int == 0xfffb9064
# Same thing with a false sync in the mix
buffer = BytesIO(b'\x11\x12\x23' * 1024 +
b"\x11" * 100 +
b"\xff\xea\x00\x00" + # false sync
b"\x22" * 100 +
b"\xff\xe2\x1c\x34" + # false sync
b"\xee" * 100 +
b"\xff\xfb\x90\x64" +
b"\x00" * 1024)
(offset, header_int, header_bytes) = findHeader(buffer, 0)
assert header_int == 0xfffb9064
@unittest.skipIf(not os.path.exists(DATA_D), "test requires data files")
def testBasicVbrMp3():
audio_file = eyed3.load(os.path.join(DATA_D, "notag-vbr.mp3"))
assert isinstance(audio_file, eyed3.mp3.Mp3AudioFile)
assert audio_file.info is not None
assert audio_file.info.time_secs == 262
assert audio_file.info.size_bytes == 6272220
# Variable bit rate, ~191
assert audio_file.info.bit_rate[0] == True
assert audio_file.info.bit_rate[1] == 191
assert audio_file.info.bit_rate_str == "~191 kb/s"
assert audio_file.info.mode == "Joint stereo"
assert audio_file.info.sample_freq == 44100
assert audio_file.info.mp3_header is not None
assert audio_file.info.mp3_header.version == 1.0
assert audio_file.info.mp3_header.layer == 3
assert audio_file.info.xing_header is not None
assert audio_file.info.lame_tag is not None
assert audio_file.info.vbri_header is None
assert audio_file.tag is None
eyeD3-0.8.4/src/test/mp3/__init__.py 0000644 0001750 0001750 00000000000 13041005225 017672 0 ustar travis travis 0000000 0000000 eyeD3-0.8.4/src/test/mp3/test_infos.py 0000644 0001750 0001750 00000004507 13041005225 020330 0 ustar travis travis 0000000 0000000 '''
Test fucntions and data by Jason Penney.
https://bitbucket.org/nicfit/eyed3/issue/32/mp3audioinfotime_secs-incorrect-for-mpeg2
To test individual files use:::
python -m test.mp3.test_infos
'''
from __future__ import print_function
import eyed3
import sys
import os
from decimal import Decimal
from .. import DATA_D, unittest
def _do_test(reported, expected):
if reported != expected:
return (False, "eyed3 reported %s (expected %s)" %
(str(reported), str(expected)))
return (True, '')
def _translate_mode(mode):
if mode == 'simple':
return 'Stereo'
if mode == 'mono':
return 'Mono'
if mode == 'joint' or mode == 'force':
return 'Joint stereo'
if mode == 'dual-mono':
return 'Dual channel stereo'
raise RuntimeError("unknown mode: %s" % mode)
def _test_file(pth):
errors = []
info = os.path.splitext(os.path.basename(pth))[0].split(' ')
fil = eyed3.load(pth)
tests = [
('mpeg_version', Decimal(str(fil.info.mp3_header.version)),
Decimal(info[0][-3:])),
('sample_freq', Decimal(str(fil.info.mp3_header.sample_freq))/1000,
Decimal(info[1][:-3])),
('vbr', fil.info.bit_rate[0], bool(info[2] == '__vbr__')),
('stereo_mode', fil.info.mode, _translate_mode(info[3])),
('duration', fil.info.time_secs, 10),
]
if info[2] != '__vbr__':
tests.append(('bit_rate', fil.info.bit_rate[1], int(info[2][:-4])))
for test, reported, expected in tests:
(passed, msg) = _do_test(reported, expected)
if not passed:
errors.append("%s: %s" % (test, msg))
print("%s: %s" % (os.path.basename(pth), 'FAIL' if errors else 'ok'))
for err in errors:
print(" %s" % err)
return errors
@unittest.skipIf(not os.path.exists(DATA_D), "test requires data files")
def test_mp3_infos(do_assert=True):
data_d = os.path.join(DATA_D, "mp3_samples")
mp3s = sorted([f for f in os.listdir(data_d) if f.endswith(".mp3")])
for mp3_file in mp3s:
errors = _test_file(os.path.join(data_d, mp3_file))
if do_assert:
assert(len(errors) == 0)
if __name__ == "__main__":
if len(sys.argv) < 2:
test_mp3_infos(do_assert=False)
else:
for mp3_file in sys.argv[1:]:
errors = _test_file(mp3_file)
eyeD3-0.8.4/src/test/test_classic_plugin.py 0000644 0001750 0001750 00000102652 13203722311 021514 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
import os
import shutil
import unittest
import six
import pytest
import eyed3
from eyed3 import main, id3, core, compat
from . import DATA_D, RedirectStdStreams
def testPluginOption():
for arg in ["--help", "-h"]:
# When help is requested and no plugin is specified, use default
with RedirectStdStreams() as out:
try:
args, _, config = main.parseCommandLine([arg])
except SystemExit as ex:
assert ex.code == 0
out.stdout.seek(0)
sout = out.stdout.read()
assert sout.find("Plugin options:\n Classic eyeD3") != -1
# When help is requested and all default plugin names are specified
for plugin_name in ["classic"]:
for args in [["--plugin=%s" % plugin_name, "--help"]]:
with RedirectStdStreams() as out:
try:
args, _, config = main.parseCommandLine(args)
except SystemExit as ex:
assert ex.code == 0
out.stdout.seek(0)
sout = out.stdout.read()
assert sout.find("Plugin options:\n Classic eyeD3") != -1
@unittest.skipIf(not os.path.exists(DATA_D), "test requires data files")
def testReadEmptyMp3():
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine([os.path.join(DATA_D,
"test.mp3")])
retval = main.main(args, config)
assert retval == 0
assert out.stderr.read().find("No ID3 v1.x/v2.x tag found") != -1
class TestDefaultPlugin(unittest.TestCase):
def __init__(self, name):
super(TestDefaultPlugin, self).__init__(name)
self.orig_test_file = "%s/test.mp3" % DATA_D
self.test_file = "/tmp/test.mp3"
@unittest.skipIf(not os.path.exists(DATA_D), "test requires data files")
def setUp(self):
shutil.copy(self.orig_test_file, self.test_file)
def tearDown(self):
# TODO: could remove the tag and compare audio file to original
os.remove(self.test_file)
def _addVersionOpt(self, version, opts):
if version == id3.ID3_DEFAULT_VERSION:
return
if version[0] == 1:
opts.append("--to-v1.1")
elif version[:2] == (2, 3):
opts.append("--to-v2.3")
elif version[:2] == (2, 4):
opts.append("--to-v2.4")
else:
assert not("Unhandled version")
def testNewTagArtist(self, version=id3.ID3_DEFAULT_VERSION):
for opts in [ ["-a", "The Cramps", self.test_file],
["--artist=The Cramps", self.test_file] ]:
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert retval == 0
af = eyed3.load(self.test_file)
assert af is not None
assert af.tag is not None
assert af.tag.artist == u"The Cramps"
def testNewTagComposer(self, version=id3.ID3_DEFAULT_VERSION):
for opts in [ ["--composer=H.R.", self.test_file] ]:
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert retval == 0
af = eyed3.load(self.test_file)
assert af is not None
assert af.tag is not None
assert af.tag.composer == u"H.R."
def testNewTagAlbum(self, version=id3.ID3_DEFAULT_VERSION):
for opts in [ ["-A", "Psychedelic Jungle", self.test_file],
["--album=Psychedelic Jungle", self.test_file] ]:
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (af is not None)
assert (af.tag is not None)
assert (af.tag.album == u"Psychedelic Jungle")
def testNewTagAlbumArtist(self, version=id3.ID3_DEFAULT_VERSION):
for opts in [ ["-b", "Various Artists", self.test_file],
["--album-artist=Various Artists", self.test_file] ]:
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert af is not None
assert af.tag is not None
assert af.tag.album_artist == u"Various Artists"
def testNewTagTitle(self, version=id3.ID3_DEFAULT_VERSION):
for opts in [ ["-t", "Green Door", self.test_file],
["--title=Green Door", self.test_file] ]:
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (af is not None)
assert (af.tag is not None)
assert (af.tag.title == u"Green Door")
def testNewTagTrackNum(self, version=id3.ID3_DEFAULT_VERSION):
for opts in [ ["-n", "14", self.test_file],
["--track=14", self.test_file] ]:
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (af is not None)
assert (af.tag is not None)
assert (af.tag.track_num[0] == 14)
def testNewTagTrackNumInvalid(self):
for opts in [ ["-n", "abc", self.test_file],
["--track=-14", self.test_file]
]:
with RedirectStdStreams() as out:
try:
args, _, config = main.parseCommandLine(opts)
except SystemExit as ex:
assert ex.code != 0
else:
assert not("Should not have gotten here")
def testNewTagTrackTotal(self, version=id3.ID3_DEFAULT_VERSION):
if version[0] == 1:
# No support for this in v1.x
return
for opts in [ ["-N", "14", self.test_file],
["--track-total=14", self.test_file] ]:
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (af is not None)
assert (af.tag is not None)
assert (af.tag.track_num[1] == 14)
def testNewTagGenre(self, version=id3.ID3_DEFAULT_VERSION):
for opts in [ ["-G", "Rock", self.test_file],
["--genre=Rock", self.test_file] ]:
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (af is not None)
assert (af.tag is not None)
assert (af.tag.genre.name == "Rock")
assert (af.tag.genre.id == 17)
def testNewTagYear(self, version=id3.ID3_DEFAULT_VERSION):
for opts in [ ["-Y", "1981", self.test_file],
["--release-year=1981", self.test_file] ]:
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (af is not None)
assert (af.tag is not None)
if version == id3.ID3_V2_3:
assert (af.tag.original_release_date.year == 1981)
else:
assert (af.tag.release_date.year == 1981)
def testNewTagReleaseDate(self, version=id3.ID3_DEFAULT_VERSION):
for date in ["1981", "1981-03-06", "1981-03"]:
orig_date = core.Date.parse(date)
for opts in [ ["--release-date=%s" % str(date), self.test_file] ]:
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (af is not None)
assert (af.tag is not None)
assert (af.tag.release_date == orig_date)
def testNewTagOrigRelease(self, version=id3.ID3_DEFAULT_VERSION):
for opts in [ ["--orig-release-date=1981", self.test_file] ]:
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (af is not None)
assert (af.tag is not None)
assert (af.tag.original_release_date.year == 1981)
def testNewTagRecordingDate(self, version=id3.ID3_DEFAULT_VERSION):
for opts in [ ["--recording-date=1993-10-30", self.test_file] ]:
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (af is not None)
assert (af.tag is not None)
assert (af.tag.recording_date.year == 1993)
assert (af.tag.recording_date.month == 10)
assert (af.tag.recording_date.day == 30)
def testNewTagEncodingDate(self, version=id3.ID3_DEFAULT_VERSION):
for opts in [ ["--encoding-date=2012-10-23T20:22", self.test_file] ]:
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (af is not None)
assert (af.tag is not None)
assert (af.tag.encoding_date.year == 2012)
assert (af.tag.encoding_date.month == 10)
assert (af.tag.encoding_date.day == 23)
assert (af.tag.encoding_date.hour == 20)
assert (af.tag.encoding_date.minute == 22)
def testNewTagTaggingDate(self, version=id3.ID3_DEFAULT_VERSION):
for opts in [ ["--tagging-date=2012-10-23T20:22", self.test_file] ]:
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (af is not None)
assert (af.tag is not None)
assert (af.tag.tagging_date.year == 2012)
assert (af.tag.tagging_date.month == 10)
assert (af.tag.tagging_date.day == 23)
assert (af.tag.tagging_date.hour == 20)
assert (af.tag.tagging_date.minute == 22)
def testNewTagPlayCount(self):
for expected, opts in [ (0, ["--play-count=0", self.test_file]),
(1, ["--play-count=+1", self.test_file]),
(6, ["--play-count=+5", self.test_file]),
(7, ["--play-count=7", self.test_file]),
(10000, ["--play-count=10000", self.test_file]),
]:
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (af is not None)
assert (af.tag is not None)
assert (af.tag.play_count == expected)
def testNewTagPlayCountInvalid(self):
for expected, opts in [ (0, ["--play-count=", self.test_file]),
(0, ["--play-count=-24", self.test_file]),
(0, ["--play-count=+", self.test_file]),
(0, ["--play-count=abc", self.test_file]),
(0, ["--play-count=False", self.test_file]),
]:
with RedirectStdStreams() as out:
try:
args, _, config = main.parseCommandLine(opts)
except SystemExit as ex:
assert ex.code != 0
else:
assert not("Should not have gotten here")
def testNewTagBpm(self):
for expected, opts in [ (1, ["--bpm=1", self.test_file]),
(180, ["--bpm=180", self.test_file]),
(117, ["--bpm", "116.7", self.test_file]),
(116, ["--bpm", "116.4", self.test_file]),
]:
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (af is not None)
assert (af.tag is not None)
assert (af.tag.bpm == expected)
def testNewTagBpmInvalid(self):
for expected, opts in [ (0, ["--bpm=", self.test_file]),
(0, ["--bpm=-24", self.test_file]),
(0, ["--bpm=+", self.test_file]),
(0, ["--bpm=abc", self.test_file]),
(0, ["--bpm", "=180", self.test_file]),
]:
with RedirectStdStreams() as out:
try:
args, _, config = main.parseCommandLine(opts)
except SystemExit as ex:
assert ex.code != 0
else:
assert not("Should not have gotten here")
def testNewTagPublisher(self):
for expected, opts in [
("SST", ["--publisher", "SST", self.test_file]),
("Dischord", ["--publisher=Dischord", self.test_file]),
]:
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (af is not None)
assert (af.tag is not None)
assert (af.tag.publisher == expected)
def testUniqueFileId_1(self):
with RedirectStdStreams() as out:
assert out
args, _, config = main.parseCommandLine(["--unique-file-id",
"Travis:Me",
self.test_file])
retval = main.main(args, config)
assert retval == 0
af = eyed3.load(self.test_file)
assert len(af.tag.unique_file_ids) == 1
assert af.tag.unique_file_ids.get("Travis").uniq_id == b"Me"
def testUniqueFileId_dup(self):
with RedirectStdStreams() as out:
assert out
args, _, config = \
main.parseCommandLine(["--unique-file-id", "Travis:Me",
"--unique-file-id=Travis:Me",
self.test_file])
retval = main.main(args, config)
assert retval == 0
af = eyed3.load(self.test_file)
assert len(af.tag.unique_file_ids) == 1
assert af.tag.unique_file_ids.get("Travis").uniq_id == b"Me"
def testUniqueFileId_N(self):
# Add 3
with RedirectStdStreams() as out:
assert out
args, _, config = \
main.parseCommandLine(["--unique-file-id", "Travis:Me",
"--unique-file-id=Engine:Kid",
"--unique-file-id", "Owner:Kid",
self.test_file])
retval = main.main(args, config)
assert retval == 0
af = eyed3.load(self.test_file)
assert len(af.tag.unique_file_ids) == 3
assert af.tag.unique_file_ids.get("Travis").uniq_id == b"Me"
assert af.tag.unique_file_ids.get("Engine").uniq_id == b"Kid"
assert af.tag.unique_file_ids.get(b"Owner").uniq_id == b"Kid"
# Remove 2
with RedirectStdStreams() as out:
assert out
args, _, config = \
main.parseCommandLine(["--unique-file-id", "Travis:",
"--unique-file-id=Engine:",
"--unique-file-id", "Owner:Kid",
self.test_file])
retval = main.main(args, config)
assert retval == 0
af = eyed3.load(self.test_file)
assert len(af.tag.unique_file_ids) == 1
# Remove not found ID
with RedirectStdStreams() as out:
args, _, config = \
main.parseCommandLine(["--unique-file-id", "Travis:",
self.test_file])
retval = main.main(args, config)
assert retval == 0
sout = out.stdout.read()
assert "Unique file ID 'Travis' not found" in sout
af = eyed3.load(self.test_file)
assert len(af.tag.unique_file_ids) == 1
# TODO:
# --text-frame, --user-text-frame
# --url-frame, --user-user-frame
# --add-image, --remove-image, --remove-all-images, --write-images
# etc.
# --rename, --force-update, -1, -2, --exclude
def testNewTagSimpleComment(self, version=id3.ID3_DEFAULT_VERSION):
if version[0] == 1:
# No support for this in v1.x
return
for opts in [ ["-c", "Starlette", self.test_file],
["--comment=Starlette", self.test_file] ]:
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (af is not None)
assert (af.tag is not None)
assert (af.tag.comments[0].text == "Starlette")
assert (af.tag.comments[0].description == "")
def testAddRemoveComment(self, version=id3.ID3_DEFAULT_VERSION):
if version[0] == 1:
# No support for this in v1.x
return
comment = u"Why can't I be you?"
for i, (c, d, l) in enumerate([(comment, u"c0", None),
(comment, u"c1", None),
(comment, u"c2", 'eng'),
(u"¿Por qué no puedo ser tú ?", u"c2",
'esp'),
]):
darg = u":{}".format(d) if d else ""
larg = u":{}".format(l) if l else ""
opts = [u"--add-comment={c}{darg}{larg}".format(**locals()),
self.test_file]
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (af is not None)
assert (af.tag is not None)
tag_comment = af.tag.comments.get(d or u"",
lang=compat.b(l if l else "eng"))
assert (tag_comment.text == c)
assert (tag_comment.description == d or u"")
assert (tag_comment.lang == compat.b(l if l else "eng"))
for d, l in [(u"c0", None),
(u"c1", None),
(u"c2", "eng"),
(u"c2", "esp"),
]:
larg = u":{}".format(l) if l else ""
opts = [u"--remove-comment={d}{larg}".format(**locals()),
self.test_file]
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
tag_comment = af.tag.comments.get(d,
lang=compat.b(l if l else "eng"))
assert tag_comment is None
assert (len(af.tag.comments) == 0)
def testRemoveAllComments(self, version=id3.ID3_DEFAULT_VERSION):
if version[0] == 1:
# No support for this in v1.x
return
comment = u"Why can't I be you?"
for i, (c, d, l) in enumerate([(comment, u"c0", None),
(comment, u"c1", None),
(comment, u"c2", 'eng'),
(u"¿Por qué no puedo ser tú ?", u"c2",
'esp'),
(comment, u"c4", "ger"),
(comment, u"c4", "rus"),
(comment, u"c5", "rus"),
]):
darg = u":{}".format(d) if d else ""
larg = u":{}".format(l) if l else ""
opts = [u"--add-comment={c}{darg}{larg}".format(**locals()),
self.test_file]
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (af is not None)
assert (af.tag is not None)
tag_comment = af.tag.comments.get(d or u"",
lang=compat.b(l if l else "eng"))
assert (tag_comment.text == c)
assert (tag_comment.description == d or u"")
assert (tag_comment.lang == compat.b(l if l else "eng"))
opts = [u"--remove-all-comments", self.test_file]
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (len(af.tag.comments) == 0)
def testAddRemoveLyrics(self, version=id3.ID3_DEFAULT_VERSION):
if version[0] == 1:
# No support for this in v1.x
return
comment = u"Why can't I be you?"
for i, (c, d, l) in enumerate([(comment, u"c0", None),
(comment, u"c1", None),
(comment, u"c2", 'eng'),
(u"¿Por qué no puedo ser tú ?", u"c2",
'esp'),
]):
darg = u":{}".format(d) if d else ""
larg = u":{}".format(l) if l else ""
opts = [u"--add-comment={c}{darg}{larg}".format(**locals()),
self.test_file]
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
assert (af is not None)
assert (af.tag is not None)
tag_comment = af.tag.comments.get(d or u"",
lang=compat.b(l if l else "eng"))
assert (tag_comment.text == c)
assert (tag_comment.description == d or u"")
assert (tag_comment.lang == compat.b(l if l else "eng"))
for d, l in [(u"c0", None),
(u"c1", None),
(u"c2", "eng"),
(u"c2", "esp"),
]:
larg = u":{}".format(l) if l else ""
opts = [u"--remove-comment={d}{larg}".format(**locals()),
self.test_file]
self._addVersionOpt(version, opts)
with RedirectStdStreams() as out:
args, _, config = main.parseCommandLine(opts)
retval = main.main(args, config)
assert (retval == 0)
af = eyed3.load(self.test_file)
tag_comment = af.tag.comments.get(d,
lang=compat.b(l if l else "eng"))
assert tag_comment is None
assert (len(af.tag.comments) == 0)
def testNewTagAll(self, version=id3.ID3_DEFAULT_VERSION):
self.testNewTagArtist(version)
self.testNewTagAlbum(version)
self.testNewTagTitle(version)
self.testNewTagTrackNum(version)
self.testNewTagTrackTotal(version)
self.testNewTagGenre(version)
self.testNewTagYear(version)
self.testNewTagSimpleComment(version)
af = eyed3.load(self.test_file)
assert (af.tag.artist == u"The Cramps")
assert (af.tag.album == u"Psychedelic Jungle")
assert (af.tag.title == u"Green Door")
assert (af.tag.track_num == (14, 14 if version[0] != 1 else None))
assert ((af.tag.genre.name, af.tag.genre.id) == ("Rock", 17))
if version == id3.ID3_V2_3:
assert (af.tag.original_release_date.year == 1981)
else:
assert (af.tag.release_date.year == 1981)
if version[0] != 1:
assert (af.tag.comments[0].text == "Starlette")
assert (af.tag.comments[0].description == "")
assert (af.tag.version == version)
def testNewTagAllVersion1(self):
self.testNewTagAll(version=id3.ID3_V1_1)
def testNewTagAllVersion2_3(self):
self.testNewTagAll(version=id3.ID3_V2_3)
def testNewTagAllVersion2_4(self):
self.testNewTagAll(version=id3.ID3_V2_4)
## XXX: newer pytest test below.
def test_lyrics(audiofile, tmpdir, eyeD3):
lyrics_files = []
for i in range(1, 4):
lfile = tmpdir / "lryics{:d}".format(i)
lfile.write_text((six.u(str(i)) * (100 * i)), "utf8")
lyrics_files.append(lfile)
audiofile = eyeD3(audiofile,
["--add-lyrics", "{}".format(lyrics_files[0]),
"--add-lyrics", "{}:desc".format(lyrics_files[1]),
"--add-lyrics", "{}:foo:en".format(lyrics_files[1]),
"--add-lyrics", "{}:foo:es".format(lyrics_files[2]),
"--add-lyrics", "{}:foo:de".format(lyrics_files[0]),
])
assert len(audiofile.tag.lyrics) == 5
assert audiofile.tag.lyrics.get(u"").text == ("1" * 100)
assert audiofile.tag.lyrics.get(u"desc").text == ("2" * 200)
assert audiofile.tag.lyrics.get(u"foo", "en").text == ("2" * 200)
assert audiofile.tag.lyrics.get(u"foo", "es").text == ("3" * 300)
assert audiofile.tag.lyrics.get(u"foo", "de").text == ("1" * 100)
audiofile = eyeD3(audiofile, ["--remove-lyrics", "foo:xxx"])
assert len(audiofile.tag.lyrics) == 5
audiofile = eyeD3(audiofile, ["--remove-lyrics", "foo:es"])
assert len(audiofile.tag.lyrics) == 4
audiofile = eyeD3(audiofile, ["--remove-lyrics", "desc"])
assert len(audiofile.tag.lyrics) == 3
audiofile = eyeD3(audiofile, ["--remove-all-lyrics"])
assert len(audiofile.tag.lyrics) == 0
eyeD3(audiofile, ["--add-lyrics", "eminem.txt"], expected_retval=2)
@pytest.mark.coveragewhore
def test_all(audiofile, image, eyeD3):
audiofile = eyeD3(audiofile,
["--artist", "Cibo Matto",
"--album-artist", "Cibo Matto",
"--album", "Viva! La Woman",
"--title", "Apple",
"--track=1", "--track-total=11",
"--disc-num=1", "--disc-total=1",
"--genre", "Pop",
"--release-date=1996-01-16",
"--orig-release-date=1996-01-16",
"--recording-date=1995-01-16",
"--encoding-date=1999-01-16",
"--tagging-date=1999-01-16",
"--comment", "From Japan",
"--publisher=\'Warner Brothers\'",
"--play-count=666",
"--bpm=99",
"--unique-file-id", "mishmash:777abc",
"--add-comment", "Trip Hop",
"--add-comment", "Quirky:Mood",
"--add-comment", "Kimyōna:Mood:jp",
"--add-comment", "Test:XXX",
"--add-popularity", "travis@ppbox.com:212:999",
"--fs-encoding=latin1",
"--no-config",
"--add-object", "{}:image/gif".format(image),
"--composer", "Cibo Matto",
])
def test_removeTag_v1(audiofile, eyeD3):
assert audiofile.tag is None
audiofile = eyeD3(audiofile, ["-1", "-a", "Government Issue"])
assert audiofile.tag.version == id3.ID3_V1_0
audiofile = eyeD3(audiofile, ["--remove-v1"])
assert audiofile.tag is None
def test_removeTag_v2(audiofile, eyeD3):
assert audiofile.tag is None
audiofile = eyeD3(audiofile, ["-2", "-a", "Integrity"])
assert audiofile.tag.version == id3.ID3_V2_4
audiofile = eyeD3(audiofile, ["--remove-v2"])
assert audiofile.tag is None
def test_removeTagWithBoth_v1(audiofile, eyeD3):
audiofile = eyeD3(eyeD3(audiofile, ["-1", "-a", "Face Value"]),
["-2", "-a", "Poison Idea"])
v1_view = eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1)
v2_view = eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2)
assert audiofile.tag.version == id3.ID3_V2_4
assert v1_view.tag.version == id3.ID3_V1_0
assert v2_view.tag.version == id3.ID3_V2_4
audiofile = eyeD3(audiofile, ["--remove-v1"])
assert audiofile.tag.version == id3.ID3_V2_4
assert eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1).tag is None
v2_tag = eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2).tag
assert v2_tag is not None
assert v2_tag.artist == "Poison Idea"
def test_removeTagWithBoth_v2(audiofile, eyeD3):
audiofile = eyeD3(eyeD3(audiofile, ["-1", "-a", "Face Value"]),
["-2", "-a", "Poison Idea"])
v1_view = eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1)
v2_view = eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2)
assert audiofile.tag.version == id3.ID3_V2_4
assert v1_view.tag.version == id3.ID3_V1_0
assert v2_view.tag.version == id3.ID3_V2_4
audiofile = eyeD3(audiofile, ["--remove-v2"])
assert audiofile.tag.version == id3.ID3_V1_0
assert eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2).tag is None
v1_tag = eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1).tag
assert v1_tag is not None and v1_tag.artist == "Face Value"
def test_removeTagWithBoth_v2_withConvert(audiofile, eyeD3):
audiofile = eyeD3(eyeD3(audiofile, ["-1", "-a", "Face Value"]),
["-2", "-a", "Poison Idea"])
v1_view = eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1)
v2_view = eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2)
assert audiofile.tag.version == id3.ID3_V2_4
assert v1_view.tag.version == id3.ID3_V1_0
assert v2_view.tag.version == id3.ID3_V2_4
audiofile = eyeD3(audiofile, ["--remove-v2", "--to-v1"])
assert audiofile.tag.version == id3.ID3_V1_0
assert eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2).tag is None
v1_tag = eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1).tag
assert v1_tag is not None and v1_tag.artist == "Face Value"
def test_removeTagWithBoth_v1_withConvert(audiofile, eyeD3):
audiofile = eyeD3(eyeD3(audiofile, ["-1", "-a", "Face Value"]),
["-2", "-a", "Poison Idea"])
v1_view = eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1)
v2_view = eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2)
assert audiofile.tag.version == id3.ID3_V2_4
assert v1_view.tag.version == id3.ID3_V1_0
assert v2_view.tag.version == id3.ID3_V2_4
audiofile = eyeD3(audiofile, ["--remove-v1", "--to-v2.3"])
assert audiofile.tag.version == id3.ID3_V2_3
assert eyeD3(audiofile, ["-1"], reload_version=id3.ID3_V1).tag is None
v2_tag = eyeD3(audiofile, ["-2"], reload_version=id3.ID3_V2).tag
assert v2_tag is not None and v2_tag.artist == "Poison Idea"
eyeD3-0.8.4/src/test/compat.py 0000644 0001750 0001750 00000002445 13161501620 016741 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2013 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
import sys
# assert functions that are not in unittest in python 2.6, and therefore not
# import from nost.tools as in python >= 2.7
if sys.version_info[:2] == (2, 6):
def assert_is_none(data):
assert data is None
def assert_is_not_none(data):
assert data is not None
def assert_in(data, container):
assert data in container
def assert_is(data1, data2):
assert data1 is data2
eyeD3-0.8.4/src/test/test__init__.py 0000644 0001750 0001750 00000002670 13161501620 020115 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2011 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
import eyed3
from .compat import *
def testLocale():
assert eyed3.LOCAL_ENCODING
assert eyed3.LOCAL_ENCODING != "ANSI_X3.4-1968"
assert eyed3.LOCAL_FS_ENCODING
def testException():
ex = eyed3.Error()
assert isinstance(ex, Exception)
msg = "this is a test"
ex = eyed3.Error(msg)
assert ex.message == msg
assert ex.args == (msg,)
ex = eyed3.Error(msg, 1, 2)
assert ex.message == msg
assert ex.args == (msg, 1, 2)
def test_log():
from eyed3 import log
assert log is not None
log.verbose("Hiya from Dr. Know")
eyeD3-0.8.4/src/test/test_display_plugin.py 0000644 0001750 0001750 00000011667 13203722311 021545 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2016 Sebastian Patschorke
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
import sys
import unittest
import pytest
from eyed3.id3 import TagFile
from eyed3.plugins.display import *
class TestDisplayPlugin(unittest.TestCase):
def __init__(self, name):
super(TestDisplayPlugin, self).__init__(name)
def testSimpleTags(self):
self.file.tag.artist = u"The Artist"
self.file.tag.title = u"Some Song"
self.file.tag.composer = u"Some Composer"
self.__checkOutput(u"%a% - %t% - %C%", u"The Artist - Some Song - Some Composer")
def testComposer(self):
self.file.tag.composer = u"Bad Brains"
self.__checkOutput(u"%C% - %composer%", u"Bad Brains - Bad Brains")
def testCommentsTag(self):
self.file.tag.comments.set(u"TEXT", description=None, lang=b"DE")
self.file.tag.comments.set(u"#d-tag", description=u"#l-tag", lang=b"#t-tag")
# Langs are chopped to 3 bytes (are are codes), so #t- is expected.
self.__checkOutput(u"%comments,output=#d #l #t,separation=|%", u" DE TEXT|#l-tag #t- #d-tag")
def testRepeatFunction(self):
self.__checkOutput(u"$repeat(*,3)", u"***")
self.__checkException(u"$repeat(*,three)", DisplayException)
def testNotEmptyFunction(self):
self.__checkOutput(u"$not-empty(foo,hello #t,nothing)", u"hello foo")
self.__checkOutput(u"$not-empty(,hello #t,nothing)", u"nothing")
def testNumberFormatFunction(self):
self.__checkOutput(u"$num(123,5)", u"00123")
self.__checkOutput(u"$num(123,3)", u"123")
self.__checkOutput(u"$num(123,0)", u"123")
self.__checkException(u"$num(nan,1)", DisplayException)
self.__checkException(u"$num(1,foo)", DisplayException)
self.__checkException(u"$num(1,)", DisplayException)
def __checkOutput(self, pattern, expected):
output = Pattern(pattern).output_for(self.file)
assert output == expected
def __checkException(self, pattern, exception_type):
with pytest.raises(exception_type):
Pattern(pattern).output_for(self.file)
def setUp(self):
import tempfile
with tempfile.NamedTemporaryFile() as temp:
temp.flush()
self.file = TagFile(temp.name)
self.file.initTag()
def tearDown(self):
pass
class TestDisplayParser(unittest.TestCase):
def __init__(self, name):
super(TestDisplayParser, self).__init__(name)
def testTextPattern(self):
pattern = Pattern(u"hello")
assert isinstance(pattern.sub_patterns[0], TextPattern)
assert len(pattern.sub_patterns) == 1
def testTagPattern(self):
pattern = Pattern(u"%comments,desc,lang,separation=|%")
assert len(pattern.sub_patterns) == 1
assert isinstance(pattern.sub_patterns[0], TagPattern)
comments_tag = pattern.sub_patterns[0]
assert (len(comments_tag.parameters) == 4)
assert comments_tag._parameter_value(u"description", None) == u"desc"
assert comments_tag._parameter_value(u"language", None) == u"lang"
assert (comments_tag._parameter_value(u"output", None) ==
AllCommentsTagPattern.PARAMETERS[2].default)
assert comments_tag._parameter_value(u"separation", None) == u"|"
def testComplexPattern(self):
pattern = Pattern(u"Output: $format(Artist: $not-empty(%artist%,#t,none),bold=y)")
assert len(pattern.sub_patterns) == 2
assert isinstance(pattern.sub_patterns[0], TextPattern)
assert isinstance(pattern.sub_patterns[1], FunctionFormatPattern)
text_patten = pattern.sub_patterns[1].parameters['text'].value
assert len(text_patten.sub_patterns) == 2
assert isinstance(text_patten.sub_patterns[0], TextPattern)
assert isinstance(text_patten.sub_patterns[1], FunctionNotEmptyPattern)
def testCompileException(self):
with pytest.raises(PatternCompileException):
Pattern(u"$bad-pattern").output_for(None)
with pytest.raises(PatternCompileException):
Pattern(u"$unknown-function()").output_for(None)
def setUp(self):
pass
def tearDown(self):
pass
eyeD3-0.8.4/src/test/test_main.py 0000644 0001750 0001750 00000011546 13161501620 017443 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2012-2015 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
import unittest
from eyed3 import main
from eyed3.compat import PY2
from . import RedirectStdStreams
class ParseCommandLineTest(unittest.TestCase):
def testHelpExitsSuccess(self):
with open("/dev/null", "w") as devnull:
with RedirectStdStreams(stderr=devnull):
for arg in ["--help", "-h"]:
try:
args, parser = main.parseCommandLine([arg])
except SystemExit as ex:
assert ex.code == 0
def testHelpOutput(self):
for arg in ["--help", "-h"]:
with RedirectStdStreams() as out:
try:
args, parser = main.parseCommandLine([arg])
except SystemExit as ex:
# __exit__ seeks and we're not there yet so...
out.stdout.seek(0)
assert out.stdout.read().startswith(u"usage:")
assert ex.code == 0
def testVersionExitsWithSuccess(self):
with open("/dev/null", "w") as devnull:
with RedirectStdStreams(stderr=devnull):
try:
args, parser = main.parseCommandLine(["--version"])
except SystemExit as ex:
assert ex.code == 0
def testListPluginsExitsWithSuccess(self):
try:
args, _, _ = main.parseCommandLine(["--plugins"])
except SystemExit as ex:
assert ex.code == 0
def testLoadPlugin(self):
from eyed3 import plugins
from eyed3.plugins.classic import ClassicPlugin
from eyed3.plugins.genres import GenreListPlugin
# XXX: in python3 the import of main is treated differently, in this
# case it adds confusing isinstance semantics demonstrated below
# where isinstance works with PY2 and does not in PY3. This is old,
# long before python3 but it is the closest explanantion I can find.
#http://mail.python.org/pipermail/python-bugs-list/2004-June/023326.html
args, _, _ = main.parseCommandLine([""])
if PY2:
assert isinstance(args.plugin, ClassicPlugin)
else:
assert args.plugin.__class__.__name__ == ClassicPlugin.__name__
args, _, _ = main.parseCommandLine(["--plugin=genres"])
if PY2:
assert isinstance(args.plugin, GenreListPlugin)
else:
assert args.plugin.__class__.__name__ == GenreListPlugin.__name__
with open("/dev/null", "w") as devnull:
with RedirectStdStreams(stderr=devnull):
try:
args, _ = main.parseCommandLine(["--plugin=DNE"])
except SystemExit as ex:
assert ex.code == 1
try:
args, _, _ = main.parseCommandLine(["--plugin"])
except SystemExit as ex:
assert ex.code == 2
def testLoggingOptions(self):
import logging
from eyed3 import log
with open("/dev/null", "w") as devnull:
with RedirectStdStreams(stderr=devnull):
try:
_ = main.parseCommandLine(["-l", "critical"])
assert log.getEffectiveLevel() == logging.CRITICAL
_ = main.parseCommandLine(["--log-level=error"])
assert log.getEffectiveLevel() == logging.ERROR
_ = main.parseCommandLine(["-l", "warning:NewLogger"])
assert (
logging.getLogger("NewLogger").getEffectiveLevel() ==
logging.WARNING
)
assert log.getEffectiveLevel() == logging.ERROR
except SystemExit:
assert not("Unexpected")
try:
_ = main.parseCommandLine(["--log-level=INVALID"])
assert not("Invalid log level, an Exception expected")
except SystemExit:
pass
eyeD3-0.8.4/src/test/test_factory.py 0000644 0001750 0001750 00000000776 13153052737 020204 0 ustar travis travis 0000000 0000000 import eyed3.id3
import factory
class TagFactory(factory.Factory):
class Meta:
model = eyed3.id3.Tag
title = u"Track title"
artist = u"Artist"
album = u"Album"
album_artist = artist
track_num = None
def test_factory():
tag = TagFactory()
assert isinstance(tag, eyed3.id3.Tag)
assert tag.title == u"Track title"
assert tag.artist == u"Artist"
assert tag.album == u"Album"
assert tag.album_artist == tag.artist
assert tag.track_num == (None, None)
eyeD3-0.8.4/src/test/test_stats_plugins.py 0000644 0001750 0001750 00000001572 13177420270 021424 0 ustar travis travis 0000000 0000000 from __future__ import unicode_literals
import os
import tempfile
import unittest
import eyed3.id3
import eyed3.main
from . import RedirectStdStreams
class TestId3FrameRules(unittest.TestCase):
def test_bad_frames(self):
try:
fd, tempf = tempfile.mkstemp(suffix='.id3')
os.close(fd)
tagfile = eyed3.id3.TagFile(tempf)
tagfile.initTag()
tagfile.tag.title = 'mytitle'
tagfile.tag.privates.set(b'mydata', b'onwer0')
tagfile.tag.save()
args = ['--plugin', 'stats', tempf]
args, _, config = eyed3.main.parseCommandLine(args)
with RedirectStdStreams() as out:
eyed3.main.main(args, config)
finally:
os.remove(tempf)
print(out.stdout.getvalue())
self.assertIn('PRIV frames are bad', out.stdout.getvalue())
eyeD3-0.8.4/src/test/__init__.py 0000644 0001750 0001750 00000004155 13131306305 017215 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2010-2015 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
import eyed3
from eyed3.compat import StringIO
import os
import sys
import logging
if sys.version_info[:2] == (2, 6):
import unittest2 as unittest
else:
import unittest
DATA_D = os.path.join(os.path.dirname(__file__), "data")
eyed3.log.setLevel(logging.ERROR)
class RedirectStdStreams(object):
'''This class is used to capture sys.stdout and sys.stderr for tests that
invoke command line scripts and wish to inspect the output.'''
def __init__(self, stdout=None, stderr=None, seek_on_exit=0):
self.stdout = stdout or StringIO()
self.stderr = stderr or StringIO()
self._seek_offset = seek_on_exit
def __enter__(self):
self._orig_stdout, self._orig_stderr = sys.stdout, sys.stderr
sys.stdout, sys.stderr = self.stdout, self.stderr
return self
def __exit__(self, exc_type, exc_value, traceback):
try:
for s in [self.stdout, self.stderr]:
s.flush()
if not s.isatty():
s.seek(self._seek_offset)
finally:
sys.stdout, sys.stderr = self._orig_stdout, self._orig_stderr
class ExternalDataTestCase(unittest.TestCase):
'''Test case for external data files.'''
def setUp(self):
pass
eyeD3-0.8.4/src/test/id3/ 0000755 0001750 0001750 00000000000 13203726215 015564 5 ustar travis travis 0000000 0000000 eyeD3-0.8.4/src/test/id3/test_frames.py 0000644 0001750 0001750 00000025200 13153052737 020456 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
import sys
import pytest
import unittest
from pathlib import Path
import eyed3
from eyed3.id3 import (LATIN1_ENCODING, UTF_8_ENCODING, UTF_16_ENCODING,
UTF_16BE_ENCODING)
from eyed3.id3 import ID3_V1_0, ID3_V1_1, ID3_V2_3, ID3_V2_4
from eyed3.id3.frames import (Frame, TextFrame, FrameHeader, ImageFrame,
LanguageCodeMixin, ObjectFrame, TermsOfUseFrame,
DEFAULT_LANG, TOS_FID, OBJECT_FID)
from eyed3.compat import unicode
if sys.version_info[0:2] > (2, 7):
from unittest.mock import patch
else:
from mock import patch
class FrameTest(unittest.TestCase):
def testCtor(self):
f = Frame(b"ABCD")
assert f.id == b"ABCD"
assert f.header is None
assert f.decompressed_size == 0
assert f.group_id is None
assert f.encrypt_method is None
assert f.data is None
assert f.data_len == 0
assert f.encoding is None
f = Frame(b"EFGH")
assert f.id == b"EFGH"
assert f.header is None
assert f.decompressed_size == 0
assert f.group_id is None
assert f.encrypt_method is None
assert f.data is None
assert f.data_len == 0
assert f.encoding is None
def testTextDelim(self):
for enc in [LATIN1_ENCODING, UTF_16BE_ENCODING, UTF_16_ENCODING,
UTF_8_ENCODING]:
f = Frame(b"XXXX")
f.encoding = enc
if enc in [LATIN1_ENCODING, UTF_8_ENCODING]:
assert (f.text_delim == b"\x00")
else:
assert (f.text_delim == b"\x00\x00")
def testInitEncoding(self):
# Default encodings per version
for ver in [ID3_V1_0, ID3_V1_1, ID3_V2_3, ID3_V2_4]:
f = Frame(b"XXXX")
f.header = FrameHeader(f.id, ver)
f._initEncoding()
if ver[0] == 1:
assert (f.encoding == LATIN1_ENCODING)
elif ver[:2] == (2, 3):
assert (f.encoding == UTF_16_ENCODING)
else:
assert (f.encoding == UTF_8_ENCODING)
# Invalid encoding for a version is coerced
for ver in [ID3_V1_0, ID3_V1_1]:
for enc in [UTF_8_ENCODING, UTF_16_ENCODING, UTF_16BE_ENCODING]:
f = Frame(b"XXXX")
f.header = FrameHeader(f.id, ver)
f.encoding = enc
f._initEncoding()
assert (f.encoding == LATIN1_ENCODING)
for ver in [ID3_V2_3]:
for enc in [UTF_8_ENCODING, UTF_16BE_ENCODING]:
f = Frame(b"XXXX")
f.header = FrameHeader(f.id, ver)
f.encoding = enc
f._initEncoding()
assert (f.encoding == UTF_16_ENCODING)
# No coersion for v2.4
for ver in [ID3_V2_4]:
for enc in [LATIN1_ENCODING, UTF_8_ENCODING, UTF_16BE_ENCODING,
UTF_16_ENCODING]:
f = Frame(b"XXXX")
f.header = FrameHeader(f.id, ver)
f.encoding = enc
f._initEncoding()
assert (f.encoding == enc)
class TextFrameTest(unittest.TestCase):
def testCtor(self):
with pytest.raises(TypeError):
TextFrame(u"TCON")
f = TextFrame(b"TCON")
assert f.text == u""
f = TextFrame(b"TCON", u"content")
assert f.text == u"content"
def testRenderParse(self):
fid = b"TPE1"
for ver in [ID3_V2_3, ID3_V2_4]:
h1 = FrameHeader(fid, ver)
h2 = FrameHeader(fid, ver)
f1 = TextFrame(b"TPE1", u"Ambulance LTD")
f1.header = h1
data = f1.render()
# FIXME: right here is why parse should be static
f2 = TextFrame(b"TIT2")
f2.parse(data[h1.size:], h2)
assert f1.id == f2.id
assert f1.text == f2.text
assert f1.encoding == f2.encoding
class ImageFrameTest(unittest.TestCase):
def testPicTypeConversions(self):
count = 0
for s in ("OTHER", "ICON", "OTHER_ICON", "FRONT_COVER", "BACK_COVER",
"LEAFLET", "MEDIA", "LEAD_ARTIST", "ARTIST", "CONDUCTOR",
"BAND", "COMPOSER", "LYRICIST", "RECORDING_LOCATION",
"DURING_RECORDING", "DURING_PERFORMANCE", "VIDEO",
"BRIGHT_COLORED_FISH", "ILLUSTRATION", "BAND_LOGO",
"PUBLISHER_LOGO"):
c = getattr(ImageFrame, s)
assert (ImageFrame.picTypeToString(c) == s)
assert (ImageFrame.stringToPicType(s) == c)
count += 1
assert (count == ImageFrame.MAX_TYPE + 1)
assert (ImageFrame.MIN_TYPE == ImageFrame.OTHER)
assert (ImageFrame.MAX_TYPE == ImageFrame.PUBLISHER_LOGO)
assert ImageFrame.picTypeToString(ImageFrame.MAX_TYPE) == \
"PUBLISHER_LOGO"
assert ImageFrame.picTypeToString(ImageFrame.MIN_TYPE) == "OTHER"
with pytest.raises(ValueError):
ImageFrame.picTypeToString(ImageFrame.MAX_TYPE + 1)
with pytest.raises(ValueError):
ImageFrame.picTypeToString(ImageFrame.MIN_TYPE - 1)
with pytest.raises(ValueError):
ImageFrame.stringToPicType("Prust")
def test_DateFrame():
from eyed3.id3.frames import DateFrame
from eyed3.core import Date
# Default ctor
df = DateFrame(b"TDRC")
assert df.text == u""
assert df.date is None
# Ctor with eyed3.core.Date arg
for d in [Date(2012),
Date(2012, 1),
Date(2012, 1, 4),
Date(2012, 1, 4, 18),
Date(2012, 1, 4, 18, 15),
Date(2012, 1, 4, 18, 15, 30),
]:
df = DateFrame(b"TDRC", d)
assert (df.text == unicode(str(d)))
# Comparison is on each member, not reference ID
assert (df.date == d)
# Test ctor str arg is converted
for d in ["2012",
"2010-01",
"2010-01-04",
"2010-01-04T18",
"2010-01-04T06:20",
"2010-01-04T06:20:15",
u"2012",
u"2010-01",
u"2010-01-04",
u"2010-01-04T18",
u"2010-01-04T06:20",
u"2010-01-04T06:20:15",
]:
df = DateFrame(b"TDRC", d)
dt = Date.parse(d)
assert (df.text == unicode(str(dt)))
assert (df.text == unicode(d))
# Comparison is on each member, not reference ID
assert (df.date == dt)
# Invalid dates
for d in ["1234:12"]:
date = DateFrame(b"TDRL")
date.date = d
assert not date.date
try:
date.date = 9
except TypeError:
pass
else:
pytest.fail("TypeError not thrown")
def test_compression():
f = open(__file__, "rb")
try:
data = f.read()
compressed = Frame.compress(data)
assert data == Frame.decompress(compressed)
finally:
f.close()
'''
FIXME:
def test_tag_compression(id3tag):
# FIXME: going to refactor FrameHeader, bbl
data = Path(__file__).read_text()
aframe = TextFrame(ARTIST_FID, text=data)
aframe.header = FrameHeader(ARTIST_FID)
import ipdb; ipdb.set_trace()
pass
'''
def test_encryption():
with pytest.raises(NotImplementedError):
Frame.encrypt("Iceburn")
with pytest.raises(NotImplementedError):
Frame.decrypt("Iceburn")
def test_LanguageCodeMixin():
with pytest.raises(TypeError):
LanguageCodeMixin().lang = u"eng"
l = LanguageCodeMixin()
l.lang = b"\x80"
assert l.lang == b"eng"
l.lang = b""
assert l.lang == b""
l.lang = None
assert l.lang == b""
def test_TermsOfUseFrame(audiofile, id3tag):
terms = TermsOfUseFrame()
assert terms.id == b"USER"
assert terms.text == u""
assert terms.lang == DEFAULT_LANG
id3tag.terms_of_use = u"Fucking MANDATORY!"
audiofile.tag = id3tag
audiofile.tag.save()
file = eyed3.load(audiofile.path)
assert file.tag.terms_of_use == u"Fucking MANDATORY!"
id3tag.terms_of_use = u"Fucking MANDATORY!"
audiofile.tag = id3tag
audiofile.tag.save()
file = eyed3.load(audiofile.path)
assert file.tag.terms_of_use == u"Fucking MANDATORY!"
id3tag.terms_of_use = (u"Fucking MANDATORY!", b"jib")
audiofile.tag = id3tag
audiofile.tag.save()
file = eyed3.load(audiofile.path)
assert file.tag.terms_of_use == u"Fucking MANDATORY!"
assert file.tag.frame_set[TOS_FID][0].lang == b"jib"
id3tag.terms_of_use = (u"Fucking MANDATORY!", b"en")
audiofile.tag = id3tag
audiofile.tag.save()
file = eyed3.load(audiofile.path)
assert file.tag.terms_of_use == u"Fucking MANDATORY!"
assert file.tag.frame_set[TOS_FID][0].lang == b"en"
def test_ObjectFrame(audiofile, id3tag):
sixsixsix = b"\x29\x0a" * 666
with Path(__file__).open("rb") as fp:
thisfile = fp.read()
obj1 = ObjectFrame(description=u"Test Object", object_data=sixsixsix,
filename=u"666.txt", mime_type="text/satan")
obj2 = ObjectFrame(description=u"Test Object2", filename=unicode(__file__),
mime_type="text/python", object_data=thisfile)
id3tag.frame_set[OBJECT_FID] = obj1
id3tag.frame_set[OBJECT_FID].append(obj2)
audiofile.tag = id3tag
audiofile.tag.save()
file = eyed3.load(audiofile.path)
assert len(file.tag.objects) == 2
obj1_2 = file.tag.objects.get(u"Test Object")
assert obj1_2.mime_type == "text/satan"
assert obj1_2.object_data == sixsixsix
assert obj1_2.filename == u"666.txt"
obj2_2 = file.tag.objects.get(u"Test Object2")
assert obj2_2.mime_type == "text/python"
assert obj2_2.object_data == thisfile
assert obj2_2.filename == __file__
def test_ObjectFrame_no_mimetype(audiofile, id3tag):
# Setting no mime-type is invalid
obj1 = ObjectFrame(object_data=b"Deep Purple")
id3tag.frame_set[OBJECT_FID] = obj1
audiofile.tag = id3tag
audiofile.tag.save()
with patch("eyed3.core.parseError") as mock:
file = eyed3.load(audiofile.path)
assert mock.call_count == 2
obj1.mime_type = "Deep"
audiofile.tag.save()
with patch("eyed3.core.parseError") as mock:
file = eyed3.load(audiofile.path)
assert mock.call_count == 1
obj1.mime_type = "Deep/Purple"
audiofile.tag.save()
with patch("eyed3.core.parseError") as mock:
file = eyed3.load(audiofile.path)
mock.assert_not_called()
obj1.object_data = b""
audiofile.tag.save()
with patch("eyed3.core.parseError") as mock:
file = eyed3.load(audiofile.path)
assert mock.call_count == 1
assert file
eyeD3-0.8.4/src/test/id3/test_tag.py 0000644 0001750 0001750 00000110374 13161501620 017750 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2011-2012 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
import os
import pytest
import unittest
import eyed3
from eyed3.core import Date
from eyed3.id3 import frames
from eyed3.compat import unicode, BytesType
from eyed3.id3 import Tag, ID3_DEFAULT_VERSION, ID3_V2_3, ID3_V2_4
from ..compat import *
from .. import DATA_D
def testTagImport():
import eyed3.id3.tag
assert eyed3.id3.Tag == eyed3.id3.tag.Tag
def testTagConstructor():
t = Tag()
assert t.file_info is None
assert t.header is not None
assert t.extended_header is not None
assert t.frame_set is not None
assert len(t.frame_set) == 0
def testFileInfoConstructor():
from eyed3.id3.tag import FileInfo
# Both bytes and unicode input file names must be accepted and the former
# must be converted to unicode.
for name in [__file__, unicode(__file__)]:
fi = FileInfo(name)
assert type(fi.name) is unicode
assert name == unicode(name)
assert fi.tag_size == 0
# FIXME Passing invalid unicode
def testTagMainProps():
tag = Tag()
# No version yet
assert tag.version == ID3_DEFAULT_VERSION
assert not(tag.isV1())
assert tag.isV2()
assert tag.artist is None
tag.artist = u"Autolux"
assert tag.artist == u"Autolux"
assert len(tag.frame_set) == 1
tag.artist = u""
assert len(tag.frame_set) == 0
tag.artist = u"Autolux"
assert tag.album is None
tag.album = u"Future Perfect"
assert tag.album == u"Future Perfect"
assert tag.album_artist is None
tag.album_artist = u"Various Artists"
assert (tag.album_artist == u"Various Artists")
assert (tag.title is None)
tag.title = u"Robots in the Garden"
assert (tag.title == u"Robots in the Garden")
assert (tag.track_num == (None, None))
tag.track_num = 7
assert (tag.track_num == (7, None))
tag.track_num = (7, None)
assert (tag.track_num == (7, None))
tag.track_num = (7, 15)
assert (tag.frame_set[frames.TRACKNUM_FID][0].text == "07/15")
assert (tag.track_num == (7, 15))
tag.track_num = (7, 150)
assert (tag.frame_set[frames.TRACKNUM_FID][0].text == "007/150")
assert (tag.track_num == (7, 150))
tag.track_num = (1, 7)
assert (tag.frame_set[frames.TRACKNUM_FID][0].text == "01/07")
assert (tag.track_num == (1, 7))
tag.track_num = None
assert (tag.track_num == (None, None))
tag.track_num = None, None
def testTagDates():
tag = Tag()
tag.release_date = 2004
assert tag.release_date == Date(2004)
tag.release_date = None
assert tag.release_date is None
tag = Tag()
for date in [Date(2002), Date(2002, 11, 26), Date(2002, 11, 26),
Date(2002, 11, 26, 4), Date(2002, 11, 26, 4, 20),
Date(2002, 11, 26, 4, 20), Date(2002, 11, 26, 4, 20, 10)]:
tag.encoding_date = date
assert (tag.encoding_date == date)
tag.encoding_date = str(date)
assert (tag.encoding_date == date)
tag.release_date = date
assert (tag.release_date == date)
tag.release_date = str(date)
assert (tag.release_date == date)
tag.original_release_date = date
assert (tag.original_release_date == date)
tag.original_release_date = str(date)
assert (tag.original_release_date == date)
tag.recording_date = date
assert (tag.recording_date == date)
tag.recording_date = str(date)
assert (tag.recording_date == date)
tag.tagging_date = date
assert (tag.tagging_date == date)
tag.tagging_date = str(date)
assert (tag.tagging_date == date)
try:
tag._setDate(2.4)
except TypeError:
pass # expected
else:
assert not("Invalid date type, expected TypeError")
def testTagComments():
tag = Tag()
for c in tag.comments:
assert not("Expected not to be here")
# Adds
with pytest.raises(TypeError):
tag.comments.set(b"bold")
with pytest.raises(TypeError):
tag.comments.set(u"bold", b"search")
tag.comments.set(u"Always Try", u"")
assert (len(tag.comments) == 1)
c = tag.comments[0]
assert (c.description == u"")
assert (c.text == u"Always Try")
assert (c.lang == b"eng")
tag.comments.set(u"Speak Out", u"Bold")
assert (len(tag.comments) == 2)
c = tag.comments[1]
assert (c.description == u"Bold")
assert (c.text == u"Speak Out")
assert (c.lang == b"eng")
tag.comments.set(u"K Town Mosh Crew", u"Crippled Youth", b"sxe")
assert (len(tag.comments) == 3)
c = tag.comments[2]
assert (c.description == u"Crippled Youth")
assert (c.text == u"K Town Mosh Crew")
assert (c.lang == b"sxe")
# Lang is different, new frame
tag.comments.set(u"K Town Mosh Crew", u"Crippled Youth", b"eng")
assert (len(tag.comments) == 4)
c = tag.comments[3]
assert (c.description == u"Crippled Youth")
assert (c.text == u"K Town Mosh Crew")
assert (c.lang == b"eng")
# Gets
assert (tag.comments.get(u"", "fre") is None)
assert (tag.comments.get(u"Crippled Youth", b"esp") is None)
c = tag.comments.get(u"")
assert c
assert (c.description == u"")
assert (c.text == u"Always Try")
assert (c.lang == b"eng")
assert tag.comments.get(u"Bold") is not None
assert tag.comments.get(u"Bold", b"eng") is not None
assert tag.comments.get(u"Crippled Youth", b"eng") is not None
assert tag.comments.get(u"Crippled Youth", b"sxe") is not None
assert (len(tag.comments) == 4)
# Iterate
count = 0
for c in tag.comments:
count += 1
assert count == 4
# Index access
assert tag.comments[0]
assert tag.comments[1]
assert tag.comments[2]
assert tag.comments[3]
try:
c = tag.comments[4]
except IndexError:
pass # expected
else:
assert not("Expected IndexError, but got success")
# Removal
with pytest.raises(TypeError):
tag.comments.remove(b"not unicode")
assert (tag.comments.remove(u"foobazz") is None)
c = tag.comments.get(u"Bold")
assert c is not None
c2 = tag.comments.remove(u"Bold")
assert (c == c2)
assert (len(tag.comments) == 3)
c = tag.comments.get(u"Crippled Youth", b"eng")
assert c is not None
c2 = tag.comments.remove(u"Crippled Youth", b"eng")
assert (c == c2)
assert (len(tag.comments) == 2)
assert (tag.comments.remove(u"Crippled Youth", b"eng") is None)
assert (len(tag.comments) == 2)
assert (tag.comments.get(u"") == tag.comments.remove(u""))
assert (len(tag.comments) == 1)
assert (tag.comments.get(u"Crippled Youth", b"sxe") ==
tag.comments.remove(u"Crippled Youth", b"sxe"))
assert (len(tag.comments) == 0)
# Index Error when there are no comments
try:
c = tag.comments[0]
except IndexError:
pass # expected
else:
assert not("Expected IndexError, but got success")
# Replacing frames thru add and frame object preservation
tag = Tag()
c1 = tag.comments.set(u"Snoop", u"Dog", b"rap")
assert tag.comments.get(u"Dog", b"rap").text == u"Snoop"
c1.text = u"Lollipop"
assert tag.comments.get(u"Dog", b"rap").text == u"Lollipop"
# now thru add
c2 = tag.comments.set(u"Doggy", u"Dog", b"rap")
assert id(c1) == id(c2)
assert tag.comments.get(u"Dog", b"rap").text == u"Doggy"
def testTagBPM():
tag = Tag()
assert (tag.bpm is None)
tag.bpm = 150
assert (tag.bpm == 150)
assert (tag.frame_set[b"TBPM"])
tag.bpm = 180
assert (tag.bpm == 180)
assert (tag.frame_set[b"TBPM"])
assert (len(tag.frame_set[b"TBPM"]) == 1)
tag.bpm = 190.5
assert type(tag.bpm) is int
assert tag.bpm == 191
assert len(tag.frame_set[b"TBPM"]) == 1
def testTagPlayCount():
tag = Tag()
assert (tag.play_count is None)
tag.play_count = 0
assert tag.play_count == 0
tag.play_count = 1
assert tag.play_count == 1
tag.play_count += 1
assert tag.play_count == 2
tag.play_count -= 1
assert tag.play_count == 1
tag.play_count *= 5
assert tag.play_count == 5
tag.play_count = None
assert tag.play_count is None
try:
tag.play_count = -1
except ValueError:
pass # expected
else:
assert not("Invalid play count, expected ValueError")
def testTagPublisher():
t = Tag()
assert (t.publisher is None)
try:
t.publisher = b"not unicode"
except TypeError:
pass #expected
else:
assert not("Expected TypeError when setting non-unicode publisher")
t.publisher = u"Dischord"
assert t.publisher == u"Dischord"
t.publisher = u"Infinity Cat"
assert t.publisher == u"Infinity Cat"
t.publisher = None
assert t.publisher is None
def testTagCdId():
tag = Tag()
assert tag.cd_id is None
tag.cd_id = b"\x01\x02"
assert tag.cd_id == b"\x01\x02"
tag.cd_id = b"\xff" * 804
assert tag.cd_id == b"\xff" * 804
try:
tag.cd_id = b"\x00" * 805
except ValueError:
pass # expected
else:
assert not("CD id is too long, expected ValueError")
def testTagImages():
from eyed3.id3.frames import ImageFrame
tag = Tag()
# No images
assert len(tag.images) == 0
for i in tag.images:
assert not("Expected no images")
try:
img = tag.images[0]
except IndexError:
pass #expected
else:
assert not("Expected IndexError for no images")
assert (tag.images.get(u"") is None)
# Image types must be within range
for i in range(ImageFrame.MIN_TYPE, ImageFrame.MAX_TYPE):
tag.images.set(i, b"\xff", b"img")
for i in (ImageFrame.MIN_TYPE - 1, ImageFrame.MAX_TYPE + 1):
try:
tag.images.set(i, b"\xff", b"img")
except ValueError:
pass # expected
else:
assert not("Expected ValueError for invalid picture type")
tag = Tag()
tag.images.set(ImageFrame.FRONT_COVER, b"\xab\xcd", b"img/gif")
assert (len(tag.images) == 1)
assert (tag.images[0].description == u"")
assert (tag.images[0].picture_type == ImageFrame.FRONT_COVER)
assert (tag.images[0].image_data == b"\xab\xcd")
assert (tag.images[0].mime_type == "img/gif")
assert (tag.images[0]._mime_type == b"img/gif")
assert (tag.images[0].image_url is None)
assert (tag.images.get(u"").description == u"")
assert (tag.images.get(u"").picture_type == ImageFrame.FRONT_COVER)
assert (tag.images.get(u"").image_data == b"\xab\xcd")
assert (tag.images.get(u"").mime_type == "img/gif")
assert (tag.images.get(u"")._mime_type == b"img/gif")
assert (tag.images.get(u"").image_url is None)
tag.images.set(ImageFrame.FRONT_COVER, b"\xdc\xba", b"img/gif",
u"Different")
assert len(tag.images) == 2
assert tag.images[1].description == u"Different"
assert tag.images[1].picture_type == ImageFrame.FRONT_COVER
assert tag.images[1].image_data == b"\xdc\xba"
assert tag.images[1].mime_type == "img/gif"
assert tag.images[1]._mime_type == b"img/gif"
assert tag.images[1].image_url is None
assert (tag.images.get(u"Different").description == u"Different")
assert (tag.images.get(u"Different").picture_type == ImageFrame.FRONT_COVER)
assert (tag.images.get(u"Different").image_data == b"\xdc\xba")
assert (tag.images.get(u"Different").mime_type == "img/gif")
assert (tag.images.get(u"Different")._mime_type == b"img/gif")
assert (tag.images.get(u"Different").image_url is None)
# This is an update (same description)
tag.images.set(ImageFrame.BACK_COVER, b"\xff\xef", b"img/jpg", u"Different")
assert (len(tag.images) == 2)
assert (tag.images[1].description == u"Different")
assert (tag.images[1].picture_type == ImageFrame.BACK_COVER)
assert (tag.images[1].image_data == b"\xff\xef")
assert (tag.images[1].mime_type == "img/jpg")
assert (tag.images[1].image_url is None)
assert (tag.images.get(u"Different").description == u"Different")
assert (tag.images.get(u"Different").picture_type == ImageFrame.BACK_COVER)
assert (tag.images.get(u"Different").image_data == b"\xff\xef")
assert (tag.images.get(u"Different").mime_type == "img/jpg")
assert (tag.images.get(u"Different").image_url is None)
count = 0
for img in tag.images:
count += 1
assert count == 2
# Remove
img = tag.images.remove(u"")
assert (img.description == u"")
assert (img.picture_type == ImageFrame.FRONT_COVER)
assert (img.image_data == b"\xab\xcd")
assert (img.mime_type == "img/gif")
assert (img.image_url is None)
assert (len(tag.images) == 1)
img = tag.images.remove(u"Different")
assert img.description == u"Different"
assert img.picture_type == ImageFrame.BACK_COVER
assert img.image_data == b"\xff\xef"
assert img.mime_type == "img/jpg"
assert img.image_url is None
assert len(tag.images) == 0
assert (tag.images.remove(u"Lundqvist") is None)
# Unicode enforcement
with pytest.raises(TypeError):
tag.images.get(b"not Unicode")
with pytest.raises(TypeError):
tag.images.set(ImageFrame.ICON, "\xff", "img", b"not Unicode")
with pytest.raises(TypeError):
tag.images.remove(b"not Unicode")
# Image URL
tag = Tag()
tag.images.set(ImageFrame.BACK_COVER, None, None, u"A URL",
img_url=b"http://www.tumblr.com/tagged/ty-segall")
img = tag.images.get(u"A URL")
assert img is not None
assert (img.image_data is None)
assert (img.image_url == b"http://www.tumblr.com/tagged/ty-segall")
assert (img.mime_type == "-->")
assert (img._mime_type == b"-->")
# Unicode mime-type in, coverted to bytes
tag = Tag()
tag.images.set(ImageFrame.BACK_COVER, b"\x00", u"img/jpg")
img = tag.images[0]
assert isinstance(img._mime_type, BytesType)
img.mime_type = u""
assert isinstance(img._mime_type, BytesType)
img.mime_type = None
assert isinstance(img._mime_type, BytesType)
assert img.mime_type == ""
def testTagLyrics():
tag = Tag()
for c in tag.lyrics:
assert not("Expected not to be here")
# Adds
with pytest.raises(TypeError):
tag.lyrics.set(b"bold")
with pytest.raises(TypeError):
tag.lyrics.set(u"bold", b"search")
tag.lyrics.set(u"Always Try", u"")
assert (len(tag.lyrics) == 1)
c = tag.lyrics[0]
assert (c.description == u"")
assert (c.text == u"Always Try")
assert (c.lang == b"eng")
tag.lyrics.set(u"Speak Out", u"Bold")
assert (len(tag.lyrics) == 2)
c = tag.lyrics[1]
assert (c.description == u"Bold")
assert (c.text == u"Speak Out")
assert (c.lang == b"eng")
tag.lyrics.set(u"K Town Mosh Crew", u"Crippled Youth", b"sxe")
assert (len(tag.lyrics) == 3)
c = tag.lyrics[2]
assert (c.description == u"Crippled Youth")
assert (c.text == u"K Town Mosh Crew")
assert (c.lang == b"sxe")
# Lang is different, new frame
tag.lyrics.set(u"K Town Mosh Crew", u"Crippled Youth", b"eng")
assert (len(tag.lyrics) == 4)
c = tag.lyrics[3]
assert (c.description == u"Crippled Youth")
assert (c.text == u"K Town Mosh Crew")
assert (c.lang == b"eng")
# Gets
assert (tag.lyrics.get(u"", b"fre") is None)
assert (tag.lyrics.get(u"Crippled Youth", b"esp") is None)
c = tag.lyrics.get(u"")
assert (c)
assert (c.description == u"")
assert (c.text == u"Always Try")
assert (c.lang == b"eng")
assert tag.lyrics.get(u"Bold") is not None
assert tag.lyrics.get(u"Bold", b"eng") is not None
assert tag.lyrics.get(u"Crippled Youth", b"eng") is not None
assert tag.lyrics.get(u"Crippled Youth", b"sxe") is not None
assert (len(tag.lyrics) == 4)
# Iterate
count = 0
for c in tag.lyrics:
count += 1
assert (count == 4)
# Index access
assert (tag.lyrics[0])
assert (tag.lyrics[1])
assert (tag.lyrics[2])
assert (tag.lyrics[3])
try:
c = tag.lyrics[4]
except IndexError:
pass # expected
else:
assert not("Expected IndexError, but got success")
# Removal
with pytest.raises(TypeError):
tag.lyrics.remove(b"not unicode")
assert tag.lyrics.remove(u"foobazz") is None
c = tag.lyrics.get(u"Bold")
assert c is not None
c2 = tag.lyrics.remove(u"Bold")
assert c == c2
assert len(tag.lyrics) == 3
c = tag.lyrics.get(u"Crippled Youth", b"eng")
assert c is not None
c2 = tag.lyrics.remove(u"Crippled Youth", b"eng")
assert c == c2
assert len(tag.lyrics) == 2
assert tag.lyrics.remove(u"Crippled Youth", b"eng") is None
assert len(tag.lyrics) == 2
assert tag.lyrics.get(u"") == tag.lyrics.remove(u"")
assert len(tag.lyrics) == 1
assert (tag.lyrics.get(u"Crippled Youth", b"sxe") ==
tag.lyrics.remove(u"Crippled Youth", b"sxe"))
assert len(tag.lyrics) == 0
# Index Error when there are no lyrics
try:
c = tag.lyrics[0]
except IndexError:
pass # expected
else:
assert not("Expected IndexError, but got success")
def testTagObjects():
tag = Tag()
# No objects
assert len(tag.objects) == 0
for i in tag.objects:
assert not("Expected no objects")
try:
img = tag.objects[0]
except IndexError:
pass #expected
else:
assert not("Expected IndexError for no objects")
assert (tag.objects.get(u"") is None)
tag = Tag()
tag.objects.set(b"\xab\xcd", b"img/gif")
assert (len(tag.objects) == 1)
assert (tag.objects[0].description == u"")
assert (tag.objects[0].filename == u"")
assert (tag.objects[0].object_data == b"\xab\xcd")
assert (tag.objects[0]._mime_type == b"img/gif")
assert (tag.objects[0].mime_type == "img/gif")
assert (tag.objects.get(u"").description == u"")
assert (tag.objects.get(u"").filename == u"")
assert (tag.objects.get(u"").object_data == b"\xab\xcd")
assert (tag.objects.get(u"").mime_type == "img/gif")
tag.objects.set(b"\xdc\xba", b"img/gif", u"Different")
assert (len(tag.objects) == 2)
assert (tag.objects[1].description == u"Different")
assert (tag.objects[1].filename == u"")
assert (tag.objects[1].object_data == b"\xdc\xba")
assert (tag.objects[1]._mime_type == b"img/gif")
assert (tag.objects[1].mime_type == "img/gif")
assert (tag.objects.get(u"Different").description == u"Different")
assert (tag.objects.get(u"Different").filename == u"")
assert (tag.objects.get(u"Different").object_data == b"\xdc\xba")
assert (tag.objects.get(u"Different").mime_type == "img/gif")
assert (tag.objects.get(u"Different")._mime_type == b"img/gif")
# This is an update (same description)
tag.objects.set(b"\xff\xef", b"img/jpg", u"Different",
u"example_filename.XXX")
assert (len(tag.objects) == 2)
assert (tag.objects[1].description == u"Different")
assert (tag.objects[1].filename == u"example_filename.XXX")
assert (tag.objects[1].object_data == b"\xff\xef")
assert (tag.objects[1].mime_type == "img/jpg")
assert (tag.objects.get(u"Different").description == u"Different")
assert (tag.objects.get(u"Different").filename == u"example_filename.XXX")
assert (tag.objects.get(u"Different").object_data == b"\xff\xef")
assert (tag.objects.get(u"Different").mime_type == "img/jpg")
count = 0
for obj in tag.objects:
count += 1
assert (count == 2)
# Remove
obj = tag.objects.remove(u"")
assert (obj.description == u"")
assert (obj.filename == u"")
assert (obj.object_data == b"\xab\xcd")
assert (obj.mime_type == "img/gif")
assert (len(tag.objects) == 1)
obj = tag.objects.remove(u"Different")
assert (obj.description == u"Different")
assert (obj.filename == u"example_filename.XXX")
assert (obj.object_data == b"\xff\xef")
assert (obj.mime_type == "img/jpg")
assert (obj._mime_type == b"img/jpg")
assert (len(tag.objects) == 0)
assert (tag.objects.remove(u"Dubinsky") is None)
# Unicode enforcement
with pytest.raises(TypeError):
tag.objects.get(b"not Unicode")
with pytest.raises(TypeError):
tag.objects.set("\xff", "img", b"not Unicode")
with pytest.raises(TypeError):
tag.objects.set("\xff", "img", u"Unicode", b"not unicode")
with pytest.raises(TypeError):
tag.objects.remove(b"not Unicode")
def testTagPrivates():
tag = Tag()
# No private frames
assert len(tag.privates) == 0
for i in tag.privates:
assert not("Expected no privates")
try:
img = tag.privates[0]
except IndexError:
pass #expected
else:
assert not("Expected IndexError for no privates")
assert (tag.privates.get(b"") is None)
tag = Tag()
tag.privates.set(b"\xab\xcd", b"owner1")
assert (len(tag.privates) == 1)
assert (tag.privates[0].owner_id == b"owner1")
assert (tag.privates[0].owner_data == b"\xab\xcd")
assert (tag.privates.get(b"owner1").owner_id == b"owner1")
assert (tag.privates.get(b"owner1").owner_data == b"\xab\xcd")
tag.privates.set(b"\xba\xdc", b"owner2")
assert (len(tag.privates) == 2)
assert (tag.privates[1].owner_id == b"owner2")
assert (tag.privates[1].owner_data == b"\xba\xdc")
assert (tag.privates.get(b"owner2").owner_id == b"owner2")
assert (tag.privates.get(b"owner2").owner_data == b"\xba\xdc")
# This is an update (same description)
tag.privates.set(b"\x00\x00\x00", b"owner1")
assert (len(tag.privates) == 2)
assert (tag.privates[0].owner_id == b"owner1")
assert (tag.privates[0].owner_data == b"\x00\x00\x00")
assert (tag.privates.get(b"owner1").owner_id == b"owner1")
assert (tag.privates.get(b"owner1").owner_data == b"\x00\x00\x00")
count = 0
for f in tag.privates:
count += 1
assert (count == 2)
# Remove
priv = tag.privates.remove(b"owner1")
assert (priv.owner_id == b"owner1")
assert (priv.owner_data == b"\x00\x00\x00")
assert (len(tag.privates) == 1)
priv = tag.privates.remove(b"owner2")
assert (priv.owner_id == b"owner2")
assert (priv.owner_data == b"\xba\xdc")
assert (len(tag.privates) == 0)
assert tag.objects.remove(u"Callahan") is None
def testTagDiscNum():
tag = Tag()
assert (tag.disc_num == (None, None))
tag.disc_num = 7
assert (tag.disc_num == (7, None))
tag.disc_num = (7, None)
assert (tag.disc_num == (7, None))
tag.disc_num = (7, 15)
assert (tag.frame_set[frames.DISCNUM_FID][0].text == "07/15")
assert (tag.disc_num == (7, 15))
tag.disc_num = (7, 150)
assert (tag.frame_set[frames.DISCNUM_FID][0].text == "007/150")
assert (tag.disc_num == (7, 150))
tag.disc_num = (1, 7)
assert (tag.frame_set[frames.DISCNUM_FID][0].text == "01/07")
assert (tag.disc_num == (1, 7))
tag.disc_num = None
assert (tag.disc_num == (None, None))
tag.disc_num = None, None
def testTagGenre():
from eyed3.id3 import Genre
tag = Tag()
assert (tag.genre is None)
try:
tag.genre = b"Not Unicode"
except TypeError:
pass # expected
else:
assert not("Non unicode genre, expected TypeError")
gobj = Genre(u"Hardcore")
tag.genre = u"Hardcore"
assert (tag.genre.name == u"Hardcore")
assert (tag.genre == gobj)
tag.genre = 130
assert tag.genre.id == 130
assert tag.genre.name == u"Terror"
tag.genre = 0
assert tag.genre.id == 0
assert tag.genre.name == u"Blues"
tag.genre = None
assert tag.genre is None
assert tag.frame_set[b"TCON"] is None
def testTagUserTextFrames():
tag = Tag()
assert (len(tag.user_text_frames) == 0)
utf1 = tag.user_text_frames.set(u"Custom content")
assert (tag.user_text_frames.get(u"").text == u"Custom content")
utf2 = tag.user_text_frames.set(u"Content custom", u"Desc1")
assert (tag.user_text_frames.get(u"Desc1").text == u"Content custom")
assert (len(tag.user_text_frames) == 2)
utf3 = tag.user_text_frames.set(u"New content", u"")
assert (tag.user_text_frames.get(u"").text == u"New content")
assert (len(tag.user_text_frames) == 2)
assert (id(utf1) == id(utf3))
assert (tag.user_text_frames[0] == utf1)
assert (tag.user_text_frames[1] == utf2)
assert (tag.user_text_frames.get(u"") == utf1)
assert (tag.user_text_frames.get(u"Desc1") == utf2)
tag.user_text_frames.remove(u"")
assert (len(tag.user_text_frames) == 1)
tag.user_text_frames.remove(u"Desc1")
assert (len(tag.user_text_frames) == 0)
tag.user_text_frames.set(u"Foobazz", u"Desc2")
assert (len(tag.user_text_frames) == 1)
def testTagUrls():
tag = Tag()
url = "http://example.com/"
url2 = "http://sample.com/"
tag.commercial_url = url
assert (tag.commercial_url == url)
tag.commercial_url = url2
assert (tag.commercial_url == url2)
tag.commercial_url = None
assert (tag.commercial_url is None)
tag.copyright_url = url
assert (tag.copyright_url == url)
tag.copyright_url = url2
assert (tag.copyright_url == url2)
tag.copyright_url = None
assert (tag.copyright_url is None)
tag.audio_file_url = url
assert (tag.audio_file_url == url)
tag.audio_file_url = url2
assert (tag.audio_file_url == url2)
tag.audio_file_url = None
assert (tag.audio_file_url is None)
tag.audio_source_url = url
assert (tag.audio_source_url == url)
tag.audio_source_url = url2
assert (tag.audio_source_url == url2)
tag.audio_source_url = None
assert (tag.audio_source_url is None)
tag.artist_url = url
assert (tag.artist_url == url)
tag.artist_url = url2
assert (tag.artist_url == url2)
tag.artist_url = None
assert (tag.artist_url is None)
tag.internet_radio_url = url
assert (tag.internet_radio_url == url)
tag.internet_radio_url = url2
assert (tag.internet_radio_url == url2)
tag.internet_radio_url = None
assert (tag.internet_radio_url is None)
tag.payment_url = url
assert (tag.payment_url == url)
tag.payment_url = url2
assert (tag.payment_url == url2)
tag.payment_url = None
assert (tag.payment_url is None)
tag.publisher_url = url
assert (tag.publisher_url == url)
tag.publisher_url = url2
assert (tag.publisher_url == url2)
tag.publisher_url = None
assert (tag.publisher_url is None)
# Frame ID enforcement
with pytest.raises(ValueError):
tag._setUrlFrame("WDDD", "url")
with pytest.raises(ValueError):
tag._getUrlFrame("WDDD")
def testTagUniqIds():
tag = Tag()
assert (len(tag.unique_file_ids) == 0)
tag.unique_file_ids.set(b"http://music.com/12354", b"test")
tag.unique_file_ids.set(b"1234", b"http://eyed3.nicfit.net")
assert tag.unique_file_ids.get(b"test").uniq_id == b"http://music.com/12354"
assert (tag.unique_file_ids.get(b"http://eyed3.nicfit.net").uniq_id ==
b"1234")
assert len(tag.unique_file_ids) == 2
tag.unique_file_ids.remove(b"test")
assert len(tag.unique_file_ids) == 1
tag.unique_file_ids.set(b"4321", b"http://eyed3.nicfit.net")
assert len(tag.unique_file_ids) == 1
assert (tag.unique_file_ids.get(b"http://eyed3.nicfit.net").uniq_id ==
b"4321")
def testTagUserUrls():
tag = Tag()
assert (len(tag.user_url_frames) == 0)
uuf1 = tag.user_url_frames.set(b"http://yo.yo.com/")
assert (tag.user_url_frames.get(u"").url == b"http://yo.yo.com/")
utf2 = tag.user_url_frames.set(b"http://run.dmc.org", u"URL")
assert (tag.user_url_frames.get(u"URL").url == b"http://run.dmc.org")
assert len(tag.user_url_frames) == 2
utf3 = tag.user_url_frames.set(b"http://my.adidas.com", u"")
assert (tag.user_url_frames.get(u"").url == b"http://my.adidas.com")
assert (len(tag.user_url_frames) == 2)
assert (id(uuf1) == id(utf3))
assert (tag.user_url_frames[0] == uuf1)
assert (tag.user_url_frames[1] == utf2)
assert (tag.user_url_frames.get(u"") == uuf1)
assert (tag.user_url_frames.get(u"URL") == utf2)
tag.user_url_frames.remove(u"")
assert (len(tag.user_url_frames) == 1)
tag.user_url_frames.remove(u"URL")
assert (len(tag.user_url_frames) == 0)
tag.user_url_frames.set(b"Foobazz", u"Desc2")
assert (len(tag.user_url_frames) == 1)
def testSortOrderConversions():
test_file = "/tmp/soconvert.id3"
tag = Tag()
# 2.3 frames to 2.4
for fid in [b"XSOA", b"XSOP", b"XSOT"]:
frame = frames.TextFrame(fid)
frame.text = fid.decode("ascii")
tag.frame_set[fid] = frame
try:
tag.save(test_file) # v2.4 is the default
tag = eyed3.load(test_file).tag
assert (tag.version == ID3_V2_4)
assert (len(tag.frame_set) == 3)
del tag.frame_set[b"TSOA"]
del tag.frame_set[b"TSOP"]
del tag.frame_set[b"TSOT"]
assert (len(tag.frame_set) == 0)
finally:
os.remove(test_file)
tag = Tag()
# 2.4 frames to 2.3
for fid in [b"TSOA", b"TSOP", b"TSOT"]:
frame = frames.TextFrame(fid)
frame.text = unicode(fid)
tag.frame_set[fid] = frame
try:
tag.save(test_file, version=eyed3.id3.ID3_V2_3)
tag = eyed3.load(test_file).tag
assert (tag.version == ID3_V2_3)
assert (len(tag.frame_set) == 3)
del tag.frame_set[b"XSOA"]
del tag.frame_set[b"XSOP"]
del tag.frame_set[b"XSOT"]
assert (len(tag.frame_set) == 0)
finally:
os.remove(test_file)
def test_XDOR_TDOR_Conversions():
test_file = "/tmp/xdortdrc.id3"
tag = Tag()
# 2.3 frames to 2.4
frame = frames.DateFrame(b"XDOR", "1990-06-24")
tag.frame_set[b"XDOR"] = frame
try:
tag.save(test_file) # v2.4 is the default
tag = eyed3.load(test_file).tag
assert tag.version == ID3_V2_4
assert len(tag.frame_set) == 1
del tag.frame_set[b"TDOR"]
assert len(tag.frame_set) == 0
finally:
os.remove(test_file)
tag = Tag()
# 2.4 frames to 2.3
frame = frames.DateFrame(b"TDRC", "2012-10-21")
tag.frame_set[frame.id] = frame
try:
tag.save(test_file, version=eyed3.id3.ID3_V2_3)
tag = eyed3.load(test_file).tag
assert tag.version == ID3_V2_3
assert len(tag.frame_set) == 2
del tag.frame_set[b"TYER"]
del tag.frame_set[b"TDAT"]
assert len(tag.frame_set) == 0
finally:
os.remove(test_file)
def test_TSST_Conversions():
test_file = "/tmp/tsst.id3"
tag = Tag()
# 2.4 TSST to 2.3 TIT3
tag.frame_set.setTextFrame(b"TSST", u"Subtitle")
try:
tag.save(test_file) # v2.4 is the default
tag = eyed3.load(test_file).tag
assert tag.version == ID3_V2_4
assert len(tag.frame_set) == 1
del tag.frame_set[b"TSST"]
assert len(tag.frame_set) == 0
tag.frame_set.setTextFrame(b"TSST", u"Subtitle")
tag.save(test_file, version=eyed3.id3.ID3_V2_3)
tag = eyed3.load(test_file).tag
assert b"TXXX" in tag.frame_set
txxx = tag.frame_set[b"TXXX"][0]
assert txxx.text == u"Subtitle"
assert txxx.description == u"Subtitle (converted)"
finally:
os.remove(test_file)
@unittest.skipIf(not os.path.exists(DATA_D), "test requires data files")
def testChapterExampleTag():
tag = eyed3.load(os.path.join(DATA_D, "id3_chapters_example.mp3")).tag
assert len(tag.table_of_contents) == 1
toc = list(tag.table_of_contents)[0]
assert id(toc) == id(tag.table_of_contents.get(toc.element_id))
assert toc.element_id == b"toc1"
assert toc.description is None
assert toc.toplevel
assert toc.ordered
assert toc.child_ids == [b'ch1', b'ch2', b'ch3']
assert tag.chapters.get(b"ch1").title == "start"
assert tag.chapters.get(b"ch1").subtitle is None
assert tag.chapters.get(b"ch1").user_url is None
assert tag.chapters.get(b"ch1").times == (0, 5000)
assert tag.chapters.get(b"ch1").offsets == (None, None)
assert len(tag.chapters.get(b"ch1").sub_frames) == 1
assert tag.chapters.get(b"ch2").title == "5 seconds"
assert tag.chapters.get(b"ch2").subtitle is None
assert tag.chapters.get(b"ch2").user_url is None
assert tag.chapters.get(b"ch2").times == (5000, 10000)
assert tag.chapters.get(b"ch2").offsets == (None, None)
assert len(tag.chapters.get(b"ch2").sub_frames) == 1
assert tag.chapters.get(b"ch3").title == "10 seconds"
assert tag.chapters.get(b"ch3").subtitle is None
assert tag.chapters.get(b"ch3").user_url is None
assert tag.chapters.get(b"ch3").times == (10000, 15000)
assert tag.chapters.get(b"ch3").offsets == (None, None)
assert len(tag.chapters.get(b"ch3").sub_frames) == 1
def testTableOfContents():
test_file = "/tmp/toc.id3"
t = Tag()
assert (len(t.table_of_contents) == 0)
toc_main = t.table_of_contents.set(b"main", toplevel=True,
child_ids=[b"c1", b"c2", b"c3", b"c4"],
description=u"Table of Conents")
assert toc_main is not None
assert (len(t.table_of_contents) == 1)
toc_dc = t.table_of_contents.set(b"director-cut", toplevel=False,
ordered=False,
child_ids=[b"d3", b"d1", b"d2"])
assert toc_dc is not None
assert (len(t.table_of_contents) == 2)
toc_dummy = t.table_of_contents.set(b"test")
assert (len(t.table_of_contents) == 3)
t.table_of_contents.remove(toc_dummy.element_id)
assert (len(t.table_of_contents) == 2)
t.save(test_file)
try:
t2 = eyed3.load(test_file).tag
finally:
os.remove(test_file)
assert len(t.table_of_contents) == 2
assert t2.table_of_contents.get(b"main").toplevel
assert t2.table_of_contents.get(b"main").ordered
assert t2.table_of_contents.get(b"main").description == toc_main.description
assert t2.table_of_contents.get(b"main").child_ids == toc_main.child_ids
assert (t2.table_of_contents.get(b"director-cut").toplevel ==
toc_dc.toplevel)
assert not t2.table_of_contents.get(b"director-cut").ordered
assert (t2.table_of_contents.get(b"director-cut").description ==
toc_dc.description)
assert (t2.table_of_contents.get(b"director-cut").child_ids ==
toc_dc.child_ids)
def testChapters():
test_file = "/tmp/chapters.id3"
t = Tag()
ch1 = t.chapters.set(b"c1", (0, 200))
ch2 = t.chapters.set(b"c2", (200, 300))
ch3 = t.chapters.set(b"c3", (300, 375))
ch4 = t.chapters.set(b"c4", (375, 600))
assert len(t.chapters) == 4
for i, c in enumerate(iter(t.chapters), 1):
if i != 2:
c.title = u"Chapter %d" % i
c.subtitle = u"Subtitle %d" % i
c.user_url = unicode("http://example.com/%d" % i).encode("ascii")
t.save(test_file)
try:
t2 = eyed3.load(test_file).tag
finally:
os.remove(test_file)
assert len(t2.chapters) == 4
for i in range(1, 5):
c = t2.chapters.get(unicode("c%d" % i).encode("latin1"))
if i == 2:
assert c.title is None
assert c.subtitle is None
assert c.user_url is None
else:
assert c.title == u"Chapter %d" % i
assert c.subtitle == u"Subtitle %d" % i
assert (c.user_url ==
unicode("http://example.com/%d" % i).encode("ascii"))
def testReadOnly():
assert not(Tag.read_only)
t = Tag()
assert not(t.read_only)
t.read_only = True
with pytest.raises(RuntimeError):
t.save()
with pytest.raises(RuntimeError):
t._saveV1Tag(None)
with pytest.raises(RuntimeError):
t._saveV2Tag(None, None, None)
eyeD3-0.8.4/src/test/id3/__init__.py 0000644 0001750 0001750 00000000000 13041005225 017652 0 ustar travis travis 0000000 0000000 eyeD3-0.8.4/src/test/id3/test_headers.py 0000644 0001750 0001750 00000043203 13161501620 020604 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2012 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
import unittest
import pytest
from eyed3.id3.headers import *
from eyed3.id3 import ID3_DEFAULT_VERSION, TagException
from ..compat import *
from io import BytesIO
class TestTagHeader(unittest.TestCase):
def testCtor(self):
h = TagHeader()
assert (h.version == ID3_DEFAULT_VERSION)
assert not(h.unsync)
assert not(h.extended)
assert not(h.experimental)
assert not(h.footer)
assert h.tag_size == 0
def testTagVersion(self):
for maj, min, rev in [(1, 0, 0), (1, 1, 0), (2, 2, 0), (2, 3, 0),
(2, 4, 0)]:
h = TagHeader((maj, min, rev))
assert (h.major_version == maj)
assert (h.minor_version == min)
assert (h.rev_version == rev)
for maj, min, rev in [(1, 0, None), (1, None, 0), (2, 5, 0), (3, 4, 0)]:
try:
h = TagHeader((maj, min, rev))
except ValueError:
pass
else:
assert not("Invalid version, expected ValueError")
def testParse(self):
# Incomplete headers
for data in [b"", b"ID3", b"ID3\x04\x00",
b"ID3\x02\x00\x00",
b"ID3\x03\x00\x00",
b"ID3\x04\x00\x00",
]:
header = TagHeader()
found = header.parse(BytesIO(data))
assert not(found)
# Invalid versions
for data in [b"ID3\x01\x00\x00",
b"ID3\x05\x00\x00",
b"ID3\x06\x00\x00",
]:
header = TagHeader()
try:
found = header.parse(BytesIO(data))
except TagException:
pass
else:
assert not("Expected TagException invalid version")
# Complete headers
for data in [b"ID3\x02\x00\x00",
b"ID3\x03\x00\x00",
b"ID3\x04\x00\x00",
]:
for sz in [0, 10, 100, 1000, 2500, 5000, 7500, 10000]:
sz_bytes = bin2bytes(bin2synchsafe(dec2bin(sz, 32)))
header = TagHeader()
found = header.parse(BytesIO(data + sz_bytes))
assert (found)
assert header.tag_size == sz
def testRenderWithUnsyncTrue(self):
h = TagHeader()
h.unsync = True
with pytest.raises(NotImplementedError):
h.render(100)
def testRender(self):
h = TagHeader()
h.unsync = False
header = h.render(100)
h2 = TagHeader()
found = h2.parse(BytesIO(header))
assert not(h2.unsync)
assert (found)
assert header == h2.render(100)
h = TagHeader()
h.footer = True
h.extended = True
header = h.render(666)
h2 = TagHeader()
found = h2.parse(BytesIO(header))
assert (found)
assert not(h2.unsync)
assert not(h2.experimental)
assert h2.footer
assert h2.extended
assert (h2.tag_size == 666)
assert (header == h2.render(666))
class TestExtendedHeader(unittest.TestCase):
def testCtor(self):
h = ExtendedTagHeader()
assert (h.size == 0)
assert (h._flags == 0)
assert (h.crc is None)
assert (h._restrictions == 0)
assert not(h.update_bit)
assert not(h.crc_bit)
assert not(h.restrictions_bit)
def testUpdateBit(self):
h = ExtendedTagHeader()
h.update_bit = 1
assert (h.update_bit)
h.update_bit = 0
assert not(h.update_bit)
h.update_bit = 1
assert (h.update_bit)
h.update_bit = False
assert not(h.update_bit)
h.update_bit = True
assert (h.update_bit)
def testCrcBit(self):
h = ExtendedTagHeader()
h.update_bit = True
h.crc_bit = 1
assert (h.update_bit)
assert (h.crc_bit)
h.crc_bit = 0
assert (h.update_bit)
assert not(h.crc_bit)
h.crc_bit = 1
assert (h.update_bit)
assert (h.crc_bit)
h.crc_bit = False
assert (h.update_bit)
assert not(h.crc_bit)
h.crc_bit = True
assert (h.update_bit)
assert (h.crc_bit)
def testRestrictionsBit(self):
h = ExtendedTagHeader()
h.update_bit = True
h.crc_bit = True
h.restrictions_bit = 1
assert (h.update_bit)
assert (h.crc_bit)
assert (h.restrictions_bit)
h.restrictions_bit = 0
assert (h.update_bit)
assert (h.crc_bit)
assert not(h.restrictions_bit)
h.restrictions_bit = 1
assert (h.update_bit)
assert (h.crc_bit)
assert (h.restrictions_bit)
h.restrictions_bit = False
assert (h.update_bit)
assert (h.crc_bit)
assert not(h.restrictions_bit)
h.restrictions_bit = True
assert (h.update_bit)
assert (h.crc_bit)
assert (h.restrictions_bit)
h = ExtendedTagHeader()
h.restrictions_bit = True
assert (h.tag_size_restriction ==
ExtendedTagHeader.RESTRICT_TAG_SZ_LARGE)
assert (h.text_enc_restriction ==
ExtendedTagHeader.RESTRICT_TEXT_ENC_NONE)
assert (h.text_length_restriction ==
ExtendedTagHeader.RESTRICT_TEXT_LEN_NONE)
assert (h.image_enc_restriction ==
ExtendedTagHeader.RESTRICT_IMG_ENC_NONE)
assert (h.image_size_restriction ==
ExtendedTagHeader.RESTRICT_IMG_SZ_NONE)
h.tag_size_restriction = ExtendedTagHeader.RESTRICT_TAG_SZ_TINY
assert (h.tag_size_restriction ==
ExtendedTagHeader.RESTRICT_TAG_SZ_TINY)
assert (h.text_enc_restriction ==
ExtendedTagHeader.RESTRICT_TEXT_ENC_NONE)
assert (h.text_length_restriction ==
ExtendedTagHeader.RESTRICT_TEXT_LEN_NONE)
assert (h.image_enc_restriction ==
ExtendedTagHeader.RESTRICT_IMG_ENC_NONE)
assert (h.image_size_restriction ==
ExtendedTagHeader.RESTRICT_IMG_SZ_NONE)
h.text_enc_restriction = ExtendedTagHeader.RESTRICT_TEXT_ENC_UTF8
assert (h.tag_size_restriction ==
ExtendedTagHeader.RESTRICT_TAG_SZ_TINY)
assert (h.text_enc_restriction ==
ExtendedTagHeader.RESTRICT_TEXT_ENC_UTF8)
assert (h.text_length_restriction ==
ExtendedTagHeader.RESTRICT_TEXT_LEN_NONE)
assert (h.image_enc_restriction ==
ExtendedTagHeader.RESTRICT_IMG_ENC_NONE)
assert (h.image_size_restriction ==
ExtendedTagHeader.RESTRICT_IMG_SZ_NONE)
h.text_length_restriction = ExtendedTagHeader.RESTRICT_TEXT_LEN_30
assert (h.tag_size_restriction ==
ExtendedTagHeader.RESTRICT_TAG_SZ_TINY)
assert (h.text_enc_restriction ==
ExtendedTagHeader.RESTRICT_TEXT_ENC_UTF8)
assert (h.text_length_restriction ==
ExtendedTagHeader.RESTRICT_TEXT_LEN_30)
assert (h.image_enc_restriction ==
ExtendedTagHeader.RESTRICT_IMG_ENC_NONE)
assert (h.image_size_restriction ==
ExtendedTagHeader.RESTRICT_IMG_SZ_NONE)
h.image_enc_restriction = ExtendedTagHeader.RESTRICT_IMG_ENC_PNG_JPG
assert (h.tag_size_restriction ==
ExtendedTagHeader.RESTRICT_TAG_SZ_TINY)
assert (h.text_enc_restriction ==
ExtendedTagHeader.RESTRICT_TEXT_ENC_UTF8)
assert (h.text_length_restriction ==
ExtendedTagHeader.RESTRICT_TEXT_LEN_30)
assert (h.image_enc_restriction ==
ExtendedTagHeader.RESTRICT_IMG_ENC_PNG_JPG)
assert (h.image_size_restriction ==
ExtendedTagHeader.RESTRICT_IMG_SZ_NONE)
h.image_size_restriction = ExtendedTagHeader.RESTRICT_IMG_SZ_256
assert (h.tag_size_restriction ==
ExtendedTagHeader.RESTRICT_TAG_SZ_TINY)
assert (h.text_enc_restriction ==
ExtendedTagHeader.RESTRICT_TEXT_ENC_UTF8)
assert (h.text_length_restriction ==
ExtendedTagHeader.RESTRICT_TEXT_LEN_30)
assert (h.image_enc_restriction ==
ExtendedTagHeader.RESTRICT_IMG_ENC_PNG_JPG)
assert (h.image_size_restriction ==
ExtendedTagHeader.RESTRICT_IMG_SZ_256)
assert " 32 frames " in h.tag_size_restriction_description
assert " 4 KB " in h.tag_size_restriction_description
h.tag_size_restriction = ExtendedTagHeader.RESTRICT_TAG_SZ_LARGE
assert " 128 frames " in h.tag_size_restriction_description
h.tag_size_restriction = ExtendedTagHeader.RESTRICT_TAG_SZ_MED
assert " 64 frames " in h.tag_size_restriction_description
h.tag_size_restriction = ExtendedTagHeader.RESTRICT_TAG_SZ_SMALL
assert " 32 frames " in h.tag_size_restriction_description
assert " 40 KB " in h.tag_size_restriction_description
assert (" UTF-8" in h.text_enc_restriction_description)
h.text_enc_restriction = ExtendedTagHeader.RESTRICT_TEXT_ENC_NONE
assert ("None" == h.text_enc_restriction_description)
assert " 30 " in h.text_length_restriction_description
h.text_length_restriction = ExtendedTagHeader.RESTRICT_TEXT_LEN_NONE
assert ("None" == h.text_length_restriction_description)
h.text_length_restriction = ExtendedTagHeader.RESTRICT_TEXT_LEN_1024
assert " 1024 " in h.text_length_restriction_description
h.text_length_restriction = ExtendedTagHeader.RESTRICT_TEXT_LEN_128
assert " 128 " in h.text_length_restriction_description
assert " PNG " in h.image_enc_restriction_description
h.image_enc_restriction = ExtendedTagHeader.RESTRICT_IMG_ENC_NONE
assert ("None" == h.image_enc_restriction_description)
assert " 256x256 " in h.image_size_restriction_description
h.image_size_restriction = ExtendedTagHeader.RESTRICT_IMG_SZ_NONE
assert ("None" == h.image_size_restriction_description)
h.image_size_restriction = ExtendedTagHeader.RESTRICT_IMG_SZ_64
assert (" 64x64 pixels or smaller" in
h.image_size_restriction_description)
h.image_size_restriction = ExtendedTagHeader.RESTRICT_IMG_SZ_64_EXACT
assert "exactly 64x64 pixels" in h.image_size_restriction_description
def testRender(self):
version = (2, 4, 0)
dummy_data = b"\xab" * 50
dummy_padding_len = 1024
h = ExtendedTagHeader()
h.update_bit = 1
h.crc_bit = 1
h.tag_size_restriction = ExtendedTagHeader.RESTRICT_TAG_SZ_MED
h.text_enc_restriction = ExtendedTagHeader.RESTRICT_TEXT_ENC_UTF8
h.text_length_restriction = ExtendedTagHeader.RESTRICT_TEXT_LEN_128
h.image_enc_restriction = ExtendedTagHeader.RESTRICT_IMG_ENC_PNG_JPG
h.image_size_restriction = ExtendedTagHeader.RESTRICT_IMG_SZ_256
header = h.render(version, dummy_data, dummy_padding_len)
h2 = ExtendedTagHeader()
h2.parse(BytesIO(header), version)
assert (h2.update_bit)
assert (h2.crc_bit)
assert (h2.restrictions_bit)
assert (h.crc == h2.crc)
assert (h.tag_size_restriction == h2.tag_size_restriction)
assert (h.text_enc_restriction == h2.text_enc_restriction)
assert (h.text_length_restriction == h2.text_length_restriction)
assert (h.image_enc_restriction == h2.image_enc_restriction)
assert (h.image_size_restriction == h2.image_size_restriction)
assert h2.render(version, dummy_data, dummy_padding_len) == header
# version 2.3
header_23 = h.render((2,3,0), dummy_data, dummy_padding_len)
h3 = ExtendedTagHeader()
h3.parse(BytesIO(header_23), (2,3,0))
assert not(h3.update_bit)
assert (h3.crc_bit)
assert not(h3.restrictions_bit)
assert (h.crc == h3.crc)
assert (0 == h3.tag_size_restriction)
assert (0 == h3.text_enc_restriction)
assert (0 == h3.text_length_restriction)
assert (0 == h3.image_enc_restriction)
assert (0 == h3.image_size_restriction)
def testRenderCrcPadding(self):
version = (2, 4, 0)
h = ExtendedTagHeader()
h.crc_bit = 1
header = h.render(version, b"\x01", 0)
h2 = ExtendedTagHeader()
h2.parse(BytesIO(header), version)
assert h.crc == h2.crc
def testInvalidFlagBits(self):
for bad_flags in [b"\x00\x20", b"\x01\x01"]:
h = ExtendedTagHeader()
try:
h.parse(BytesIO(b"\x00\x00\x00\xff" + bad_flags), (2, 4, 0))
except TagException:
pass
else:
assert not("Bad ExtendedTagHeader flags, expected "
"TagException")
class TestFrameHeader(unittest.TestCase):
def testCtor(self):
h = FrameHeader(b"TIT2", ID3_DEFAULT_VERSION)
assert (h.size == 10)
assert (h.id == b"TIT2")
assert (h.data_size == 0)
assert (h._flags == [0] * 16)
h = FrameHeader(b"TIT2", (2, 3, 0))
assert (h.size == 10)
assert (h.id == b"TIT2")
assert (h.data_size == 0)
assert (h._flags == [0] * 16)
h = FrameHeader(b"TIT2", (2, 2, 0))
assert (h.size == 6)
assert (h.id == b"TIT2")
assert (h.data_size == 0)
assert (h._flags == [0] * 16)
def testBitMask(self):
for v in [(2, 2, 0), (2, 3, 0)]:
h = FrameHeader(b"TXXX", v)
assert (h.TAG_ALTER == 0)
assert (h.FILE_ALTER == 1)
assert (h.READ_ONLY == 2)
assert (h.COMPRESSED == 8)
assert (h.ENCRYPTED == 9)
assert (h.GROUPED == 10)
assert (h.UNSYNC == 14)
assert (h.DATA_LEN == 4)
for v in [(2, 4, 0), (1, 0, 0), (1, 1, 0)]:
h = FrameHeader(b"TXXX", v)
assert (h.TAG_ALTER == 1)
assert (h.FILE_ALTER == 2)
assert (h.READ_ONLY == 3)
assert (h.COMPRESSED == 12)
assert (h.ENCRYPTED == 13)
assert (h.GROUPED == 9)
assert (h.UNSYNC == 14)
assert (h.DATA_LEN == 15)
for v in [(2, 5, 0), (3, 0, 0)]:
try:
h = FrameHeader(b"TIT2", v)
except ValueError:
pass
else:
assert not("Expected a ValueError from invalid version, "
"but got success")
for v in [1, "yes", "no", True, 23]:
h = FrameHeader(b"APIC", (2, 4, 0))
h.tag_alter = v
h.file_alter = v
h.read_only = v
h.compressed = v
h.encrypted = v
h.grouped = v
h.unsync = v
h.data_length_indicator = v
assert (h.tag_alter == 1)
assert (h.file_alter == 1)
assert (h.read_only == 1)
assert (h.compressed == 1)
assert (h.encrypted == 1)
assert (h.grouped == 1)
assert (h.unsync == 1)
assert (h.data_length_indicator == 1)
for v in [0, False, None]:
h = FrameHeader(b"APIC", (2, 4, 0))
h.tag_alter = v
h.file_alter = v
h.read_only = v
h.compressed = v
h.encrypted = v
h.grouped = v
h.unsync = v
h.data_length_indicator = v
assert (h.tag_alter == 0)
assert (h.file_alter == 0)
assert (h.read_only == 0)
assert (h.compressed == 0)
assert (h.encrypted == 0)
assert (h.grouped == 0)
assert (h.unsync == 0)
assert (h.data_length_indicator == 0)
h1 = FrameHeader(b"APIC", (2, 3, 0))
h1.tag_alter = True
h1.grouped = True
h1.file_alter = 1
h1.encrypted = None
h1.compressed = 4
h1.data_length_indicator = 0
h1.read_only = 1
h1.unsync = 1
h2 = FrameHeader(b"APIC", (2, 4, 0))
assert (h2.tag_alter == 0)
assert (h2.grouped == 0)
h2.copyFlags(h1)
assert (h2.tag_alter)
assert (h2.grouped)
assert (h2.file_alter)
assert not(h2.encrypted)
assert (h2.compressed)
assert not(h2.data_length_indicator)
assert (h2.read_only)
assert (h2.unsync)
def testValidFrameId(self):
for id in [b"", b"a", b"tx", b"tit", b"TIT", b"Tit2", b"aPic"]:
assert not(FrameHeader._isValidFrameId(id))
for id in [b"TIT2", b"APIC", b"1234"]:
assert FrameHeader._isValidFrameId(id)
def testRenderWithUnsyncTrue(self):
h = FrameHeader(b"TIT2", ID3_DEFAULT_VERSION)
h.unsync = True
with pytest.raises(NotImplementedError):
h.render(100)
eyeD3-0.8.4/src/test/id3/test_id3.py 0000644 0001750 0001750 00000013312 13161501620 017646 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2012 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
import unittest
import pytest
from eyed3.id3 import *
from eyed3.compat import unicode
class GenreTests(unittest.TestCase):
def testEmptyGenre(self):
g = Genre()
assert g.id is None
assert g.name is None
def testValidGenres(self):
# Create with id
for i in range(genres.GENRE_MAX):
g = Genre()
g.id = i
assert (g.id == i)
assert (g.name == genres[i])
g = Genre(id=i)
assert (g.id == i)
assert (g.name == genres[i])
# Create with name
for name in [n for n in genres if n is not None and type(n) is not int]:
g = Genre()
g.name = name
assert (g.id == genres[name])
assert (g.name == genres[g.id])
assert (g.name.lower() == name)
g = Genre(name=name)
assert (g.id == genres[name])
assert (g.name.lower() == name)
def test255Padding(self):
for i in range(GenreMap.GENRE_MAX + 1, 256):
assert genres[i] is None
with pytest.raises(KeyError):
genres.__getitem__(256)
def testCustomGenres(self):
# Genres can be created for any name, their ID is None
g = Genre(name=u"Grindcore")
assert g.name == u"Grindcore"
assert g.id is None
# But when constructing with IDs they must map.
with pytest.raises(ValueError):
Genre.__call__(id=1024)
def testRemappedNames(self):
g = Genre(id=3, name=u"dance stuff")
assert (g.id == 3)
assert (g.name == u"Dance")
g = Genre(id=666, name=u"Funky")
assert (g.id is None)
assert (g.name == u"Funky")
def testGenreEq(self):
for s in [u"Hardcore", u"(129)Hardcore",
u"(129)", u"(0129)",
u"129", u"0129"]:
assert Genre.parse(s) == Genre.parse(s)
assert Genre.parse(s) != Genre.parse(u"Blues")
def testParseGenre(self):
test_list = [u"Hardcore", u"(129)Hardcore",
u"(129)", u"(0129)",
u"129", u"0129"]
# This is typically what will happen when parsing tags, a blob of text
# is parsed into Genre
for s in test_list:
g = Genre.parse(s)
assert g.name == u"Hardcore"
assert g.id == 129
g = Genre.parse(u"")
assert g is None
g = Genre.parse(u"1")
assert (g.id == 1)
assert(g.name == u"Classic Rock")
def testUnicode(self):
assert (unicode(Genre(u"Hardcore")) == u"(129)Hardcore")
assert (unicode(Genre(u"Grindcore")) == u"Grindcore")
class VersionTests(unittest.TestCase):
def setUp(self):
self.id3_versions = [(ID3_V1, (1, None, None), "v1.x"),
(ID3_V1_0, (1, 0, 0), "v1.0"),
(ID3_V1_1, (1, 1, 0), "v1.1"),
(ID3_V2, (2, None, None), "v2.x"),
(ID3_V2_2, (2, 2, 0), "v2.2"),
(ID3_V2_3, (2, 3, 0), "v2.3"),
(ID3_V2_4, (2, 4, 0), "v2.4"),
(ID3_DEFAULT_VERSION, (2, 4, 0), "v2.4"),
(ID3_ANY_VERSION, (1|2, None, None), "v1.x/v2.x"),
]
def testId3Versions(self):
for v in [ID3_V1, ID3_V1_0, ID3_V1_1]:
assert (v[0] == 1)
assert (ID3_V1_0[1] == 0)
assert (ID3_V1_0[2] == 0)
assert (ID3_V1_1[1] == 1)
assert (ID3_V1_1[2] == 0)
for v in [ID3_V2, ID3_V2_2, ID3_V2_3, ID3_V2_4]:
assert (v[0] == 2)
assert (ID3_V2_2[1] == 2)
assert (ID3_V2_3[1] == 3)
assert (ID3_V2_4[1] == 4)
assert (ID3_ANY_VERSION == (ID3_V1[0] | ID3_V2[0], None, None))
assert (ID3_DEFAULT_VERSION == ID3_V2_4)
def test_versionToString(self):
for const, tple, string in self.id3_versions:
assert versionToString(const) == string
with pytest.raises(TypeError):
versionToString(666)
with pytest.raises(ValueError):
versionToString((3,1,0))
def test_isValidVersion(self):
for v, _, _ in self.id3_versions:
assert isValidVersion(v)
for _, v, _ in self.id3_versions:
if None in v:
assert not isValidVersion(v, True)
else:
assert isValidVersion(v, True)
assert not isValidVersion((3, 1, 1))
def testNormalizeVersion(self):
assert (normalizeVersion(ID3_V1) == ID3_V1_1)
assert (normalizeVersion(ID3_V2) == ID3_V2_4)
assert (normalizeVersion(ID3_DEFAULT_VERSION) == ID3_V2_4)
assert (normalizeVersion(ID3_ANY_VERSION) == ID3_DEFAULT_VERSION)
# Correcting the bogus
assert (normalizeVersion((2, 2, 1)) == ID3_V2_2)
eyeD3-0.8.4/src/test/test_plugins.py 0000644 0001750 0001750 00000003477 13161501620 020204 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2011 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
from eyed3.plugins import *
from .compat import *
def test_load():
plugins = load()
assert "classic" in list(plugins.keys())
assert "genres" in list(plugins.keys())
assert load("classic") == plugins["classic"]
assert load("genres") == plugins["genres"]
assert (load("classic", reload=True).__class__.__name__ ==
plugins["classic"].__class__.__name__)
assert (load("genres", reload=True).__class__.__name__ ==
plugins["genres"].__class__.__name__)
assert load("DNE") is None
def test_Plugin():
import argparse
from eyed3.utils import FileHandler
class MyPlugin(Plugin):
pass
p = MyPlugin(argparse.ArgumentParser())
assert p.arg_group is not None
# In reality, this is parsed args
p.start("dummy_args", "dummy_config")
assert p.args == "dummy_args"
assert p.config == "dummy_config"
assert p.handleFile("f.txt") is None
assert p.handleDone() is None
eyeD3-0.8.4/src/test/conftest.py 0000644 0001750 0001750 00000002464 13203715221 017305 0 ustar travis travis 0000000 0000000 import shutil
import pytest
import eyed3
from uuid import uuid4
from pathlib import Path
DATA_D = Path(__file__).parent / "data"
def _tempCopy(src, dest_dir):
testfile = Path(str(dest_dir)) / "{}.mp3".format(uuid4())
shutil.copyfile(str(src), str(testfile))
return testfile
@pytest.fixture(scope="function")
def audiofile(tmpdir):
"""Makes a copy of test.mp3 and loads it using eyed3.load()."""
testfile = _tempCopy(DATA_D / "test.mp3", tmpdir)
yield eyed3.load(testfile)
if testfile.exists():
testfile.unlink()
@pytest.fixture(scope="function")
def id3tag():
"""Returns a default-constructed eyed3.id3.Tag."""
from eyed3.id3 import Tag
return Tag()
@pytest.fixture(scope="function")
def image(tmpdir):
img_file = _tempCopy(DATA_D / "CypressHill3TemplesOfBoom.jpg", tmpdir)
return img_file
@pytest.fixture(scope="session")
def eyeD3():
from eyed3 import main
def func(audiofile, args, expected_retval=0, reload_version=None):
try:
args, _, config = main.parseCommandLine(args + [audiofile.path])
retval = main.main(args, config)
except SystemExit as exit:
retval = exit.code
assert retval == expected_retval
return eyed3.load(audiofile.path, tag_version=reload_version)
return func
eyeD3-0.8.4/src/test/test_utils.py 0000644 0001750 0001750 00000005604 13161501620 017655 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
import os
import pytest
import eyed3.utils.console
from eyed3.utils import guessMimetype
from eyed3.utils.console import (printMsg, printWarning, printHeader, Fore,
WARNING_COLOR, HEADER_COLOR)
from . import DATA_D, RedirectStdStreams
@pytest.mark.skipif(not os.path.exists(DATA_D),
reason="test requires data files")
@pytest.mark.parametrize(("ext", "valid_types"),
[("id3", ["application/x-id3"]),
("tag", ["application/x-id3"]),
("aac", ["audio/x-aac", "audio/x-hx-aac-adts"]),
("aiff", ["audio/x-aiff"]),
("amr", ["audio/amr", "application/octet-stream"]),
("au", ["audio/basic"]),
("m4a", ["audio/mp4", "audio/x-m4a"]),
("mka", ["video/x-matroska",
"application/octet-stream"]),
("mp3", ["audio/mpeg"]),
("mp4", ["video/mp4", "audio/x-m4a"]),
("mpg", ["video/mpeg"]),
("ogg", ["audio/ogg", "application/ogg"]),
("ra", ["audio/x-pn-realaudio",
"application/vnd.rn-realmedia"]),
("wav", ["audio/x-wav"]),
("wma", ["audio/x-ms-wma", "video/x-ms-wma",
"video/x-ms-asf"])])
def testSampleMimeTypes(ext, valid_types):
guessed = guessMimetype(os.path.join(DATA_D, "sample.%s" % ext))
if guessed:
assert guessed in valid_types
def test_printWarning():
eyed3.utils.console.USE_ANSI = False
with RedirectStdStreams() as out:
printWarning("Built To Spill")
assert (out.stdout.read() == "Built To Spill\n")
eyed3.utils.console.USE_ANSI = True
with RedirectStdStreams() as out:
printWarning("Built To Spill")
assert (out.stdout.read() == "%sBuilt To Spill%s\n" % (WARNING_COLOR(),
Fore.RESET))
def test_printMsg():
eyed3.utils.console.USE_ANSI = False
with RedirectStdStreams() as out:
printMsg("EYEHATEGOD")
assert (out.stdout.read() == "EYEHATEGOD\n")
eyed3.utils.console.USE_ANSI = True
with RedirectStdStreams() as out:
printMsg("EYEHATEGOD")
assert (out.stdout.read() == "EYEHATEGOD\n")
def test_printHeader():
eyed3.utils.console.USE_ANSI = False
with RedirectStdStreams() as out:
printHeader("Furthur")
assert (out.stdout.read() == "Furthur\n")
eyed3.utils.console.USE_ANSI = True
with RedirectStdStreams() as out:
printHeader("Furthur")
assert (out.stdout.read() == "%sFurthur%s\n" % (HEADER_COLOR(),
Fore.RESET))
eyeD3-0.8.4/src/test/test_core.py 0000644 0001750 0001750 00000012774 13161501620 017453 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2011 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
import os
from pathlib import Path
import pytest
import eyed3
from eyed3 import core
def test_AudioFile_rename(audiofile):
orig_path = audiofile.path
# Happy path
audiofile.rename("Spoon")
assert Path(audiofile.path).exists()
assert not Path(orig_path).exists()
assert (Path(orig_path).parent /
"Spoon{}".format(Path(orig_path).suffix)).exists()
# File exist
with pytest.raises(IOError):
audiofile.rename("Spoon")
# Parent dir does not exist
with pytest.raises(IOError):
audiofile.rename("subdir/BloodOnTheWall")
def test_import_load():
assert eyed3.load == core.load
# eyed3.load raises IOError for non files and non-existent files
def test_ioerror_load():
# Non existent
with pytest.raises(IOError):
core.load("filedoesnotexist.txt")
# Non file
with pytest.raises(IOError):
core.load(os.path.abspath(os.path.curdir))
def test_none_load():
# File mimetypes that are not supported return None
assert core.load(__file__) == None
def test_AudioFile():
from eyed3.core import AudioFile
# Abstract method
with pytest.raises(NotImplementedError):
AudioFile("somefile.mp3")
class DummyAudioFile(AudioFile):
def _read(self):
pass
# precondition is that __file__ is already absolute
assert os.path.isabs(__file__)
af = DummyAudioFile(__file__)
# All paths are turned into absolute paths
assert af.path == os.path.abspath(__file__)
def test_AudioInfo():
from eyed3.core import AudioInfo
info = AudioInfo()
assert (info.time_secs == 0)
assert (info.size_bytes == 0)
def test_Date():
from eyed3.core import Date
for d in [Date(1973),
Date(year=1973),
Date.parse("1973")]:
assert (d.year == 1973)
assert (d.month == None)
assert (d.day == None)
assert (d.hour == None)
assert (d.minute == None)
assert (d.second == None)
assert (str(d) == "1973")
for d in [Date(1973, 3),
Date(year=1973, month=3),
Date.parse("1973-03")]:
assert (d.year == 1973)
assert (d.month == 3)
assert (d.day == None)
assert (d.hour == None)
assert (d.minute == None)
assert (d.second == None)
assert (str(d) == "1973-03")
for d in [Date(1973, 3, 6),
Date(year=1973, month=3, day=6),
Date.parse("1973-3-6")]:
assert (d.year == 1973)
assert (d.month == 3)
assert (d.day == 6)
assert (d.hour == None)
assert (d.minute == None)
assert (d.second == None)
assert (str(d) == "1973-03-06")
for d in [Date(1973, 3, 6, 23),
Date(year=1973, month=3, day=6, hour=23),
Date.parse("1973-3-6T23")]:
assert (d.year == 1973)
assert (d.month == 3)
assert (d.day == 6)
assert (d.hour == 23)
assert (d.minute == None)
assert (d.second == None)
assert (str(d) == "1973-03-06T23")
for d in [Date(1973, 3, 6, 23, 20),
Date(year=1973, month=3, day=6, hour=23, minute=20),
Date.parse("1973-3-6T23:20")]:
assert (d.year == 1973)
assert (d.month == 3)
assert (d.day == 6)
assert (d.hour == 23)
assert (d.minute == 20)
assert (d.second == None)
assert (str(d) == "1973-03-06T23:20")
for d in [Date(1973, 3, 6, 23, 20, 15),
Date(year=1973, month=3, day=6, hour=23, minute=20,
second=15),
Date.parse("1973-3-6T23:20:15")]:
assert (d.year == 1973)
assert (d.month == 3)
assert (d.day == 6)
assert (d.hour == 23)
assert (d.minute == 20)
assert (d.second == 15)
assert (str(d) == "1973-03-06T23:20:15")
with pytest.raises(ValueError):
Date.parse("")
with pytest.raises(ValueError):
Date.parse("ABC")
with pytest.raises(ValueError):
Date.parse("2010/1/24")
with pytest.raises(ValueError):
Date(2012, 0)
with pytest.raises(ValueError):
Date(2012, 1, 35)
with pytest.raises(ValueError):
Date(2012, 1, 4, -1)
with pytest.raises(ValueError):
Date(2012, 1, 4, 24)
with pytest.raises(ValueError):
Date(2012, 1, 4, 18, 60)
with pytest.raises(ValueError):
Date(2012, 1, 4, 18, 14, 61)
dt = Date(1973, 3, 6, 23, 20, 15)
assert not dt == None
dp = Date(1980, 7, 3, 10, 5, 1)
assert dt != dp
assert dt < dp
assert not dp < dt
assert None < dp
assert not dp < dp
assert dp <= dp
assert hash(dt) != hash(dp)
eyeD3-0.8.4/src/test/test_binfuncs.py 0000644 0001750 0001750 00000005521 13161501620 020322 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2009 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
import pytest
from eyed3.utils.binfuncs import *
def test_bytes2bin():
# test ones and zeros, sz==8
for i in range(1, 11):
zeros = bytes2bin(b"\x00" * i)
ones = bytes2bin(b"\xFF" * i)
assert len(zeros) == (8 * i) and len(zeros) == len(ones)
for i in range(len(zeros)):
assert zeros[i] == 0
assert ones[i] == 1
# test 'sz' bounds checking
with pytest.raises(ValueError):
bytes2bin(b"a", -1)
with pytest.raises(ValueError):
bytes2bin(b"a", 0)
with pytest.raises(ValueError):
bytes2bin(b"a", 9)
# Test 'sz'
for sz in range(1, 9):
res = bytes2bin(b"\x00\xFF", sz=sz)
assert len(res) == 2 * sz
assert res[:sz] == [0] * sz
assert res[sz:] == [1] * sz
def test_bin2bytes():
res = bin2bytes([0])
assert len(res) == 1
assert ord(res) == 0
res = bin2bytes([1] * 8)
assert len(res) == 1
assert ord(res) == 255
def test_bin2dec():
assert bin2dec([1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0]) == 2730
def test_bytes2dec():
assert bytes2dec(b"\x00\x11\x22\x33") == 1122867
def test_dec2bin():
assert dec2bin(3036790792) == [1, 0, 1, 1, 0, 1, 0, 1,
0, 0, 0, 0, 0, 0, 0, 1,
1, 1, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 1, 0, 0, 0]
assert dec2bin(1, p=8) == [0, 0, 0, 0, 0, 0, 0, 1]
def test_dec2bytes():
assert dec2bytes(ord(b"a")) == b"\x61"
def test_bin2syncsafe():
with pytest.raises(ValueError):
bin2synchsafe(bytes2bin(b"\xff\xff\xff\xff"))
with pytest.raises(ValueError):
bin2synchsafe([0] * 33)
assert bin2synchsafe([1] * 7) == [1] * 7
assert bin2synchsafe(dec2bin(255)) == [0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 1,
0, 1, 1, 1, 1, 1, 1, 1]
eyeD3-0.8.4/src/eyed3/ 0000755 0001750 0001750 00000000000 13203726215 015137 5 ustar travis travis 0000000 0000000 eyeD3-0.8.4/src/eyed3/plugins/ 0000755 0001750 0001750 00000000000 13203726215 016620 5 ustar travis travis 0000000 0000000 eyeD3-0.8.4/src/eyed3/plugins/genres.py 0000644 0001750 0001750 00000005256 13061344514 020465 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2012 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
from __future__ import print_function
import math
from eyed3 import id3
from eyed3.plugins import Plugin
class GenreListPlugin(Plugin):
SUMMARY = u"Display the full list of standard ID3 genres."
DESCRIPTION = u"ID3 v1 defined a list of genres and mapped them to "\
"to numeric values so they can be stored as a single "\
"byte.\nIt is *recommended* that these genres are used "\
"although most newer software (including eyeD3) does not "\
"care."
NAMES = ["genres"]
def __init__(self, arg_parser):
super(GenreListPlugin, self).__init__(arg_parser)
self.arg_group.add_argument("-1", "--single-column",
action="store_true",
help="List on genre per line.")
def start(self, args, config):
self._printGenres(args)
def _printGenres(self, args):
# Filter out 'Unknown'
genre_ids = [i for i in id3.genres
if type(i) is int and id3.genres[i] is not None]
genre_ids.sort()
if args.single_column:
for gid in genre_ids:
print(u"%3d: %s" % (gid, id3.genres[gid]))
else:
offset = int(math.ceil(float(len(genre_ids)) / 2))
for i in range(offset):
if i < len(genre_ids):
c1 = u"%3d: %s" % (i, id3.genres[i])
else:
c1 = u""
if (i * 2) < len(genre_ids):
try:
c2 = u"%3d: %s" % (i + offset, id3.genres[i + offset])
except IndexError:
break
else:
c2 = u""
print(c1 + (u" " * (40 - len(c1))) + c2)
print(u"")
eyeD3-0.8.4/src/eyed3/plugins/classic.py 0000644 0001750 0001750 00000153176 13203722311 020621 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2007-2016 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
from __future__ import print_function
import os
import re
from functools import partial
from argparse import ArgumentTypeError
from eyed3 import LOCAL_ENCODING
from eyed3.plugins import LoaderPlugin
from eyed3 import core, id3, mp3, utils, compat
from eyed3.utils import makeUniqueFileName
from eyed3.utils.console import (printMsg, printError, printWarning, boldText,
HEADER_COLOR, Fore, getTtySize)
from eyed3.id3.frames import ImageFrame
from eyed3.utils.log import getLogger
log = getLogger(__name__)
FIELD_DELIM = ':'
DEFAULT_MAX_PADDING = 64 * 1024
class ClassicPlugin(LoaderPlugin):
SUMMARY = u"Classic eyeD3 interface for viewing and editing tags."
DESCRIPTION = u"""
All PATH arguments are parsed and displayed. Directory paths are searched
recursively. Any editing options (--artist, --title) are applied to each file
read.
All date options (-Y, --release-year excepted) follow ISO 8601 format. This is
``yyyy-mm-ddThh:mm:ss``. The year is required, and each component thereafter is
optional. For example, 2012-03 is valid, 2012--12 is not.
"""
NAMES = ["classic"]
def __init__(self, arg_parser):
super(ClassicPlugin, self).__init__(arg_parser)
g = self.arg_group
def UnicodeArg(arg):
return _unicodeArgValue(arg)
def PositiveIntArg(i):
i = int(i)
if i < 0:
raise ArgumentTypeError("positive number required")
return i
# Common options
g.add_argument("-a", "--artist", type=UnicodeArg, dest="artist",
metavar="STRING", help=ARGS_HELP["--artist"])
g.add_argument("-A", "--album", type=UnicodeArg, dest="album",
metavar="STRING", help=ARGS_HELP["--album"])
g.add_argument("-b", "--album-artist", type=UnicodeArg,
dest="album_artist", metavar="STRING",
help=ARGS_HELP["--album-artist"])
g.add_argument("-t", "--title", type=UnicodeArg, dest="title",
metavar="STRING", help=ARGS_HELP["--title"])
g.add_argument("-n", "--track", type=PositiveIntArg, dest="track",
metavar="NUM", help=ARGS_HELP["--track"])
g.add_argument("-N", "--track-total", type=PositiveIntArg,
dest="track_total", metavar="NUM",
help=ARGS_HELP["--track-total"])
g.add_argument("--track-offset", type=int, dest="track_offset",
metavar="N", help=ARGS_HELP["--track-offset"])
g.add_argument("--composer", type=UnicodeArg, dest="composer",
metavar="STRING", help=ARGS_HELP["--composer"])
g.add_argument("-d", "--disc-num", type=PositiveIntArg, dest="disc_num",
metavar="NUM", help=ARGS_HELP["--disc-num"])
g.add_argument("-D", "--disc-total", type=PositiveIntArg,
dest="disc_total", metavar="NUM",
help=ARGS_HELP["--disc-total"])
g.add_argument("-G", "--genre", type=UnicodeArg, dest="genre",
metavar="GENRE", help=ARGS_HELP["--genre"])
g.add_argument("--non-std-genres", dest="non_std_genres",
action="store_true", help=ARGS_HELP["--non-std-genres"])
g.add_argument("-Y", "--release-year", type=PositiveIntArg,
dest="release_year", metavar="YEAR",
help=ARGS_HELP["--release-year"])
g.add_argument("-c", "--comment", dest="simple_comment",
type=UnicodeArg, metavar="STRING",
help=ARGS_HELP["--comment"])
g.add_argument("--rename", dest="rename_pattern", metavar="PATTERN",
help=ARGS_HELP["--rename"])
gid3 = arg_parser.add_argument_group("ID3 options")
def _splitArgs(arg, maxsplit=None):
NEW_DELIM = "#DELIM#"
arg = re.sub(r"\\%s" % FIELD_DELIM, NEW_DELIM, arg)
t = tuple(re.sub(NEW_DELIM, FIELD_DELIM, s)
for s in arg.split(FIELD_DELIM))
if maxsplit is not None and maxsplit < 2:
raise ValueError("Invalid maxsplit value: {}".format(maxsplit))
elif maxsplit and len(t) > maxsplit:
t = t[:maxsplit - 1] + (FIELD_DELIM.join(t[maxsplit - 1:]),)
assert len(t) <= maxsplit
return t
def _unicodeArgValue(arg):
if not isinstance(arg, compat.UnicodeType):
return compat.unicode(arg, LOCAL_ENCODING)
else:
return arg
def DescLangArg(arg):
"""DESCRIPTION[:LANG]"""
arg = _unicodeArgValue(arg)
vals = _splitArgs(arg, 2)
desc = vals[0]
lang = vals[1] if len(vals) > 1 else id3.DEFAULT_LANG
return (desc, compat.b(lang)[:3] or id3.DEFAULT_LANG)
def DescTextArg(arg):
"""DESCRIPTION:TEXT"""
arg = _unicodeArgValue(arg)
vals = _splitArgs(arg, 2)
desc = vals[0].strip()
text = FIELD_DELIM.join(vals[1:] if len(vals) > 1 else [])
return (desc or u"", text or u"")
KeyValueArg = DescTextArg
def DescUrlArg(arg):
desc, url = DescTextArg(arg)
return (desc, url.encode("latin1"))
def FidArg(arg):
arg = _unicodeArgValue(arg)
fid = arg.strip().encode("ascii")
if not fid:
raise ArgumentTypeError("No frame ID")
return fid
def TextFrameArg(arg):
"""FID:TEXT"""
arg = _unicodeArgValue(arg)
vals = _splitArgs(arg, 2)
fid = vals[0].strip().encode("ascii")
if not fid:
raise ArgumentTypeError("No frame ID")
text = vals[1] if len(vals) > 1 else u""
return (fid, text)
def UrlFrameArg(arg):
"""FID:TEXT"""
fid, url = TextFrameArg(arg)
return (fid, url.encode("latin1"))
def DateArg(date_str):
return core.Date.parse(date_str) if date_str else ""
def CommentArg(arg):
"""
COMMENT[:DESCRIPTION[:LANG]
"""
arg = _unicodeArgValue(arg)
vals = _splitArgs(arg, 3)
text = vals[0]
if not text:
raise ArgumentTypeError("text required")
desc = vals[1] if len(vals) > 1 else u""
lang = vals[2] if len(vals) > 2 else id3.DEFAULT_LANG
return (text, desc, compat.b(lang)[:3])
def LyricsArg(arg):
text, desc, lang = CommentArg(arg)
try:
with open(text, "rb") as fp:
data = fp.read()
except Exception: # noqa: B901
raise ArgumentTypeError("Unable to read file")
return (_unicodeArgValue(data), desc, lang)
def PlayCountArg(pc):
if not pc:
raise ArgumentTypeError("value required")
increment = False
if pc[0] == "+":
pc = int(pc[1:])
increment = True
else:
pc = int(pc)
if pc < 0:
raise ArgumentTypeError("out of range")
return (increment, pc)
def BpmArg(bpm):
bpm = int(float(bpm) + 0.5)
if bpm <= 0:
raise ArgumentTypeError("out of range")
return bpm
def DirArg(d):
if not d or not os.path.isdir(d):
raise ArgumentTypeError("invalid directory: %s" % d)
return d
def ImageArg(s):
"""PATH:TYPE[:DESCRIPTION]
Returns (path, type_id, mime_type, description)
"""
args = _splitArgs(s, 3)
if len(args) < 2:
raise ArgumentTypeError("Format is: PATH:TYPE[:DESCRIPTION]")
path, type_str = args[:2]
desc = UnicodeArg(args[2]) if len(args) > 2 else u""
mt = None
try:
type_id = id3.frames.ImageFrame.stringToPicType(type_str)
except: # noqa: B901
raise ArgumentTypeError("invalid pic type: {}".format(type_str))
if not path:
raise ArgumentTypeError("path required")
elif True in [path.startswith(prefix)
for prefix in ["http://", "https://"]]:
mt = ImageFrame.URL_MIME_TYPE
else:
if not os.path.isfile(path):
raise ArgumentTypeError("file does not exist")
mt = utils.guessMimetype(path)
if mt is None:
raise ArgumentTypeError("Cannot determine mime-type")
return (path, type_id, mt, desc)
def ObjectArg(s):
"""OBJ_PATH:MIME-TYPE[:DESCRIPTION[:FILENAME]],
Returns (path, mime_type, description, filename)
"""
args = _splitArgs(s, 4)
if len(args) < 2:
raise ArgumentTypeError("too few parts")
path = args[0]
mt = None
desc = None
filename = None
if path:
mt = args[1]
desc = UnicodeArg(args[2]) if len(args) > 2 else u""
filename = UnicodeArg(args[3]) \
if len(args) > 3 \
else UnicodeArg(os.path.basename(path))
if not os.path.isfile(path):
raise ArgumentTypeError("file does not exist")
if not mt:
raise ArgumentTypeError("mime-type required")
else:
raise ArgumentTypeError("path required")
return (path, mt, desc, filename)
def UniqFileIdArg(arg):
owner_id, id = KeyValueArg(arg)
if not owner_id:
raise ArgumentTypeError("owner_id required")
id = id.encode("latin1") # don't want to pass unicode
if len(id) > 64:
raise ArgumentTypeError("id must be <= 64 bytes")
return (owner_id, id)
def PopularityArg(arg):
"""EMAIL:RATING[:PLAY_COUNT]
Returns (email, rating, play_count)
"""
args = _splitArgs(arg, 3)
if len(args) < 2:
raise ArgumentTypeError("Incorrect number of argument "
"components")
email = args[0]
rating = int(float(args[1]))
if rating < 0 or rating > 255:
raise ArgumentTypeError("Rating out-of-range")
play_count = 0
if len(args) > 2:
play_count = int(args[2])
if play_count < 0:
raise ArgumentTypeError("Play count out-of-range")
return (email, rating, play_count)
# Tag versions
gid3.add_argument("-1", "--v1", action="store_const", const=id3.ID3_V1,
dest="tag_version", default=id3.ID3_ANY_VERSION,
help=ARGS_HELP["--v1"])
gid3.add_argument("-2", "--v2", action="store_const", const=id3.ID3_V2,
dest="tag_version", default=id3.ID3_ANY_VERSION,
help=ARGS_HELP["--v2"])
gid3.add_argument("--to-v1.1", action="store_const", const=id3.ID3_V1_1,
dest="convert_version", help=ARGS_HELP["--to-v1.1"])
gid3.add_argument("--to-v2.3", action="store_const", const=id3.ID3_V2_3,
dest="convert_version", help=ARGS_HELP["--to-v2.3"])
gid3.add_argument("--to-v2.4", action="store_const", const=id3.ID3_V2_4,
dest="convert_version", help=ARGS_HELP["--to-v2.4"])
# Dates
gid3.add_argument("--release-date", type=DateArg, dest="release_date",
metavar="DATE",
help=ARGS_HELP["--release-date"])
gid3.add_argument("--orig-release-date", type=DateArg,
dest="orig_release_date", metavar="DATE",
help=ARGS_HELP["--orig-release-date"])
gid3.add_argument("--recording-date", type=DateArg,
dest="recording_date", metavar="DATE",
help=ARGS_HELP["--recording-date"])
gid3.add_argument("--encoding-date", type=DateArg, dest="encoding_date",
metavar="DATE", help=ARGS_HELP["--encoding-date"])
gid3.add_argument("--tagging-date", type=DateArg, dest="tagging_date",
metavar="DATE", help=ARGS_HELP["--tagging-date"])
# Misc
gid3.add_argument("--publisher", action="store", type=UnicodeArg,
dest="publisher", metavar="STRING",
help=ARGS_HELP["--publisher"])
gid3.add_argument("--play-count", type=PlayCountArg, dest="play_count",
metavar="<+>N", default=None,
help=ARGS_HELP["--play-count"])
gid3.add_argument("--bpm", type=BpmArg, dest="bpm", metavar="N",
default=None, help=ARGS_HELP["--bpm"])
gid3.add_argument("--unique-file-id", action="append",
type=UniqFileIdArg, dest="unique_file_ids",
metavar="OWNER_ID:ID", default=[],
help=ARGS_HELP["--unique-file-id"])
# Comments
gid3.add_argument("--add-comment", action="append", dest="comments",
metavar="COMMENT[:DESCRIPTION[:LANG]]", default=[],
type=CommentArg, help=ARGS_HELP["--add-comment"])
gid3.add_argument("--remove-comment", action="append", type=DescLangArg,
dest="remove_comment", default=[],
metavar="DESCRIPTION[:LANG]",
help=ARGS_HELP["--remove-comment"])
gid3.add_argument("--remove-all-comments", action="store_true",
dest="remove_all_comments",
help=ARGS_HELP["--remove-all-comments"])
gid3.add_argument("--add-lyrics", action="append", type=LyricsArg,
dest="lyrics", default=[],
metavar="LYRICS_FILE[:DESCRIPTION[:LANG]]",
help=ARGS_HELP["--add-lyrics"])
gid3.add_argument("--remove-lyrics", action="append", type=DescLangArg,
dest="remove_lyrics", default=[],
metavar="DESCRIPTION[:LANG]",
help=ARGS_HELP["--remove-lyrics"])
gid3.add_argument("--remove-all-lyrics", action="store_true",
dest="remove_all_lyrics",
help=ARGS_HELP["--remove-all-lyrics"])
gid3.add_argument("--text-frame", action="append", type=TextFrameArg,
dest="text_frames", metavar="FID:TEXT", default=[],
help=ARGS_HELP["--text-frame"])
gid3.add_argument("--user-text-frame", action="append",
type=DescTextArg,
dest="user_text_frames", metavar="DESC:TEXT",
default=[], help=ARGS_HELP["--user-text-frame"])
gid3.add_argument("--url-frame", action="append", type=UrlFrameArg,
dest="url_frames", metavar="FID:URL", default=[],
help=ARGS_HELP["--url-frame"])
gid3.add_argument("--user-url-frame", action="append", type=DescUrlArg,
dest="user_url_frames", metavar="DESCRIPTION:URL",
default=[], help=ARGS_HELP["--user-url-frame"])
gid3.add_argument("--add-image", action="append", type=ImageArg,
dest="images", metavar="IMG_PATH:TYPE[:DESCRIPTION]",
default=[], help=ARGS_HELP["--add-image"])
gid3.add_argument("--remove-image", action="append", type=UnicodeArg,
dest="remove_image", default=[],
metavar="DESCRIPTION",
help=ARGS_HELP["--remove-image"])
gid3.add_argument("--remove-all-images", action="store_true",
dest="remove_all_images",
help=ARGS_HELP["--remove-all-images"])
gid3.add_argument("--write-images", dest="write_images_dir",
metavar="DIR", type=DirArg,
help=ARGS_HELP["--write-images"])
gid3.add_argument("--add-object", action="append", type=ObjectArg,
dest="objects", default=[],
metavar="OBJ_PATH:MIME-TYPE[:DESCRIPTION[:FILENAME]]",
help=ARGS_HELP["--add-object"])
gid3.add_argument("--remove-object", action="append", type=UnicodeArg,
dest="remove_object", default=[],
metavar="DESCRIPTION",
help=ARGS_HELP["--remove-object"])
gid3.add_argument("--write-objects", action="store",
dest="write_objects_dir", metavar="DIR", default=None,
help=ARGS_HELP["--write-objects"])
gid3.add_argument("--remove-all-objects", action="store_true",
dest="remove_all_objects",
help=ARGS_HELP["--remove-all-objects"])
gid3.add_argument("--add-popularity", action="append",
type=PopularityArg, dest="popularities", default=[],
metavar="EMAIL:RATING[:PLAY_COUNT]",
help=ARGS_HELP["--add-popularty"])
gid3.add_argument("--remove-popularity", action="append", type=str,
dest="remove_popularity", default=[],
metavar="EMAIL",
help=ARGS_HELP["--remove-popularity"])
gid3.add_argument("--remove-v1", action="store_true", dest="remove_v1",
default=False, help=ARGS_HELP["--remove-v1"])
gid3.add_argument("--remove-v2", action="store_true", dest="remove_v2",
default=False, help=ARGS_HELP["--remove-v2"])
gid3.add_argument("--remove-all", action="store_true", default=False,
dest="remove_all", help=ARGS_HELP["--remove-all"])
gid3.add_argument("--remove-frame", action="append", default=[],
dest="remove_fids", metavar="FID", type=FidArg,
help=ARGS_HELP["--remove-frame"])
# 'True' means 'apply default max_padding, but only if saving anyhow'
gid3.add_argument("--max-padding", type=int, dest="max_padding",
default=True, metavar="NUM_BYTES",
help=ARGS_HELP["--max-padding"])
gid3.add_argument("--no-max-padding", dest="max_padding",
action="store_const", const=None,
help=ARGS_HELP["--no-max-padding"])
_encodings = ["latin1", "utf8", "utf16", "utf16-be"]
gid3.add_argument("--encoding", dest="text_encoding", default=None,
choices=_encodings, metavar='|'.join(_encodings),
help=ARGS_HELP["--encoding"])
# Misc options
gid4 = arg_parser.add_argument_group("Misc options")
gid4.add_argument("--force-update", action="store_true", default=False,
dest="force_update", help=ARGS_HELP["--force-update"])
gid4.add_argument("-v", "--verbose", action="store_true",
dest="verbose", help=ARGS_HELP["--verbose"])
gid4.add_argument("--preserve-file-times", action="store_true",
dest="preserve_file_time",
help=ARGS_HELP["--preserve-file-times"])
def handleFile(self, f):
parse_version = self.args.tag_version
super(ClassicPlugin, self).handleFile(f, tag_version=parse_version)
if not self.audio_file:
return
self.terminal_width = getTtySize()[1]
self.printHeader(f)
printMsg("-" * self.terminal_width)
if self.handleRemoves(self.audio_file.tag):
# Reload after removal
super(ClassicPlugin, self).handleFile(f, tag_version=parse_version)
if not self.audio_file:
return
new_tag = False
if not self.audio_file.tag:
self.audio_file.initTag(version=parse_version)
new_tag = True
try:
save_tag = (self.handleEdits(self.audio_file.tag) or
self.handlePadding(self.audio_file.tag) or
self.args.force_update or self.args.convert_version)
except ValueError as ex:
printError(str(ex))
return
self.printAudioInfo(self.audio_file.info)
if not save_tag and new_tag:
printError("No ID3 %s tag found!" %
id3.versionToString(self.args.tag_version))
return
self.printTag(self.audio_file.tag)
if save_tag:
# Use current tag version unless a convert was supplied
version = (self.args.convert_version or
self.audio_file.tag.version)
printWarning("Writing ID3 version %s" %
id3.versionToString(version))
# DEFAULT_MAX_PADDING is not set up as argument default,
# because we don't want to rewrite the file if the user
# did not trigger that explicitly:
max_padding = self.args.max_padding
if max_padding is True:
max_padding = DEFAULT_MAX_PADDING
self.audio_file.tag.save(
version=version, encoding=self.args.text_encoding,
backup=self.args.backup,
preserve_file_time=self.args.preserve_file_time,
max_padding=max_padding)
if self.args.rename_pattern:
# Handle file renaming.
from eyed3.id3.tag import TagTemplate
template = TagTemplate(self.args.rename_pattern)
name = template.substitute(self.audio_file.tag, zeropad=True)
orig = self.audio_file.path
try:
self.audio_file.rename(name)
printWarning("Renamed '%s' to '%s'" %
(orig, self.audio_file.path))
except IOError as ex:
printError(str(ex))
printMsg("-" * self.terminal_width)
def printHeader(self, file_path):
file_len = len(file_path)
from stat import ST_SIZE
file_size = os.stat(file_path)[ST_SIZE]
size_str = utils.formatSize(file_size)
size_len = len(size_str) + 5
if file_len + size_len >= self.terminal_width:
file_path = "..." + file_path[-(75 - size_len):]
file_len = len(file_path)
pat_len = self.terminal_width - file_len - size_len
printMsg("%s%s%s[ %s ]%s" %
(boldText(file_path, c=HEADER_COLOR()),
HEADER_COLOR(), " " * pat_len, size_str, Fore.RESET))
def printAudioInfo(self, info):
if isinstance(info, mp3.Mp3AudioInfo):
printMsg(boldText("Time: ") +
"%s\tMPEG%d, Layer %s\t[ %s @ %s Hz - %s ]" %
(utils.formatTime(info.time_secs),
info.mp3_header.version,
"I" * info.mp3_header.layer,
info.bit_rate_str,
info.mp3_header.sample_freq, info.mp3_header.mode))
printMsg("-" * self.terminal_width)
def _getDefaultNameForObject(self, obj_frame, suffix=""):
if obj_frame.filename:
name_str = obj_frame.filename
else:
name_str = obj_frame.description
name_str += ".%s" % obj_frame.mime_type.split("/")[1]
if suffix:
name_str += suffix
return name_str
def printTag(self, tag):
if isinstance(tag, id3.Tag):
if self.args.quiet:
printMsg("ID3 %s: %d frames" %
(id3.versionToString(tag.version),
len(tag.frame_set)))
return
printMsg("ID3 %s:" % id3.versionToString(tag.version))
artist = tag.artist if tag.artist else u""
title = tag.title if tag.title else u""
album = tag.album if tag.album else u""
printMsg("%s: %s" % (boldText("title"), title))
printMsg("%s: %s" % (boldText("artist"), artist))
printMsg("%s: %s" % (boldText("album"), album))
if tag.album_artist:
printMsg("%s: %s" % (boldText("album artist"),
tag.album_artist))
if tag.composer:
printMsg("%s: %s" % (boldText("composer"), tag.composer))
for date, date_label in [
(tag.release_date, "release date"),
(tag.original_release_date, "original release date"),
(tag.recording_date, "recording date"),
(tag.encoding_date, "encoding date"),
(tag.tagging_date, "tagging date"),
]:
if date:
printMsg("%s: %s" % (boldText(date_label), str(date)))
track_str = ""
(track_num, track_total) = tag.track_num
if track_num is not None:
track_str = str(track_num)
if track_total:
track_str += "/%d" % track_total
genre = tag._getGenre(id3_std=not self.args.non_std_genres)
genre_str = "%s: %s (id %s)" % (boldText("genre"),
genre.name,
str(genre.id)) if genre else u""
printMsg("%s: %s\t\t%s" % (boldText("track"), track_str, genre_str))
disc_str = ""
(num, total) = tag.disc_num
if num is not None:
disc_str = str(num)
if total:
disc_str += "/%d" % total
printMsg("%s: %s" % (boldText("disc"), disc_str))
# PCNT
play_count = tag.play_count
if tag.play_count is not None:
printMsg("%s %d" % (boldText("Play Count:"), play_count))
# POPM
for popm in tag.popularities:
printMsg("%s [email: %s] [rating: %d] [play count: %d]" %
(boldText("Popularity:"), popm.email, popm.rating,
popm.count))
# TBPM
bpm = tag.bpm
if bpm is not None:
printMsg("%s %d" % (boldText("BPM:"), bpm))
# TPUB
pub = tag.publisher
if pub is not None:
printMsg("%s %s" % (boldText("Publisher/label:"), pub))
# UFID
for ufid in tag.unique_file_ids:
printMsg("%s [%s] : %s" %
(boldText("Unique File ID:"), ufid.owner_id,
ufid.uniq_id.decode("unicode_escape")))
# COMM
for c in tag.comments:
printMsg("%s: [Description: %s] [Lang: %s]\n%s" %
(boldText("Comment"), c.description or "",
c.lang.decode("ascii") or "", c.text or ""))
# USLT
for l in tag.lyrics:
printMsg("%s: [Description: %s] [Lang: %s]\n%s" %
(boldText("Lyrics"), l.description or u"",
l.lang.decode("ascii") or "", l.text))
# TXXX
for f in tag.user_text_frames:
printMsg("%s: [Description: %s]\n%s" %
(boldText("UserTextFrame"), f.description, f.text))
# URL frames
for desc, url in (("Artist URL", tag.artist_url),
("Audio source URL", tag.audio_source_url),
("Audio file URL", tag.audio_file_url),
("Internet radio URL", tag.internet_radio_url),
("Commercial URL", tag.commercial_url),
("Payment URL", tag.payment_url),
("Publisher URL", tag.publisher_url),
("Copyright URL", tag.copyright_url),
):
if url:
printMsg("%s: %s" % (boldText(desc), url))
# user url frames
for u in tag.user_url_frames:
printMsg("%s [Description: %s]: %s" % (u.id, u.description,
u.url))
# APIC
for img in tag.images:
if img.mime_type not in ImageFrame.URL_MIME_TYPE_VALUES:
printMsg("%s: [Size: %d bytes] [Type: %s]" %
(boldText(img.picTypeToString(img.picture_type) +
" Image"),
len(img.image_data),
img.mime_type))
printMsg("Description: %s" % img.description)
printMsg("")
if self.args.write_images_dir:
img_path = "%s%s" % (self.args.write_images_dir, os.sep)
if not os.path.isdir(img_path):
raise IOError("Directory does not exist: %s" %
img_path)
img_file = makeUniqueFileName(
os.path.join(img_path, img.makeFileName()))
printWarning("Writing %s..." % img_file)
with open(img_file, "wb") as fp:
fp.write(img.image_data)
else:
printMsg("%s: [Type: %s] [URL: %s]" %
(boldText(img.picTypeToString(img.picture_type) +
" Image"),
img.mime_type, img.image_url))
printMsg("Description: %s" % img.description)
printMsg("")
# GOBJ
for obj in tag.objects:
printMsg("%s: [Size: %d bytes] [Type: %s]" %
(boldText("GEOB"), len(obj.object_data),
obj.mime_type))
printMsg("Description: %s" % obj.description)
printMsg("Filename: %s" % obj.filename)
printMsg("\n")
if self.args.write_objects_dir:
obj_path = "%s%s" % (self.args.write_objects_dir, os.sep)
if not os.path.isdir(obj_path):
raise IOError("Directory does not exist: %s" % obj_path)
obj_file = self._getDefaultNameForObject(obj)
count = 1
while os.path.exists(os.path.join(obj_path, obj_file)):
obj_file = self._getDefaultNameForObject(obj,
str(count))
count += 1
printWarning("Writing %s..." % os.path.join(obj_path,
obj_file))
with open(os.path.join(obj_path, obj_file), "wb") as fp:
fp.write(obj.object_data)
# PRIV
for p in tag.privates:
printMsg("%s: [Data: %d bytes]" % (boldText("PRIV"),
len(p.data)))
printMsg("Owner Id: %s" % p.owner_id.decode("ascii"))
# MCDI
if tag.cd_id:
printMsg("\n%s: [Data: %d bytes]" % (boldText("MCDI"),
len(tag.cd_id)))
# USER
if tag.terms_of_use:
printMsg("\nTerms of Use (%s): %s" % (boldText("USER"),
tag.terms_of_use))
# --verbose
if self.args.verbose:
printMsg("-" * self.terminal_width)
printMsg("%d ID3 Frames:" % len(tag.frame_set))
for fid in tag.frame_set:
frames = tag.frame_set[fid]
num_frames = len(frames)
count = " x %d" % num_frames if num_frames > 1 else ""
total_bytes = 0
if not tag.isV1():
total_bytes = sum(
tuple(frame.header.data_size + frame.header.size
for frame in frames if frame.header))
else:
total_bytes = 30
if total_bytes:
printMsg("%s%s (%d bytes)" % (fid.decode("ascii"),
count, total_bytes))
printMsg("%d bytes unused (padding)" %
(tag.file_info.tag_padding_size, ))
else:
raise TypeError("Unknown tag type: " + str(type(tag)))
def handleRemoves(self, tag):
remove_version = 0
status = False
rm_str = ""
if self.args.remove_all:
remove_version = id3.ID3_ANY_VERSION
rm_str = "v1.x and/or v2.x"
elif self.args.remove_v1:
remove_version = id3.ID3_V1
rm_str = "v1.x"
elif self.args.remove_v2:
remove_version = id3.ID3_V2
rm_str = "v2.x"
if remove_version:
status = id3.Tag.remove(
tag.file_info.name, remove_version,
preserve_file_time=self.args.preserve_file_time)
printWarning("Removing ID3 %s tag: %s" %
(rm_str, "SUCCESS" if status else "FAIL"))
return status
def handlePadding(self, tag):
max_padding = self.args.max_padding
if max_padding is None or max_padding is True:
return False
padding = tag.file_info.tag_padding_size
needs_change = padding > max_padding
return needs_change
def handleEdits(self, tag):
retval = False
# --remove-all-*, Handling removes first means later options are still
# applied
for what, arg, fid in (("comments", self.args.remove_all_comments,
id3.frames.COMMENT_FID),
("lyrics", self.args.remove_all_lyrics,
id3.frames.LYRICS_FID),
("images", self.args.remove_all_images,
id3.frames.IMAGE_FID),
("objects", self.args.remove_all_objects,
id3.frames.OBJECT_FID),
):
if arg and tag.frame_set[fid]:
printWarning("Removing all %s..." % what)
del tag.frame_set[fid]
retval = True
# --artist, --title, etc. All common/simple text frames.
for (what, setFunc) in (
("artist", partial(tag._setArtist, self.args.artist)),
("album", partial(tag._setAlbum, self.args.album)),
("album artist", partial(tag._setAlbumArtist,
self.args.album_artist)),
("title", partial(tag._setTitle, self.args.title)),
("genre", partial(tag._setGenre, self.args.genre,
id3_std=not self.args.non_std_genres)),
("release date", partial(tag._setReleaseDate,
self.args.release_date)),
("original release date", partial(tag._setOrigReleaseDate,
self.args.orig_release_date)),
("recording date", partial(tag._setRecordingDate,
self.args.recording_date)),
("encoding date", partial(tag._setEncodingDate,
self.args.encoding_date)),
("tagging date", partial(tag._setTaggingDate,
self.args.tagging_date)),
("beats per minute", partial(tag._setBpm, self.args.bpm)),
("publisher", partial(tag._setPublisher, self.args.publisher)),
("composer", partial(tag._setComposer, self.args.composer)),
):
if setFunc.args[0] is not None:
printWarning("Setting %s: %s" % (what, setFunc.args[0]))
setFunc()
retval = True
def _checkNumberedArgTuples(curr, new):
n = None
if new not in [(None, None), curr]:
n = [None] * 2
for i in (0, 1):
if new[i] == 0:
n[i] = None
else:
n[i] = new[i] or curr[i]
n = tuple(n)
# Returing None means do nothing, (None, None) would clear both vals
return n
# --track, --track-total
track_info = _checkNumberedArgTuples(tag.track_num,
(self.args.track,
self.args.track_total))
if track_info is not None:
printWarning("Setting track info: %s" % str(track_info))
tag.track_num = track_info
retval = True
# --track-offset
if self.args.track_offset:
offset = self.args.track_offset
tag.track_num = (tag.track_num[0] + offset, tag.track_num[1])
printWarning("%s track info by %d: %d" %
("Incrementing" if offset > 0 else "Decrementing",
offset, tag.track_num[0]))
retval = True
# --disc-num, --disc-total
disc_info = _checkNumberedArgTuples(tag.disc_num,
(self.args.disc_num,
self.args.disc_total))
if disc_info is not None:
printWarning("Setting disc info: %s" % str(disc_info))
tag.disc_num = disc_info
retval = True
# -Y, --release-year
if self.args.release_year is not None:
# empty string means clean, None means not given
year = self.args.release_year
printWarning("Setting release year: %s" % year)
tag.release_date = int(year) if year else None
retval = True
# -c , simple comment
if self.args.simple_comment:
# Just add it as if it came in --add-comment
self.args.comments.append((self.args.simple_comment, u"",
id3.DEFAULT_LANG))
# --remove-comment, remove-lyrics, --remove-image, --remove-object
for what, arg, accessor in (("comment", self.args.remove_comment,
tag.comments),
("lyrics", self.args.remove_lyrics,
tag.lyrics),
("image", self.args.remove_image,
tag.images),
("object", self.args.remove_object,
tag.objects),
):
for vals in arg:
if type(vals) in compat.StringTypes:
frame = accessor.remove(vals)
else:
frame = accessor.remove(*vals)
if frame:
printWarning("Removed %s %s" % (what, str(vals)))
retval = True
else:
printError("Removing %s failed, %s not found" %
(what, str(vals)))
# --add-comment, --add-lyrics
for what, arg, accessor in (("comment", self.args.comments,
tag.comments),
("lyrics", self.args.lyrics, tag.lyrics),
):
for text, desc, lang in arg:
printWarning("Setting %s: %s/%s" %
(what, desc, compat.unicode(lang, "ascii")))
accessor.set(text, desc, compat.b(lang))
retval = True
# --play-count
playcount_arg = self.args.play_count
if playcount_arg:
increment, pc = playcount_arg
if increment:
printWarning("Increment play count by %d" % pc)
tag.play_count += pc
else:
printWarning("Setting play count to %d" % pc)
tag.play_count = pc
retval = True
# --add-popularty
for email, rating, play_count in self.args.popularities:
tag.popularities.set(email.encode("latin1"), rating, play_count)
retval = True
# --remove-popularity
for email in self.args.remove_popularity:
popm = tag.popularities.remove(email.encode("latin1"))
if popm:
retval = True
# --text-frame, --url-frame
for what, arg, setter in (
("text frame", self.args.text_frames, tag.setTextFrame),
("url frame", self.args.url_frames, tag._setUrlFrame),
):
for fid, text in arg:
if text:
printWarning("Setting %s %s to '%s'" % (fid, what, text))
else:
printWarning("Removing %s %s" % (fid, what))
setter(fid, text)
retval = True
# --user-text-frame, --user-url-frame
for what, arg, accessor in (
("user text frame", self.args.user_text_frames,
tag.user_text_frames),
("user url frame", self.args.user_url_frames,
tag.user_url_frames),
):
for desc, text in arg:
if text:
printWarning("Setting '%s' %s to '%s'" % (desc, what, text))
accessor.set(text, desc)
else:
printWarning("Removing '%s' %s" % (desc, what))
accessor.remove(desc)
retval = True
# --add-image
for img_path, img_type, img_mt, img_desc in self.args.images:
assert(img_path)
printWarning("Adding image %s" % img_path)
if img_mt not in ImageFrame.URL_MIME_TYPE_VALUES:
with open(img_path, "rb") as img_fp:
tag.images.set(img_type, img_fp.read(), img_mt, img_desc)
else:
tag.images.set(img_type, None, None, img_desc, img_url=img_path)
retval = True
# --add-object
for obj_path, obj_mt, obj_desc, obj_fname in self.args.objects or []:
assert(obj_path)
printWarning("Adding object %s" % obj_path)
with open(obj_path, "rb") as obj_fp:
tag.objects.set(obj_fp.read(), obj_mt, obj_desc, obj_fname)
retval = True
# --unique-file-id
for arg in self.args.unique_file_ids:
owner_id, id = arg
if not id:
if tag.unique_file_ids.remove(owner_id):
printWarning("Removed unique file ID '%s'" % owner_id)
retval = True
else:
printWarning("Unique file ID '%s' not found" % owner_id)
else:
tag.unique_file_ids.set(id, owner_id.encode("latin1"))
printWarning("Setting unique file ID '%s' to %s" %
(owner_id, id))
retval = True
# --remove-frame
for fid in self.args.remove_fids:
assert(isinstance(fid, compat.BytesType))
if fid in tag.frame_set:
del tag.frame_set[fid]
retval = True
return retval
def _getTemplateKeys():
keys = list(id3.TagTemplate("")._makeMapping(None, False).keys())
keys.sort()
return ", ".join(["$%s" % v for v in keys])
ARGS_HELP = {
"--artist": "Set the artist name.",
"--album": "Set the album name.",
"--album-artist": u"Set the album artist name. '%s', for example. "
"Another example is collaborations when the "
"track artist might be 'Eminem featuring Proof' "
"the album artist would be 'Eminem'." %
core.VARIOUS_ARTISTS,
"--title": "Set the track title.",
"--track": "Set the track number. Use 0 to clear.",
"--track-total": "Set total number of tracks. Use 0 to clear.",
"--disc-num": "Set the disc number. Use 0 to clear.",
"--disc-total": "Set total number of discs in set. Use 0 to clear.",
"--genre": "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 --plugin=genres' for a list of "
"standard ID3 genre names/ids.",
"--non-std-genres": "Disables certain ID3 genre standards, such as the "
"mapping of numeric value to genre names.",
"--release-year": "Set the year the track was released. Use the date "
"options for more precise values or dates other "
"than release.",
"--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.",
"--v2": "Only read/write ID3 v2.x tags. This is the default unless "
"the file only contains a v1 tag.",
"--to-v1.1": "Convert the file's tag to ID3 v1.1 (Or 1.0 if there is "
"no track number)",
"--to-v2.3": "Convert the file's tag to ID3 v2.3",
"--to-v2.4": "Convert the file's tag to ID3 v2.4",
"--release-date": "Set the date the track/album was released",
"--orig-release-date": "Set the original date the track/album was "
"released",
"--recording-date": "Set the date the track/album was recorded",
"--encoding-date": "Set the date the file was encoded",
"--tagging-date": "Set the date the file was tagged",
"--comment": "Set a comment. In ID3 tags this is the comment with "
"an empty description. See --add-comment to add multiple "
"comment frames.",
"--add-comment":
"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 "
"default DESCRIPTION is '' and the default language code is '%s'." %
compat.unicode(id3.DEFAULT_LANG, "ascii"),
"--remove-comment": "Remove comment matching DESCRIPTION and LANG. "
"The default language code is '%s'." %
compat.unicode(id3.DEFAULT_LANG, "ascii"),
"--remove-all-comments": "Remove all comments from the tag.",
"--add-lyrics":
"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 default DESCRIPTION is '' and the default language code is "
"'%s'." % compat.unicode(id3.DEFAULT_LANG, "ascii"),
"--remove-lyrics": "Remove lyrics matching DESCRIPTION and LANG. "
"The default language code is '%s'." %
compat.unicode(id3.DEFAULT_LANG, "ascii"),
"--remove-all-lyrics": "Remove all lyrics from the tag.",
"--publisher": "Set the publisher/label name",
"--play-count": "Set the number of times played counter. If the "
"argument value begins with '+' the tag's play count "
"is incremented by N, otherwise the value is set to "
"exactly N.",
"--bpm": "Set the beats per minute value.",
"--text-frame": "Set the value of a text frame. To remove the "
"frame, specify an empty value. For example, "
"--text-frame='TDRC:'",
"--user-text-frame": "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:'",
"--url-frame": "Set the value of a URL frame. To remove the frame, "
"specify an empty value. e.g., --url-frame='WCOM:'",
"--user-url-frame": "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:'",
"--add-image": "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 DESCRIPTION is ''. If PATH begins "
"with 'http[s]://' then it is interpreted as a URL "
"instead of a file containing image data. The TYPE must "
"be one of the following: %s."
% (", ".join([ImageFrame.picTypeToString(t)
for t in range(ImageFrame.MIN_TYPE,
ImageFrame.MAX_TYPE + 1)]),
),
"--remove-image": "Remove image matching DESCRIPTION.",
"--remove-all-images": "Remove all images from the tag",
"--write-images": "Causes all attached images (APIC frames) to be "
"written to the specified directory.",
"--add-object": "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 DESCRIPTION is ''.",
"--remove-object": "Remove object matching DESCRIPTION.",
"--remove-all-objects": "Remove all objects from the tag",
"--write-objects": "Causes all attached objects (GEOB frames) to be "
"written to the specified directory.",
"--add-popularty": "Adds a pupularity metric. There may be multiples "
"popularity values, but each must have a unique "
"email address component. The rating is a number "
"between 0 (worst) and 255 (best). The play count "
"is optional, and defaults to 0, since there is "
"already a dedicated play count frame.",
"--remove-popularity": "Removes the popularity frame with the "
"specified email key.",
"--remove-v1": "Remove ID3 v1.x tag.",
"--remove-v2": "Remove ID3 v2.x tag.",
"--remove-all": "Remove ID3 v1.x and v2.x tags.",
"--remove-frame": "Remove all frames with the given ID. This option "
"may be specified multiple times.",
"--max-padding": "Shrink file if tag padding (unused space) exceeds "
"the given number of bytes. "
"(Useful e.g. after removal of large cover art.) "
"Default is 64 KiB, file will be rewritten with "
"default padding (1 KiB) or max padding, whichever "
"is smaller.",
"--no-max-padding": "Disable --max-padding altogether.",
"--force-update": "Rewrite the tag despite there being no edit "
"options.",
"--verbose": "Show all available tag data",
"--unique-file-id": "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.",
"--encoding": "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 (e.g. --artist, "
"--title, etc.) or --force-update is specified.",
"--rename": "Rename file (the extension is not affected) "
"based on data in the tag using substitution "
"variables: " + _getTemplateKeys(),
"--preserve-file-times": "When writing, do not update file "
"modification times.",
"--track-offset": "Increment/decrement the track number by [-]N. "
"This option is applied after --track=N is set.",
"--composer": "Set the composer's name.",
}
eyeD3-0.8.4/src/eyed3/plugins/nfo.py 0000644 0001750 0001750 00000013014 13153052737 017760 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2009-2012 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
from __future__ import print_function
import time
import eyed3
from eyed3.utils.console import printMsg
from eyed3.utils import formatSize, formatTime
from eyed3.id3 import versionToString
from eyed3.plugins import LoaderPlugin
class NfoPlugin(LoaderPlugin):
NAMES = ["nfo"]
SUMMARY = u"Create NFO files for each directory scanned."
DESCRIPTION = u"Each directory scanned is treated as an album and a "\
"`NFO `_ file is "\
"written to standard out.\n\n"\
"NFO files are often found in music archives."
def __init__(self, arg_parser):
super(NfoPlugin, self).__init__(arg_parser)
self.albums = {}
def handleFile(self, f):
super(NfoPlugin, self).handleFile(f)
if self.audio_file and self.audio_file.tag:
tag = self.audio_file.tag
album = tag.album
if album and album not in self.albums:
self.albums[album] = []
self.albums[album].append(self.audio_file)
elif album:
self.albums[album].append(self.audio_file)
def handleDone(self):
if not self.albums:
printMsg(u"No albums found.")
return
for album in self.albums:
audio_files = self.albums[album]
if not audio_files:
continue
audio_files.sort(key=lambda af: (af.tag.track_num[0] or 999,
af.tag.track_num[1] or 999))
max_title_len = 0
avg_bitrate = 0
encoder_info = ''
for audio_file in audio_files:
tag = audio_file.tag
# Compute maximum title length
title_len = len(tag.title or u"")
if title_len > max_title_len:
max_title_len = title_len
# Compute average bitrate
avg_bitrate += audio_file.info.bit_rate[1]
# Grab the last lame version in case not all files have one
if "encoder_version" in audio_file.info.lame_tag:
version = audio_file.info.lame_tag['encoder_version']
encoder_info = (version or encoder_info)
avg_bitrate = avg_bitrate / len(audio_files)
printMsg("")
printMsg("Artist : %s" % audio_files[0].tag.artist)
printMsg("Album : %s" % album)
printMsg("Released : %s" %
(audio_files[0].tag.original_release_date or
audio_files[0].tag.release_date))
printMsg("Recorded : %s" % audio_files[0].tag.recording_date)
genre = audio_files[0].tag.genre
if genre:
genre = genre.name
else:
genre = ""
printMsg("Genre : %s" % genre)
printMsg("")
printMsg("Source : ")
printMsg("Encoder : %s" % encoder_info)
printMsg("Codec : mp3")
printMsg("Bitrate : ~%s K/s @ %s Hz, %s" %
(avg_bitrate, audio_files[0].info.sample_freq,
audio_files[0].info.mode))
printMsg("Tag : ID3 %s" %
versionToString(audio_files[0].tag.version))
printMsg("")
printMsg("Ripped By: ")
printMsg("")
printMsg("Track Listing")
printMsg("-------------")
count = 0
total_time = 0
total_size = 0
for audio_file in audio_files:
tag = audio_file.tag
count += 1
title = tag.title or u""
title_len = len(title)
padding = " " * ((max_title_len - title_len) + 3)
time_secs = audio_file.info.time_secs
total_time += time_secs
total_size += audio_file.info.size_bytes
zero_pad = "0" * (len(str(len(audio_files))) - len(str(count)))
printMsg(" %s%d. %s%s(%s)" %
(zero_pad, count, title, padding,
formatTime(time_secs)))
printMsg("")
printMsg("Total play time : %s" %
formatTime(total_time))
printMsg("Total size : %s" %
formatSize(total_size))
printMsg("")
printMsg("=" * 78)
printMsg(".NFO file created with eyeD3 %s on %s" %
(eyed3.version, time.asctime()))
printMsg("For more information about eyeD3 go to %s" %
"http://eyeD3.nicfit.net/")
printMsg("=" * 78)
eyeD3-0.8.4/src/eyed3/plugins/xep_118.py 0000644 0001750 0001750 00000004013 13061344514 020355 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2009 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
import os
from eyed3 import compat
from eyed3.plugins import LoaderPlugin
from eyed3.utils.console import printMsg
class Xep118Plugin(LoaderPlugin):
NAMES = ["xep-118"]
SUMMARY = u"Outputs all tags in XEP-118 XML format. "\
"(see: http://xmpp.org/extensions/xep-0118.html)"
def handleFile(self, f):
super(Xep118Plugin, self).handleFile(f)
if self.audio_file and self.audio_file.tag:
xml = self.getXML(self.audio_file)
printMsg(xml)
def getXML(self, audio_file):
tag = audio_file.tag
xml = u"\n"
if tag.artist:
xml += " %s\n" % tag.artist
if tag.title:
xml += " %s\n" % tag.title
if tag.album:
xml += " %s\n" % tag.album
xml += (" \n" %
compat.unicode(os.path.abspath(audio_file.path)))
if audio_file.info:
xml += " %s\n" % \
compat.unicode(audio_file.info.time_secs)
xml += "\n"
return xml
eyeD3-0.8.4/src/eyed3/plugins/itunes.py 0000644 0001750 0001750 00000005622 13061344514 020506 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2012 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
from __future__ import print_function
from eyed3.plugins import LoaderPlugin
from eyed3.id3.apple import PCST, PCST_FID, WFED, WFED_FID
class Podcast(LoaderPlugin):
NAMES = ['itunes-podcast']
SUMMARY = u"Adds (or removes) the tags necessary for Apple iTunes to "\
"identify the file as a podcast."
def __init__(self, arg_parser):
super(Podcast, self).__init__(arg_parser)
g = self.arg_group
g.add_argument("--add", action="store_true",
help="Add the podcast frames.")
g.add_argument("--remove", action="store_true",
help="Remove the podcast frames.")
def _add(self, tag):
save = False
if PCST_FID not in tag.frame_set:
tag.frame_set[PCST_FID] = PCST()
save = True
if WFED_FID not in tag.frame_set:
tag.frame_set[WFED_FID] = WFED(u"http://eyeD3.nicfit.net/")
save = True
if save:
print("\tAdding...")
tag.save(backup=self.args.backup)
self._printStatus(tag)
def _remove(self, tag):
save = False
for fid in [PCST_FID, WFED_FID]:
try:
del tag.frame_set[fid]
save = True
except KeyError:
continue
if save:
print("\tRemoving...")
tag.save(backup=self.args.backup)
self._printStatus(tag)
def _printStatus(self, tag):
status = ":-("
if PCST_FID in tag.frame_set:
status = ":-/"
if WFED_FID in tag.frame_set:
status = ":-)"
print("\tiTunes podcast? %s" % status)
def handleFile(self, f):
super(Podcast, self).handleFile(f)
if self.audio_file and self.audio_file.tag:
print(f)
tag = self.audio_file.tag
self._printStatus(tag)
if self.args.remove:
self._remove(self.audio_file.tag)
elif self.args.add:
self._add(self.audio_file.tag)
eyeD3-0.8.4/src/eyed3/plugins/art.py 0000644 0001750 0001750 00000020612 13061344514 017761 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2014 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
from __future__ import print_function
import os
import hashlib
from eyed3.utils import art
from eyed3 import compat
from eyed3.utils import guessMimetype
from eyed3.utils import makeUniqueFileName
from eyed3.plugins import LoaderPlugin
from eyed3.utils.console import printMsg, printWarning
from eyed3.id3.frames import ImageFrame
try:
import PIL # noqa
_have_PIL = True
except ImportError:
_have_PIL = False
DESCR_FNAME_PREFIX = "filename: "
class ArtFile(object):
def __init__(self, file_path):
self.art_type = art.matchArtFile(file_path)
self.file_path = file_path
self.id3_art_type = (art.TO_ID3_ART_TYPES[self.art_type][0]
if self.art_type else None)
self._img_data = None
self._mime_type = None
@property
def image_data(self):
if self._img_data:
return self._img_data
with open(self.file_path, "rb") as f:
self._img_data = f.read()
return self._img_data
@property
def mime_type(self):
if self._mime_type:
return self._mime_type
self._mime_type = guessMimetype(self.file_path)
return self._mime_type
class ArtPlugin(LoaderPlugin):
SUMMARY = u"Art for albums, artists, etc."
DESCRIPTION = u""
NAMES = ["art"]
def __init__(self, arg_parser):
super(ArtPlugin, self).__init__(arg_parser, cache_files=True,
track_images=True)
self._retval = 0
g = self.arg_group
g.add_argument("--update-files", action="store_true",
help="Write art files from tag images.")
g.add_argument("--update-tags", action="store_true",
help="Write tag image from art files.")
def start(self, args, config):
if args.update_files and args.update_tags:
# Not using add_mutually_exclusive_group from argparse because
# the options belong to the plugin opts group (self.arg_group)
raise StopIteration("The --update-tags and --update-files options "
"are mutually exclusive, use only one at a "
"time.")
super(ArtPlugin, self).start(args, config)
def handleDirectory(self, d, _):
global md5_file_cache
md5_file_cache.clear()
try:
if not self._file_cache:
print("%s: nothing to do." % d)
return
printMsg("\nProcessing %s" % d)
# File images
dir_art = []
for img_file in self._dir_images:
img_base = os.path.basename(img_file)
art_file = ArtFile(img_file)
try:
pil_img = pilImage(img_file)
except IOError as ex:
printWarning(compat.unicode(ex))
continue
if art_file.art_type:
printMsg("file %s: %s\n\t%s" % (img_base, art_file.art_type,
pilImageDetails(pil_img)))
dir_art.append(art_file)
else:
printMsg("file %s: unknown (ignored)" % img_base)
if not dir_art:
print("No art files found.")
self._retval += 1
# Tag images
all_tags = sorted([f.tag for f in self._file_cache],
key=lambda x: x.file_info.name)
for tag in all_tags:
file_base = os.path.basename(tag.file_info.name)
for img in tag.images:
try:
pil_img = pilImage(img)
except IOError as ex:
printWarning(compat.unicode(ex))
continue
if img.picture_type in art.FROM_ID3_ART_TYPES:
img_type = art.FROM_ID3_ART_TYPES[img.picture_type]
printMsg("tag %s: %s (Description: %s)\n\t%s" %
(file_base, img_type, img.description,
pilImageDetails(pil_img)))
if self.args.update_files:
assert(not self.args.update_tags)
path = os.path.dirname(tag.file_info.name)
if img.description.startswith(DESCR_FNAME_PREFIX):
# Use filename from Image description
fname = img.description[
len(DESCR_FNAME_PREFIX):].strip()
fname = os.path.splitext(fname)[0]
else:
fname = art.FILENAMES[img_type][0].strip("*")
fname = img.makeFileName(name=fname)
if (md5File(os.path.join(path, fname)) ==
md5Data(img.image_data)):
printMsg("Skipping writing of %s, file "
"exists and is exactly the same." %
img_file)
else:
img_file = makeUniqueFileName(
os.path.join(path, fname),
uniq=img.description)
printWarning("Writing %s..." % img_file)
with open(img_file, "wb") as fp:
fp.write(img.image_data)
else:
printMsg("tag %s: unhandled image type %d (ignored)" %
(file_base, img.picture_type))
# Copy file art to tags.
if self.args.update_tags:
assert(not self.args.update_files)
for tag in all_tags:
for art_file in dir_art:
descr = "filename: %s" % \
os.path.splitext(
os.path.basename(art_file.file_path))[0]
tag.images.set(art_file.id3_art_type,
art_file.image_data, art_file.mime_type,
description=descr)
tag.save()
finally:
# Cleans up...
super(ArtPlugin, self).handleDirectory(d, _)
def handleDone(self):
return self._retval
def pilImage(source):
if not _have_PIL:
return None
from PIL import Image
if isinstance(source, ImageFrame):
return Image.open(compat.StringIO(source.image_data))
else:
return Image.open(source)
def pilImageDetails(img):
if not img:
return ''
return "[%dx%d %s md5:%s]" % (img.size[0], img.size[1],
img.format.lower(),
md5Data(img.tobytes()))
def md5Data(data):
md5 = hashlib.md5()
md5.update(data)
return md5.hexdigest()
md5_file_cache = {}
def md5File(file_name):
'''Compute md5 hash for contents of ``file_name``.'''
global md5_file_cache
if file_name in md5_file_cache:
return md5_file_cache[file_name]
md5 = hashlib.md5()
try:
with open(file_name, "rb") as f:
md5.update(f.read())
md5_file_cache[file_name] = md5.hexdigest()
return md5_file_cache[file_name]
except IOError:
return None
eyeD3-0.8.4/src/eyed3/plugins/display.py 0000644 0001750 0001750 00000120463 13203723345 020646 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2014-2016 Sebastian Patschorke
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
from __future__ import print_function
import os
import re
import abc
from argparse import ArgumentTypeError
from eyed3 import id3, compat
from eyed3.utils import console, formatSize, formatTime
from eyed3.plugins import LoaderPlugin
try:
from eyed3.plugins._display_parser import DisplayPatternParser
_have_grako = True
except ImportError:
_have_grako = False
class Pattern(object):
def __init__(self, text=None, sub_patterns=None):
self.__text = text
self.__sub_patterns = sub_patterns
def output_for(self, audio_file):
output = u""
for sub_pattern in self.sub_patterns or []:
output += sub_pattern.output_for(audio_file)
return output
def __get_sub_patterns(self):
if self.__sub_patterns is None and self.__text is not None:
self.__compile()
return self.__sub_patterns
def __set_sub_patterns(self, sub_patterns):
self.__sub_patterns = sub_patterns
sub_patterns = property(__get_sub_patterns, __set_sub_patterns)
def __compile(self):
# TODO: add support for comments in pattern
parser = DisplayPatternParser(whitespace='')
try:
asts = parser.parse(self.__text, rule_name='start')
self.sub_patterns = self.__compile_asts(asts)
self.__text = None
except BaseException as parsing_error:
raise PatternCompileException(compat.unicode(parsing_error))
def __compile_asts(self, asts):
patterns = []
for ast in asts:
patterns.append(self.__compile_ast(ast))
return patterns
def __compile_ast(self, ast):
if ast is None:
return None
if "text" in ast:
return TextPattern(ast["text"])
if "tag" in ast:
parameters = self.__compile_parameters(ast["parameters"])
return self.__create_complex_pattern(TagPattern, ast["name"],
parameters)
if "function" in ast:
parameters = self.__compile_parameters(ast["parameters"])
if len(parameters) == 1 and parameters[0][0] is None and len(
parameters[0][1].sub_patterns) == 0:
parameters = []
return self.__create_complex_pattern(FunctionPattern, ast["name"],
parameters)
def __compile_parameters(self, parameter_asts):
parameters = []
for parameter_ast in parameter_asts:
sub_patterns = self.__compile_asts(parameter_ast["value"])
parameters.append((parameter_ast["name"],
Pattern(sub_patterns=sub_patterns)))
return parameters
def __create_complex_pattern(self, base_class, class_name, parameters):
pattern_class = self.__find_pattern_class(base_class, class_name)
if pattern_class is not None:
return pattern_class(class_name, parameters)
raise PatternCompileException("Unknown " + base_class.TYPE + " '" +
class_name + "'")
def __find_pattern_class(self, base_class, class_name):
for pattern_class in Pattern.sub_pattern_classes(base_class):
if class_name in pattern_class.NAMES:
return pattern_class
@staticmethod
def sub_pattern_classes(base_class):
sub_classes = []
for pattern_class in base_class.__subclasses__():
if len(pattern_class.__subclasses__()) > 0:
sub_classes.extend(Pattern.sub_pattern_classes(pattern_class))
continue
sub_classes.append(pattern_class)
return sub_classes
@staticmethod
def pattern_class_parameters(pattern_class):
try:
return pattern_class.PARAMETERS
except AttributeError:
return []
def __repr__(self):
return self.__str__()
def __str__(self):
return str(self.__sub_patterns)
class TextPattern(Pattern):
SPECIAL_CHARACTERS = list("\\%$,()=nt")
SPECIAL_CHARACTERS_DESCRIPTIONS = list("\\%$,()=") + ["New line", "Tab"]
def __init__(self, text):
super(TextPattern, self).__init__(text)
self.__text = text
self.__replace_escapes()
def __replace_escapes(self):
escape_matches = list(re.compile('\\\\.').finditer(self.__text))
escape_matches.reverse()
for escape_match in escape_matches:
character = self.__text[escape_match.end() - 1]
if character not in TextPattern.SPECIAL_CHARACTERS:
raise PatternCompileException("Unknown escape character '" +
character + "'")
if character == 'n':
character = os.linesep
if character == 't':
character = '\t'
self.__text = self.__text[:escape_match.start()] + character + \
self.__text[escape_match.end():]
def output_for(self, audio_file):
return self.__text
def __str__(self):
return "text:" + self.__text
class ComplexPattern(Pattern):
__metaclass__ = abc.ABCMeta
TYPE = "unknown"
NAMES = []
DESCRIPTION = ""
PARAMETERS = []
class ExpectedParameter(object):
def __init__(self, name, **kwargs):
self.name = name
if "default" in kwargs:
self.requried = False
self.default = kwargs["default"]
else:
self.requried = True
def __repr__(self):
return self.__str__()
def __str__(self):
if self.requried:
return self.name
return self.name + "(" + str(self.default) + ")"
class Parameter(object):
def __init__(self, value, provided):
self.value = value
self.provided = provided
def __repr__(self):
return self.__str__()
def __str__(self):
return str(self.value) + "(" + str(self.provided) + ")"
def __init__(self, name, parameters):
super(ComplexPattern, self).__init__()
self.__name = name
self.__import_parameters(parameters)
def output_for(self, audio_file):
output = self._get_output_for(audio_file)
return output or ""
@abc.abstractmethod
def _get_output_for(self, audio_file):
pass
def __import_parameters(self, parameters):
expected_parameters = Pattern.pattern_class_parameters(self.__class__)
self.__parameters = {}
for expected_parameter in expected_parameters:
found = False
for parameter in parameters:
if (parameter[0] is None or
parameter[0] == expected_parameter.name):
self.__parameters[expected_parameter.name] = \
ComplexPattern.Parameter(parameter[1], True)
parameters.remove(parameter)
found = True
break
if parameter[0] is not None:
break
if not expected_parameter.requried:
self.__parameters[expected_parameter.name] = \
ComplexPattern.Parameter(
Pattern(text=expected_parameter.default), True)
found = True
break
raise PatternCompileException(
self._error_message("Unexpected parameter"))
if not found:
if expected_parameter.requried:
raise PatternCompileException(self._error_message(
"Missing required parameter '" +
expected_parameter.name + "'"))
self.__parameters[expected_parameter.name] = \
ComplexPattern.Parameter(
Pattern(text=expected_parameter.default), False)
if len(parameters) > 0:
raise PatternCompileException(
self._error_message("Unexpected parameter"))
def __get_parameters(self):
return self.__parameters
parameters = property(__get_parameters)
def _parameter_value(self, name, audio_file):
return self.parameters[name].value.output_for(audio_file)
def _parameter_bool(self, name, audio_file):
value = self._parameter_value(name, audio_file)
return value.lower() in ("yes", "true", "y", "t", "1", "on")
def __get_name(self):
return self.__name
name = property(__get_name)
def _error_message(self, message):
return self.TYPE.capitalize() + " " + self.__name + ": " + message
def __str__(self):
return self.TYPE + ":" + self.name + str(self.parameters)
class PlaceholderUsagePattern(object):
__metaclass__ = abc.ABCMeta
def _replace_placeholders(self, text, replacements):
if len(replacements) == 0:
return text
replacement = replacements.pop(0)
subtexts = []
for subtext in text.split(replacement[0]):
subtexts.append(
self._replace_placeholders(subtext, list(replacements)))
return (replacement[1] or "").join(subtexts)
class TagPattern(ComplexPattern):
__metaclass__ = abc.ABCMeta
TYPE = "tag"
class ArtistTagPattern(TagPattern):
NAMES = ["a", "artist"]
DESCRIPTION = "Artist"
def _get_output_for(self, audio_file):
return audio_file.tag.artist
class AlbumTagPattern(TagPattern):
NAMES = ["A", "album"]
DESCRIPTION = "Album"
def _get_output_for(self, audio_file):
return audio_file.tag.album
class AlbumArtistTagPattern(TagPattern):
NAMES = ["b", "album-artist"]
DESCRIPTION = "Album artist"
def _get_output_for(self, audio_file):
return audio_file.tag.album_artist
class ComposerTagPattern(TagPattern):
NAMES = ["C", "composer"]
DESCRIPTION = "Composer"
def _get_output_for(self, audio_file):
return audio_file.tag.composer
class TitleTagPattern(TagPattern):
NAMES = ["t", "title"]
DESCRIPTION = "Title"
def _get_output_for(self, audio_file):
return audio_file.tag.title
class TrackTagPattern(TagPattern):
NAMES = ["n", "track"]
DESCRIPTION = "Track number"
def _get_output_for(self, audio_file):
n = audio_file.tag.track_num[0]
return str(n or "")
class TrackTotalTagPattern(TagPattern):
NAMES = ["N", "track-total"]
DESCRIPTION = "Total track number"
def _get_output_for(self, audio_file):
n = audio_file.tag.track_num[1]
return str(n or "")
class DiscTagPattern(TagPattern):
NAMES = ["d", "disc", "disc-num"]
DESCRIPTION = "Disc number"
def _get_output_for(self, audio_file):
n = audio_file.tag.disc_num[0]
return str(n or "")
class DiscTotalTagPattern(TagPattern):
NAMES = ["D", "disc-total"]
DESCRIPTION = "Total disc number"
def _get_output_for(self, audio_file):
n = audio_file.tag.disc_num[1]
return str(n or "")
class GenreTagPattern(TagPattern):
NAMES = ["G", "genre"]
DESCRIPTION = "Genre"
def _get_output_for(self, audio_file):
return audio_file.tag.genre.name
class GenreIdTagPattern(TagPattern):
NAMES = ["genre-id"]
DESCRIPTION = "Genre ID"
def _get_output_for(self, audio_file):
return str(audio_file.tag.genre.id) if audio_file.tag.genre else None
class YearTagPattern(TagPattern):
NAMES = ["Y", "year"]
DESCRIPTION = "Release year"
def _get_output_for(self, audio_file):
return audio_file.tag.release_date.year
class DescriptableTagPattern(TagPattern):
__metaclass__ = abc.ABCMeta
PARAMETERS = [ComplexPattern.ExpectedParameter("description", default=None),
ComplexPattern.ExpectedParameter("language", default=None)]
def _get_matching_elements(self, elements, audio_file):
matching_elements = []
for element in elements:
if (self.__matches("description", element.description,
audio_file) and
self.__matches("language", element.lang, audio_file)):
matching_elements.append(element)
return matching_elements
def __matches(self, parameter_name, comment_attribute_value, audio_file):
if not self.parameters[parameter_name].provided:
return True
if self.parameters[parameter_name].value is None:
return (comment_attribute_value is None or
comment_attribute_value == "")
return (self._parameter_value(parameter_name, audio_file) ==
comment_attribute_value)
class CommentTagPattern(DescriptableTagPattern):
NAMES = ["c", "comment"]
PARAMETERS = DescriptableTagPattern.PARAMETERS
DESCRIPTION = "First comment that matches description and language."
def _get_output_for(self, audio_file):
matching_comments = self._get_matching_elements(audio_file.tag.comments,
audio_file)
return matching_comments[0].text if len(matching_comments) > 0 else None
class AllCommentsTagPattern(DescriptableTagPattern, PlaceholderUsagePattern):
NAMES = ["comments"]
PARAMETERS = DescriptableTagPattern.PARAMETERS + \
[ComplexPattern.ExpectedParameter("output",
default="Comment: [Description: #d] [Lang: #l]: #t"),
ComplexPattern.ExpectedParameter("separation", default="\\n")]
DESCRIPTION = "All comments that are matching description and language " \
"(with output placeholders #d as description, #l as " \
" language & #t as text)."
def _get_output_for(self, audio_file):
output_pattern = self._parameter_value("output", audio_file)
separation = self._parameter_value("separation", audio_file)
outputs = []
for comment in self._get_matching_elements(audio_file.tag.comments,
audio_file):
replacements = [["#d", comment.description],
["#l", comment.lang.decode("ascii")],
["#t", comment.text]]
outputs.append(self._replace_placeholders(output_pattern,
replacements))
return separation.join(outputs)
class AbstractDateTagPattern(TagPattern):
__metaclass__ = abc.ABCMeta
def _get_output_for(self, audio_file):
return str(self._get_date(audio_file) or "")
@abc.abstractmethod
def _get_date(self, audio_file):
pass
class ReleaseDateTagPattern(AbstractDateTagPattern):
NAMES = ["release-date"]
DESCRIPTION = "Relase date"
def _get_date(self, audio_file):
return audio_file.tag.release_date
class OriginalReleaseDateTagPattern(AbstractDateTagPattern):
NAMES = ["original-release-date"]
DESCRIPTION = "Original Relase date"
def _get_date(self, audio_file):
return audio_file.tag.original_release_date
class RecordingDateTagPattern(AbstractDateTagPattern):
NAMES = ["recording-date"]
DESCRIPTION = "Recording date"
def _get_date(self, audio_file):
return audio_file.tag.recording_date
class EncodingDateTagPattern(AbstractDateTagPattern):
NAMES = ["encoding-date"]
DESCRIPTION = "Encoding date"
def _get_date(self, audio_file):
return audio_file.tag.encoding_date
class TaggingDateTagPattern(AbstractDateTagPattern):
NAMES = ["tagging-date"]
DESCRIPTION = "Tagging date"
def _get_date(self, audio_file):
return audio_file.tag.tagging_date
class PlayCountTagPattern(TagPattern):
NAMES = ["play-count"]
DESCRIPTION = "Play count"
def _get_output_for(self, audio_file):
return audio_file.tag.play_count
class PopularitiesTagPattern(TagPattern, PlaceholderUsagePattern):
NAMES = ["popm", "popularities"]
PARAMETERS = [ComplexPattern.ExpectedParameter("output",
default="Popularity: [email: #e] [rating: #r] [play count: #c]"),
ComplexPattern.ExpectedParameter("separation", default="\\n")]
DESCRIPTION = "Popularities (with output placeholders #e as email, "\
"#r as rating & #c as count)"
def _get_output_for(self, audio_file):
output_pattern = self._parameter_value("output", audio_file)
separation = self._parameter_value("separation", audio_file)
outputs = []
for popularity in audio_file.tag.popularities:
replacements = [["#e", popularity.email],
["#r", popularity.rating],
["#c", popularity.count]]
outputs.append(self._replace_placeholders(output_pattern,
replacements))
return separation.join(outputs)
class BPMTagPattern(TagPattern):
NAMES = ["bpm"]
DESCRIPTION = "BPM"
def _get_output_for(self, audio_file):
return audio_file.tag.bpm
class PublisherTagPattern(TagPattern):
NAMES = ["publisher"]
DESCRIPTION = "Publisher"
def _get_output_for(self, audio_file):
return audio_file.tag.publisher
class UniqueFileIDTagPattern(TagPattern, PlaceholderUsagePattern):
NAMES = ["ufids", "unique-file-ids"]
PARAMETERS = [ComplexPattern.ExpectedParameter("output",
default="Unique File ID: [#o] : #i"),
ComplexPattern.ExpectedParameter("separation", default="\\n")]
DESCRIPTION = "Unique File IDs (with output placeholders #o as owner & #i "\
" as unique id)"
def _get_output_for(self, audio_file):
output_pattern = self._parameter_value("output", audio_file)
separation = self._parameter_value("separation", audio_file)
outputs = []
for ufid in audio_file.tag.unique_file_ids:
replacements = [["#o", ufid.owner_id],
["#i", ufid.uniq_id.encode("string_escape")]]
outputs.append(self._replace_placeholders(output_pattern,
replacements))
return separation.join(outputs)
class LyricsTagPattern(DescriptableTagPattern, PlaceholderUsagePattern):
NAMES = ["lyrics"]
PARAMETERS = DescriptableTagPattern.PARAMETERS + \
[ComplexPattern.ExpectedParameter(
"output",
default="Lyrics: [Description: #d] [Lang: #l]: #t"),
ComplexPattern.ExpectedParameter("separation", default="\\n")]
DESCRIPTION = "All lyrics that are matching description and language " + \
"(with output placeholders #d as description, #l as "\
"language & #t as text)."
def _get_output_for(self, audio_file):
output_pattern = self._parameter_value("output", audio_file)
separation = self._parameter_value("separation", audio_file)
outputs = []
for l in self._get_matching_elements(audio_file.tag.lyrics, audio_file):
replacements = [["#d", l.description],
["#l", l.lang.decode("ascii")],
["#t", l.text]]
outputs.append(self._replace_placeholders(output_pattern,
replacements))
return separation.join(outputs)
class TextsTagPattern(TagPattern, PlaceholderUsagePattern):
NAMES = ["txxx", "texts"]
PARAMETERS = [
ComplexPattern.ExpectedParameter(
"output", default="UserTextFrame: [Description: #d] #t"),
ComplexPattern.ExpectedParameter("separation", default="\\n")]
DESCRIPTION = "User text frames (with output placeholders #d as "\
"description & #t as text)"
def _get_output_for(self, audio_file):
output_pattern = self._parameter_value("output", audio_file)
separation = self._parameter_value("separation", audio_file)
outputs = []
for frame in audio_file.tag.user_text_frames:
replacements = [["#d", frame.description],
["#t", frame.text]]
outputs.append(self._replace_placeholders(output_pattern,
replacements))
return separation.join(outputs)
class ArtistURLTagPattern(TagPattern):
NAMES = ["artist-url"]
DESCRIPTION = "Artist URL"
def _get_output_for(self, audio_file):
return audio_file.tag.artist_url
class AudioSourceURLTagPattern(TagPattern):
NAMES = ["audio-source-url"]
DESCRIPTION = "Audio source URL"
def _get_output_for(self, audio_file):
return audio_file.tag.audio_source_url
class AudioFileURLTagPattern(TagPattern):
NAMES = ["audio-file-url"]
DESCRIPTION = "Audio file URL"
def _get_output_for(self, audio_file):
return audio_file.tag.audio_file_url
class InternetRadioURLTagPattern(TagPattern):
NAMES = ["internet-radio-url"]
DESCRIPTION = "Internet radio URL"
def _get_output_for(self, audio_file):
return audio_file.tag.internet_radio_url
class CommercialURLTagPattern(TagPattern):
NAMES = ["commercial-url"]
DESCRIPTION = "Comercial URL"
def _get_output_for(self, audio_file):
return audio_file.tag.copyright_url
class PaymentURLTagPattern(TagPattern):
NAMES = ["payment-url"]
DESCRIPTION = "Payment URL"
def _get_output_for(self, audio_file):
return audio_file.tag.payment_url
class PublisherURLTagPattern(TagPattern):
NAMES = ["publisher-url"]
DESCRIPTION = "Publisher URL"
def _get_output_for(self, audio_file):
return audio_file.tag.publisher_url
class CopyrightTagPattern(TagPattern):
NAMES = ["copyright-url"]
DESCRIPTION = "Copyright URL"
def _get_output_for(self, audio_file):
return audio_file.tag.copyright_url
class UserURLsTagPattern(TagPattern, PlaceholderUsagePattern):
NAMES = ["user-urls"]
PARAMETERS = [ComplexPattern.ExpectedParameter("output",
default="#i [Description: #d]: #u"),
ComplexPattern.ExpectedParameter("separation", default="\\n")]
DESCRIPTION = "User URL frames (with output placeholders #i as frame id, "\
"#d as description & #u as url)"
def _get_output_for(self, audio_file):
output_pattern = self._parameter_value("output", audio_file)
separation = self._parameter_value("separation", audio_file)
outputs = []
for frame in audio_file.tag.user_url_frames:
replacements = [["#i", frame.id],
["#d", frame.description],
["#u", frame.url]]
outputs.append(self._replace_placeholders(output_pattern,
replacements))
return separation.join(outputs)
class ImagesTagPattern(TagPattern, PlaceholderUsagePattern):
NAMES = ["images", "apic"]
PARAMETERS = [ComplexPattern.ExpectedParameter(
"output",
default="#t Image: [Type: #m] [Size: #b bytes] #d"),
ComplexPattern.ExpectedParameter("separation", default="\\n")]
DESCRIPTION = "Attached pictures (APIC)" \
"(with output placeholders #t as image type, "\
"#m as mime type, #s as size in bytes & #d as description)"
def _get_output_for(self, audio_file):
output_pattern = self._parameter_value("output", audio_file)
separation = self._parameter_value("separation", audio_file)
outputs = []
for img in audio_file.tag.images:
if img.mime_type not in id3.frames.ImageFrame.URL_MIME_TYPE_VALUES:
replacements = [["#t", img.picTypeToString(img.picture_type)],
["#m", img.mime_type.decode("ascii")],
["#s", len(img.image_data)],
["#d", img.description]]
outputs.append(self._replace_placeholders(output_pattern,
replacements))
return separation.join(outputs)
class ImageURLsTagPattern(TagPattern, PlaceholderUsagePattern):
NAMES = ["image-urls"]
PARAMETERS = [ComplexPattern.ExpectedParameter(
"output", default="#t Image: [Type: #m] [URL: #u] #d"),
ComplexPattern.ExpectedParameter("separation", default="\\n")]
DESCRIPTION = "Attached pictures URLs" \
"(with output placeholders #t as image type, "\
"#m as mime type, #u as URL & #d as description)"
def _get_output_for(self, audio_file):
output_pattern = self._parameter_value("output", audio_file)
separation = self._parameter_value("separation", audio_file)
outputs = []
for img in audio_file.tag.images:
if img.mime_type in id3.frames.ImageFrame.URL_MIME_TYPE_VALUES:
replacements = [["#t", img.picTypeToString(img.picture_type)],
["#m", img.mime_type],
["#u", img.image_url],
["#d", img.description]]
outputs.append(self._replace_placeholders(output_pattern,
replacements))
return separation.join(outputs)
class ObjectsTagPattern(TagPattern, PlaceholderUsagePattern):
NAMES = ["objects", "gobj"]
PARAMETERS = [ComplexPattern.ExpectedParameter("output",
default="GEOB: [Size: #s bytes] [Type: #t] "
"Description: #d | Filename: #f"),
ComplexPattern.ExpectedParameter("separation", default="\\n")]
DESCRIPTION = "Objects (GOBJ)" \
"(with output placeholders #s as size, #m as mime type, "\
"#d as description and #f as file name)"
def _get_output_for(self, audio_file):
output_pattern = self._parameter_value("output", audio_file)
separation = self._parameter_value("separation", audio_file)
outputs = []
for obj in audio_file.tag.objects:
replacements = [["#s", len(obj.object_data)],
["#m", obj.mime_type],
["#d", obj.description],
["#f", obj.filename]]
outputs.append(self._replace_placeholders(output_pattern,
replacements))
return separation.join(outputs)
class PrivatesTagPattern(TagPattern, PlaceholderUsagePattern):
NAMES = ["privates", "priv"]
PARAMETERS = [ComplexPattern.ExpectedParameter("output",
default="PRIV-Content: #b bytes | Owner: #o"),
ComplexPattern.ExpectedParameter("separation", default="\\n")]
DESCRIPTION = "Privates (APIC) (with output placeholders #c as content, "\
"#b as number of bytes & #o as owner)"
def _get_output_for(self, audio_file):
output_pattern = self._parameter_value("output", audio_file)
separation = self._parameter_value("separation", audio_file)
outputs = []
for private in audio_file.tag.privates:
replacements = [["#b", "%i" % len(private.data)],
["#c", private.data.decode("ascii")],
["#o", private.owner_id.decode("ascii")]]
outputs.append(self._replace_placeholders(output_pattern,
replacements))
return separation.join(outputs)
class MusicCDIdTagPattern(TagPattern):
NAMES = ["music-cd-id", "mcdi"]
DESCRIPTION = "Music CD Identification"
def _get_output_for(self, audio_file):
if audio_file.tag.cd_id is not None:
return audio_file.tag.cd_id.decode("ascii")
else:
return None
class TermsOfUseTagPattern(TagPattern):
NAMES = ["terms-of-use"]
DESCRIPTION = "Terms of use"
def _get_output_for(self, audio_file):
return audio_file.tag.terms_of_use
class FunctionPattern(ComplexPattern):
__metaclass__ = abc.ABCMeta
TYPE = "function"
class FunctionFormatPattern(FunctionPattern):
NAMES = ["format"]
PARAMETERS = [ComplexPattern.ExpectedParameter("text"),
ComplexPattern.ExpectedParameter("bold", default=None),
ComplexPattern.ExpectedParameter("color", default=None)]
DESCRIPTION = "Formats text bold and colored (grey, red, green, yellow, "\
"blue, magenta, cyan or white)"
def _get_output_for(self, audio_file):
text = self._parameter_value("text", audio_file)
bold = self._parameter_bool("bold", audio_file)
color_name = self._parameter_value("color", audio_file)
return console.formatText(text, b=bold, c=self.__color(color_name))
def __color(self, color_name):
return {"GREY": console.Fore.GREY,
"RED": console.Fore.RED,
"GREEN": console.Fore.GREEN,
"YELLOW": console.Fore.YELLOW,
"BLUE": console.Fore.BLUE,
"MAGENTA": console.Fore.MAGENTA,
"CYAN": console.Fore.CYAN,
"WHITE": console.Fore.WHITE}.get(color_name.upper(), None)
class FunctionNumberPattern(FunctionPattern):
NAMES = ["num", "number-format"]
PARAMETERS = [ComplexPattern.ExpectedParameter("number"),
ComplexPattern.ExpectedParameter("digits")]
DESCRIPTION = "Appends leading zeros"
def _get_output_for(self, audio_file):
number = self._parameter_value("number", audio_file)
digits = self._parameter_value("digits", audio_file)
try:
number = int(number)
except ValueError:
raise DisplayException(self._error_message("'" + number +
"' is not a number."))
try:
digits = int(digits)
except ValueError:
raise DisplayException(self._error_message("'" + digits +
"' is not a number."))
output = str(number)
return ("0" * max(0, digits - len(output))) + output
class FunctionFilenamePattern(FunctionPattern):
NAMES = ["filename", "fn"]
PARAMETERS = [ComplexPattern.ExpectedParameter("basename", default=None)]
DESCRIPTION = "File name"
def _get_output_for(self, audio_file):
if self._parameter_bool("basename", audio_file):
return os.path.basename(audio_file.path)
return audio_file.path
class FunctionFilesizePattern(FunctionPattern):
NAMES = ["filesize"]
DESCRIPTION = "Size of file"
def _get_output_for(self, audio_file):
from stat import ST_SIZE
file_size = os.stat(audio_file.path)[ST_SIZE]
return formatSize(file_size)
class FunctionTagVersionPattern(FunctionPattern):
NAMES = ["tag-version"]
DESCRIPTION = "Tag version"
def _get_output_for(self, audio_file):
return id3.versionToString(audio_file.tag.version)
class FunctionLengthPattern(FunctionPattern):
NAMES = ["length"]
DESCRIPTION = "Length of aufio file"
def _get_output_for(self, audio_file):
return formatTime(audio_file.info.time_secs)
class FunctionMPEGVersionPattern(FunctionPattern, PlaceholderUsagePattern):
NAMES = ["mpeg-version"]
PARAMETERS = [ComplexPattern.ExpectedParameter("output",
default="MPEG#v\, Layer #l")]
DESCRIPTION = "MPEG version (with output placeholders #v as version & "\
"#l as layer)"
def _get_output_for(self, audio_file):
output = self._parameter_value("output", audio_file)
replacements = [["#v", str(audio_file.info.mp3_header.version)],
["#l", "I" * audio_file.info.mp3_header.layer]]
return self._replace_placeholders(output, replacements)
class FunctionBitRatePattern(FunctionPattern):
NAMES = ["bit-rate"]
DESCRIPTION = "Bit rate of aufio file"
def _get_output_for(self, audio_file):
return audio_file.info.bit_rate_str
class FunctionSampleFrequencePattern(FunctionPattern):
NAMES = ["sample-freq"]
DESCRIPTION = "Sample frequence of aufio file in Hz"
def _get_output_for(self, audio_file):
return str(audio_file.info.mp3_header.sample_freq)
class FunctionAudioModePattern(FunctionPattern):
NAMES = ["audio-mode"]
DESCRIPTION = "Mode of aufio file: mono/stereo"
def _get_output_for(self, audio_file):
return audio_file.info.mp3_header.mode
class FunctionNotEmptyPattern(FunctionPattern, PlaceholderUsagePattern):
NAMES = ["not-empty"]
PARAMETERS = [ComplexPattern.ExpectedParameter("text"),
ComplexPattern.ExpectedParameter("output", default="#t"),
ComplexPattern.ExpectedParameter("empty", default=None)]
DESCRIPTION = "If condition is not empty (with output placeholder #t as "\
"text)"
def _get_output_for(self, audio_file):
text = self._parameter_value("text", audio_file)
if len(text) > 0:
output = self._parameter_value("output", audio_file)
return self._replace_placeholders(output, [["#t", text]])
return output.replace("#t", text)
else:
return self._parameter_value("empty", audio_file)
class FunctionRepeatPattern(FunctionPattern):
NAMES = ["repeat"]
PARAMETERS = [ComplexPattern.ExpectedParameter("text"),
ComplexPattern.ExpectedParameter("count")]
DESCRIPTION = "Repeats text"
def _get_output_for(self, audio_file):
output = u""
content = self._parameter_value("text", audio_file)
count = self._parameter_value("count", audio_file)
try:
count = int(count)
except ValueError:
raise DisplayException(self._error_message("'" + count +
"' is not a number."))
for i in range(count):
output += content
return output
class DisplayPlugin(LoaderPlugin):
NAMES = ["display"]
SUMMARY = u"Tag Display"
DESCRIPTION = ""u"""
Prints specific tag information.
"""
def __init__(self, arg_parser):
super(DisplayPlugin, self).__init__(arg_parser)
def filename(fn):
if not os.path.exists(fn):
raise ArgumentTypeError("The file %s does not exist!" % fn)
return fn
pattern_group = \
self.arg_group.add_mutually_exclusive_group(required=True)
pattern_group.add_argument("--pattern-help", action="store_true",
dest="pattern_help",
help=ARGS_HELP["--pattern-help"])
pattern_group.add_argument("-p", "--pattern", dest="pattern_string",
metavar="STRING",
help=ARGS_HELP["--pattern"])
pattern_group.add_argument("-f", "--pattern-file", dest="pattern_file",
metavar="FILE", type=filename,
help=ARGS_HELP["--pattern-file"])
self.arg_group.add_argument("--no-newline", action="store_true",
dest="no_newline",
help=ARGS_HELP["--no-newline"])
self.__pattern = None
self.__return_code = 0
def start(self, args, config):
super(DisplayPlugin, self).start(args, config)
if args.pattern_help:
self.__print_pattern_help()
return
if not _have_grako:
console.printError(u"Unknown module 'grako'" + os.linesep +
u"Please install grako! " +
u"E.g. $ pip install grako")
self.__return_code = 2
return
if args.pattern_string is not None:
self.__pattern = Pattern(args.pattern_string)
if args.pattern_file is not None:
pfile = open(args.pattern_file, "r")
self.__pattern = Pattern(''.join(pfile.read().splitlines()))
pfile.close()
self.__output_ending = "" if args.no_newline else os.linesep
def handleFile(self, f, *args, **kwargs):
if self.args.pattern_help:
return
if self.__return_code != 0:
return
super(DisplayPlugin, self).handleFile(f)
if not self.audio_file:
return
try:
print(self.__pattern.output_for(self.audio_file),
end=self.__output_ending)
except PatternCompileException as e:
self.__return_code = 1
console.printError(e.message)
except DisplayException as e:
self.__return_code = 1
console.printError(e.message)
def handleDone(self):
return self.__return_code
def __print_pattern_help(self):
# FIXME: Force some order
print(console.formatText("ID3 Tags:", b=True))
self.__print_complex_pattern_help(TagPattern)
print(os.linesep)
print(console.formatText("Functions:", b=True))
self.__print_complex_pattern_help(FunctionPattern)
print(os.linesep)
print(console.formatText("Special characters:", b=True))
print(console.formatText("\tescape seq. character"))
for i in range(len(TextPattern.SPECIAL_CHARACTERS)):
print(("\t\\%s" + (" " * 12) + "%s") %
(TextPattern.SPECIAL_CHARACTERS[i],
TextPattern.SPECIAL_CHARACTERS_DESCRIPTIONS[i]))
def __print_complex_pattern_help(self, base_class):
rows = []
# TODO line wrap for description
for pattern_class in Pattern.sub_pattern_classes(base_class):
rows.append([", ".join(pattern_class.NAMES),
pattern_class.DESCRIPTION])
parameters = Pattern.pattern_class_parameters(pattern_class)
if len(parameters) > 0:
rows.append(["", "Parameter" +
("s:" if len(parameters) > 1 else ":")])
for parameter in parameters:
parameter_desc = parameter.name
if not parameter.requried:
default = ", default='" + parameter.default + \
"'" if parameter.default else ""
parameter_desc += " (optional" + default + ")"
rows.append(["", " " + parameter_desc])
self.__print_rows(rows, "\t", " ")
def __print_rows(self, rows, indent, spacing):
row_widths = []
for row in rows:
for n in range(len(row)):
width = len(row[n])
if len(row_widths) <= n:
row_widths.append(width)
else:
row_widths[n] = max(row_widths[n], width)
for row in rows:
out = indent
for n in range(len(row)):
out += row[n]
if n < len(row) - 1:
out += (" " * (row_widths[n] - len(row[n]))) + spacing
print(out)
class DisplayException(Exception):
def __init__(self, message):
self.__message = message
def __get_message(self):
return self.__message
message = property(__get_message)
class PatternCompileException(Exception):
def __init__(self, message):
self.__message = message
def __get_message(self):
return self.__message
message = property(__get_message)
ARGS_HELP = {
"--pattern-help": "Detailed pattern help",
"--pattern": "Pattern string",
"--pattern-file": "Pattern file",
"--no-newline": "Print no newline after each output"
}
eyeD3-0.8.4/src/eyed3/plugins/pymod.py 0000644 0001750 0001750 00000006455 13061344514 020334 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2014 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
from __future__ import print_function
from eyed3.plugins import LoaderPlugin
from eyed3.compat import importmod
_DEFAULT_MOD = "eyeD3mod.py"
class PyModulePlugin(LoaderPlugin):
SUMMARY = u"Imports a Python module file and calls its functions for the "\
"the various plugin events."
DESCRIPTION = u"""
If no module if provided (see -m/--module) a file named %(_DEFAULT_MOD)s in
the current working directory is imported. If any of the following methods
exist they still be invoked:
def audioFile(audio_file):
'''Invoked for every audio file that is encountered. The ``audio_file``
is of type ``eyed3.core.AudioFile``; currently this is the concrete type
``eyed3.mp3.Mp3AudioFile``.'''
pass
def audioDir(d, audio_files, images):
'''This function is invoked for any directory (``d``) that contains audio
(``audio_files``) or image (``images``) media.'''
pass
def done():
'''This method is invoke before successful exit.'''
pass
""" % globals()
NAMES = ["pymod"]
def __init__(self, arg_parser):
super(PyModulePlugin, self).__init__(arg_parser, cache_files=True,
track_images=True)
self._mod = None
self.arg_group.add_argument("-m", "--module", dest="module",
help="The Python module module to invoke. "
"The default is ./%s" % _DEFAULT_MOD)
def start(self, args, config):
mod_file = args.module or _DEFAULT_MOD
try:
self._mod = importmod(mod_file)
except IOError as ex:
raise IOError("Module file not found: %s" % mod_file)
except (NameError, IndentationError, ImportError, SyntaxError) as ex:
raise IOError("Module load error: %s" % str(ex))
def handleFile(self, f):
super(PyModulePlugin, self).handleFile(f)
if not self.audio_file:
return
if "audioFile" in dir(self._mod):
self._mod.audioFile(self.audio_file)
def handleDirectory(self, d, _):
if not self._file_cache and not self._dir_images:
return
if "audioDir" in dir(self._mod):
self._mod.audioDir(d, self._file_cache, self._dir_images)
super(PyModulePlugin, self).handleDirectory(d, _)
def handleDone(self):
super(PyModulePlugin, self).handleDone()
if "done" in dir(self._mod):
self._mod.done()
eyeD3-0.8.4/src/eyed3/plugins/stats.py 0000644 0001750 0001750 00000037320 13177420270 020337 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2009 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
from __future__ import print_function
import os
import sys
import operator
from collections import Counter
from eyed3 import id3, mp3
from eyed3.core import AUDIO_MP3
from eyed3.utils import guessMimetype
from eyed3.utils.console import Fore, Style, printMsg
from eyed3.plugins import LoaderPlugin
from eyed3.id3 import frames
ID3_VERSIONS = [id3.ID3_V1_0, id3.ID3_V1_1,
id3.ID3_V2_2, id3.ID3_V2_3, id3.ID3_V2_4]
_OP_STRINGS = {operator.le: "<=",
operator.lt: "< ",
operator.ge: ">=",
operator.gt: "> ",
operator.eq: "= ",
operator.ne: "!=",
}
class Rule(object):
def test(self):
raise NotImplementedError()
PREFERRED_ID3_VERSIONS = [id3.ID3_V2_3,
id3.ID3_V2_4,
]
class Id3TagRules(Rule):
def test(self, path, audio_file):
scores = []
if audio_file is None:
return None
if not audio_file.tag:
return [(-75, "Missing ID3 tag")]
tag = audio_file.tag
if tag.version not in PREFERRED_ID3_VERSIONS:
scores.append((-30, "ID3 version not in %s" %
PREFERRED_ID3_VERSIONS))
if not tag.title:
scores.append((-30, "Tag missing title"))
if not tag.artist:
scores.append((-28, "Tag missing artist"))
if not tag.album:
scores.append((-26, "Tag missing album"))
if not tag.track_num[0]:
scores.append((-24, "Tag missing track number"))
if not tag.track_num[1]:
scores.append((-22, "Tag missing total # of tracks"))
if not tag.getBestDate():
scores.append((-30, "Tag missing any useful dates"))
else:
if not tag.original_release_date:
# Original release date is so rarely used but is almost always
# what I mean or wanna know.
scores.append((-10, "No original release date"))
elif not tag.release_date:
scores.append((-5, "No release date"))
# TLEN, best gotten from audio_file.info.time_secs but having it in
# the tag is good, I guess.
if b"TLEN" not in tag.frame_set:
scores.append((-5, "No TLEN frame"))
return scores
class BitrateRule(Rule):
BITRATE_DEDUCTIONS = [(128, -20), (192, -10)]
def test(self, path, audio_file):
scores = []
if not audio_file:
return None
if not audio_file.info:
# Detected as an audio file but not real audio data found.
return [(-90, "No audio data found")]
is_vbr, bitrate = audio_file.info.bit_rate
for threshold, score in self.BITRATE_DEDUCTIONS:
if bitrate < threshold:
scores.append((score, "Bit rate < %d" % threshold))
break
return scores
VALID_MIME_TYPES = mp3.MIME_TYPES + ["image/png",
"image/gif",
"image/jpeg",
]
class FileRule(Rule):
def test(self, path, audio_file):
mt = guessMimetype(path)
for name in os.path.split(path):
if name.startswith('.'):
return [(-100, "Hidden file type")]
if mt not in VALID_MIME_TYPES:
return [(-100, "Unsupported file type: %s" % mt)]
return None
VALID_ARTWORK_NAMES = ("cover", "cover-front", "cover-back")
class ArtworkRule(Rule):
def test(self, path, audio_file):
mt = guessMimetype(path)
if mt and mt.startswith("image/"):
name, ext = os.path.splitext(os.path.basename(path))
if name not in VALID_ARTWORK_NAMES:
return [(-10, "Artwork file not in %s" %
str(VALID_ARTWORK_NAMES))]
return None
BAD_FRAMES = [frames.PRIVATE_FID, frames.OBJECT_FID]
class Id3FrameRules(Rule):
def test(self, path, audio_file):
scores = []
if not audio_file or not audio_file.tag:
return
tag = audio_file.tag
for fid in tag.frame_set:
if fid[0] == 'T' and fid != "TXXX" and len(tag.frame_set[fid]) > 1:
scores.append((-10, "Multiple %s frames" % fid.decode('ascii')))
elif fid in BAD_FRAMES:
scores.append((-13, "%s frames are bad, mmmkay?" %
fid.decode('ascii')))
return scores
class Stat(Counter):
TOTAL = "total"
def __init__(self, *args, **kwargs):
super(Stat, self).__init__(*args, **kwargs)
self[self.TOTAL] = 0
self._key_names = {}
def compute(self, file, audio_file):
self[self.TOTAL] += 1
self._compute(file, audio_file)
def _compute(self, file, audio_file):
pass
def report(self):
self._report()
def _sortedKeys(self, most_common=False):
def keyDisplayName(k):
return self._key_names[k] if k in self._key_names else k
key_map = {}
for k in list(self.keys()):
key_map[keyDisplayName(k)] = k
if not most_common:
sorted_names = list(key_map.keys())
sorted_names.remove(self.TOTAL)
sorted_names.sort()
sorted_names.append(self.TOTAL)
else:
most_common = self.most_common()
sorted_names = []
remainder_names = []
for k, v in most_common:
if k != self.TOTAL and v > 0:
sorted_names.append(keyDisplayName(k))
elif k != self.TOTAL:
remainder_names.append(keyDisplayName(k))
remainder_names.sort()
sorted_names = sorted_names + remainder_names
sorted_names.append(self.TOTAL)
return [key_map[name] for name in sorted_names]
def _report(self, most_common=False):
keys = self._sortedKeys(most_common=most_common)
key_col_width = 0
val_col_width = 0
for key in keys:
key = self._key_names[key] if key in self._key_names else key
key_col_width = max(key_col_width, len(str(key)))
val_col_width = max(val_col_width, len(str(self[key])))
key_col_width += 1
val_col_width += 1
for k in keys:
key_name = self._key_names[k] if k in self._key_names else k
value = self[k]
percent = self.percent(k) if value and k != "total" else ""
print("{padding}{key}:{value}{percent}".format(
padding=' ' * 4,
key=str(key_name).ljust(key_col_width),
value=str(value).rjust(val_col_width),
percent=" ( %s%.2f%%%s )" % (Fore.GREEN, percent, Fore.RESET)
if percent else "",
))
def percent(self, key):
return (float(self[key]) / float(self["total"])) * 100
class AudioStat(Stat):
def compute(self, audio_file):
assert(audio_file)
self["total"] += 1
self._compute(audio_file)
def _compute(self, audio_file):
pass
class FileCounterStat(Stat):
SUPPORTED_AUDIO = "audio"
UNSUPPORTED_AUDIO = "audio (unsupported)"
HIDDEN_FILES = "hidden"
OTHER_FILES = "other"
def __init__(self):
super(FileCounterStat, self).__init__()
for k in ("audio", "hidden", "audio (unsupported)"):
self[k] = 0
def _compute(self, file, audio_file):
mt = guessMimetype(file)
if audio_file:
self[self.SUPPORTED_AUDIO] += 1
elif mt and mt.startswith("audio/"):
self[self.UNSUPPORTED_AUDIO] += 1
elif os.path.basename(file).startswith('.'):
self[self.HIDDEN_FILES] += 1
else:
self[self.OTHER_FILES] += 1
def _report(self):
print(Style.BRIGHT + Fore.GREY + "Files:" + Style.RESET_ALL)
super(FileCounterStat, self)._report()
class MimeTypeStat(Stat):
def _compute(self, file, audio_file):
mt = guessMimetype(file)
self[mt] += 1
def _report(self):
print(Style.BRIGHT + Fore.GREY + "Mime-Types:" + Style.RESET_ALL)
super(MimeTypeStat, self)._report(most_common=True)
class Id3VersionCounter(AudioStat):
def __init__(self):
super(Id3VersionCounter, self).__init__()
for v in ID3_VERSIONS:
self[v] = 0
self._key_names[v] = id3.versionToString(v)
def _compute(self, audio_file):
if audio_file.tag:
self[audio_file.tag.version] += 1
else:
self[None] += 1
def _report(self):
print(Style.BRIGHT + Fore.GREY + "ID3 versions:" + Style.RESET_ALL)
super(Id3VersionCounter, self)._report()
class Id3FrameCounter(AudioStat):
def _compute(self, audio_file):
if audio_file.tag:
for frame_id in audio_file.tag.frame_set:
self[frame_id] += len(audio_file.tag.frame_set[frame_id])
def _report(self):
print(Style.BRIGHT + Fore.GREY + "ID3 frames:" + Style.RESET_ALL)
super(Id3FrameCounter, self)._report(most_common=True)
class BitrateCounter(AudioStat):
def __init__(self):
super(BitrateCounter, self).__init__()
self["cbr"] = 0
self["vbr"] = 0
self.bitrate_keys = [(operator.le, 96),
(operator.le, 112),
(operator.le, 128),
(operator.le, 160),
(operator.le, 192),
(operator.le, 256),
(operator.le, 320),
(operator.gt, 320),
]
for k in self.bitrate_keys:
self[k] = 0
op, bitrate = k
self._key_names[k] = "%s %d" % (_OP_STRINGS[op], bitrate)
def _compute(self, audio_file):
if audio_file.type != AUDIO_MP3 or audio_file.info is None:
self["total"] -= 1
return
vbr, br = audio_file.info.bit_rate
if vbr:
self["vbr"] += 1
else:
self["cbr"] += 1
for key in self.bitrate_keys:
key_op, key_br = key
if key_op(br, key_br):
self[key] += 1
break
def _report(self):
print(Style.BRIGHT + Fore.GREY + "MP3 bitrates:" + Style.RESET_ALL)
super(BitrateCounter, self)._report(most_common=True)
def _sortedKeys(self, most_common=False):
keys = super(BitrateCounter, self)._sortedKeys(most_common=most_common)
keys.remove("cbr")
keys.remove("vbr")
keys.insert(0, "cbr")
keys.insert(1, "vbr")
return keys
class RuleViolationStat(Stat):
def _report(self):
print(Style.BRIGHT + Fore.GREY + "Rule Violations:" + Style.RESET_ALL)
super(RuleViolationStat, self)._report(most_common=True)
class Id3ImageTypeCounter(AudioStat):
def __init__(self):
super(Id3ImageTypeCounter, self).__init__()
self._key_names = {}
for attr in dir(frames.ImageFrame):
val = getattr(frames.ImageFrame, attr)
if isinstance(val, int) and not attr.endswith("_TYPE"):
self._key_names[val] = attr
for v in self._key_names:
self[v] = 0
def _compute(self, audio_file):
if audio_file.tag:
for img in audio_file.tag.images:
self[img.picture_type] += 1
def _report(self):
print(Style.BRIGHT + Fore.GREY + "APIC image types:" + Style.RESET_ALL)
super(Id3ImageTypeCounter, self)._report()
class StatisticsPlugin(LoaderPlugin):
NAMES = ['stats']
SUMMARY = u"Computes statistics for all audio files scanned."
def __init__(self, arg_parser):
super(StatisticsPlugin, self).__init__(arg_parser)
self.arg_group.add_argument(
"--verbose", action="store_true", default=False,
help="Show details for each file with rule violations.")
self._stats = []
self._rules_stat = RuleViolationStat()
self._stats.append(FileCounterStat())
self._stats.append(MimeTypeStat())
self._stats.append(Id3VersionCounter())
self._stats.append(Id3FrameCounter())
self._stats.append(Id3ImageTypeCounter())
self._stats.append(BitrateCounter())
self._score_sum = 0
self._score_count = 0
self._rules_log = {}
self._rules = [Id3TagRules(),
FileRule(),
ArtworkRule(),
BitrateRule(),
Id3FrameRules(),
]
def handleFile(self, path):
super(StatisticsPlugin, self).handleFile(path)
if not self.args.quiet:
sys.stdout.write('.')
sys.stdout.flush()
for stat in self._stats:
if isinstance(stat, AudioStat):
if self.audio_file:
stat.compute(self.audio_file)
else:
stat.compute(path, self.audio_file)
self._score_count += 1
total_score = 100
for rule in self._rules:
scores = rule.test(path, self.audio_file) or []
if scores:
if path not in self._rules_log:
self._rules_log[path] = []
for score, text in scores:
self._rules_stat[text] += 1
self._rules_log[path].append((score, text))
# += because negative values are returned
total_score += score
if total_score != 100:
self._rules_stat[Stat.TOTAL] += 1
self._score_sum += total_score
def handleDone(self):
if self._num_loaded == 0:
super(StatisticsPlugin, self).handleDone()
return
print()
for stat in self._stats + [self._rules_stat]:
stat.report()
print()
# Detailed rule violations
if self.args.verbose:
for path in self._rules_log:
printMsg(path) # does the right thing for unicode
for score, text in self._rules_log[path]:
print("\t%s%s%s (%s)" % (Fore.RED, str(score).center(3),
Fore.RESET, text))
def prettyScore():
score = float(self._score_sum) / float(self._score_count)
if score > 80:
color = Fore.GREEN
elif score > 70:
color = Fore.YELLOW
else:
color = Fore.RED
return (score, color)
score, color = prettyScore()
print("%sScore%s = %s%d%%%s" % (Style.BRIGHT, Style.RESET_BRIGHT,
color, score, Fore.RESET))
if not self.args.verbose:
print("Run with --verbose to see files and their rule violations")
print()
eyeD3-0.8.4/src/eyed3/plugins/__init__.py 0000644 0001750 0001750 00000017001 13177420270 020732 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2012 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
from __future__ import print_function
import os
import sys
from eyed3 import core, utils
from eyed3.utils import guessMimetype
from eyed3.utils.console import printMsg, printError
from eyed3.utils.log import getLogger
_PLUGINS = {}
log = getLogger(__name__)
def load(name=None, reload=False, paths=None):
"""Returns the eyed3.plugins.Plugin *class* identified by ``name``.
If ``name`` is ``None`` then the full list of plugins is returned.
Once a plugin is loaded its class object is cached, and future calls to
this function will returned the cached version. Use ``reload=True`` to
refresh the cache."""
global _PLUGINS
if len(list(_PLUGINS.keys())) and not reload:
# Return from the cache if possible
try:
return _PLUGINS[name] if name else _PLUGINS
except KeyError:
# It's not in the cache, look again and refresh cash
_PLUGINS = {}
else:
_PLUGINS = {}
def _isValidModule(f, d):
"""Determine if file ``f`` is a valid module file name."""
# 1) tis a file
# 2) does not start with '_', or '.'
# 3) avoid the .pyc dup
return bool(os.path.isfile(os.path.join(d, f)) and
f[0] not in ('_', '.') and f.endswith(".py"))
log.debug("Extra plugin paths: %s" % paths)
for d in [os.path.dirname(__file__)] + (paths if paths else []):
log.debug("Searching '%s' for plugins", d)
if not os.path.isdir(d):
continue
if d not in sys.path:
sys.path.append(d)
try:
for f in os.listdir(d):
if not _isValidModule(f, d):
continue
mod_name = os.path.splitext(f)[0]
try:
mod = __import__(mod_name, globals=globals(),
locals=locals())
except ImportError as ex:
log.warning("Plugin '%s' requires packages that are not "
"installed: %s" % ((f, d), ex))
continue
except Exception as ex:
log.exception("Bad plugin '%s'", (f, d))
continue
for attr in [getattr(mod, a) for a in dir(mod)]:
if (type(attr) == type and issubclass(attr, Plugin)):
# This is a eyed3.plugins.Plugin
PluginClass = attr
if (PluginClass not in list(_PLUGINS.values()) and
len(PluginClass.NAMES)):
log.debug("loading plugin '%s' from '%s%s%s'",
mod, d, os.path.sep, f)
# Setting the main name outside the loop to ensure
# there is at least one, otherwise a KeyError is
# thrown.
main_name = PluginClass.NAMES[0]
_PLUGINS[main_name] = PluginClass
for alias in PluginClass.NAMES[1:]:
# Add alternate names
_PLUGINS[alias] = PluginClass
# If 'plugin' is found return it immediately
if name and name in PluginClass.NAMES:
return PluginClass
finally:
if d in sys.path:
sys.path.remove(d)
log.debug("Plugins loaded: %s", _PLUGINS)
if name:
# If a specific plugin was requested and we've not returned yet...
return None
return _PLUGINS
class Plugin(utils.FileHandler):
"""Base class for all eyeD3 plugins"""
SUMMARY = u"eyeD3 plugin"
"""One line about the plugin"""
DESCRIPTION = u""
"""Detailed info about the plugin"""
NAMES = []
"""A list of **at least** one name for invoking the plugin, values [1:]
are treated as alias"""
def __init__(self, arg_parser):
self.arg_parser = arg_parser
self.arg_group = arg_parser.add_argument_group(
"Plugin options", u"%s\n%s" % (self.SUMMARY, self.DESCRIPTION))
def start(self, args, config):
"""Called after command line parsing but before any paths are
processed. The ``self.args`` argument (the parsed command line) and
``self.config`` (the user config, if any) is set here."""
self.args = args
self.config = config
def handleFile(self, f):
pass
def handleDone(self):
"""Called after all file/directory processing; before program exit.
The return value is passed to sys.exit (None results in 0)."""
pass
class LoaderPlugin(Plugin):
"""A base class that provides auto loading of audio files"""
def __init__(self, arg_parser, cache_files=False, track_images=False):
"""Constructor. If ``cache_files`` is True (off by default) then each
AudioFile is appended to ``_file_cache`` during ``handleFile`` and
the list is cleared by ``handleDirectory``."""
super(LoaderPlugin, self).__init__(arg_parser)
self._num_loaded = 0
self._file_cache = [] if cache_files else None
self._dir_images = [] if track_images else None
def handleFile(self, f, *args, **kwargs):
"""Loads ``f`` and sets ``self.audio_file`` to an instance of
:class:`eyed3.core.AudioFile` or ``None`` if an error occurred or the
file is not a recognized type.
The ``*args`` and ``**kwargs`` are passed to :func:`eyed3.core.load`.
"""
self.audio_file = None
try:
self.audio_file = core.load(f, *args, **kwargs)
except NotImplementedError as ex:
# Frame decryption, for instance...
printError(str(ex))
return
if self.audio_file:
self._num_loaded += 1
if self._file_cache is not None:
self._file_cache.append(self.audio_file)
elif self._dir_images is not None:
mt = guessMimetype(f)
if mt and mt.startswith("image/"):
self._dir_images.append(f)
def handleDirectory(self, d, _):
"""Override to make use of ``self._file_cache``. By default the list
is cleared, subclasses should consider doing the same otherwise every
AudioFile will be cached."""
if self._file_cache is not None:
self._file_cache = []
if self._dir_images is not None:
self._dir_images = []
def handleDone(self):
"""If no audio files were loaded this simply prints 'Nothing to do'."""
if self._num_loaded == 0:
printMsg("Nothing to do")
eyeD3-0.8.4/src/eyed3/plugins/fixup.py 0000644 0001750 0001750 00000066374 13061344514 020345 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2013-2014 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
from __future__ import print_function
import os
from collections import defaultdict
from eyed3.id3 import ID3_V2_4
from eyed3.id3.tag import TagTemplate
from eyed3.plugins import LoaderPlugin
from eyed3.compat import UnicodeType
from eyed3.utils import art
from eyed3.utils.prompt import prompt
from eyed3.utils.console import printMsg, Style, Fore
from eyed3 import core, compat
from eyed3.core import (ALBUM_TYPE_IDS, TXXX_ALBUM_TYPE,
LP_TYPE, EP_TYPE, COMP_TYPE, VARIOUS_TYPE, DEMO_TYPE,
LIVE_TYPE, SINGLE_TYPE, VARIOUS_ARTISTS)
EP_MAX_HINT = 9
LP_MAX_HINT = 19
NORMAL_FNAME_FORMAT = u"${artist} - ${track:num} - ${title}"
VARIOUS_FNAME_FORMAT = u"${track:num} - ${artist} - ${title}"
SINGLE_FNAME_FORMAT = u"${artist} - ${title}"
NORMAL_DNAME_FORMAT = u"${best_date:prefer_release} - ${album}"
LIVE_DNAME_FORMAT = u"${best_date:prefer_recording} - ${album}"
def _printChecking(msg, end='\n'):
print(Style.BRIGHT + Fore.GREEN + u"Checking" + Style.RESET_ALL +
" %s" % msg,
end=end)
def _fixCase(s):
if s:
fixed_values = []
for word in s.split():
fixed_values.append(word.capitalize())
return u" ".join(fixed_values)
else:
return s
def dirDate(d):
s = str(d)
if "T" in s:
s = s.split("T")[0]
return s.replace('-', '.')
class FixupPlugin(LoaderPlugin):
NAMES = ["fixup"]
SUMMARY = \
u"Performs various checks and fixes to directories of audio files."
DESCRIPTION = u"""
Operates on directories at a time, fixing each as a unit (album,
compilation, live set, etc.). All of these should have common dates,
for example but other characteristics may vary. The ``--type`` should be used
whenever possible, ``lp`` is the default.
The following test and fixes always apply:
1. Every file will be given an ID3 tag if one is missing.
2. Set ID3 v2.4.
3. Set a consistent album name for all files in the directory.
4. Set a consistent artist name for all files, unless the type is
``various`` in which case the artist may vary (but must exist).
5. Ensure each file has a title.
6. Ensure each file has a track # and track total.
7. Ensure all files have a release and original release date, unless the
type is ``live`` in which case the recording date is set.
8. All ID3 frames of the following types are removed: USER, PRIV
9. All ID3 files have TLEN (track length in ms) set (or updated).
10. The album/dir type is set in the tag. Types of ``lp`` and ``various``
do not have this field set since the latter is the default and the
former can be determined during sync. In ID3 terms the value is in
TXXX (description: ``%(TXXX_ALBUM_TYPE)s``).
11. Files are renamed as follows:
- Type ``various``: %(VARIOUS_FNAME_FORMAT)s
- Type ``single``: %(SINGLE_FNAME_FORMAT)s
- All other types: %(NORMAL_FNAME_FORMAT)s
- A rename template can be supplied in --file-rename-pattern
12. Directories are renamed as follows:
- Type ``live``: %(LIVE_DNAME_FORMAT)s
- All other types: %(NORMAL_DNAME_FORMAT)s
- A rename template can be supplied in --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 default.
- ``ep``: A short collection of songs from a single artist. The string 'ep'
is written to the tag's ``%(TXXX_ALBUM_TYPE)s`` field.
- ``various``: A collection of songs from different artists. The string
'various' is written to the tag's ``%(TXXX_ALBUM_TYPE)s`` field.
- ``live``: A collection of live recordings from a single artist. The string
'live' is written to the tag's ``%(TXXX_ALBUM_TYPE)s`` field.
- ``compilation``: A collection of songs from various recordings by a single
artist. The string 'compilation' is written to the tag's
``%(TXXX_ALBUM_TYPE)s`` field. Compilation dates, unlike other types, may
differ.
- ``demo``: A demo recording by a single artist. The string 'demo' is
written to the tag's ``%(TXXX_ALBUM_TYPE)s`` field.
- ``single``: A track that should no be associated with an album (even if
it has album metadata). The string 'single' is written to the tag's
``%(TXXX_ALBUM_TYPE)s`` field.
""" % globals()
def __init__(self, arg_parser):
super(FixupPlugin, self).__init__(arg_parser, cache_files=True,
track_images=True)
g = self.arg_group
self._handled_one = False
g.add_argument("-t", "--type", choices=ALBUM_TYPE_IDS, dest="dir_type",
default=None, type=UnicodeType,
help=ARGS_HELP["--type"])
g.add_argument("--fix-case", action="store_true", dest="fix_case",
help=ARGS_HELP["--fix-case"])
g.add_argument("-n", "--dry-run", action="store_true", dest="dry_run",
help=ARGS_HELP["--dry-run"])
g.add_argument("--no-prompt", action="store_true", dest="no_prompt",
help=ARGS_HELP["--no-prompt"])
g.add_argument("--dotted-dates", action="store_true",
help=ARGS_HELP["--dotted-dates"])
g.add_argument("--file-rename-pattern", dest="file_rename_pattern",
help=ARGS_HELP["--file-rename-pattern"])
g.add_argument("--dir-rename-pattern", dest="dir_rename_pattern",
help=ARGS_HELP["--dir-rename-pattern"])
self._curr_dir_type = None
self._dir_files_to_remove = set()
def _getOne(self, key, values, default=None, Type=UnicodeType,
required=True):
values = set(values)
if None in values:
values.remove(None)
if len(values) != 1:
printMsg(
u"Detected %s %s names%s" %
("0" if len(values) == 0 else "multiple",
key,
"." if not values
else (":\n\t%s" % "\n\t".join([compat.unicode(v)
for v in values])),
))
value = prompt(u"Enter %s" % key.title(), default=default,
type_=Type, required=required)
else:
value = values.pop()
return value
def _getDates(self, audio_files):
tags = [f.tag for f in audio_files if f.tag]
rel_dates = set([t.release_date for t in tags if t.release_date])
orel_dates = set([t.original_release_date for t in tags
if t.original_release_date])
rec_dates = set([t.recording_date for t in tags if t.recording_date])
release_date, original_release_date, recording_date = None, None, None
def reduceDate(type_str, dates_set, default_date=None):
if len(dates_set or []) != 1:
reduced = self._getOne(type_str, dates_set,
default=str(default_date) if default_date
else None,
Type=core.Date.parse)
else:
reduced = dates_set.pop()
return reduced
if (False not in [a.tag.album_type == LIVE_TYPE for a in audio_files] or
self._curr_dir_type == LIVE_TYPE):
# The recording date is most meaningful for live music.
recording_date = reduceDate("recording date",
rec_dates | orel_dates | rel_dates)
rec_dates = set([recording_date])
# Want when these set if they may recording time.
orel_dates.difference_update(rec_dates)
rel_dates.difference_update(rec_dates)
if orel_dates:
original_release_date = reduceDate("original release date",
orel_dates | rel_dates)
orel_dates = set([original_release_date])
if rel_dates | orel_dates:
release_date = reduceDate("release date",
rel_dates | orel_dates)
elif (False not in [a.tag.album_type == COMP_TYPE
for a in audio_files] or
self._curr_dir_type == COMP_TYPE):
# The release date is most meaningful for comps, other track dates
# may differ.
if len(rel_dates) != 1:
release_date = reduceDate("release date",
rel_dates | orel_dates)
rel_dates = set([release_date])
else:
release_date = list(rel_dates)[0]
else:
if len(orel_dates) != 1:
# The original release date is most meaningful for studio music.
original_release_date = reduceDate("original release date",
orel_dates | rel_dates |
rec_dates)
orel_dates = set([original_release_date])
else:
original_release_date = list(orel_dates)[0]
if len(rel_dates) != 1:
release_date = reduceDate("release date",
rel_dates | orel_dates)
rel_dates = set([release_date])
else:
release_date = list(rel_dates)[0]
if rec_dates.difference(orel_dates | rel_dates):
recording_date = reduceDate("recording date", rec_dates)
return release_date, original_release_date, recording_date
def _resolveArtistInfo(self, audio_files):
assert(self._curr_dir_type != SINGLE_TYPE)
tags = [f.tag for f in audio_files if f.tag]
artists = set([t.album_artist for t in tags if t.album_artist])
# There can be 0 or 1 album artist values.
album_artist = None
if len(artists) > 1:
album_artist = self._getOne("album artist", artists, required=False)
elif artists:
album_artist = artists.pop()
artists = list(set([t.artist for t in tags if t.artist]))
if len(artists) > 1:
# There can be more then 1 artist when VARIOUS_TYPE or
# album_artist != None.
if not album_artist and self._curr_dir_type != VARIOUS_TYPE:
if prompt("Multiple artist names exist, process directory as "
"various artists", default=True):
self._curr_dir_type = VARIOUS_TYPE
else:
artists = [self._getOne("artist", artists, required=True)]
elif (album_artist == VARIOUS_ARTISTS and
self._curr_dir_type != VARIOUS_TYPE):
self._curr_dir_type = VARIOUS_TYPE
elif len(artists) == 0:
artists = [self._getOne("artist", [], required=True)]
# Fix up artist and album artist discrepancies
if len(artists) == 1 and album_artist:
artist = artists[0]
if (album_artist != artist):
print("When there is only one artist it should match the "
"album artist. Choices are: ")
for s in [artist, album_artist]:
print("\t%s" % s)
album_artist = prompt("Select common artist and album artist",
choices=[artist, album_artist])
artists = [album_artist]
if self.args.fix_case:
album_artist = _fixCase(album_artist)
artists = [_fixCase(a) for a in artists]
return album_artist, artists
def _getAlbum(self, audio_files):
tags = [f.tag for f in audio_files if f.tag]
albums = set([t.album for t in tags if t.album])
album_name = (albums.pop() if len(albums) == 1
else self._getOne("album", albums))
assert(album_name)
return album_name if not self.args.fix_case else _fixCase(album_name)
def _checkCoverArt(self, directory, audio_files):
valid_cover = False
# Check for cover file.
_printChecking("for cover art...")
for dimg in self._dir_images:
art_type = art.matchArtFile(dimg)
if art_type == art.FRONT_COVER:
dimg_name = os.path.basename(dimg)
print("\t%s" % dimg_name)
valid_cover = True
if not valid_cover:
# FIXME: move the logic out fixup and into art.
# Look for a cover in the tags.
for tag in [af.tag for af in audio_files if af.tag]:
if valid_cover:
# It could be set below...
break
for img in tag.images:
if img.picture_type == img.FRONT_COVER:
file_name = img.makeFileName("cover")
print("\tFound front cover in tag, writing '%s'" %
file_name)
with open(os.path.join(directory, file_name),
"wb") as img_file:
img_file.write(img.image_data)
img_file.close()
valid_cover = True
return valid_cover
def start(self, args, config):
import eyed3.utils.prompt
eyed3.utils.prompt.DISABLE_PROMPT = "exit" if args.no_prompt else None
super(FixupPlugin, self).start(args, config)
def handleFile(self, f, *args, **kwargs):
super(FixupPlugin, self).handleFile(f, *args, **kwargs)
if not self.audio_file and f not in self._dir_images:
self._dir_files_to_remove.add(f)
def handleDirectory(self, directory, _):
if not self._file_cache:
return
directory = os.path.abspath(directory)
print("\n" + Style.BRIGHT + Fore.GREY +
"Scanning directory%s %s" % (Style.RESET_ALL, directory))
def _path(af):
return af.path
self._handled_one = True
# Make sure all of the audio files has a tag.
for f in self._file_cache:
if f.tag is None:
f.initTag()
audio_files = sorted(list(self._file_cache), key=_path)
self._file_cache = []
edited_files = set()
self._curr_dir_type = self.args.dir_type
if self._curr_dir_type is None:
types = set([a.tag.album_type for a in audio_files])
if len(types) == 1:
self._curr_dir_type = types.pop()
# Check for corrections to LP, EP, COMP
if (self._curr_dir_type is None and len(audio_files) < EP_MAX_HINT):
# Do you want EP?
if False in [a.tag.album_type == EP_TYPE for a in audio_files]:
if prompt("Only %d audio files, process directory as an EP" %
len(audio_files),
default=True):
self._curr_dir_type = EP_TYPE
else:
self._curr_dir_type = EP_TYPE
elif (self._curr_dir_type in (EP_TYPE, DEMO_TYPE) and
len(audio_files) > EP_MAX_HINT):
# Do you want LP?
if prompt("%d audio files is large for type %s, process "
"directory as an LP" % (len(audio_files),
self._curr_dir_type),
default=True):
self._curr_dir_type = LP_TYPE
last = defaultdict(lambda: None)
album_artist = None
artists = set()
album = None
if self._curr_dir_type != SINGLE_TYPE:
album_artist, artists = self._resolveArtistInfo(audio_files)
print(Fore.BLUE + u"Album artist: " + Style.RESET_ALL +
(album_artist or u""))
print(Fore.BLUE + "Artist" + ("s" if len(artists) > 1 else "") +
": " + Style.RESET_ALL + u", ".join(artists))
album = self._getAlbum(audio_files)
print(Fore.BLUE + "Album: " + Style.RESET_ALL + album)
rel_date, orel_date, rec_date = self._getDates(audio_files)
for what, d in [("Release", rel_date),
("Original", orel_date),
("Recording", rec_date)]:
print(Fore.BLUE + ("%s date: " % what) + Style.RESET_ALL +
str(d))
num_audio_files = len(audio_files)
track_nums = set([f.tag.track_num[0] for f in audio_files])
fix_track_nums = set(range(1, num_audio_files + 1)) != track_nums
new_track_nums = []
dir_type = self._curr_dir_type
for f in sorted(audio_files, key=_path):
print(Style.BRIGHT + Fore.GREEN + u"Checking" + Fore.RESET +
Fore.GREY + (" %s" % os.path.basename(f.path)) +
Style.RESET_ALL)
if not f.tag:
print("\tAdding new tag")
f.initTag()
edited_files.add(f)
tag = f.tag
if tag.version != ID3_V2_4:
print("\tConverting to ID3 v2.4")
tag.version = ID3_V2_4
edited_files.add(f)
if (dir_type != SINGLE_TYPE and album_artist != tag.album_artist):
print(u"\tSetting album artist: %s" % album_artist)
tag.album_artist = album_artist
edited_files.add(f)
if not tag.artist and dir_type in (VARIOUS_TYPE, SINGLE_TYPE):
# Prompt artist
tag.artist = prompt("Artist name", default=last["artist"])
last["artist"] = tag.artist
elif len(artists) == 1 and tag.artist != artists[0]:
assert(dir_type != SINGLE_TYPE)
print(u"\tSetting artist: %s" % artists[0])
tag.artist = artists[0]
edited_files.add(f)
if tag.album != album and dir_type != SINGLE_TYPE:
print(u"\tSetting album: %s" % album)
tag.album = album
edited_files.add(f)
orig_title = tag.title
if not tag.title:
tag.title = prompt("Track title")
tag.title = tag.title.strip()
if self.args.fix_case:
tag.title = _fixCase(tag.title)
if orig_title != tag.title:
print(u"\tSetting title: %s" % tag.title)
edited_files.add(f)
if dir_type != SINGLE_TYPE:
# Track numbers
tnum, ttot = tag.track_num
update = False
if ttot != num_audio_files:
update = True
ttot = num_audio_files
if fix_track_nums or not (1 <= tnum <= num_audio_files):
tnum = None
while tnum is None:
tnum = int(prompt("Track #", type_=int))
if not (1 <= tnum <= num_audio_files):
print(Fore.RED + "Out of range: " + Fore.RESET +
"1 <= %d <= %d" % (tnum, num_audio_files))
tnum = None
elif tnum in new_track_nums:
print(Fore.RED + "Duplicate value: " + Fore.RESET +
str(tnum))
tnum = None
else:
update = True
new_track_nums.append(tnum)
if update:
tag.track_num = (tnum, ttot)
print("\tSetting track numbers: %s" % str(tag.track_num))
edited_files.add(f)
else:
# Singles
if tag.track_num != (None, None):
tag.track_num = (None, None)
edited_files.add(f)
if dir_type != SINGLE_TYPE:
# Dates
if rec_date and tag.recording_date != rec_date:
print("\tSetting %s date (%s)" %
("recording", str(rec_date)))
tag.recording_date = rec_date
edited_files.add(f)
if rel_date and tag.release_date != rel_date:
print("\tSetting %s date (%s)" % ("release", str(rel_date)))
tag.release_date = rel_date
edited_files.add(f)
if orel_date and tag.original_release_date != orel_date:
print("\tSetting %s date (%s)" % ("original release",
str(orel_date)))
tag.original_release_date = orel_date
edited_files.add(f)
for frame in list(tag.frameiter(["USER", "PRIV"])):
print("\tRemoving %s frames: %s" %
(frame.id,
frame.owner_id if frame.id == b"PRIV" else frame.text))
tag.frame_set[frame.id].remove(frame)
edited_files.add(f)
# Add TLEN
tlen = tag.getTextFrame("TLEN")
real_tlen = f.info.time_secs * 1000
if tlen is None or int(tlen) != real_tlen:
print("\tSetting TLEN (%d)" % real_tlen)
tag.setTextFrame("TLEN", UnicodeType(real_tlen))
edited_files.add(f)
# Add custom album type if special and otherwise not able to be
# determined.
curr_type = tag.album_type
if curr_type != dir_type:
print("\tSetting %s = %s" % (TXXX_ALBUM_TYPE, dir_type))
tag.album_type = dir_type
edited_files.add(f)
try:
if not self._checkCoverArt(directory, audio_files):
if not prompt("Proceed without valid cover file", default=True):
return
finally:
self._dir_images = []
# Determine other changes, like file and/or directory renames
# so they can be reported before save confirmation.
# File renaming
file_renames = []
if self.args.file_rename_pattern:
format_str = self.args.file_rename_pattern
else:
if dir_type == SINGLE_TYPE:
format_str = SINGLE_FNAME_FORMAT
elif dir_type in (VARIOUS_TYPE, COMP_TYPE):
format_str = VARIOUS_FNAME_FORMAT
else:
format_str = NORMAL_FNAME_FORMAT
for f in audio_files:
orig_name, orig_ext = os.path.splitext(os.path.basename(f.path))
new_name = TagTemplate(format_str).substitute(f.tag, zeropad=True)
if orig_name != new_name:
printMsg(u"Rename file to %s%s" % (new_name, orig_ext))
file_renames.append((f, new_name, orig_ext))
# Directory renaming
dir_rename = None
if dir_type != SINGLE_TYPE:
if self.args.dir_rename_pattern:
dir_format = self.args.dir_rename_pattern
else:
if dir_type == LIVE_TYPE:
dir_format = LIVE_DNAME_FORMAT
else:
dir_format = NORMAL_DNAME_FORMAT
template = TagTemplate(dir_format,
dotted_dates=self.args.dotted_dates)
pref_dir = template.substitute(audio_files[0].tag, zeropad=True)
if os.path.basename(directory) != pref_dir:
new_dir = os.path.join(os.path.dirname(directory), pref_dir)
printMsg("Rename directory to %s" % new_dir)
dir_rename = (directory, new_dir)
# Cruft files to remove
file_removes = []
if self._dir_files_to_remove:
for f in self._dir_files_to_remove:
print("Remove file: " + os.path.basename(f))
file_removes.append(f)
self._dir_files_to_remove = set()
if not self.args.dry_run:
confirmed = False
if (edited_files or file_renames or dir_rename or file_removes):
confirmed = prompt("\nSave changes", default=True)
if confirmed:
for f in edited_files:
print(u"Saving %s" % os.path.basename(f.path))
f.tag.save(version=ID3_V2_4, preserve_file_time=True)
for f, new_name, orig_ext in file_renames:
printMsg(u"Renaming file to %s%s" % (new_name, orig_ext))
f.rename(new_name, preserve_file_time=True)
if file_removes:
for f in file_removes:
printMsg("Removing file %s" % os.path.basename(f))
os.remove(f)
if dir_rename:
printMsg("Renaming directory to %s" % dir_rename[1])
s = os.stat(dir_rename[0])
os.rename(dir_rename[0], dir_rename[1])
# With a rename use the origianl access time
os.utime(dir_rename[1], (s.st_atime, s.st_atime))
else:
printMsg("\nNo changes made (run without -n/--dry-run)")
def handleDone(self):
if not self._handled_one:
printMsg("Nothing to do")
def _getTemplateKeys():
from eyed3.id3.tag import TagTemplate
keys = list(TagTemplate("")._makeMapping(None, False).keys())
keys.sort()
return ", ".join(["$%s" % v for v in keys])
ARGS_HELP = {
"--type": "How to treat each directory. The default is '%s', "
"although you may be prompted for an alternate choice "
"if the files look like another type." % ALBUM_TYPE_IDS[0],
"--fix-case": "Fix casing on each string field by capitalizing each "
"word.",
"--dry-run": "Only print the operations that would take place, but do "
"not execute them.",
"--no-prompt": "Exit if prompted.",
"--dotted-dates": "Separate date with '.' instead of '-' when naming "
"directories.",
"--file-rename-pattern": "Rename file (the extension is not affected) "
"based on data in the tag using substitution "
"variables: " + _getTemplateKeys(),
"--dir-rename-pattern": "Rename directory based on data in the tag "
"using substitution variables: " +
_getTemplateKeys(),
}
eyeD3-0.8.4/src/eyed3/plugins/_display_parser.py 0000644 0001750 0001750 00000013707 13061344514 022362 0 ustar travis travis 0000000 0000000 #!/usr/bin/env python
# -*- coding: utf-8 -*-
# CAVEAT UTILITOR
#
# This file was automatically generated by Grako.
#
# https://pypi.python.org/pypi/grako/
#
# Any changes you make to it will be overwritten the next time
# the file is generated.
from __future__ import (print_function, division, absolute_import,
unicode_literals)
from grako.parsing import graken, Parser
from grako.util import re, RE_FLAGS # noqa
__version__ = (2016, 2, 17, 20, 35, 22, 2)
__all__ = [
'DisplayPatternParser',
'DisplayPatternSemantics',
'main'
]
class DisplayPatternParser(Parser):
def __init__(self,
whitespace=None,
nameguard=None,
comments_re=None,
eol_comments_re=None,
ignorecase=None,
left_recursion=True,
**kwargs):
super(DisplayPatternParser, self).__init__(
whitespace=whitespace,
nameguard=nameguard,
comments_re=comments_re,
eol_comments_re=eol_comments_re,
ignorecase=ignorecase,
left_recursion=left_recursion,
**kwargs
)
@graken()
def _start_(self):
self._pattern_()
self._check_eof()
@graken()
def _pattern_(self):
def block0():
with self._choice():
with self._option():
self._text_()
with self._option():
self._tag_()
with self._option():
self._function_()
self._error('no available options')
self._closure(block0)
@graken()
def _tag_(self):
with self._group():
self._token('%')
self._string_()
self.ast['name'] = self.last_node
def block2():
self._token(',')
with self._group():
self._parameter_()
self.ast.setlist('parameters', self.last_node)
self._closure(block2)
self._token('%')
self.ast['tag'] = self.last_node
self.ast._define(
['tag', 'name'],
['parameters']
)
@graken()
def _function_(self):
with self._group():
self._token('$')
self._string_()
self.ast['name'] = self.last_node
self._token('(')
with self._optional():
with self._group():
self._parameter_()
self.ast.setlist('parameters', self.last_node)
def block3():
self._token(',')
with self._group():
self._parameter_()
self.ast.setlist('parameters', self.last_node)
self._closure(block3)
self._token(')')
self.ast['function'] = self.last_node
self.ast._define(
['function', 'name'],
['parameters']
)
@graken()
def _parameter_(self):
with self._optional():
def block0():
self._token(' ')
self._closure(block0)
self._string_()
self.ast['name'] = self.last_node
self._token('=')
with self._optional():
self._pattern_()
self.ast['value'] = self.last_node
self.ast._define(
['name', 'value'],
[]
)
@graken()
def _text_(self):
self._pattern(r'(\\\\|\\%|\\\$|\\,|\\\(|\\\)|\\=|\\n|\\t|[^\\%$,()])+')
self.ast['text'] = self.last_node
self.ast._define(
['text'],
[]
)
@graken()
def _string_(self):
self._pattern(r'([^\\%$,()=])+')
class DisplayPatternSemantics(object):
def start(self, ast):
return ast
def pattern(self, ast):
return ast
def tag(self, ast):
return ast
def function(self, ast):
return ast
def parameter(self, ast):
return ast
def text(self, ast):
return ast
def string(self, ast):
return ast
def main(filename, startrule, trace=False, whitespace=None, nameguard=None):
import json
with open(filename) as f:
text = f.read()
parser = DisplayPatternParser(parseinfo=False)
ast = parser.parse(
text,
startrule,
filename=filename,
trace=trace,
whitespace=whitespace,
nameguard=nameguard)
print('AST:')
print(ast)
print()
print('JSON:')
print(json.dumps(ast, indent=2))
print()
if __name__ == '__main__':
import argparse
import string
import sys
class ListRules(argparse.Action):
def __call__(self, parser, namespace, values, option_string):
print('Rules:')
for r in DisplayPatternParser.rule_list():
print(r)
print()
sys.exit(0)
parser = argparse.ArgumentParser(
description="Simple parser for DisplayPattern.")
parser.add_argument('-l', '--list', action=ListRules, nargs=0,
help="list all rules and exit")
parser.add_argument('-n', '--no-nameguard', action='store_true',
dest='no_nameguard',
help="disable the 'nameguard' feature")
parser.add_argument('-t', '--trace', action='store_true',
help="output trace information")
parser.add_argument('-w', '--whitespace', type=str,
default=string.whitespace,
help="whitespace specification")
parser.add_argument('file', metavar="FILE", help="the input file to parse")
parser.add_argument('startrule', metavar="STARTRULE",
help="the start rule for parsing")
args = parser.parse_args()
main(
args.file,
args.startrule,
trace=args.trace,
whitespace=args.whitespace,
nameguard=not args.no_nameguard
)
eyeD3-0.8.4/src/eyed3/plugins/DisplayPattern.ebnf 0000644 0001750 0001750 00000002721 13061344514 022421 0 ustar travis travis 0000000 0000000 (*
################################################################################
# Copyright (C) 2016 Sebastian Patschorke
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
parser generation:
$ python -m grako -o src/eyed3/plugins/display_parser.py src/eyed3/plugins/DisplayPattern.ebnf
*)
start = pattern $ ;
pattern = { text | tag | function }* ;
tag = tag:( "%" name:string { "," parameters+:(parameter) }* "%" );
function = function:("$" name:string "(" [ parameters+:(parameter) { "," parameters+:(parameter) }* ] ")" );
parameter = [ {" "}* name:string "=" ] [ value:pattern ] ;
text = text:?/(\\\\|\\%|\\\$|\\,|\\\(|\\\)|\\=|\\n|\\t|[^\\%$,()])+/? ;
string = ?/([^\\%$,()=])+/? ;
eyeD3-0.8.4/src/eyed3/plugins/lameinfo.py 0000644 0001750 0001750 00000011310 13153052736 020764 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2009 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
import os
import math
from eyed3.utils import formatSize
from eyed3.utils.console import printMsg, boldText, Fore, HEADER_COLOR
from eyed3.plugins import LoaderPlugin
class LameInfoPlugin(LoaderPlugin):
NAMES = ["lameinfo", "xing"]
SUMMARY = u"Outputs lame header (if one exists) for file."
DESCRIPTION = (
u"The 'lame' (or xing) header provides extra information about the mp3 "
"that is useful to players and encoders but not officially part of "
"the mp3 specification. Variable bit rate mp3s, for example, use this "
"header.\n\n"
"For more details see "
"`here `_"
)
def printHeader(self, filePath):
from stat import ST_SIZE
fileSize = os.stat(filePath)[ST_SIZE]
size_str = formatSize(fileSize)
print("\n%s\t%s[ %s ]%s" % (boldText(os.path.basename(filePath),
HEADER_COLOR()),
HEADER_COLOR(), size_str,
Fore.RESET))
print("-" * 79)
def handleFile(self, f):
super(LameInfoPlugin, self).handleFile(f)
self.printHeader(f)
if (self.audio_file is None or self.audio_file.info is None or
not self.audio_file.info.lame_tag):
printMsg('No LAME Tag')
return
format = '%-20s: %s'
lt = self.audio_file.info.lame_tag
if "infotag_crc" not in lt:
try:
printMsg('%s: %s' % ('Encoder Version', lt['encoder_version']))
except KeyError:
pass
return
values = []
values.append(('Encoder Version', lt['encoder_version']))
values.append(('LAME Tag Revision', lt['tag_revision']))
values.append(('VBR Method', lt['vbr_method']))
values.append(('Lowpass Filter', lt['lowpass_filter']))
if "replaygain" in lt:
try:
peak = lt['replaygain']['peak_amplitude']
db = 20 * math.log10(peak)
val = '%.8f (%+.1f dB)' % (peak, db)
values.append(('Peak Amplitude', val))
except KeyError:
pass
for type in ['radio', 'audiofile']:
try:
gain = lt['replaygain'][type]
name = '%s Replay Gain' % gain['name'].capitalize()
val = '%s dB (%s)' % (gain['adjustment'],
gain['originator'])
values.append((name, val))
except KeyError:
pass
values.append(('Encoding Flags', ' '.join((lt['encoding_flags']))))
if lt['nogap']:
values.append(('No Gap', ' and '.join(lt['nogap'])))
values.append(('ATH Type', lt['ath_type']))
values.append(('Bitrate (%s)' % lt['bitrate'][1], lt['bitrate'][0]))
values.append(('Encoder Delay', '%s samples' % lt['encoder_delay']))
values.append(('Encoder Padding', '%s samples' % lt['encoder_padding']))
values.append(('Noise Shaping', lt['noise_shaping']))
values.append(('Stereo Mode', lt['stereo_mode']))
values.append(('Unwise Settings', lt['unwise_settings']))
values.append(('Sample Frequency', lt['sample_freq']))
values.append(('MP3 Gain', '%s (%+.1f dB)' % (lt['mp3_gain'],
lt['mp3_gain'] * 1.5)))
values.append(('Preset', lt['preset']))
values.append(('Surround Info', lt['surround_info']))
values.append(('Music Length', '%s' % formatSize(lt['music_length'])))
values.append(('Music CRC-16', '%04X' % lt['music_crc']))
values.append(('LAME Tag CRC-16', '%04X' % lt['infotag_crc']))
for v in values:
printMsg(format % (v))
eyeD3-0.8.4/src/eyed3/mp3/ 0000755 0001750 0001750 00000000000 13203726215 015636 5 ustar travis travis 0000000 0000000 eyeD3-0.8.4/src/eyed3/mp3/__init__.py 0000644 0001750 0001750 00000017166 13177420270 017764 0 ustar travis travis 0000000 0000000 ################################################################################
# Copyright (C) 2002-2007 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
import os
import re
from .. import Error
from .. import id3
from .. import core, utils
from ..utils.log import getLogger
log = getLogger(__name__)
class Mp3Exception(Error):
"""Used to signal mp3-related errors."""
pass
NAME = "mpeg"
MIME_TYPES = ["audio/mpeg", "audio/mp3", "audio/x-mp3", "audio/x-mpeg",
"audio/mpeg3", "audio/x-mpeg3", "audio/mpg", "audio/x-mpg",
"audio/x-mpegaudio",
]
'''Mime-types that are recognized at MP3'''
OTHER_MIME_TYPES = ['application/octet-stream', # ???
'audio/x-hx-aac-adts', # ???
'audio/x-wav', # RIFF wrapped mp3s
]
'''Mime-types that have been seen to contain mp3 data.'''
EXTENSIONS = [".mp3"]
'''Valid file extensions.'''
def isMp3File(file_name):
'''Does a mime-type check on ``file_name`` and returns ``True`` it the
file is mp3, and ``False`` otherwise.'''
return utils.guessMimetype(file_name) in MIME_TYPES
class Mp3AudioInfo(core.AudioInfo):
def __init__(self, file_obj, start_offset, tag):
from . import headers
from .headers import timePerFrame
log.debug("mp3 header search starting @ %x" % start_offset)
core.AudioInfo.__init__(self)
self.mp3_header = None
self.xing_header = None
self.vbri_header = None
self.lame_tag = None
'''If not ``None``, the Lame header.
See :class:`eyed3.mp3.headers.LameHeader`'''
self.bit_rate = (None, None)
'''2-tuple, (vrb?:boolean, bitrate:int)'''
while self.mp3_header is None:
# Find first mp3 header
(header_pos,
header_int,
header_bytes) = headers.findHeader(file_obj, start_offset)
if not header_int:
try:
fname = file_obj.name
except AttributeError:
fname = 'unknown'
raise headers.Mp3Exception(
"Unable to find a valid mp3 frame in '%s'" % fname)
try:
self.mp3_header = headers.Mp3Header(header_int)
log.debug("mp3 header %x found at position: 0x%x" %
(header_int, header_pos))
except headers.Mp3Exception as ex:
log.debug("Invalid mp3 header: %s" % str(ex))
# keep looking...
start_offset += 4
file_obj.seek(header_pos)
mp3_frame = file_obj.read(self.mp3_header.frame_length)
if re.compile(b'Xing|Info').search(mp3_frame):
# Check for Xing/Info header information.
self.xing_header = headers.XingHeader()
if not self.xing_header.decode(mp3_frame):
log.debug("Ignoring corrupt Xing header")
self.xing_header = None
elif mp3_frame.find(b'VBRI') >= 0:
# Check for VBRI header information.
self.vbri_header = headers.VbriHeader()
if not self.vbri_header.decode(mp3_frame):
log.debug("Ignoring corrupt VBRI header")
self.vbri_header = None
# Check for LAME Tag
self.lame_tag = headers.LameHeader(mp3_frame)
# Set file size
import stat
self.size_bytes = os.stat(file_obj.name)[stat.ST_SIZE]
# Compute track play time.
tpf = None
if self.xing_header and self.xing_header.vbr:
tpf = timePerFrame(self.mp3_header, True)
self.time_secs = int(tpf * self.xing_header.numFrames)
elif self.vbri_header and self.vbri_header.version == 1:
tpf = timePerFrame(self.mp3_header, True)
self.time_secs = int(tpf * self.vbri_header.num_frames)
else:
tpf = timePerFrame(self.mp3_header, False)
length = self.size_bytes
if tag and tag.isV2():
length -= tag.header.SIZE + tag.header.tag_size
# Handle the case where there is a v2 tag and a v1 tag.
file_obj.seek(-128, 2)
if file_obj.read(3) == "TAG":
length -= 128
elif tag and tag.isV1():
length -= 128
self.time_secs = int((length / self.mp3_header.frame_length) * tpf)
# Compute bitate
if (self.xing_header and self.xing_header.vbr and
self.xing_header.numFrames): # if xing_header.numFrames == 0
# ZeroDivisionError
br = int((self.xing_header.numBytes * 8) /
(tpf * self.xing_header.numFrames * 1000))
vbr = True
else:
br = self.mp3_header.bit_rate
vbr = False
self.bit_rate = (vbr, br)
self.sample_freq = self.mp3_header.sample_freq
self.mode = self.mp3_header.mode
##
# Helper to get the bitrate as a string. The prefix '~' is used to denote
# variable bit rates.
@property
def bit_rate_str(self):
(vbr, bit_rate) = self.bit_rate
brs = "%d kb/s" % bit_rate
if vbr:
brs = "~" + brs
return brs
class Mp3AudioFile(core.AudioFile):
"""Audio file container for mp3 files."""
def __init__(self, path, version=id3.ID3_ANY_VERSION):
self._tag_version = version
core.AudioFile.__init__(self, path)
assert(self.type == core.AUDIO_MP3)
def _read(self):
with open(self.path, 'rb') as file_obj:
self._tag = id3.Tag()
tag_found = self._tag.parse(file_obj, self._tag_version)
# Compute offset for starting mp3 data search
if tag_found and self._tag.isV1():
mp3_offset = 0
elif tag_found and self._tag.isV2():
mp3_offset = self._tag.header.SIZE + self._tag.header.tag_size
else:
mp3_offset = 0
self._tag = None
try:
self._info = Mp3AudioInfo(file_obj, mp3_offset, self._tag)
except Mp3Exception as ex:
# Only logging a warning here since we can still operate on
# the tag.
log.warning(ex)
self._info = None
self.type = core.AUDIO_MP3
def initTag(self, version=id3.ID3_DEFAULT_VERSION):
"""Add a id3.Tag to the file (removing any existing tag if one exists).
"""
self.tag = id3.Tag()
self.tag.version = version
self.tag.file_info = id3.FileInfo(self.path)
@core.AudioFile.tag.setter
def tag(self, t):
if t:
t.file_info = id3.FileInfo(self.path)
if self._tag and self._tag.file_info:
t.file_info.tag_size = self._tag.file_info.tag_size
t.file_info.tag_padding_size = \
self._tag.file_info.tag_padding_size
self._tag = t
eyeD3-0.8.4/src/eyed3/mp3/headers.py 0000644 0001750 0001750 00000077651 13177420270 017645 0 ustar travis travis 0000000 0000000 ################################################################################
# Copyright (C) 2002-2015 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
from math import log10
from . import Mp3Exception
from ..utils.binfuncs import bytes2bin, bytes2dec, bin2dec
from .. import compat
from ..utils.log import getLogger
log = getLogger(__name__)
def isValidHeader(header):
"""Determine if ``header`` (an integer, 4 bytes compared) is a valid mp3
frame header."""
# Test for the mp3 frame sync: 11 set bits.
sync = (header >> 16)
if sync & 0xffe0 != 0xffe0:
# ffe0 is 11 sync bits, 12 are not used in order to support identifying
# mpeg v2.5 (bits 20,19)
return False
# All the remaining tests are not entireley required, but do help in
# finding false syncs
version = (header >> 19) & 0x3
if version == 1:
# This is a "reserved" version
log.debug("invalid mpeg version")
return False
layer = (header >> 17) & 0x3
if layer == 0:
# This is a "reserved" layer
log.debug("invalid mpeg layer")
return False
bitrate = (header >> 12) & 0xf
if bitrate in (0, 0xf):
# free and bad bitrate values
log.debug("invalid mpeg bitrate")
return False
sample_rate = (header >> 10) & 0x3
if sample_rate == 0x3:
# this is a "reserved" sample rate
log.debug("invalid mpeg sample rate")
return False
return True
def findHeader(fp, start_pos=0):
"""Locate the first mp3 header in file stream ``fp`` starting a offset
``start_pos`` (defaults to 0). Returned is a 3-tuple containing the offset
where the header was found, the header as an integer, and the header as 4
bytes. If no header is found header_int will equal 0.
"""
def find_sync(fp, start_pos=0):
CHUNK_SIZE = 8192 # Measured as optimal
fp.seek(start_pos)
data = fp.read(CHUNK_SIZE)
while data:
sync_pos = data.find(b'\xff', 0)
if sync_pos >= 0:
header = data[sync_pos:sync_pos + 4]
if len(header) == 4:
return (start_pos + sync_pos, header)
data = fp.read(CHUNK_SIZE)
return (None, None)
sync_pos, header_bytes = find_sync(fp, start_pos)
while sync_pos is not None:
header = bytes2dec(header_bytes)
if isValidHeader(header):
return (sync_pos, header, header_bytes)
sync_pos, header_bytes = find_sync(fp, start_pos + sync_pos + 2)
return (None, None, None)
def timePerFrame(mp3_header, vbr):
"""Computes the number of seconds per mp3 frame. It can be used to
compute overall playtime and bitrate. The mp3 layer and sample
rate from ``mp3_header`` are used to compute the number of seconds
(fractional float point value) per mp3 frame. Be sure to set ``vbr`` True
when dealing with VBR, otherwise playtimes may be incorrect."""
# https://bitbucket.org/nicfit/eyed3/issue/32/mp3audioinfotime_secs-incorrect-for-mpeg2
if mp3_header.version >= 2.0 and vbr:
row = _mp3VersionKey(mp3_header.version)
else:
row = 0
return (float(SAMPLES_PER_FRAME_TABLE[row][mp3_header.layer]) /
float(mp3_header.sample_freq))
def compute_time_per_frame(mp3_header):
"""Deprecated, use timePerFrame instead."""
import warnings
warnings.warn("Use timePerFrame instead", DeprecationWarning, stacklevel=2)
return timePerFrame(mp3_header, False)
class Mp3Header:
"""Header container for MP3 frames."""
def __init__(self, header_data=None):
self.version = None
self.layer = None
self.error_protection = None
self.bit_rate = None
self.sample_freq = None
self.padding = None
self.private_bit = None
self.copyright = None
self.original = None
self.emphasis = None
self.mode = None
# This value is left as is: 0<=mode_extension<=3.
# See http://www.dv.co.yu/mpgscript/mpeghdr.htm for how to interpret
self.mode_extension = None
self.frame_length = None
if header_data:
self.decode(header_data)
# This may throw an Mp3Exception if the header is malformed.
def decode(self, header):
if not isValidHeader(header):
raise Mp3Exception("Invalid MPEG header")
# MPEG audio version from bits 19 and 20.
version = (header >> 19) & 0x3
self.version = [2.5, None, 2.0, 1.0][version]
if self.version is None:
raise Mp3Exception("Illegal MPEG version")
# MPEG layer
self.layer = 4 - ((header >> 17) & 0x3)
if self.layer == 4:
raise Mp3Exception("Illegal MPEG layer")
# Decode some simple values.
self.error_protection = not (header >> 16) & 0x1
self.padding = (header >> 9) & 0x1
self.private_bit = (header >> 8) & 0x1
self.copyright = (header >> 3) & 0x1
self.original = (header >> 2) & 0x1
# Obtain sampling frequency.
sample_bits = (header >> 10) & 0x3
self.sample_freq = \
SAMPLE_FREQ_TABLE[sample_bits][_mp3VersionKey(self.version)]
if not self.sample_freq:
raise Mp3Exception("Illegal MPEG sampling frequency")
# Compute bitrate.
bit_rate_row = (header >> 12) & 0xf
if int(self.version) == 1 and self.layer == 1:
bit_rate_col = 0
elif int(self.version) == 1 and self.layer == 2:
bit_rate_col = 1
elif int(self.version) == 1 and self.layer == 3:
bit_rate_col = 2
elif int(self.version) == 2 and self.layer == 1:
bit_rate_col = 3
elif int(self.version) == 2 and (self.layer == 2 or
self.layer == 3):
bit_rate_col = 4
else:
raise Mp3Exception("Mp3 version %f and layer %d is an invalid "
"combination" % (self.version, self.layer))
self.bit_rate = BIT_RATE_TABLE[bit_rate_row][bit_rate_col]
if self.bit_rate is None:
raise Mp3Exception("Invalid bit rate")
# We know know the bit rate specified in this frame, but if the file
# is VBR we need to obtain the average from the Xing header.
# This is done by the caller since right now all we have is the frame
# header.
# Emphasis; whatever that means??
emph = header & 0x3
if emph == 0:
self.emphasis = EMPHASIS_NONE
elif emph == 1:
self.emphasis = EMPHASIS_5015
elif emph == 2:
self.emphasis = EMPHASIS_CCIT
else:
raise Mp3Exception("Illegal mp3 emphasis value: %d" % emph)
# Channel mode.
mode_bits = (header >> 6) & 0x3
if mode_bits == 0:
self.mode = MODE_STEREO
elif mode_bits == 1:
self.mode = MODE_JOINT_STEREO
elif mode_bits == 2:
self.mode = MODE_DUAL_CHANNEL_STEREO
else:
self.mode = MODE_MONO
self.mode_extension = (header >> 4) & 0x3
# Layer II has restrictions wrt to mode and bit rate. This code
# enforces them.
if self.layer == 2:
m = self.mode
br = self.bit_rate
if (br in [32, 48, 56, 80] and (m != MODE_MONO)):
raise Mp3Exception("Invalid mode/bitrate combination for layer "
"II")
if (br in [224, 256, 320, 384] and (m == MODE_MONO)):
raise Mp3Exception("Invalid mode/bitrate combination for layer "
"II")
br = self.bit_rate * 1000
sf = self.sample_freq
p = self.padding
if self.layer == 1:
# Layer 1 uses 32 bit slots for padding.
p = self.padding * 4
self.frame_length = int((((12 * br) / sf) + p) * 4)
else:
# Layer 2 and 3 uses 8 bit slots for padding.
p = self.padding * 1
self.frame_length = int(((144 * br) / sf) + p)
# Dump the state.
log.debug("MPEG audio version: " + str(self.version))
log.debug("MPEG audio layer: " + ("I" * self.layer))
log.debug("MPEG sampling frequency: " + str(self.sample_freq))
log.debug("MPEG bit rate: " + str(self.bit_rate))
log.debug("MPEG channel mode: " + self.mode)
log.debug("MPEG channel mode extension: " + str(self.mode_extension))
log.debug("MPEG CRC error protection: " + str(self.error_protection))
log.debug("MPEG original: " + str(self.original))
log.debug("MPEG copyright: " + str(self.copyright))
log.debug("MPEG private bit: " + str(self.private_bit))
log.debug("MPEG padding: " + str(self.padding))
log.debug("MPEG emphasis: " + str(self.emphasis))
log.debug("MPEG frame length: " + str(self.frame_length))
class VbriHeader(object):
def __init__(self):
self.vbr = True
self.version = None
##
# \brief Decode the VBRI info from \a frame.
# http://www.codeproject.com/audio/MPEGAudioInfo.asp#VBRIHeader
def decode(self, frame):
# The header is 32 bytes after the end of the first MPEG audio header,
# therefore 4 + 32 = 36
offset = 36
head = frame[offset:offset + 4]
if head != 'VBRI':
return False
log.debug("VBRI header detected @ %x" % (offset))
offset += 4
self.version = bin2dec(bytes2bin(frame[offset:offset + 2]))
offset += 2
self.delay = bin2dec(bytes2bin(frame[offset:offset + 2]))
offset += 2
self.quality = bin2dec(bytes2bin(frame[offset:offset + 2]))
offset += 2
self.num_bytes = bin2dec(bytes2bin(frame[offset:offset + 4]))
offset += 4
self.num_frames = bin2dec(bytes2bin(frame[offset:offset + 4]))
offset += 4
return True
class XingHeader:
"""Header class for the Xing header extensions."""
def __init__(self):
self.numFrames = int()
self.numBytes = int()
self.toc = [0] * 100
self.vbrScale = int()
# Pass in the first mp3 frame from the file as a byte string.
# If an Xing header is present in the file it'll be in the first mp3
# frame. This method returns true if the Xing header is found in the
# frame, and false otherwise.
def decode(self, frame):
# mp3 version
version = (compat.byteOrd(frame[1]) >> 3) & 0x1
# channel mode.
mode = (compat.byteOrd(frame[3]) >> 6) & 0x3
# Find the start of the Xing header.
if version:
# +4 in all of these to skip initial mp3 frame header.
if mode != 3:
pos = 32 + 4
else:
pos = 17 + 4
else:
if mode != 3:
pos = 17 + 4
else:
pos = 9 + 4
head = frame[pos:pos + 4]
self.vbr = (head == b'Xing') and True or False
if head not in [b'Xing', b'Info']:
return False
log.debug("%s header detected @ %x" % (head, pos))
pos += 4
# Read Xing flags.
headFlags = bin2dec(bytes2bin(frame[pos:pos + 4]))
pos += 4
log.debug("%s header flags: 0x%x" % (head, headFlags))
# Read frames header flag and value if present
if headFlags & FRAMES_FLAG:
self.numFrames = bin2dec(bytes2bin(frame[pos:pos + 4]))
pos += 4
log.debug("%s numFrames: %d" % (head, self.numFrames))
# Read bytes header flag and value if present
if headFlags & BYTES_FLAG:
self.numBytes = bin2dec(bytes2bin(frame[pos:pos + 4]))
pos += 4
log.debug("%s numBytes: %d" % (head, self.numBytes))
# Read TOC header flag and value if present
if headFlags & TOC_FLAG:
self.toc = frame[pos:pos + 100]
pos += 100
log.debug("%s TOC (100 bytes): PRESENT" % head)
else:
log.debug("%s TOC (100 bytes): NOT PRESENT" % head)
# Read vbr scale header flag and value if present
if headFlags & VBR_SCALE_FLAG and head == b'Xing':
self.vbrScale = bin2dec(bytes2bin(frame[pos:pos + 4]))
pos += 4
log.debug("%s vbrScale: %d" % (head, self.vbrScale))
return True
##
# \brief Mp3 Info tag (AKA LAME Tag)
#
# Lame (and some other encoders) write a tag containing various bits of info
# about the options used at encode time. If available, the following are
# parsed and stored in the LameHeader dict:
#
# encoder_version: short encoder version [str]
# tag_revision: revision number of the tag [int]
# vbr_method: VBR method used for encoding [str]
# lowpass_filter: lowpass filter frequency in Hz [int]
# replaygain: if available, radio and audiofile gain (see below) [dict]
# encoding_flags: encoding flags used [list]
# nogap: location of gaps when --nogap was used [list]
# ath_type: ATH type [int]
# bitrate: bitrate and type (Constant, Target, Minimum) [tuple]
# encoder_delay: samples added at the start of the mp3 [int]
# encoder_padding: samples added at the end of the mp3 [int]
# noise_shaping: noise shaping method [int]
# stereo_mode: stereo mode used [str]
# unwise_settings: whether unwise settings were used [boolean]
# sample_freq: source sample frequency [str]
# mp3_gain: mp3 gain adjustment (rarely used) [float]
# preset: preset used [str]
# surround_info: surround information [str]
# music_length: length in bytes of original mp3 [int]
# music_crc: CRC-16 of the mp3 music data [int]
# infotag_crc: CRC-16 of the info tag [int]
#
# Prior to ~3.90, Lame simply stored the encoder version in the first frame.
# If the infotag_crc is invalid, then we try to read this version string. A
# simple way to tell if the LAME Tag is complete is to check for the
# infotag_crc key.
#
# Replay Gain data is only available since Lame version 3.94b. If set, the
# replaygain dict has the following structure:
#
# \code
# peak_amplitude: peak signal amplitude [float]
# radio:
# name: name of the gain adjustment [str]
# adjustment: gain adjustment [float]
# originator: originator of the gain adjustment [str]
# audiofile: [same as radio]
# \endcode
#
# Note that as of 3.95.1, Lame uses 89dB as a reference level instead of the
# 83dB that is specified in the Replay Gain spec. This is not automatically
# compensated for. You can do something like this if you want:
#
# \code
# import eyeD3
# af = eyeD3.mp3.Mp3AudioFile('/path/to/some.mp3')
# lamever = af.lameTag['encoder_version']
# name, ver = lamever[:4], lamever[4:]
# gain = af.lameTag['replaygain']['radio']['adjustment']
# if name == 'LAME' and eyeD3.mp3.lamevercmp(ver, '3.95') > 0:
# gain -= 6
# \endcode
#
# Radio and Audiofile Replay Gain are often referrered to as Track and Album
# gain, respectively. See http://replaygain.hydrogenaudio.org/ for futher
# details on Replay Gain.
#
# See http://gabriel.mp3-tech.org/mp3infotag.html for the gory details of the
# LAME Tag.
class LameHeader(dict):
# from the LAME source:
# http://lame.cvs.sourceforge.net/*checkout*/lame/lame/libmp3lame/VbrTag.c
_crc16_table = [
0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440,
0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40,
0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841,
0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40,
0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41,
0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641,
0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040,
0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240,
0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441,
0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41,
0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840,
0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41,
0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40,
0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640,
0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041,
0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240,
0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441,
0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41,
0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840,
0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41,
0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40,
0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640,
0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041,
0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241,
0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440,
0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40,
0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841,
0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40,
0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41,
0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641,
0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040]
ENCODER_FLAGS = {
'NSPSYTUNE': 0x0001,
'NSSAFEJOINT': 0x0002,
'NOGAP_NEXT': 0x0004,
'NOGAP_PREV': 0x0008}
PRESETS = {
0: 'Unknown',
# 8 to 320 are reserved for ABR bitrates
410: 'V9',
420: 'V8',
430: 'V7',
440: 'V6',
450: 'V5',
460: 'V4',
470: 'V3',
480: 'V2',
490: 'V1',
500: 'V0',
1000: 'r3mix',
1001: 'standard',
1002: 'extreme',
1003: 'insane',
1004: 'standard/fast',
1005: 'extreme/fast',
1006: 'medium',
1007: 'medium/fast'}
REPLAYGAIN_NAME = {
0: 'Not set',
1: 'Radio',
2: 'Audiofile'}
REPLAYGAIN_ORIGINATOR = {
0: 'Not set',
1: 'Set by artist',
2: 'Set by user',
3: 'Set automatically',
100: 'Set by simple RMS average'}
SAMPLE_FREQUENCIES = {
0: '<= 32 kHz',
1: '44.1 kHz',
2: '48 kHz',
3: '> 48 kHz'}
STEREO_MODES = {
0: 'Mono',
1: 'Stereo',
2: 'Dual',
3: 'Joint',
4: 'Force',
5: 'Auto',
6: 'Intensity',
7: 'Undefined'}
SURROUND_INFO = {
0: 'None',
1: 'DPL encoding',
2: 'DPL2 encoding',
3: 'Ambisonic encoding',
8: 'Reserved'}
VBR_METHODS = {
0: 'Unknown',
1: 'Constant Bitrate',
2: 'Average Bitrate',
3: 'Variable Bitrate method1 (old/rh)',
4: 'Variable Bitrate method2 (mtrh)',
5: 'Variable Bitrate method3 (mt)',
6: 'Variable Bitrate method4',
8: 'Constant Bitrate (2 pass)',
9: 'Average Bitrate (2 pass)',
15: 'Reserved'}
def __init__(self, frame):
"""Read the LAME info tag.
frame should be the first frame of an mp3.
"""
self.decode(frame)
def _crc16(self, data, val=0):
"""Compute a CRC-16 checksum on a data stream."""
for c in compat.byteiter(data):
val = self._crc16_table[ord(c) ^ (val & 0xff)] ^ (val >> 8)
return val
def decode(self, frame):
"""Decode the LAME info tag."""
try:
pos = frame.index(b"LAME")
except: # noqa: B901
return
log.debug('Lame info tag found at position %d' % pos)
# check the info tag crc.Iif it's not valid, no point parsing much more.
lamecrc = bin2dec(bytes2bin(frame[190:192]))
if self._crc16(frame[:190]) != lamecrc:
log.warning('Lame tag CRC check failed')
try:
# Encoder short VersionString, 9 bytes
self['encoder_version'] = \
compat.unicode(frame[pos:pos + 9].rstrip(), "latin1")
log.debug('Lame Encoder Version: %s' % self['encoder_version'])
pos += 9
# Info Tag revision + VBR method, 1 byte
self['tag_revision'] = bin2dec(bytes2bin(frame[pos:pos + 1])[:5])
vbr_method = bin2dec(bytes2bin(frame[pos:pos + 1])[5:])
self['vbr_method'] = self.VBR_METHODS.get(vbr_method, 'Unknown')
log.debug('Lame info tag version: %s' % self['tag_revision'])
log.debug('Lame VBR method: %s' % self['vbr_method'])
pos += 1
# Lowpass filter value, 1 byte
self['lowpass_filter'] = bin2dec(
bytes2bin(frame[pos:pos + 1])) * 100
log.debug('Lame Lowpass filter value: %s Hz' %
self['lowpass_filter'])
pos += 1
# Replay Gain, 8 bytes total
replaygain = {}
# Peak signal amplitude, 4 bytes
peak = bin2dec(bytes2bin(frame[pos:pos + 4])) << 5
if peak > 0:
peak /= float(1 << 28)
db = 20 * log10(peak)
replaygain['peak_amplitude'] = peak
log.debug('Lame Peak signal amplitude: %.8f (%+.1f dB)' %
(peak, db))
pos += 4
# Radio and Audiofile Gain, AKA track and album, 2 bytes each
for gaintype in ['radio', 'audiofile']:
name = bin2dec(bytes2bin(frame[pos:pos + 2])[:3])
orig = bin2dec(bytes2bin(frame[pos:pos + 2])[3:6])
sign = bin2dec(bytes2bin(frame[pos:pos + 2])[6:7])
adj = bin2dec(bytes2bin(frame[pos:pos + 2])[7:]) / 10.0
if sign:
adj *= -1
# FIXME Lame 3.95.1 and above use 89dB as a reference instead of
# 83dB as defined by the Replay Gain spec. Should this be
# compensated for?
# lamever =self['encoder_version']
# if (lamever[:4] == 'LAME' and
# lamevercmp(lamever[4:], '3.95') > 0):
# adj -= 6
if orig:
name = self.REPLAYGAIN_NAME.get(name, 'Unknown')
orig = self.REPLAYGAIN_ORIGINATOR.get(orig, 'Unknown')
replaygain[gaintype] = {'name': name, 'adjustment': adj,
'originator': orig}
log.debug('Lame %s Replay Gain: %s dB (%s)' %
(name, adj, orig))
pos += 2
if replaygain:
self['replaygain'] = replaygain
# Encoding flags + ATH Type, 1 byte
encflags = bin2dec(bytes2bin(frame[pos:pos + 1])[:4])
(self['encoding_flags'],
self['nogap']) = self._parse_encflags(encflags)
self['ath_type'] = bin2dec(bytes2bin(frame[pos:pos + 1])[4:])
log.debug('Lame Encoding flags: %s' %
' '.join(self['encoding_flags']))
if self['nogap']:
log.debug('Lame No gap: %s' % ' and '.join(self['nogap']))
log.debug('Lame ATH type: %s' % self['ath_type'])
pos += 1
# if ABR {specified bitrate} else {minimal bitrate}, 1 byte
btype = 'Constant'
if 'Average' in self['vbr_method']:
btype = 'Target'
elif 'Variable' in self['vbr_method']:
btype = 'Minimum'
# bitrate may be modified below after preset is read
self['bitrate'] = (bin2dec(bytes2bin(frame[pos:pos + 1])), btype)
log.debug('Lame Bitrate (%s): %s' % (btype, self['bitrate'][0]))
pos += 1
# Encoder delays, 3 bytes
self['encoder_delay'] = bin2dec(bytes2bin(frame[pos:pos + 3])[:12])
self['encoder_padding'] = bin2dec(
bytes2bin(frame[pos:pos + 3])[12:])
log.debug('Lame Encoder delay: %s samples' % self['encoder_delay'])
log.debug('Lame Encoder padding: %s samples' %
self['encoder_padding'])
pos += 3
# Misc, 1 byte
sample_freq = bin2dec(bytes2bin(frame[pos:pos + 1])[:2])
unwise_settings = bin2dec(bytes2bin(frame[pos:pos + 1])[2:3])
stereo_mode = bin2dec(bytes2bin(frame[pos:pos + 1])[3:6])
self['noise_shaping'] = bin2dec(bytes2bin(frame[pos:pos + 1])[6:])
self['sample_freq'] = self.SAMPLE_FREQUENCIES.get(sample_freq,
'Unknown')
self['unwise_settings'] = bool(unwise_settings)
self['stereo_mode'] = self.STEREO_MODES.get(stereo_mode, 'Unknown')
log.debug('Lame Source Sample Frequency: %s' % self['sample_freq'])
log.debug('Lame Unwise settings used: %s' % self['unwise_settings'])
log.debug('Lame Stereo mode: %s' % self['stereo_mode'])
log.debug('Lame Noise Shaping: %s' % self['noise_shaping'])
pos += 1
# MP3 Gain, 1 byte
sign = bytes2bin(frame[pos:pos + 1])[0]
gain = bin2dec(bytes2bin(frame[pos:pos + 1])[1:])
if sign:
gain *= -1
self['mp3_gain'] = gain
db = gain * 1.5
log.debug('Lame MP3 Gain: %s (%+.1f dB)' % (self['mp3_gain'], db))
pos += 1
# Preset and surround info, 2 bytes
surround = bin2dec(bytes2bin(frame[pos:pos + 2])[2:5])
preset = bin2dec(bytes2bin(frame[pos:pos + 2])[5:])
if preset in range(8, 321):
if self['bitrate'][0] >= 255:
# the value from preset is better in this case
self['bitrate'] = (preset, btype)
log.debug('Lame Bitrate (%s): %s' %
(btype, self['bitrate'][0]))
if 'Average' in self['vbr_method']:
preset = 'ABR %s' % preset
else:
preset = 'CBR %s' % preset
else:
preset = self.PRESETS.get(preset, preset)
self['surround_info'] = self.SURROUND_INFO.get(surround, surround)
self['preset'] = preset
log.debug('Lame Surround Info: %s' % self['surround_info'])
log.debug('Lame Preset: %s' % self['preset'])
pos += 2
# MusicLength, 4 bytes
self['music_length'] = bin2dec(bytes2bin(frame[pos:pos + 4]))
log.debug('Lame Music Length: %s bytes' % self['music_length'])
pos += 4
# MusicCRC, 2 bytes
self['music_crc'] = bin2dec(bytes2bin(frame[pos:pos + 2]))
log.debug('Lame Music CRC: %04X' % self['music_crc'])
pos += 2
# CRC-16 of Info Tag, 2 bytes
self['infotag_crc'] = lamecrc # we read this earlier
log.debug('Lame Info Tag CRC: %04X' % self['infotag_crc'])
pos += 2
except IndexError:
log.warning("Truncated LAME info header, values incomplete.")
def _parse_encflags(self, flags):
"""Parse encoder flags.
Returns a tuple containing lists of encoder flags and nogap data in
human readable format.
"""
encoder_flags, nogap = [], []
if not flags:
return encoder_flags, nogap
if flags & self.ENCODER_FLAGS['NSPSYTUNE']:
encoder_flags.append('--nspsytune')
if flags & self.ENCODER_FLAGS['NSSAFEJOINT']:
encoder_flags.append('--nssafejoint')
NEXT = self.ENCODER_FLAGS['NOGAP_NEXT']
PREV = self.ENCODER_FLAGS['NOGAP_PREV']
if flags & (NEXT | PREV):
encoder_flags.append('--nogap')
if flags & PREV:
nogap.append('before')
if flags & NEXT:
nogap.append('after')
return encoder_flags, nogap
##
# \brief Compare LAME version strings.
#
# alpha and beta versions are considered older.
# Versions with sub minor parts or end with 'r' are considered newer.
#
# \param x The first version to compare.
# \param y The second version to compare.
# \returns Return negative if xy.
def lamevercmp(x, y):
x = x.ljust(5)
y = y.ljust(5)
if x[:5] == y[:5]:
return 0
ret = compat.cmp(x[:4], y[:4])
if ret:
return ret
xmaj, xmin = x.split('.')[:2]
ymaj, ymin = y.split('.')[:2]
minparts = ['.']
# lame 3.96.1 added the use of r in the very short version for post releases
if (xmaj == '3' and xmin >= '96') or (ymaj == '3' and ymin >= '96'):
minparts.append('r')
if x[4] in minparts:
return 1
if y[4] in minparts:
return -1
if x[4] == ' ':
return 1
if y[4] == ' ':
return -1
return compat.cmp(x[4], y[4])
# MPEG1 MPEG2 MPEG2.5
SAMPLE_FREQ_TABLE = ((44100, 22050, 11025),
(48000, 24000, 12000),
(32000, 16000, 8000),
(None, None, None))
# V1/L1 V1/L2 V1/L3 V2/L1 V2/L2&L3
BIT_RATE_TABLE = ((0, 0, 0, 0, 0), # noqa
(32, 32, 32, 32, 8), # noqa
(64, 48, 40, 48, 16), # noqa
(96, 56, 48, 56, 24), # noqa
(128, 64, 56, 64, 32), # noqa
(160, 80, 64, 80, 40), # noqa
(192, 96, 80, 96, 48), # noqa
(224, 112, 96, 112, 56), # noqa
(256, 128, 112, 128, 64), # noqa
(288, 160, 128, 144, 80), # noqa
(320, 192, 160, 160, 96), # noqa
(352, 224, 192, 176, 112), # noqa
(384, 256, 224, 192, 128), # noqa
(416, 320, 256, 224, 144), # noqa
(448, 384, 320, 256, 160), # noqa
(None, None, None, None, None))
# Rows 1 and 2 (mpeg 2.x) are only used for those versions *and* VBR.
# L1 L2 L3
SAMPLES_PER_FRAME_TABLE = ((None, 384, 1152, 1152), # MPEG 1
(None, 384, 1152, 576), # MPEG 2
(None, 384, 1152, 576), # MPEG 2.5
)
# Emphasis constants
EMPHASIS_NONE = "None"
EMPHASIS_5015 = "50/15 ms"
EMPHASIS_CCIT = "CCIT J.17"
# Mode constants
MODE_STEREO = "Stereo"
MODE_JOINT_STEREO = "Joint stereo"
MODE_DUAL_CHANNEL_STEREO = "Dual channel stereo"
MODE_MONO = "Mono"
# Xing flag bits
FRAMES_FLAG = 0x0001
BYTES_FLAG = 0x0002
TOC_FLAG = 0x0004
VBR_SCALE_FLAG = 0x0008
def _mp3VersionKey(version):
"""Map mp3 version float to a data structure index.
1 -> 0, 2 -> 1, 2.5 -> 2
"""
key = None
if version == 2.5:
key = 2
else:
key = int(version - 1)
assert(0 <= key <= 2)
return key
eyeD3-0.8.4/src/eyed3/compat.py 0000644 0001750 0001750 00000010350 13134312320 016762 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2013 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
'''
Compatibility for various versions of Python (e.g. 2.6, 2.7, and 3.3)
'''
import os
import sys
import types
PY2 = sys.version_info[0] == 2
if PY2:
# Python2
StringTypes = types.StringTypes
UnicodeType = unicode # noqa
BytesType = str
unicode = unicode # noqa
_og_chr = chr
from ConfigParser import SafeConfigParser as ConfigParser
from ConfigParser import Error as ConfigParserError
from StringIO import StringIO
def chr(i):
'''byte strings units are single byte strings'''
return _og_chr(i)
input = raw_input # noqa
cmp = cmp # noqa
else:
# Python3
StringTypes = (str,)
UnicodeType = str
BytesType = bytes
unicode = str
from configparser import ConfigParser # noqa
from configparser import Error as ConfigParserError # noqa
from io import StringIO # noqa
def chr(i):
'''byte strings units are ints'''
return intToByteString(i)
input = input
def cmp(a, b):
return (a > b) - (a < b)
if sys.version_info[0:2] < (3, 4):
# py3.4 has two maps, nice nicer. Make it so for other versions.
import logging
logging._nameToLevel = {_k: _v
for _k, _v in logging._levelNames.items()
if isinstance(_k, str)}
def b(x, encoder=None):
if isinstance(x, BytesType):
return x
else:
import codecs
encoder = encoder or codecs.latin_1_encode
return encoder(x)[0]
def intToByteString(n):
'''Convert the integer ``n`` to a single character byte string.'''
if PY2:
return chr(n)
else:
return bytes((n,))
def byteiter(bites):
assert(isinstance(bites, BytesType))
for b in bites:
yield b if PY2 else intToByteString(b)
def byteOrd(bite):
'''The utility handles the following difference with byte strings in
Python 2 and 3:
b"123"[1] == b"2" (Python2)
b"123"[1] == 50 (Python3)
As this function name implies, the oridinal value is returned given either
a byte string of length 1 (python2) or a integer value (python3). With
Python3 the value is simply return.
'''
if PY2:
assert(isinstance(bite, BytesType))
return ord(bite)
else:
assert(isinstance(bite, int))
return bite
def importmod(mod_file):
'''Imports a Ptyhon module referenced by absolute or relative path
``mod_file``. The module is retured.'''
mod_name = os.path.splitext(os.path.basename(mod_file))[0]
if PY2:
import imp
mod = imp.load_source(mod_name, mod_file)
else:
import importlib.machinery
loader = importlib.machinery.SourceFileLoader(mod_name, mod_file)
mod = loader.load_module()
return mod
class UnicodeMixin(object):
'''A shim to handlke __unicode__ missing from Python3.
Inspired by: http://lucumr.pocoo.org/2011/1/22/forwards-compatible-python/
'''
if PY2:
def __str__(self):
return unicode(self).encode('utf-8')
else:
def __str__(self):
return self.__unicode__()
eyeD3-0.8.4/src/eyed3/core.py 0000644 0001750 0001750 00000031110 13166564566 016456 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
"""Basic core types and utilities."""
import os
import sys
import time
import functools
import pathlib
from . import LOCAL_FS_ENCODING
from .utils import guessMimetype
from . import compat
from .utils.log import getLogger
log = getLogger(__name__)
AUDIO_NONE = 0
"""Audio type selecter for no audio."""
AUDIO_MP3 = 1
"""Audio type selecter for mpeg (mp3) audio."""
AUDIO_TYPES = (AUDIO_NONE, AUDIO_MP3)
LP_TYPE = u"lp"
EP_TYPE = u"ep"
COMP_TYPE = u"compilation"
LIVE_TYPE = u"live"
VARIOUS_TYPE = u"various"
DEMO_TYPE = u"demo"
SINGLE_TYPE = u"single"
ALBUM_TYPE_IDS = [LP_TYPE, EP_TYPE, COMP_TYPE, LIVE_TYPE, VARIOUS_TYPE,
DEMO_TYPE, SINGLE_TYPE]
VARIOUS_ARTISTS = u"Various Artists"
TXXX_ALBUM_TYPE = u"eyeD3#album_type"
"""A key that can be used in a TXXX frame to specify the type of collection
(or album) a file belongs. See :class:`eyed3.core.ALBUM_TYPE_IDS`."""
TXXX_ARTIST_ORIGIN = u"eyeD3#artist_origin"
"""A key that can be used in a TXXX frame to specify the origin of an
artist/band. i.e. where they are from.
The format is: citystatecountry"""
def load(path, tag_version=None):
"""Loads the file identified by ``path`` and returns a concrete type of
:class:`eyed3.core.AudioFile`. If ``path`` is not a file an ``IOError`` is
raised. ``None`` is returned when the file type (i.e. mime-type) is not
recognized.
The following AudioFile types are supported:
* :class:`eyed3.mp3.Mp3AudioFile` - For mp3 audio files.
* :class:`eyed3.id3.TagFile` - For raw ID3 data files.
If ``tag_version`` is not None (the default) only a specific version of
metadata is loaded. This value must be a version constant specific to the
eventual format of the metadata.
"""
from . import mp3, id3
if not isinstance(path, pathlib.Path):
if compat.PY2:
path = pathlib.Path(path.encode(sys.getfilesystemencoding()))
else:
path = pathlib.Path(path)
log.debug("Loading file: %s" % path)
if path.exists():
if not path.is_file():
raise IOError("not a file: %s" % path)
else:
raise IOError("file not found: %s" % path)
mtype = guessMimetype(path)
log.debug("File mime-type: %s" % mtype)
if (mtype in mp3.MIME_TYPES or
(mtype in mp3.OTHER_MIME_TYPES and
path.suffix.lower() in mp3.EXTENSIONS)):
return mp3.Mp3AudioFile(path, tag_version)
elif mtype == "application/x-id3":
return id3.TagFile(path, tag_version)
else:
return None
class AudioInfo(object):
"""A base container for common audio details."""
time_secs = 0
"""The number of seconds of audio data (i.e., the playtime)"""
size_bytes = 0
"""The number of bytes of audio data."""
class Tag(object):
"""An abstract interface for audio tag (meta) data (e.g. artist, title,
etc.)
"""
read_only = False
def _setArtist(self, val):
raise NotImplementedError
def _getArtist(self):
raise NotImplementedError
def _getAlbumArtist(self):
raise NotImplementedError
def _setAlbumArtist(self, val):
raise NotImplementedError
def _setAlbum(self, val):
raise NotImplementedError
def _getAlbum(self):
raise NotImplementedError
def _setTitle(self, val):
raise NotImplementedError
def _getTitle(self):
raise NotImplementedError
def _setTrackNum(self, val):
raise NotImplementedError
def _getTrackNum(self):
raise NotImplementedError
@property
def artist(self):
return self._getArtist()
@artist.setter
def artist(self, v):
self._setArtist(v)
@property
def album_artist(self):
return self._getAlbumArtist()
@album_artist.setter
def album_artist(self, v):
self._setAlbumArtist(v)
@property
def album(self):
return self._getAlbum()
@album.setter
def album(self, v):
self._setAlbum(v)
@property
def title(self):
return self._getTitle()
@title.setter
def title(self, v):
self._setTitle(v)
@property
def track_num(self):
"""Track number property.
Must return a 2-tuple of (track-number, total-number-of-tracks).
Either tuple value may be ``None``.
"""
return self._getTrackNum()
@track_num.setter
def track_num(self, v):
self._setTrackNum(v)
def __init__(self, title=None, artist=None, album=None, album_artist=None,
track_num=None):
self.title = title
self.artist = artist
self.album = album
self.album_artist = album_artist
self.track_num = track_num
class AudioFile(object):
"""Abstract base class for audio file types (AudioInfo + Tag)"""
def _read(self):
"""Subclasses MUST override this method and set ``self._info``,
``self._tag`` and ``self.type``.
"""
raise NotImplementedError()
def rename(self, name, fsencoding=LOCAL_FS_ENCODING,
preserve_file_time=False):
"""Rename the file to ``name``.
The encoding used for the file name is :attr:`eyed3.LOCAL_FS_ENCODING`
unless overridden by ``fsencoding``. Note, if the target file already
exists, or the full path contains non-existent directories the
operation will fail with :class:`IOError`.
File times are not modified when ``preserve_file_time`` is ``True``,
``False`` is the default.
"""
curr_path = pathlib.Path(self.path)
ext = curr_path.suffix
new_path = curr_path.parent / "{name}{ext}".format(**locals())
if new_path.exists():
raise IOError(u"File '%s' exists, will not overwrite" % new_path)
elif not new_path.parent.exists():
raise IOError(u"Target directory '%s' does not exists, will not "
"create" % new_path.parent)
os.rename(self.path, str(new_path))
if self.tag:
self.tag.file_info.name = str(new_path)
if preserve_file_time:
self.tag.file_info.touch((self.tag.file_info.atime,
self.tag.file_info.mtime))
self.path = str(new_path)
@property
def path(self):
"""The absolute path of this file."""
return self._path
@path.setter
def path(self, t):
"""Set the path"""
from os.path import abspath, realpath, normpath
self._path = normpath(realpath(abspath(t)))
@property
def info(self):
"""Returns a concrete implemenation of :class:`eyed3.core.AudioInfo`"""
return self._info
@property
def tag(self):
"""Returns a concrete implemenation of :class:`eyed3.core.Tag`"""
return self._tag
@tag.setter
def tag(self, t):
self._tag = t
def __init__(self, path):
"""Construct with a path and invoke ``_read``.
All other members are set to None."""
if isinstance(path, pathlib.Path):
path = str(path)
self.path = path
self.type = None
self._info = None
self._tag = None
self._read()
@functools.total_ordering
class Date(object):
"""
A class for representing a date and time (optional). This class differs
from ``datetime.datetime`` in that the default values for month, day,
hour, minute, and second is ``None`` and not 'January 1, 00:00:00'.
This allows for an object that is simply 1987, and not January 1 12AM,
for example. But when more resolution is required those vales can be set
as well.
"""
TIME_STAMP_FORMATS = ["%Y",
"%Y-%m",
"%Y-%m-%d",
"%Y-%m-%dT%H",
"%Y-%m-%dT%H:%M",
"%Y-%m-%dT%H:%M:%S",
# The following end with 'Z' signally time is UTC
"%Y-%m-%dT%HZ",
"%Y-%m-%dT%H:%MZ",
"%Y-%m-%dT%H:%M:%SZ",
# The following are wrong per the specs, but ...
"%Y-%m-%d %H:%M:%S",
"%Y-00-00",
]
"""Valid time stamp formats per ISO 8601 and used by \c strptime."""
def __init__(self, year, month=None, day=None,
hour=None, minute=None, second=None):
# Validate with datetime
from datetime import datetime
_ = datetime(year, month if month is not None else 1,
day if day is not None else 1,
hour if hour is not None else 0,
minute if minute is not None else 0,
second if second is not None else 0)
self._year = year
self._month = month
self._day = day
self._hour = hour
self._minute = minute
self._second = second
# Python's date classes do a lot more date validation than does not
# need to be duplicated here. Validate it
_ = Date._validateFormat(str(self)) # noqa
@property
def year(self):
return self._year
@property
def month(self):
return self._month
@property
def day(self):
return self._day
@property
def hour(self):
return self._hour
@property
def minute(self):
return self._minute
@property
def second(self):
return self._second
def __eq__(self, rhs):
if not rhs:
return False
return (self.year == rhs.year and
self.month == rhs.month and
self.day == rhs.day and
self.hour == rhs.hour and
self.minute == rhs.minute and
self.second == rhs.second)
def __ne__(self, rhs):
return not(self == rhs)
def __lt__(self, rhs):
if not rhs:
return False
for l, r in ((self.year, rhs.year),
(self.month, rhs.month),
(self.day, rhs.day),
(self.hour, rhs.hour),
(self.minute, rhs.minute),
(self.second, rhs.second)):
l = l if l is not None else -1
r = r if r is not None else -1
if l < r:
return True
elif l > r:
return False
return False
def __hash__(self):
return hash(str(self))
@staticmethod
def _validateFormat(s):
pdate = None
for fmt in Date.TIME_STAMP_FORMATS:
try:
pdate = time.strptime(s, fmt)
break
except ValueError:
# date string did not match format.
continue
if pdate is None:
raise ValueError("Invalid date string: %s" % s)
assert(pdate)
return pdate, fmt
@staticmethod
def parse(s):
"""Parses date strings that conform to ISO-8601."""
if not isinstance(s, compat.UnicodeType):
s = s.decode("ascii")
s = s.strip('\x00')
pdate, fmt = Date._validateFormat(s)
# Here is the difference with Python date/datetime objects, some
# of the members can be None
kwargs = {}
if "%m" in fmt:
kwargs["month"] = pdate.tm_mon
if "%d" in fmt:
kwargs["day"] = pdate.tm_mday
if "%H" in fmt:
kwargs["hour"] = pdate.tm_hour
if "%M" in fmt:
kwargs["minute"] = pdate.tm_min
if "%S" in fmt:
kwargs["second"] = pdate.tm_sec
return Date(pdate.tm_year, **kwargs)
def __str__(self):
"""Returns date strings that conform to ISO-8601.
The returned string will be no larger than 17 characters."""
s = "%d" % self.year
if self.month:
s += "-%s" % str(self.month).rjust(2, '0')
if self.day:
s += "-%s" % str(self.day).rjust(2, '0')
if self.hour is not None:
s += "T%s" % str(self.hour).rjust(2, '0')
if self.minute is not None:
s += ":%s" % str(self.minute).rjust(2, '0')
if self.second is not None:
s += ":%s" % str(self.second).rjust(2, '0')
return s
def __unicode__(self):
return compat.unicode(str(self), "latin1")
def parseError(ex):
"""A function that is invoked when non-fatal parse, format, etc. errors
occur. In most cases the invalid values will be ignored or possibly fixed.
This function simply logs the error."""
log.warning(ex)
eyeD3-0.8.4/src/eyed3/__init__.py 0000644 0001750 0001750 00000002424 13177421624 017260 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
import sys
import locale
from .__about__ import __version__ as version # noqa
_DEFAULT_ENCODING = "latin1"
LOCAL_ENCODING = locale.getpreferredencoding(do_setlocale=True)
'''The local encoding, used when parsing command line options, console output,
etc. The default is always ``latin1`` if it cannot be determined, it is NOT
the value shown.'''
if not LOCAL_ENCODING or LOCAL_ENCODING == "ANSI_X3.4-1968": # pragma: no cover
LOCAL_ENCODING = _DEFAULT_ENCODING
LOCAL_FS_ENCODING = sys.getfilesystemencoding()
'''The local file system encoding, the default is ``latin1`` if it cannot be
determined.'''
if not LOCAL_FS_ENCODING: # pragma: no cover
LOCAL_FS_ENCODING = _DEFAULT_ENCODING
class Error(Exception):
'''Base exception type for all eyed3 errors.'''
def __init__(self, *args):
super(Error, self).__init__(*args)
if args:
# The base class will do exactly this if len(args) == 1,
# but not when > 1. Note, the 2.7 base class will, 3 will not.
# Make it so.
self.message = args[0]
from .utils.log import log # noqa
from .core import load # noqa
del sys
del locale
eyeD3-0.8.4/src/eyed3/id3/ 0000755 0001750 0001750 00000000000 13203726215 015616 5 ustar travis travis 0000000 0000000 eyeD3-0.8.4/src/eyed3/id3/__init__.py 0000644 0001750 0001750 00000034503 13177420270 017736 0 ustar travis travis 0000000 0000000 ################################################################################
# Copyright (C) 2002-2014 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
import re
from .. import core
from .. import Error
from .. import compat
from ..utils import requireUnicode
from ..utils.log import getLogger
log = getLogger(__name__)
# Version constants and helpers
ID3_V1 = (1, None, None)
'''Version 1, 1.0 or 1.1'''
ID3_V1_0 = (1, 0, 0)
'''Version 1.0, specifically'''
ID3_V1_1 = (1, 1, 0)
'''Version 1.1, specifically'''
ID3_V2 = (2, None, None)
'''Version 2, 2.2, 2.3 or 2.4'''
ID3_V2_2 = (2, 2, 0)
'''Version 2.2, specifically'''
ID3_V2_3 = (2, 3, 0)
'''Version 2.3, specifically'''
ID3_V2_4 = (2, 4, 0)
'''Version 2.4, specifically'''
ID3_DEFAULT_VERSION = ID3_V2_4
'''The default version for eyeD3 tags and save operations.'''
ID3_ANY_VERSION = (ID3_V1[0] | ID3_V2[0], None, None)
'''Useful for operations where any version will suffice.'''
LATIN1_ENCODING = b"\x00"
'''Byte code for latin1'''
UTF_16_ENCODING = b"\x01"
'''Byte code for UTF-16'''
UTF_16BE_ENCODING = b"\x02"
'''Byte code for UTF-16 (big endian)'''
UTF_8_ENCODING = b"\x03"
'''Byte code for UTF-8 (Not supported in ID3 versions < 2.4)'''
DEFAULT_LANG = b"eng"
'''Default language code for frames that contain a language portion.'''
def isValidVersion(v, fully_qualified=False):
'''Check the tuple ``v`` against the list of valid ID3 version constants.
If ``fully_qualified`` is ``True`` it is enforced that there are 3
components to the version in ``v``. Returns ``True`` when valid and
``False`` otherwise.'''
valid = v in [ID3_V1, ID3_V1_0, ID3_V1_1,
ID3_V2, ID3_V2_2, ID3_V2_3, ID3_V2_4,
ID3_ANY_VERSION]
if not valid:
return False
if fully_qualified:
return (None not in (v[0], v[1], v[2]))
else:
return True
def normalizeVersion(v):
"""If version tuple ``v`` is of the non-specific type (v1 or v2, any, etc.)
a fully qualified version is returned."""
if v == ID3_V1:
v = ID3_V1_1
elif v == ID3_V2:
assert(ID3_DEFAULT_VERSION[0] & ID3_V2[0])
v = ID3_DEFAULT_VERSION
elif v == ID3_ANY_VERSION:
v = ID3_DEFAULT_VERSION
# Now, correct bogus version as seen in the wild
if v[:2] == (2, 2) and v[2] != 0:
v = (2, 2, 0)
return v
# Convert an ID3 version constant to a display string
def versionToString(v):
'''Conversion version tuple ``v`` to a string description.'''
if v == ID3_ANY_VERSION:
return "v1.x/v2.x"
elif v[0] == 1:
if v == ID3_V1_0:
return "v1.0"
elif v == ID3_V1_1:
return "v1.1"
elif v == ID3_V1:
return "v1.x"
elif v[0] == 2:
if v == ID3_V2_2:
return "v2.2"
elif v == ID3_V2_3:
return "v2.3"
elif v == ID3_V2_4:
return "v2.4"
elif v == ID3_V2:
return "v2.x"
raise ValueError("Invalid ID3 version constant: %s" % str(v))
class GenreException(Error):
'''Excpetion type for exceptions related to genres.'''
class Genre(compat.UnicodeMixin):
'''A genre in terms of a ``name`` and and ``id``. Only when ``name`` is
a "standard" genre (as defined by ID3 v1) will ``id`` be a value other
than ``None``.'''
@requireUnicode("name")
def __init__(self, name=None, id=None):
'''Constructor takes an optional ``name`` and ``id``. If ``id`` is
provided the ``name``, regardless of value, is set to the string the
id maps to. Likewise, if ``name`` is passed and is a standard genre the
``id`` is set to the correct value. Any invalid id values cause a
``ValueError`` to be raised. Genre names that are not in the standard
list are still accepted but the ``id`` value is set to ``None``.'''
self.id, self.name = None, None
if not name and id is None:
return
# An ID always takes precedence
if id is not None:
try:
self.id = id
# valid id will set name
if name and name != self.name:
log.warning("Genre ID takes precedence and remapped "
"'%s' to '%s'" % (name, self.name))
except ValueError:
log.warning("Invalid numeric genre ID: %d" % id)
if not name:
# Gave an invalid ID and no name to fallback on
raise
self.name = name
self.id = None
else:
# All we have is a name
self.name = name
assert(self.id or self.name)
@property
def id(self):
'''The Genre's id property.
When setting the value is strictly enforced and if the value is not
a valid genre code a ``ValueError`` is raised. Otherwise the id is
set **and** the ``name`` property is updated to the code's string
name.
'''
return self._id
@id.setter
def id(self, val):
global genres
if val is None:
self._id = None
return
val = int(val)
if val not in list(genres.keys()) or not genres[val]:
raise ValueError("Invalid numeric genre ID: %d" % val)
name = genres[val]
self._id = val
self._name = name
@property
def name(self):
'''The Genre's name property.
When setting the value the name is looked up in the standard genre
map and if found the ``id`` ppropery is set to the numeric valud **and**
the name is normalized to the sting found in the map. Non standard
genres are set (with a warning log) and the ``id`` is set to ``None``.
It is valid to set the value to ``None``.
'''
return self._name
@name.setter
@requireUnicode(1)
def name(self, val):
global genres
if val is None:
self._name = None
return
if val.lower() in list(genres.keys()):
self._id = genres[val]
# normalize the name
self._name = genres[self._id]
else:
log.warning("Non standard genre name: %s" % val)
self._id = None
self._name = val
@staticmethod
@requireUnicode(1)
def parse(g_str, id3_std=True):
"""Parses genre information from `genre_str`.
The following formats are supported:
01, 2, 23, 125 - ID3 v1.x style.
(01), (2), (129)Hardcore, (9)Metal, Indie - ID3v2 style with and without
refinement.
Raises GenreException when an invalid string is passed.
"""
g_str = g_str.strip()
if not g_str:
return None
def strip0Padding(s):
if len(s) > 1:
return s.lstrip(u"0")
else:
return s
if id3_std:
# ID3 v1 style.
# Match 03, 34, 129.
regex = re.compile("[0-9][0-9]*$")
if regex.match(g_str):
return Genre(id=int(strip0Padding(g_str)))
# ID3 v2 style.
# Match (03), (0)Blues, (15) Rap
regex = re.compile("\(([0-9][0-9]*)\)(.*)$")
m = regex.match(g_str)
if m:
(id, name) = m.groups()
id = int(strip0Padding(id))
if id and name:
id = id
name = name.strip()
else:
id = id
name = None
return Genre(id=id, name=name)
# Let everything else slide, genres suck anyway
return Genre(id=None, name=g_str)
def __unicode__(self):
'''When Python2 support is dropped this method must be renamed __str__
and the UnicodeMixin base class is dropped.'''
s = u""
if self.id is not None:
s += u"(%d)" % self.id
if self.name:
s += self.name
return s
def __eq__(self, rhs):
return self.id == rhs.id and self.name == rhs.name
def __ne__(self, rhs):
return not self.__eq__(rhs)
class GenreMap(dict):
'''Classic genres defined around ID3 v1 but suitable anywhere. This class
is used primarily as a way to map numeric genre values to a string name.
Genre strings on the other hand are not required to exist in this list.
'''
GENRE_MIN = 0
GENRE_MAX = None
ID3_GENRE_MIN = 0
ID3_GENRE_MAX = 79
WINAMP_GENRE_MIN = 80
WINAMP_GENRE_MAX = 191
def __init__(self, *args):
'''The optional ``*args`` are passed directly to the ``dict``
constructor.'''
global ID3_GENRES
super(GenreMap, self).__init__(*args)
# ID3 genres as defined by the v1.1 spec with WinAmp extensions.
for i, g in enumerate(ID3_GENRES):
self[i] = g
self[g.lower() if g else None] = i
GenreMap.GENRE_MAX = len(ID3_GENRES) - 1
# Pad up to 255
for i in range(GenreMap.GENRE_MAX + 1, 255 + 1):
self[i] = None
self[None] = 255
def __getitem__(self, key):
if key and type(key) is not int:
key = key.lower()
return super(GenreMap, self).__getitem__(key)
class TagFile(core.AudioFile):
'''
A shim class for dealing with files that contain only ID3 data, no audio.
'''
def __init__(self, path, version=ID3_ANY_VERSION):
self._tag_version = version
core.AudioFile.__init__(self, path)
assert(self.type == core.AUDIO_NONE)
def _read(self):
with open(self.path, 'rb') as file_obj:
tag = Tag()
tag_found = tag.parse(file_obj, self._tag_version)
self._tag = tag if tag_found else None
self.type = core.AUDIO_NONE
def initTag(self, version=ID3_DEFAULT_VERSION):
'''Add a id3.Tag to the file (removing any existing tag if one exists).
'''
self.tag = Tag()
self.tag.version = version
self.tag.file_info = FileInfo(self.path)
ID3_GENRES = [
u'Blues',
u'Classic Rock',
u'Country',
u'Dance',
u'Disco',
u'Funk',
u'Grunge',
u'Hip-Hop',
u'Jazz',
u'Metal',
u'New Age',
u'Oldies',
u'Other',
u'Pop',
u'R&B',
u'Rap',
u'Reggae',
u'Rock',
u'Techno',
u'Industrial',
u'Alternative',
u'Ska',
u'Death Metal',
u'Pranks',
u'Soundtrack',
u'Euro-Techno',
u'Ambient',
u'Trip-Hop',
u'Vocal',
u'Jazz+Funk',
u'Fusion',
u'Trance',
u'Classical',
u'Instrumental',
u'Acid',
u'House',
u'Game',
u'Sound Clip',
u'Gospel',
u'Noise',
u'AlternRock',
u'Bass',
u'Soul',
u'Punk',
u'Space',
u'Meditative',
u'Instrumental Pop',
u'Instrumental Rock',
u'Ethnic',
u'Gothic',
u'Darkwave',
u'Techno-Industrial',
u'Electronic',
u'Pop-Folk',
u'Eurodance',
u'Dream',
u'Southern Rock',
u'Comedy',
u'Cult',
u'Gangsta Rap',
u'Top 40',
u'Christian Rap',
u'Pop / Funk',
u'Jungle',
u'Native American',
u'Cabaret',
u'New Wave',
u'Psychedelic',
u'Rave',
u'Showtunes',
u'Trailer',
u'Lo-Fi',
u'Tribal',
u'Acid Punk',
u'Acid Jazz',
u'Polka',
u'Retro',
u'Musical',
u'Rock & Roll',
u'Hard Rock',
u'Folk',
u'Folk-Rock',
u'National Folk',
u'Swing',
u'Fast Fusion',
u'Bebob',
u'Latin',
u'Revival',
u'Celtic',
u'Bluegrass',
u'Avantgarde',
u'Gothic Rock',
u'Progressive Rock',
u'Psychedelic Rock',
u'Symphonic Rock',
u'Slow Rock',
u'Big Band',
u'Chorus',
u'Easy Listening',
u'Acoustic',
u'Humour',
u'Speech',
u'Chanson',
u'Opera',
u'Chamber Music',
u'Sonata',
u'Symphony',
u'Booty Bass',
u'Primus',
u'Porn Groove',
u'Satire',
u'Slow Jam',
u'Club',
u'Tango',
u'Samba',
u'Folklore',
u'Ballad',
u'Power Ballad',
u'Rhythmic Soul',
u'Freestyle',
u'Duet',
u'Punk Rock',
u'Drum Solo',
u'A Cappella',
u'Euro-House',
u'Dance Hall',
u'Goa',
u'Drum & Bass',
u'Club-House',
u'Hardcore',
u'Terror',
u'Indie',
u'BritPop',
u'Negerpunk',
u'Polsk Punk',
u'Beat',
u'Christian Gangsta Rap',
u'Heavy Metal',
u'Black Metal',
u'Crossover',
u'Contemporary Christian',
u'Christian Rock',
u'Merengue',
u'Salsa',
u'Thrash Metal',
u'Anime',
u'JPop',
u'Synthpop',
# https://de.wikipedia.org/wiki/Liste_der_ID3v1-Genres
u'Abstract',
u'Art Rock',
u'Baroque',
u'Bhangra',
u'Big Beat',
u'Breakbeat',
u'Chillout',
u'Downtempo',
u'Dub',
u'EBM',
u'Eclectic',
u'Electro',
u'Electroclash',
u'Emo',
u'Experimental',
u'Garage',
u'Global',
u'IDM',
u'Illbient',
u'Industro-Goth',
u'Jam Band',
u'Krautrock',
u'Leftfield',
u'Lounge',
u'Math Rock',
u'New Romantic',
u'Nu-Breakz',
u'Post-Punk',
u'Post-Rock',
u'Psytrance',
u'Shoegaze',
u'Space Rock',
u'Trop Rock',
u'World Music',
u'Neoclassical',
u'Audiobook',
u'Audio Theatre',
u'Neue Deutsche Welle',
u'Podcast',
u'Indie Rock',
u'G-Funk',
u'Dubstep',
u'Garage Rock',
u'Psybient',
]
'''ID3 genres, as defined in ID3 v1. The position in the list is the genre's
numeric byte value.'''
genres = GenreMap()
'''A map of standard genre names and IDs per the ID3 v1 genre definition.'''
from . import frames # noqa
from .tag import Tag, TagException, TagTemplate, FileInfo # noqa
eyeD3-0.8.4/src/eyed3/id3/apple.py 0000644 0001750 0001750 00000004150 13061344514 017271 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2012 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
'''
Here lies Apple frames, all of which are non-standard. All of these would have
been standard user text frames by anyone not being a bastard, on purpose.
'''
from .frames import Frame, TextFrame
PCST_FID = b"PCST"
WFED_FID = b"WFED"
TKWD_FID = b"TKWD"
TDES_FID = b"TDES"
TGID_FID = b"TGID"
class PCST(Frame):
'''Indicates a podcast. The 4 bytes of data is undefined, and is typically
all 0.'''
def __init__(self, id=PCST_FID):
super(PCST, self).__init__(PCST_FID)
def render(self):
self.data = b"\x00" * 4
return super(PCST, self).render()
class TKWD(TextFrame):
'''Podcast keywords.'''
def __init__(self, id=TKWD_FID):
super(TKWD, self).__init__(TKWD_FID)
class TDES(TextFrame):
'''Podcast description. One encoding byte followed by text per encoding.'''
def __init__(self, id=TDES_FID):
super(TDES, self).__init__(TDES_FID)
class TGID(TextFrame):
'''Podcast URL of the audio file. This should be a W frame!'''
def __init__(self, id=TGID_FID):
super(TGID, self).__init__(TGID_FID)
class WFED(TextFrame):
'''Another podcast URL, the feed URL it is said.'''
def __init__(self, id=WFED_FID, url=""):
super(WFED, self).__init__(WFED_FID, url)
eyeD3-0.8.4/src/eyed3/id3/tag.py 0000644 0001750 0001750 00000205165 13203722311 016745 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2007-2012 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
import os
import string
import shutil
import tempfile
from functools import partial
from codecs import ascii_encode
from ..utils import requireUnicode, chunkCopy, datePicker
from .. import core
from ..core import TXXX_ALBUM_TYPE, TXXX_ARTIST_ORIGIN, ALBUM_TYPE_IDS
from .. import Error
from . import (ID3_ANY_VERSION, ID3_V1, ID3_V1_0, ID3_V1_1,
ID3_V2, ID3_V2_2, ID3_V2_3, ID3_V2_4, versionToString)
from . import DEFAULT_LANG
from . import Genre
from . import frames
from .headers import TagHeader, ExtendedTagHeader
from .. import compat
from ..compat import StringTypes, BytesType, unicode, UnicodeType, b
from ..utils.log import getLogger
log = getLogger(__name__)
class TagException(Error):
pass
ID3_V1_COMMENT_DESC = u"ID3v1.x Comment"
DEFAULT_PADDING = 256
class Tag(core.Tag):
def __init__(self, **kwargs):
self.clear()
core.Tag.__init__(self, **kwargs)
def clear(self):
"""Reset all tag data."""
# ID3 tag header
self.header = TagHeader()
# Optional extended header in v2 tags.
self.extended_header = ExtendedTagHeader()
# Contains the tag's frames. ID3v1 fields are read and converted
# the the corresponding v2 frame.
self.frame_set = frames.FrameSet()
self._comments = CommentsAccessor(self.frame_set)
self._images = ImagesAccessor(self.frame_set)
self._lyrics = LyricsAccessor(self.frame_set)
self._objects = ObjectsAccessor(self.frame_set)
self._privates = PrivatesAccessor(self.frame_set)
self._user_texts = UserTextsAccessor(self.frame_set)
self._unique_file_ids = UniqueFileIdAccessor(self.frame_set)
self._user_urls = UserUrlsAccessor(self.frame_set)
self._chapters = ChaptersAccessor(self.frame_set)
self._tocs = TocAccessor(self.frame_set)
self._popularities = PopularitiesAccessor(self.frame_set)
self.file_info = None
def parse(self, fileobj, version=ID3_ANY_VERSION):
assert(fileobj)
self.clear()
version = version or ID3_ANY_VERSION
close_file = False
try:
filename = fileobj.name
except AttributeError:
if type(fileobj) in StringTypes:
filename = fileobj
fileobj = open(filename, "rb")
close_file = True
else:
raise ValueError("Invalid type: %s" % str(type(fileobj)))
self.file_info = FileInfo(filename)
try:
tag_found = False
padding = 0
# The & is for supporting the "meta" versions, any, etc.
if version[0] & 2:
tag_found, padding = self._loadV2Tag(fileobj)
if not tag_found and version[0] & 1:
tag_found, padding = self._loadV1Tag(fileobj)
if tag_found:
self.extended_header = None
if tag_found and self.isV2:
self.file_info.tag_size = (TagHeader.SIZE +
self.header.tag_size)
if tag_found:
self.file_info.tag_padding_size = padding
finally:
if close_file:
fileobj.close()
return tag_found
def _loadV2Tag(self, fp):
"""Returns (tag_found, padding_len)"""
padding = 0
# Look for a tag and if found load it.
if not self.header.parse(fp):
return (False, 0)
# Read the extended header if present.
if self.header.extended:
self.extended_header.parse(fp, self.header.version)
# Header is definitely there so at least one frame *must* follow.
padding = self.frame_set.parse(fp, self.header,
self.extended_header)
log.debug("Tag contains %d bytes of padding." % padding)
return (True, padding)
def _loadV1Tag(self, fp):
v1_enc = "latin1"
# Seek to the end of the file where all v1x tags are written.
# v1.x tags are 128 bytes min and max
fp.seek(0, 2)
if fp.tell() < 128:
return (False, 0)
fp.seek(-128, 2)
tag_data = fp.read(128)
if tag_data[0:3] != b"TAG":
return (False, 0)
log.debug("Located ID3 v1 tag")
# v1.0 is implied until a v1.1 feature is recognized.
self.version = ID3_V1_0
STRIP_CHARS = compat.b(string.whitespace) + b"\x00"
title = tag_data[3:33].strip(STRIP_CHARS)
log.debug("Tite: %s" % title)
if title:
self.title = unicode(title, v1_enc)
artist = tag_data[33:63].strip(STRIP_CHARS)
log.debug("Artist: %s" % artist)
if artist:
self.artist = unicode(artist, v1_enc)
album = tag_data[63:93].strip(STRIP_CHARS)
log.debug("Album: %s" % album)
if album:
self.album = unicode(album, v1_enc)
year = tag_data[93:97].strip(STRIP_CHARS)
log.debug("Year: %s" % year)
try:
if year and int(year):
# Values here typically mean the year of release
self.release_date = int(year)
except ValueError:
# Bogus year strings.
log.warn("ID3v1.x tag contains invalid year: %s" % year)
pass
# Can't use STRIP_CHARS here, since the final byte is numeric
comment = tag_data[97:127].rstrip(b"\x00")
# Track numbers stuffed in the comment field is what makes v1.1
if comment:
if (len(comment) >= 2 and
# Python the slices (the chars), so this is really
# comment[2] and comment[-1]
comment[-2:-1] == b"\x00" and comment[-1:] != b"\x00"):
log.debug("Track Num found, setting version to v1.1")
self.version = ID3_V1_1
track = compat.byteOrd(comment[-1])
self.track_num = (track, None)
log.debug("Track: " + str(track))
comment = comment[:-2].strip(STRIP_CHARS)
# There may only have been a track #
if comment:
log.debug("Comment: %s" % comment)
self.comments.set(unicode(comment, v1_enc), ID3_V1_COMMENT_DESC)
genre = ord(tag_data[127:128])
log.debug("Genre ID: %d" % genre)
try:
self.genre = genre
except ValueError as ex:
log.warning(ex)
self.genre = None
return (True, 0)
@property
def version(self):
return self.header.version
@version.setter
def version(self, v):
# Tag version changes required possible frame conversion
std, non = self._checkForConversions(v)
converted = []
if non:
converted = self._convertFrames(std, non, v)
if converted:
self.frame_set.clear()
for frame in (std + converted):
self.frame_set[frame.id] = frame
self.header.version = v
def isV1(self):
"""Test ID3 major version for v1.x"""
return self.header.major_version == 1
def isV2(self):
"""Test ID3 major version for v2.x"""
return self.header.major_version == 2
@requireUnicode(2)
def setTextFrame(self, fid, txt):
fid = b(fid, ascii_encode)
if not fid.startswith(b"T") or fid.startswith(b"TX"):
raise ValueError("Invalid frame-id for text frame")
if not txt and self.frame_set[fid]:
del self.frame_set[fid]
elif txt:
self.frame_set.setTextFrame(fid, txt)
def getTextFrame(self, fid):
fid = b(fid, ascii_encode)
if not fid.startswith(b"T") or fid.startswith(b"TX"):
raise ValueError("Invalid frame-id for text frame")
f = self.frame_set[fid]
return f[0].text if f else None
@requireUnicode(1)
def _setArtist(self, val):
self.setTextFrame(frames.ARTIST_FID, val)
def _getArtist(self):
return self.getTextFrame(frames.ARTIST_FID)
@requireUnicode(1)
def _setAlbumArtist(self, val):
self.setTextFrame(frames.ALBUM_ARTIST_FID, val)
def _getAlbumArtist(self):
return self.getTextFrame(frames.ALBUM_ARTIST_FID)
@requireUnicode(1)
def _setComposer(self, val):
self.setTextFrame(frames.COMPOSER_FID, val)
def _getComposer(self):
return self.getTextFrame(frames.COMPOSER_FID)
@property
def composer(self):
return self._getComposer()
@composer.setter
def composer(self, v):
self._setComposer(v)
@requireUnicode(1)
def _setAlbum(self, val):
self.setTextFrame(frames.ALBUM_FID, val)
def _getAlbum(self):
return self.getTextFrame(frames.ALBUM_FID)
@requireUnicode(1)
def _setTitle(self, val):
self.setTextFrame(frames.TITLE_FID, val)
def _getTitle(self):
return self.getTextFrame(frames.TITLE_FID)
def _setTrackNum(self, val):
self._setNum(frames.TRACKNUM_FID, val)
def _getTrackNum(self):
return self._splitNum(frames.TRACKNUM_FID)
def _splitNum(self, fid):
f = self.frame_set[fid]
first, second = None, None
if f and f[0].text:
n = f[0].text.split('/')
try:
first = int(n[0])
second = int(n[1]) if len(n) == 2 else None
except ValueError as ex:
log.warning(str(ex))
return (first, second)
def _setNum(self, fid, val):
if type(val) is tuple:
tn, tt = val
elif type(val) is int:
tn, tt = val, None
elif val is None:
tn, tt = None, None
n = (tn, tt)
if n[0] is None and n[1] is None:
if self.frame_set[fid]:
del self.frame_set[fid]
return
total_str = ""
if n[1] is not None:
if n[1] >= 0 and n[1] <= 9:
total_str = "0" + str(n[1])
else:
total_str = str(n[1])
t = n[0] if n[0] else 0
track_str = str(t)
# Pad with zeros according to how large the total count is.
if len(track_str) == 1:
track_str = "0" + track_str
if len(track_str) < len(total_str):
track_str = ("0" * (len(total_str) - len(track_str))) + track_str
final_str = ""
if track_str and total_str:
final_str = "%s/%s" % (track_str, total_str)
elif track_str and not total_str:
final_str = track_str
self.frame_set.setTextFrame(fid, unicode(final_str))
@property
def comments(self):
return self._comments
def _getBpm(self):
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
bpm = None
if frames.BPM_FID in self.frame_set:
bpm_str = self.frame_set[frames.BPM_FID][0].text or u"0"
try:
# Round floats since the spec says this is an integer. Python3
# changed how 'round' works, hence the using of decimal
bpm = int(Decimal(bpm_str).quantize(1, ROUND_HALF_UP))
except (InvalidOperation, ValueError) as ex:
log.warning(ex)
return bpm
def _setBpm(self, bpm):
assert(bpm >= 0)
self.setTextFrame(frames.BPM_FID, unicode(str(bpm)))
bpm = property(_getBpm, _setBpm)
@property
def play_count(self):
if frames.PLAYCOUNT_FID in self.frame_set:
pc = self.frame_set[frames.PLAYCOUNT_FID][0]
return pc.count
else:
return None
@play_count.setter
def play_count(self, count):
if count is None:
del self.frame_set[frames.PLAYCOUNT_FID]
return
if count < 0:
raise ValueError("Invalid play count value: %d" % count)
if self.frame_set[frames.PLAYCOUNT_FID]:
pc = self.frame_set[frames.PLAYCOUNT_FID][0]
pc.count = count
else:
self.frame_set[frames.PLAYCOUNT_FID] = \
frames.PlayCountFrame(count=count)
def _getPublisher(self):
if frames.PUBLISHER_FID in self.frame_set:
pub = self.frame_set[frames.PUBLISHER_FID]
return pub[0].text
else:
return None
@requireUnicode(1)
def _setPublisher(self, p):
self.setTextFrame(frames.PUBLISHER_FID, p)
publisher = property(_getPublisher, _setPublisher)
@property
def cd_id(self):
if frames.CDID_FID in self.frame_set:
return self.frame_set[frames.CDID_FID][0].toc
else:
return None
@cd_id.setter
def cd_id(self, toc):
if len(toc) > 804:
raise ValueError("CD identifier table of contents can be no "
"greater than 804 bytes")
if self.frame_set[frames.CDID_FID]:
cdid = self.frame_set[frames.CDID_FID][0]
cdid.toc = BytesType(toc)
else:
self.frame_set[frames.CDID_FID] = \
frames.MusicCDIdFrame(toc=toc)
@property
def images(self):
return self._images
def _getEncodingDate(self):
return self._getDate(b"TDEN")
def _setEncodingDate(self, date):
self._setDate(b"TDEN", date)
encoding_date = property(_getEncodingDate, _setEncodingDate)
@property
def best_release_date(self):
"""This method tries its best to return a date of some sort, amongst
alll the possible date frames. The order of preference for a release
date is 1) date of original release 2) date of this versions release
3) the recording date. Or None is returned."""
import warnings
warnings.warn("Use Tag.getBestDate() instead", DeprecationWarning,
stacklevel=2)
return (self.original_release_date or
self.release_date or
self.recording_date)
def getBestDate(self, prefer_recording_date=False):
"""This method returns a date of some sort, amongst all the possible
date frames. The order of preference is:
1) date of original release
2) date of this versions release
3) the recording date.
Unless ``prefer_recording_date`` is ``True`` in which case the order is
3, 1, 2.
``None`` will be returned if no dates are available."""
return datePicker(self, prefer_recording_date)
def _getReleaseDate(self):
return self._getDate(b"TDRL") if self.version == ID3_V2_4 \
else self._getV23OrignalReleaseDate()
def _setReleaseDate(self, date):
self._setDate(b"TDRL" if self.version == ID3_V2_4 else b"TORY", date)
release_date = property(_getReleaseDate, _setReleaseDate)
"""The date the audio was released. This is NOT the original date the
work was released, instead it is more like the pressing or version of the
release. Original release date is usually what is intended but many programs
use this frame and/or don't distinguish between the two."""
def _getOrigReleaseDate(self):
return self._getDate(b"TDOR") or self._getV23OrignalReleaseDate()
def _setOrigReleaseDate(self, date):
self._setDate(b"TDOR", date)
original_release_date = property(_getOrigReleaseDate, _setOrigReleaseDate)
"""The date the work was originally released."""
def _getRecordingDate(self):
return self._getDate(b"TDRC") or self._getV23RecordingDate()
def _setRecordingDate(self, date):
if date is None:
for fid in (b"TDRC", b"TYER", b"TDAT", b"TIME"):
self._setDate(fid, None)
elif self.version == ID3_V2_4:
self._setDate(b"TDRC", date)
else:
self._setDate(b"TYER", unicode(date.year))
if None not in (date.month, date.day):
date_str = u"%s%s" % (str(date.day).rjust(2, "0"),
str(date.month).rjust(2, "0"))
self._setDate(b"TDAT", date_str)
if None not in (date.hour, date.minute):
date_str = u"%s%s" % (str(date.hour).rjust(2, "0"),
str(date.minute).rjust(2, "0"))
self._setDate(b"TIME", date_str)
recording_date = property(_getRecordingDate, _setRecordingDate)
"""The date of the recording. Many applications use this for release date
regardless of the fact that this value is rarely known, and release dates
are more correct."""
def _getV23RecordingDate(self):
# v2.3 TYER (yyyy), TDAT (DDMM), TIME (HHmm)
date = None
try:
date_str = b""
if b"TYER" in self.frame_set:
date_str = self.frame_set[b"TYER"][0].text.encode("latin1")
date = core.Date.parse(date_str)
if b"TDAT" in self.frame_set:
text = self.frame_set[b"TDAT"][0].text.encode("latin1")
date_str += b"-%s-%s" % (text[2:], text[:2])
date = core.Date.parse(date_str)
if b"TIME" in self.frame_set:
text = self.frame_set[b"TIME"][0].text.encode("latin1")
date_str += b"T%s:%s" % (text[:2], text[2:])
date = core.Date.parse(date_str)
except ValueError as ex:
log.warning("Invalid v2.3 TYER, TDAT, or TIME frame: %s" % ex)
return date
def _getV23OrignalReleaseDate(self):
date, date_str = None, None
try:
for fid in (b"XDOR", b"TORY"):
# Prefering XDOR over TORY since it can contain full date.
if fid in self.frame_set:
date_str = self.frame_set[fid][0].text.encode("latin1")
break
if date_str:
date = core.Date.parse(date_str)
except ValueError as ex:
log.warning("Invalid v2.3 TORY/XDOR frame: %s" % ex)
return date
def _getTaggingDate(self):
return self._getDate(b"TDTG")
def _setTaggingDate(self, date):
self._setDate(b"TDTG", date)
tagging_date = property(_getTaggingDate, _setTaggingDate)
def _setDate(self, fid, date):
assert(fid in frames.DATE_FIDS or
fid in frames.DEPRECATED_DATE_FIDS)
if date is None:
try:
del self.frame_set[fid]
except KeyError:
pass
return
# Special casing the conversion to DATE objects cuz TDAT and TIME won't
if fid not in (b"TDAT", b"TIME"):
# Convert to ISO format which is what FrameSet wants.
date_type = type(date)
if date_type is int:
# The integer year
date = core.Date(date)
elif date_type in StringTypes:
date = core.Date.parse(date)
elif not isinstance(date, core.Date):
raise TypeError("Invalid type: %s" % str(type(date)))
date_text = unicode(str(date))
if fid in self.frame_set:
self.frame_set[fid][0].date = date
else:
self.frame_set[fid] = frames.DateFrame(fid, date_text)
def _getDate(self, fid):
if fid in (b"TORY", b"XDOR"):
return self._getV23OrignalReleaseDate()
if fid in self.frame_set:
if fid in (b"TYER", b"TDAT", b"TIME"):
if fid == b"TYER":
# Contain years only, date conversion can happen
return core.Date(int(self.frame_set[fid][0].text))
else:
return self.frame_set[fid][0].text
else:
return self.frame_set[fid][0].date
else:
return None
@property
def lyrics(self):
return self._lyrics
@property
def disc_num(self):
return self._splitNum(frames.DISCNUM_FID)
@disc_num.setter
def disc_num(self, val):
self._setNum(frames.DISCNUM_FID, val)
@property
def objects(self):
return self._objects
@property
def privates(self):
return self._privates
@property
def popularities(self):
return self._popularities
def _getGenre(self, id3_std=True):
f = self.frame_set[frames.GENRE_FID]
if f and f[0].text:
try:
return Genre.parse(f[0].text, id3_std=id3_std)
except ValueError:
return None
else:
return None
def _setGenre(self, g, id3_std=True):
"""Set the genre.
Four types are accepted for the ``g`` argument.
A Genre object, an acceptable (see Genre.parse) genre string,
or an integer genre ID all will set the value. A value of None will
remove the genre."""
if g is None:
if self.frame_set[frames.GENRE_FID]:
del self.frame_set[frames.GENRE_FID]
return
if isinstance(g, unicode):
g = Genre.parse(g, id3_std=id3_std)
elif isinstance(g, int):
g = Genre(id=g)
elif not isinstance(g, Genre):
raise TypeError("Invalid genre data type: %s" % str(type(g)))
self.frame_set.setTextFrame(frames.GENRE_FID, unicode(g))
genre = property(_getGenre, _setGenre)
"""genre property."""
non_std_genre = property(partial(_getGenre, id3_std=False),
partial(_setGenre, id3_std=False))
"""Non-standard genres."""
@property
def user_text_frames(self):
return self._user_texts
def _setUrlFrame(self, fid, url):
if fid not in frames.URL_FIDS:
raise ValueError("Invalid URL frame-id")
if self.frame_set[fid]:
if not url:
del self.frame_set[fid]
else:
self.frame_set[fid][0].url = url
else:
self.frame_set[fid] = frames.UrlFrame(fid, url)
def _getUrlFrame(self, fid):
if fid not in frames.URL_FIDS:
raise ValueError("Invalid URL frame-id")
f = self.frame_set[fid]
return f[0].url if f else None
@property
def commercial_url(self):
return self._getUrlFrame(frames.URL_COMMERCIAL_FID)
@commercial_url.setter
def commercial_url(self, url):
self._setUrlFrame(frames.URL_COMMERCIAL_FID, url)
@property
def copyright_url(self):
return self._getUrlFrame(frames.URL_COPYRIGHT_FID)
@copyright_url.setter
def copyright_url(self, url):
self._setUrlFrame(frames.URL_COPYRIGHT_FID, url)
@property
def audio_file_url(self):
return self._getUrlFrame(frames.URL_AUDIOFILE_FID)
@audio_file_url.setter
def audio_file_url(self, url):
self._setUrlFrame(frames.URL_AUDIOFILE_FID, url)
@property
def audio_source_url(self):
return self._getUrlFrame(frames.URL_AUDIOSRC_FID)
@audio_source_url.setter
def audio_source_url(self, url):
self._setUrlFrame(frames.URL_AUDIOSRC_FID, url)
@property
def artist_url(self):
return self._getUrlFrame(frames.URL_ARTIST_FID)
@artist_url.setter
def artist_url(self, url):
self._setUrlFrame(frames.URL_ARTIST_FID, url)
@property
def internet_radio_url(self):
return self._getUrlFrame(frames.URL_INET_RADIO_FID)
@internet_radio_url.setter
def internet_radio_url(self, url):
self._setUrlFrame(frames.URL_INET_RADIO_FID, url)
@property
def payment_url(self):
return self._getUrlFrame(frames.URL_PAYMENT_FID)
@payment_url.setter
def payment_url(self, url):
self._setUrlFrame(frames.URL_PAYMENT_FID, url)
@property
def publisher_url(self):
return self._getUrlFrame(frames.URL_PUBLISHER_FID)
@publisher_url.setter
def publisher_url(self, url):
self._setUrlFrame(frames.URL_PUBLISHER_FID, url)
@property
def user_url_frames(self):
return self._user_urls
@property
def unique_file_ids(self):
return self._unique_file_ids
@property
def terms_of_use(self):
if self.frame_set[frames.TOS_FID]:
return self.frame_set[frames.TOS_FID][0].text
@terms_of_use.setter
def terms_of_use(self, tos):
"""Set the terms of use text.
To specify a language (other than DEFAULT_LANG) code with the text pass
a tuple:
(text, lang)
Language codes are 3 *bytes* of ascii data.
"""
if isinstance(tos, tuple):
tos, lang = tos
else:
lang = DEFAULT_LANG
if self.frame_set[frames.TOS_FID]:
self.frame_set[frames.TOS_FID][0].text = tos
self.frame_set[frames.TOS_FID][0].lang = lang
else:
self.frame_set[frames.TOS_FID] = frames.TermsOfUseFrame(text=tos,
lang=lang)
def _raiseIfReadonly(self):
if self.read_only:
raise RuntimeError("Tag is set read only.")
def save(self, filename=None, version=None, encoding=None, backup=False,
preserve_file_time=False, max_padding=None):
"""Save the tag. If ``filename`` is not give the value from the
``file_info`` member is used, or a ``TagException`` is raised. The
``version`` argument can be used to select an ID3 version other than
the version read. ``Select text encoding with ``encoding`` or use
the existing (or default) encoding. If ``backup`` is True the orignal
file is preserved; likewise if ``preserve_file_time`` is True the
file´s modification/access times are not updated.
"""
self._raiseIfReadonly()
if not (filename or self.file_info):
raise TagException("No file")
elif filename:
self.file_info = FileInfo(filename)
version = version if version else self.version
if version == ID3_V2_2:
raise NotImplementedError("Unable to write ID3 v2.2")
self.version = version
if backup and os.path.isfile(self.file_info.name):
backup_name = "%s.%s" % (self.file_info.name, "orig")
i = 1
while os.path.isfile(backup_name):
backup_name = "%s.%s.%d" % (self.file_info.name, "orig", i)
i += 1
shutil.copyfile(self.file_info.name, backup_name)
if version[0] == 1:
self._saveV1Tag(version)
elif version[0] == 2:
self._saveV2Tag(version, encoding, max_padding)
else:
assert(not "Version bug: %s" % str(version))
if preserve_file_time and None not in (self.file_info.atime,
self.file_info.mtime):
self.file_info.touch((self.file_info.atime, self.file_info.mtime))
else:
self.file_info.initStatTimes()
def _saveV1Tag(self, version):
self._raiseIfReadonly()
assert(version[0] == 1)
def pack(s, n):
assert(type(s) is BytesType)
return s.ljust(n, b'\x00')[:n]
def encode(s):
return s.encode("latin_1", "replace")
# Build tag buffer.
tag = b"TAG"
tag += pack(encode(self.title) if self.title else b"", 30)
tag += pack(encode(self.artist) if self.artist else b"", 30)
tag += pack(encode(self.album) if self.album else b"", 30)
release_date = self.getBestDate()
year = unicode(release_date.year).encode("ascii") if release_date \
else b""
tag += pack(year, 4)
cmt = ""
for c in self.comments:
if c.description == ID3_V1_COMMENT_DESC:
cmt = c.text
# We prefer this one over ""
break
elif c.description == u"":
cmt = c.text
# Keep searching in case we find the description eyeD3 uses.
cmt = pack(encode(cmt), 30)
if version != ID3_V1_0:
track = self.track_num[0]
if track is not None:
cmt = cmt[0:28] + b"\x00" + compat.chr(int(track) & 0xff)
tag += cmt
if not self.genre or self.genre.id is None:
genre = 12 # Other
else:
genre = self.genre.id
tag += compat.chr(genre & 0xff)
assert(len(tag) == 128)
mode = "rb+" if os.path.isfile(self.file_info.name) else "w+b"
with open(self.file_info.name, mode) as tag_file:
# Write the tag over top an original or append it.
try:
tag_file.seek(-128, 2)
if tag_file.read(3) == b"TAG":
tag_file.seek(-128, 2)
else:
tag_file.seek(0, 2)
except IOError:
# File is smaller than 128 bytes.
tag_file.seek(0, 2)
tag_file.write(tag)
tag_file.flush()
def _checkForConversions(self, target_version):
"""Check the current frame set against `target_version` for frames
requiring conversion.
:param: The version the frames need to map to.
:returns: A 2-tuple where the first element is a list of frames that
are accepted for `target_version`, and the second a list of frames
requiring conversion.
"""
std_frames = []
non_std_frames = []
for f in self.frame_set.getAllFrames():
try:
_, fversion, _ = frames.ID3_FRAMES[f.id]
if fversion in (target_version, ID3_V2):
std_frames.append(f)
else:
non_std_frames.append(f)
except KeyError:
# Not a standard frame (ID3_FRAMES)
try:
_, fversion, _ = frames.NONSTANDARD_ID3_FRAMES[f.id]
# but is it one we can handle.
if fversion in (target_version, ID3_V2):
std_frames.append(f)
else:
non_std_frames.append(f)
except KeyError:
# Don't know anything about this pass it on for the error
# check there.
non_std_frames.append(f)
return std_frames, non_std_frames
def _render(self, version, curr_tag_size, max_padding_size):
converted_frames = []
std_frames, non_std_frames = self._checkForConversions(version)
if non_std_frames:
converted_frames = self._convertFrames(std_frames, non_std_frames,
version)
# Render all frames first so the data size is known for the tag header.
frame_data = b""
for f in std_frames + converted_frames:
frame_header = frames.FrameHeader(f.id, version)
if f.header:
frame_header.copyFlags(f.header)
f.header = frame_header
log.debug("Rendering frame: %s" % frame_header.id)
raw_frame = f.render()
log.debug("Rendered %d bytes" % len(raw_frame))
frame_data += raw_frame
log.debug("Rendered %d total frame bytes" % len(frame_data))
# eyeD3 never writes unsync'd data
self.header.unsync = False
pending_size = TagHeader.SIZE + len(frame_data)
if self.header.extended:
# Using dummy data and padding, the actual size of this header
# will be the same regardless, it's more about the flag bits
tmp_ext_header_data = self.extended_header.render(version,
b"\x00", 0)
pending_size += len(tmp_ext_header_data)
padding_size = 0
if pending_size > curr_tag_size:
# current tag (minus padding) larger than the current (plus padding)
padding_size = DEFAULT_PADDING
rewrite_required = True
else:
padding_size = curr_tag_size - pending_size
if max_padding_size is not None and padding_size > max_padding_size:
padding_size = min(DEFAULT_PADDING, max_padding_size)
rewrite_required = True
else:
rewrite_required = False
assert(padding_size >= 0)
log.debug("Using %d bytes of padding" % padding_size)
# Extended header
ext_header_data = b""
if self.header.extended:
log.debug("Rendering extended header")
ext_header_data += self.extended_header.render(self.header.version,
frame_data,
padding_size)
# Render the tag header.
total_size = pending_size + padding_size
log.debug("Rendering %s tag header with size %d" %
(versionToString(version),
total_size - TagHeader.SIZE))
header_data = self.header.render(total_size - TagHeader.SIZE)
# Assemble the entire tag.
tag_data = (header_data +
ext_header_data +
frame_data)
assert(len(tag_data) == (total_size - padding_size))
return (rewrite_required, tag_data, b"\x00" * padding_size)
def _saveV2Tag(self, version, encoding, max_padding):
self._raiseIfReadonly()
assert(version[0] == 2 and version[1] != 2)
log.debug("Rendering tag version: %s" % versionToString(version))
file_exists = os.path.exists(self.file_info.name)
if encoding:
# Any invalid encoding is going to get coersed to a valid value
# when the frame is rendered.
for f in self.frame_set.getAllFrames():
f.encoding = frames.stringToEncoding(encoding)
curr_tag_size = 0
if file_exists:
# We may be converting from 1.x to 2.x so we need to find any
# current v2.x tag otherwise we're gonna hork the file.
# This also resets all offsets, state, etc. and makes me feel safe.
tmp_tag = Tag()
if tmp_tag.parse(self.file_info.name, ID3_V2):
log.debug("Found current v2.x tag:")
curr_tag_size = tmp_tag.file_info.tag_size
log.debug("Current tag size: %d" % curr_tag_size)
rewrite_required, tag_data, padding = self._render(version,
curr_tag_size,
max_padding)
log.debug("Writing %d bytes of tag data and %d bytes of "
"padding" % (len(tag_data), len(padding)))
if rewrite_required:
# Open tmp file
with tempfile.NamedTemporaryFile("wb", delete=False) \
as tmp_file:
tmp_file.write(tag_data + padding)
# Copy audio data in chunks
with open(self.file_info.name, "rb") as tag_file:
if curr_tag_size != 0:
seek_point = curr_tag_size
else:
seek_point = 0
log.debug("Seeking to beginning of audio data, "
"byte %d (%x)" % (seek_point, seek_point))
tag_file.seek(seek_point)
chunkCopy(tag_file, tmp_file)
tmp_file.flush()
# Move tmp to orig.
shutil.copyfile(tmp_file.name, self.file_info.name)
os.unlink(tmp_file.name)
else:
with open(self.file_info.name, "r+b") as tag_file:
tag_file.write(tag_data + padding)
else:
_, tag_data, padding = self._render(version, 0, None)
with open(self.file_info.name, "wb") as tag_file:
tag_file.write(tag_data + padding)
log.debug("Tag write complete. Updating FileInfo state.")
self.file_info.tag_size = len(tag_data) + len(padding)
def _convertFrames(self, std_frames, convert_list, version):
"""Maps frame incompatibilities between ID3 v2.3 and v2.4.
The items in ``std_frames`` need no conversion, but the list/frames
may be edited if necessary (e.g. a converted frame replaces a frame
in the list). The items in ``convert_list`` are the frames to convert
and return. The ``version`` is the target ID3 version."""
from . import versionToString
from .frames import (DATE_FIDS, DEPRECATED_DATE_FIDS,
DateFrame, TextFrame)
converted_frames = []
flist = list(convert_list)
# Date frame conversions.
date_frames = {}
for f in flist:
if version == ID3_V2_4:
if f.id in DEPRECATED_DATE_FIDS:
date_frames[f.id] = f
else:
if f.id in DATE_FIDS:
date_frames[f.id] = f
if date_frames:
if version == ID3_V2_4:
if b"TORY" in date_frames or b"XDOR" in date_frames:
# XDOR -> TDOR (full date)
# TORY -> TDOR (year only)
date = self._getV23OrignalReleaseDate()
if date:
converted_frames.append(DateFrame(b"TDOR", date))
for fid in (b"TORY", b"XDOR"):
if fid in flist:
flist.remove(date_frames[fid])
del date_frames[fid]
# TYER, TDAT, TIME -> TDRC
if (b"TYER" in date_frames or b"TDAT" in date_frames or
b"TIME" in date_frames):
date = self._getV23RecordingDate()
if date:
converted_frames.append(DateFrame(b"TDRC", date))
for fid in [b"TYER", b"TDAT", b"TIME"]:
if fid in date_frames:
flist.remove(date_frames[fid])
del date_frames[fid]
elif version == ID3_V2_3:
if b"TDOR" in date_frames:
date = date_frames[b"TDOR"].date
if date:
converted_frames.append(DateFrame(b"TORY",
unicode(date.year)))
flist.remove(date_frames[b"TDOR"])
del date_frames[b"TDOR"]
if b"TDRC" in date_frames:
date = date_frames[b"TDRC"].date
if date:
converted_frames.append(DateFrame(b"TYER",
unicode(date.year)))
if None not in (date.month, date.day):
date_str = u"%s%s" %\
(str(date.day).rjust(2, "0"),
str(date.month).rjust(2, "0"))
converted_frames.append(TextFrame(b"TDAT",
date_str))
if None not in (date.hour, date.minute):
date_str = u"%s%s" %\
(str(date.hour).rjust(2, "0"),
str(date.minute).rjust(2, "0"))
converted_frames.append(TextFrame(b"TIME",
date_str))
flist.remove(date_frames[b"TDRC"])
del date_frames[b"TDRC"]
if b"TDRL" in date_frames:
# TDRL -> XDOR
date = date_frames[b"TDRL"].date
if date:
converted_frames.append(DateFrame(b"XDOR", str(date)))
flist.remove(date_frames[b"TDRL"])
del date_frames[b"TDRL"]
# All other date frames have no conversion
for fid in date_frames:
log.warning("%s frame being dropped due to conversion to %s" %
(fid, versionToString(version)))
flist.remove(date_frames[fid])
# Convert sort order frames 2.3 (XSO*) <-> 2.4 (TSO*)
prefix = b"X" if version == ID3_V2_4 else b"T"
fids = [prefix + suffix
for suffix in [b"SOA", b"SOP", b"SOT"]]
soframes = [f for f in flist if f.id in fids]
for frame in soframes:
frame.id = (b"X" if prefix == b"T" else b"T") + frame.id[1:]
flist.remove(frame)
converted_frames.append(frame)
# TSIZ (v2.3) are completely deprecated, remove them
if version == ID3_V2_4:
flist = [f for f in flist if f.id != b"TSIZ"]
# TSST (v2.4) --> TIT3 (2.3)
if version == ID3_V2_3 and b"TSST" in [f.id for f in flist]:
tsst_frame = [f for f in flist if f.id == b"TSST"][0]
flist.remove(tsst_frame)
tsst_frame = frames.UserTextFrame(
description=u"Subtitle (converted)", text=tsst_frame.text)
converted_frames.append(tsst_frame)
# Raise an error for frames that could not be converted.
if len(flist) != 0:
unconverted = u", ".join([f.id.decode("ascii") for f in flist])
if version[0] != 1:
raise TagException("Unable to covert the following frames to "
"version %s: %s" % (versionToString(version),
unconverted))
# Some frames in converted_frames may replace/edit frames in std_frames.
for cframe in converted_frames:
for sframe in std_frames:
if cframe.id == sframe.id:
std_frames.remove(sframe)
return converted_frames
@staticmethod
def remove(filename, version=ID3_ANY_VERSION, preserve_file_time=False):
retval = False
if version[0] & ID3_V1[0]:
# ID3 v1.x
tag = Tag()
with open(filename, "r+b") as tag_file:
found = tag.parse(tag_file, ID3_V1)
if found:
tag_file.seek(-128, 2)
log.debug("Removing ID3 v1.x Tag")
tag_file.truncate()
retval |= True
if version[0] & ID3_V2[0]:
tag = Tag()
with open(filename, "rb") as tag_file:
found = tag.parse(tag_file, ID3_V2)
if found:
log.debug("Removing ID3 %s tag" %
versionToString(tag.version))
tag_file.seek(tag.file_info.tag_size)
# Open tmp file
with tempfile.NamedTemporaryFile("wb", delete=False) \
as tmp_file:
chunkCopy(tag_file, tmp_file)
# Move tmp to orig
shutil.copyfile(tmp_file.name, filename)
os.unlink(tmp_file.name)
retval |= True
if preserve_file_time and retval and None not in (tag.file_info.atime,
tag.file_info.mtime):
tag.file_info.touch((tag.file_info.atime, tag.file_info.mtime))
return retval
@property
def chapters(self):
return self._chapters
@property
def table_of_contents(self):
return self._tocs
@property
def album_type(self):
if TXXX_ALBUM_TYPE in self.user_text_frames:
return self.user_text_frames.get(TXXX_ALBUM_TYPE).text
else:
return None
@album_type.setter
def album_type(self, t):
if not t:
self.user_text_frames.remove(TXXX_ALBUM_TYPE)
elif t in ALBUM_TYPE_IDS:
self.user_text_frames.set(t, TXXX_ALBUM_TYPE)
else:
raise ValueError("Invalid album_type: %s" % t)
@property
def artist_origin(self):
"""Returns a 3-tuple: (city, state, country) Any may be ``None``."""
if TXXX_ARTIST_ORIGIN in self.user_text_frames:
origin = self.user_text_frames.get(TXXX_ARTIST_ORIGIN).text
vals = origin.split('\t')
else:
vals = [None] * 3
vals.extend([None] * (3 - len(vals)))
vals = [None if not v else v for v in vals]
assert(len(vals) == 3)
return vals
@artist_origin.setter
def artist_origin(self, city, state, country):
vals = (city, state, country)
vals = [None if not v else v for v in vals]
if vals == (None, None, None):
self.user_text_frames.remove(TXXX_ARTIST_ORIGIN)
else:
assert(len(vals) == 3)
self.user_text_frames.set('\t'.join(vals), TXXX_ARTIST_ORIGIN)
def frameiter(self, fids=None):
"""A iterator for tag frames. If ``fids`` is passed it must be a list
of frame IDs to filter and return."""
fids = fids or []
fids = [(b(f, ascii_encode)
if isinstance(f, UnicodeType) else f) for f in fids]
for f in self.frame_set.getAllFrames():
if not fids or f.id in fids:
yield f
class FileInfo:
"""
This class is for storing information about a parsed file. It containts info
such as the filename, original tag size, and amount of padding; all of which
can make rewriting faster.
"""
def __init__(self, file_name, tagsz=0, tpadd=0):
from .. import LOCAL_FS_ENCODING
if type(file_name) is unicode:
self.name = file_name
else:
try:
self.name = unicode(file_name, LOCAL_FS_ENCODING)
except UnicodeDecodeError:
# Work around the local encoding not matching that of a mounted
# filesystem
log.warning(u"Mismatched file system encoding for file '%s'" %
repr(file_name))
self.name = file_name
self.tag_size = tagsz or 0 # This includes the padding byte count.
self.tag_padding_size = tpadd or 0
self.initStatTimes()
def initStatTimes(self):
try:
s = os.stat(self.name)
except OSError:
self.atime, self.mtime = None, None
else:
self.atime, self.mtime = s.st_atime, s.st_mtime
def touch(self, times):
"""times is a 2-tuple of (atime, mtime)."""
os.utime(self.name, times)
self.initStatTimes()
class AccessorBase(object):
def __init__(self, fid, fs, match_func=None):
self._fid = fid
self._fs = fs
self._match_func = match_func
def __iter__(self):
for f in self._fs[self._fid] or []:
yield f
def __len__(self):
return len(self._fs[self._fid] or [])
def __getitem__(self, i):
frames = self._fs[self._fid]
if not frames:
raise IndexError("list index out of range")
return frames[i]
def get(self, *args, **kwargs):
for frame in self._fs[self._fid] or []:
if self._match_func(frame, *args, **kwargs):
return frame
return None
def remove(self, *args, **kwargs):
"""Returns the removed item or ``None`` if not found."""
fid_frames = self._fs[self._fid] or []
for frame in fid_frames:
if self._match_func(frame, *args, **kwargs):
fid_frames.remove(frame)
return frame
return None
class DltAccessor(AccessorBase):
def __init__(self, FrameClass, fid, fs):
def match_func(frame, description, lang=DEFAULT_LANG):
return (frame.description == description and
frame.lang == (lang if isinstance(lang, BytesType)
else lang.encode("ascii")))
super(DltAccessor, self).__init__(fid, fs, match_func)
self.FrameClass = FrameClass
@requireUnicode(1, 2)
def set(self, text, description=u"", lang=DEFAULT_LANG):
lang = lang or DEFAULT_LANG
for f in self._fs[self._fid] or []:
if f.description == description and f.lang == lang:
# Exists, update text
f.text = text
return f
new_frame = self.FrameClass(description=description, lang=lang,
text=text)
self._fs[self._fid] = new_frame
return new_frame
@requireUnicode(1)
def remove(self, description, lang=DEFAULT_LANG):
return super(DltAccessor, self).remove(description,
lang=lang or DEFAULT_LANG)
@requireUnicode(1)
def get(self, description, lang=DEFAULT_LANG):
return super(DltAccessor, self).get(description,
lang=lang or DEFAULT_LANG)
class CommentsAccessor(DltAccessor):
def __init__(self, fs):
super(CommentsAccessor, self).__init__(frames.CommentFrame,
frames.COMMENT_FID, fs)
class LyricsAccessor(DltAccessor):
def __init__(self, fs):
super(LyricsAccessor, self).__init__(frames.LyricsFrame,
frames.LYRICS_FID, fs)
class ImagesAccessor(AccessorBase):
def __init__(self, fs):
def match_func(frame, description):
return frame.description == description
super(ImagesAccessor, self).__init__(frames.IMAGE_FID, fs, match_func)
@requireUnicode("description")
def set(self, type_, img_data, mime_type, description=u"", img_url=None):
"""Add an image of ``type_`` (a type constant from ImageFrame).
The ``img_data`` is either bytes or ``None``. In the latter case
``img_url`` MUST be the URL to the image. In this case ``mime_type``
is ignored and "-->" is used to signal this as a link and not data
(per the ID3 spec)."""
img_url = b(img_url) if img_url else None
if not img_data and not img_url:
raise ValueError("img_url MUST not be none when no image data")
mime_type = mime_type if img_data else frames.ImageFrame.URL_MIME_TYPE
mime_type = b(mime_type)
images = self._fs[frames.IMAGE_FID] or []
for img in images:
if img.description == description:
# update
if not img_data:
img.image_url = img_url
img.image_data = None
img.mime_type = frames.ImageFrame.URL_MIME_TYPE
else:
img.image_url = None
img.image_data = img_data
img.mime_type = mime_type
img.picture_type = type_
return img
img_frame = frames.ImageFrame(description=description,
image_data=img_data,
image_url=img_url,
mime_type=mime_type,
picture_type=type_)
self._fs[frames.IMAGE_FID] = img_frame
return img_frame
@requireUnicode(1)
def remove(self, description):
return super(ImagesAccessor, self).remove(description)
@requireUnicode(1)
def get(self, description):
return super(ImagesAccessor, self).get(description)
class ObjectsAccessor(AccessorBase):
def __init__(self, fs):
def match_func(frame, description):
return frame.description == description
super(ObjectsAccessor, self).__init__(frames.OBJECT_FID, fs, match_func)
@requireUnicode("description", "filename")
def set(self, data, mime_type, description=u"", filename=u""):
objects = self._fs[frames.OBJECT_FID] or []
for obj in objects:
if obj.description == description:
# update
obj.object_data = data
obj.mime_type = mime_type
obj.filename = filename
return obj
obj_frame = frames.ObjectFrame(description=description,
filename=filename,
object_data=data,
mime_type=mime_type)
self._fs[frames.OBJECT_FID] = obj_frame
return obj_frame
@requireUnicode(1)
def remove(self, description):
return super(ObjectsAccessor, self).remove(description)
@requireUnicode(1)
def get(self, description):
return super(ObjectsAccessor, self).get(description)
class PrivatesAccessor(AccessorBase):
def __init__(self, fs):
def match_func(frame, owner_id):
return frame.owner_id == owner_id
super(PrivatesAccessor, self).__init__(frames.PRIVATE_FID, fs,
match_func)
def set(self, data, owner_id):
priv_frames = self._fs[frames.PRIVATE_FID] or []
for f in priv_frames:
if f.owner_id == owner_id:
# update
f.owner_data = data
return f
priv_frame = frames.PrivateFrame(owner_id=owner_id,
owner_data=data)
self._fs[frames.PRIVATE_FID] = priv_frame
return priv_frame
def remove(self, owner_id):
return super(PrivatesAccessor, self).remove(owner_id)
def get(self, owner_id):
return super(PrivatesAccessor, self).get(owner_id)
class UserTextsAccessor(AccessorBase):
def __init__(self, fs):
def match_func(frame, description):
return frame.description == description
super(UserTextsAccessor, self).__init__(frames.USERTEXT_FID, fs,
match_func)
@requireUnicode(1, "description")
def set(self, text, description=u""):
flist = self._fs[frames.USERTEXT_FID] or []
for utf in flist:
if utf.description == description:
# update
utf.text = text
return utf
utf = frames.UserTextFrame(description=description,
text=text)
self._fs[frames.USERTEXT_FID] = utf
return utf
@requireUnicode(1)
def remove(self, description):
return super(UserTextsAccessor, self).remove(description)
@requireUnicode(1)
def get(self, description):
return super(UserTextsAccessor, self).get(description)
@requireUnicode(1)
def __contains__(self, description):
return bool(self.get(description))
class UniqueFileIdAccessor(AccessorBase):
def __init__(self, fs):
def match_func(frame, owner_id):
return frame.owner_id == owner_id
super(UniqueFileIdAccessor, self).__init__(frames.UNIQUE_FILE_ID_FID,
fs, match_func)
def set(self, data, owner_id):
data, owner_id = b(data), b(owner_id)
if len(data) > 64:
raise TagException("UFID data must be 64 bytes or less")
flist = self._fs[frames.UNIQUE_FILE_ID_FID] or []
for f in flist:
if f.owner_id == owner_id:
# update
f.uniq_id = data
return f
uniq_id_frame = frames.UniqueFileIDFrame(owner_id=owner_id,
uniq_id=data)
self._fs[frames.UNIQUE_FILE_ID_FID] = uniq_id_frame
return uniq_id_frame
def remove(self, owner_id):
owner_id = b(owner_id)
return super(UniqueFileIdAccessor, self).remove(owner_id)
def get(self, owner_id):
owner_id = b(owner_id)
return super(UniqueFileIdAccessor, self).get(owner_id)
class UserUrlsAccessor(AccessorBase):
def __init__(self, fs):
def match_func(frame, description):
return frame.description == description
super(UserUrlsAccessor, self).__init__(frames.USERURL_FID, fs,
match_func)
@requireUnicode("description")
def set(self, url, description=u""):
flist = self._fs[frames.USERURL_FID] or []
for uuf in flist:
if uuf.description == description:
# update
uuf.url = url
return uuf
uuf = frames.UserUrlFrame(description=description, url=url)
self._fs[frames.USERURL_FID] = uuf
return uuf
@requireUnicode(1)
def remove(self, description):
return super(UserUrlsAccessor, self).remove(description)
@requireUnicode(1)
def get(self, description):
return super(UserUrlsAccessor, self).get(description)
class PopularitiesAccessor(AccessorBase):
def __init__(self, fs):
def match_func(frame, email):
return frame.email == email
super(PopularitiesAccessor, self).__init__(frames.POPULARITY_FID, fs,
match_func)
def set(self, email, rating, play_count):
flist = self._fs[frames.POPULARITY_FID] or []
for popm in flist:
if popm.email == email:
# update
popm.rating = rating
popm.count = play_count
return popm
popm = frames.PopularityFrame(email=email, rating=rating,
count=play_count)
self._fs[frames.POPULARITY_FID] = popm
return popm
def remove(self, email):
return super(PopularitiesAccessor, self).remove(email)
def get(self, email):
return super(PopularitiesAccessor, self).get(email)
class ChaptersAccessor(AccessorBase):
def __init__(self, fs):
def match_func(frame, element_id):
return frame.element_id == element_id
super(ChaptersAccessor, self).__init__(frames.CHAPTER_FID, fs,
match_func)
def set(self, element_id, times, offsets=(None, None), sub_frames=None):
flist = self._fs[frames.CHAPTER_FID] or []
for chap in flist:
if chap.element_id == element_id:
# update
chap.times, chap.offsets = times, offsets
if sub_frames:
chap.sub_frames = sub_frames
return chap
chap = frames.ChapterFrame(element_id=element_id,
times=times, offsets=offsets,
sub_frames=sub_frames)
self._fs[frames.CHAPTER_FID] = chap
return chap
def remove(self, element_id):
return super(ChaptersAccessor, self).remove(element_id)
def get(self, element_id):
return super(ChaptersAccessor, self).get(element_id)
def __getitem__(self, elem_id):
"""Overiding the index based __getitem__ for one indexed with chapter
element IDs. These are stored in the tag's table of contents frames."""
for chapter in (self._fs[frames.CHAPTER_FID] or []):
if chapter.element_id == elem_id:
return chapter
raise IndexError("chapter '%s' not found" % elem_id)
class TocAccessor(AccessorBase):
def __init__(self, fs):
def match_func(frame, element_id):
return frame.element_id == element_id
super(TocAccessor, self).__init__(frames.TOC_FID, fs, match_func)
def __iter__(self):
tocs = list(self._fs[self._fid] or [])
for toc_frame in tocs:
# Find and put top level at the front of the list
if toc_frame.toplevel:
tocs.remove(toc_frame)
tocs.insert(0, toc_frame)
break
for toc in tocs:
yield toc
@requireUnicode("description")
def set(self, element_id, toplevel=False, ordered=True, child_ids=None,
description=u""):
flist = self._fs[frames.TOC_FID] or []
# Enforce one top-level
if toplevel:
for toc in flist:
if toc.toplevel:
raise ValueError("There may only be one top-level "
"table of contents. Toc '%s' is current "
"top-level." % toc.element_id)
for toc in flist:
if toc.element_id == element_id:
# update
toc.toplevel = toplevel
toc.ordered = ordered
toc.child_ids = child_ids
toc.description = description
return toc
toc = frames.TocFrame(element_id=element_id, toplevel=toplevel,
ordered=ordered, child_ids=child_ids,
description=description)
self._fs[frames.TOC_FID] = toc
return toc
def remove(self, element_id):
return super(TocAccessor, self).remove(element_id)
def get(self, element_id):
return super(TocAccessor, self).get(element_id)
def __getitem__(self, elem_id):
"""Overiding the index based __getitem__ for one indexed with table
of contents element IDs."""
for toc in (self._fs[frames.TOC_FID] or []):
if toc.element_id == elem_id:
return toc
raise IndexError("toc '%s' not found" % elem_id)
class TagTemplate(string.Template):
idpattern = r'[_a-z][_a-z0-9:]*'
def __init__(self, pattern, path_friendly=True, dotted_dates=False):
super(TagTemplate, self).__init__(pattern)
self._path_friendly = path_friendly
self._dotted_dates = dotted_dates
def substitute(self, tag, zeropad=True):
mapping = self._makeMapping(tag, zeropad)
# Helper function for .sub()
def convert(mo):
named = mo.group('named')
if named is not None:
try:
if type(mapping[named]) is tuple:
func, args = mapping[named][0], mapping[named][1:]
return u'%s' % func(tag, named, *args)
# We use this idiom instead of str() because the latter
# will fail if val is a Unicode containing non-ASCII
return u'%s' % (mapping[named],)
except KeyError:
return self.delimiter + named
braced = mo.group('braced')
if braced is not None:
try:
if type(mapping[braced]) is tuple:
func, args = mapping[braced][0], mapping[braced][1:]
return u'%s' % func(tag, braced, *args)
return u'%s' % (mapping[braced],)
except KeyError:
return self.delimiter + '{' + braced + '}'
if mo.group('escaped') is not None:
return self.delimiter
if mo.group('invalid') is not None:
return self.delimiter
raise ValueError('Unrecognized named group in pattern',
self.pattern)
name = self.pattern.sub(convert, self.template)
return name.replace('/', '-') if self._path_friendly else name
safe_substitute = substitute
def _dates(self, tag, param):
if param.startswith("release_"):
date = tag.release_date
elif param.startswith("recording_"):
date = tag.recording_date
elif param.startswith("original_release_"):
date = tag.original_release_date
else:
date = tag.getBestDate(
prefer_recording_date=":prefer_recording" in param)
if date and param.endswith(":year"):
dstr = unicode(date.year)
elif date:
dstr = unicode(date)
else:
dstr = u""
if self._dotted_dates:
dstr = dstr.replace('-', '.')
return dstr
def _nums(self, num_tuple, param, zeropad):
nn, nt = ((unicode(n) if n else None) for n in num_tuple)
if zeropad:
if nt:
nt = nt.rjust(2, "0")
nn = nn.rjust(len(nt) if nt else 2, "0")
if param.endswith(":num"):
return nn
elif param.endswith(":total"):
return nt
else:
raise ValueError("Unknown template param: %s" % param)
def _track(self, tag, param, zeropad):
return self._nums(tag.track_num, param, zeropad)
def _disc(self, tag, param, zeropad):
return self._nums(tag.disc_num, param, zeropad)
def _file(self, tag, param):
assert(param.startswith("file"))
if param.endswith(":ext"):
return os.path.splitext(tag.file_info.name)[1][1:]
else:
return tag.file_info.name
def _makeMapping(self, tag, zeropad):
return {"artist": tag.artist if tag else None,
"album_artist": tag.album_artist if tag else None,
"album": tag.album if tag else None,
"title": tag.title if tag else None,
"track:num": (self._track, zeropad) if tag else None,
"track:total": (self._track, zeropad) if tag else None,
"release_date": (self._dates,) if tag else None,
"release_date:year": (self._dates,) if tag else None,
"recording_date": (self._dates,) if tag else None,
"recording_date:year": (self._dates,) if tag else None,
"original_release_date": (self._dates,) if tag else None,
"original_release_date:year": (self._dates,) if tag else None,
"best_date": (self._dates,) if tag else None,
"best_date:year": (self._dates,) if tag else None,
"best_date:prefer_recording": (self._dates,) if tag else None,
"best_date:prefer_release": (self._dates,) if tag else None,
"best_date:prefer_recording:year": (self._dates,) if tag
else None,
"best_date:prefer_release:year": (self._dates,) if tag
else None,
"file": (self._file,) if tag else None,
"file:ext": (self._file,) if tag else None,
"disc:num": (self._disc, zeropad) if tag else None,
"disc:total": (self._disc, zeropad) if tag else None,
}
eyeD3-0.8.4/src/eyed3/id3/headers.py 0000644 0001750 0001750 00000061240 13166607546 017623 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
################################################################################
# Copyright (C) 2002-2015 Travis Shirk
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
#
################################################################################
import math
import logging
import binascii
from ..utils import requireBytes
from ..utils.binfuncs import (bin2dec, bytes2bin, bin2bytes,
bin2synchsafe, dec2bin)
from .. import core
from .. import compat
from ..compat import byteOrd
from . import ID3_DEFAULT_VERSION, isValidVersion, normalizeVersion
from ..utils.log import getLogger
log = getLogger(__name__)
NULL_FRAME_FLAGS = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
class TagHeader(object):
SIZE = 10
def __init__(self, version=ID3_DEFAULT_VERSION):
self.clear()
self.version = version
def clear(self):
self.tag_size = 0
# Flag bits
self.unsync = False
self.extended = False
self.experimental = False
# v2.4 addition
self.footer = False
@property
def version(self):
return tuple([v for v in self._version])
@version.setter
def version(self, v):
v = normalizeVersion(v)
if not isValidVersion(v, fully_qualified=True):
raise ValueError("Invalid version: %s" % str(v))
self._version = v
@property
def major_version(self):
return self._version[0]
@property
def minor_version(self):
return self._version[1]
@property
def rev_version(self):
return self._version[2]
def parse(self, f):
'''Parse an ID3 v2 header starting at the current position of ``f``.
If a header is parsed ``True`` is returned, otherwise ``False``. If
a header is found but malformed an ``eyed3.id3.tag.TagException`` is
thrown.
'''
from .tag import TagException
self.clear()
# 3 bytes: v2 header is "ID3".
if f.read(3) != b"ID3":
return False
log.debug("Located ID3 v2 tag")
# 2 bytes: the minor and revision versions.
version = f.read(2)
if len(version) != 2:
return False
major = 2
minor = byteOrd(version[0])
rev = byteOrd(version[1])
log.debug("TagHeader [major]: %d " % major)
log.debug("TagHeader [minor]: %d " % minor)
log.debug("TagHeader [rev]: %d " % rev)
if not (major == 2 and (minor >= 2 and minor <= 4)):
raise TagException("ID3 v%d.%d is not supported" % (major, minor))
self.version = (major, minor, rev)
# 1 byte (first 4 bits): flags
data = f.read(1)
if not data:
return False
(self.unsync,
self.extended,
self.experimental,
self.footer) = (bool(b) for b in bytes2bin(data)[0:4])
log.debug("TagHeader [flags]: unsync(%d) extended(%d) "
"experimental(%d) footer(%d)" % (self.unsync, self.extended,
self.experimental,
self.footer))
# 4 bytes: The size of the extended header (if any), frames, and padding
# afer unsynchronization. This is a sync safe integer, so only the
# bottom 7 bits of each byte are used.
tag_size_bytes = f.read(4)
if len(tag_size_bytes) != 4:
return False
log.debug("TagHeader [size string]: 0x%02x%02x%02x%02x" %
(byteOrd(tag_size_bytes[0]), byteOrd(tag_size_bytes[1]),
byteOrd(tag_size_bytes[2]), byteOrd(tag_size_bytes[3])))
self.tag_size = bin2dec(bytes2bin(tag_size_bytes, 7))
log.debug("TagHeader [size]: %d (0x%x)" % (self.tag_size,
self.tag_size))
return True
def render(self, tag_len=None):
if tag_len is not None:
self.tag_size = tag_len
if self.unsync:
raise NotImplementedError("eyeD3 does not write (only reads) "
"unsync'd data")
data = b"ID3"
data += compat.chr(self.minor_version) + compat.chr(self.rev_version)
data += bin2bytes([int(self.unsync),
int(self.extended),
int(self.experimental),
int(self.footer),
0, 0, 0, 0])
log.debug("Setting tag size to %d" % self.tag_size)
data += bin2bytes(bin2synchsafe(dec2bin(self.tag_size, 32)))
log.debug("TagHeader rendered %d bytes" % len(data))
return data
class ExtendedTagHeader(object):
RESTRICT_TAG_SZ_LARGE = 0x00
RESTRICT_TAG_SZ_MED = 0x01
RESTRICT_TAG_SZ_SMALL = 0x02
RESTRICT_TAG_SZ_TINY = 0x03
RESTRICT_TEXT_ENC_NONE = 0x00
RESTRICT_TEXT_ENC_UTF8 = 0x01
RESTRICT_TEXT_LEN_NONE = 0x00
RESTRICT_TEXT_LEN_1024 = 0x01
RESTRICT_TEXT_LEN_128 = 0x02
RESTRICT_TEXT_LEN_30 = 0x03
RESTRICT_IMG_ENC_NONE = 0x00
RESTRICT_IMG_ENC_PNG_JPG = 0x01
RESTRICT_IMG_SZ_NONE = 0x00
RESTRICT_IMG_SZ_256 = 0x01
RESTRICT_IMG_SZ_64 = 0x02
RESTRICT_IMG_SZ_64_EXACT = 0x03
def __init__(self):
self.size = 0
self._flags = 0
self.crc = None
self._restrictions = 0
@property
def update_bit(self):
return bool(self._flags & 0x40)
@update_bit.setter
def update_bit(self, v):
if v:
self._flags |= 0x40
else:
self._flags &= ~0x40
@property
def crc_bit(self):
return bool(self._flags & 0x20)
@crc_bit.setter
def crc_bit(self, v):
if v:
self._flags |= 0x20
else:
self._flags &= ~0x20
@property
def crc(self):
return self._crc
@crc.setter
def crc(self, v):
self.crc_bit = 1 if v else 0
self._crc = v
@property
def restrictions_bit(self):
return bool(self._flags & 0x10)
@restrictions_bit.setter
def restrictions_bit(self, v):
if v:
self._flags |= 0x10
else:
self._flags &= ~0x10
@property
def tag_size_restriction(self):
return self._restrictions >> 6
@tag_size_restriction.setter
def tag_size_restriction(self, v):
assert(v >= 0 and v <= 3)
self.restrictions_bit = 1
self._restrictions = (v << 6) | (self._restrictions & 0x3f)
@property
def tag_size_restriction_description(self):
val = self.tag_size_restriction
if val == ExtendedTagHeader.RESTRICT_TAG_SZ_LARGE:
return "No more than 128 frames and 1 MB total tag size"
elif val == ExtendedTagHeader.RESTRICT_TAG_SZ_MED:
return "No more than 64 frames and 128 KB total tag size"
elif val == ExtendedTagHeader.RESTRICT_TAG_SZ_SMALL:
return "No more than 32 frames and 40 KB total tag size"
elif val == ExtendedTagHeader.RESTRICT_TAG_SZ_TINY:
return "No more than 32 frames and 4 KB total tag size"
@property
def text_enc_restriction(self):
return (self._restrictions & 0x20) >> 5
@text_enc_restriction.setter
def text_enc_restriction(self, v):
assert(v == 0 or v == 1)
self.restrictions_bit = 1
self._restrictions ^= 0x20
@property
def text_enc_restriction_description(self):
if self.text_enc_restriction:
return "Strings are only encoded with ISO-8859-1 or UTF-8"
else:
return "None"
@property
def text_length_restriction(self):
return (self._restrictions >> 3) & 0x03
@text_length_restriction.setter
def text_length_restriction(self, v):
assert(v >= 0 and v <= 3)
self.restrictions_bit = 1
self._restrictions = (v << 3) | (self._restrictions & 0xe7)
@property
def text_length_restriction_description(self):
val = self.text_length_restriction
if val == ExtendedTagHeader.RESTRICT_TEXT_LEN_NONE:
return "None"
elif val == ExtendedTagHeader.RESTRICT_TEXT_LEN_1024:
return "No string is longer than 1024 characters."
elif val == ExtendedTagHeader.RESTRICT_TEXT_LEN_128:
return "No string is longer than 128 characters."
elif val == ExtendedTagHeader.RESTRICT_TEXT_LEN_30:
return "No string is longer than 30 characters."
@property
def image_enc_restriction(self):
return (self._restrictions & 0x04) >> 2
@image_enc_restriction.setter
def image_enc_restriction(self, v):
assert(v == 0 or v == 1)
self.restrictions_bit = 1
self._restrictions ^= 0x04
@property
def image_enc_restriction_description(self):
if self.image_enc_restriction:
return "Images are encoded only with PNG [PNG] or JPEG [JFIF]."
else:
return "None"
@property
def image_size_restriction(self):
return self._restrictions & 0x03
@image_size_restriction.setter
def image_size_restriction(self, v):
assert(v >= 0 and v <= 3)
self.restrictions_bit = 1
self._restrictions = v | (self._restrictions & 0xfc)
@property
def image_size_restriction_description(self):
val = self.image_size_restriction
if val == ExtendedTagHeader.RESTRICT_IMG_SZ_NONE:
return "None"
elif val == ExtendedTagHeader.RESTRICT_IMG_SZ_256:
return "All images are 256x256 pixels or smaller."
elif val == ExtendedTagHeader.RESTRICT_IMG_SZ_64:
return "All images are 64x64 pixels or smaller."
elif val == ExtendedTagHeader.RESTRICT_IMG_SZ_64_EXACT:
return "All images are exactly 64x64 pixels, unless required "\
"otherwise."
def _syncsafeCRC(self):
bites = b""
bites += compat.chr((self.crc >> 28) & 0x7f)
bites += compat.chr((self.crc >> 21) & 0x7f)
bites += compat.chr((self.crc >> 14) & 0x7f)
bites += compat.chr((self.crc >> 7) & 0x7f)
bites += compat.chr((self.crc >> 0) & 0x7f)
return bites
def render(self, version, frame_data, padding=0):
assert(version[0] == 2)
data = b""
if version[1] == 4:
# Version 2.4
size = 6
# Extended flags.
if self.update_bit:
data += b"\x00"
if self.crc_bit:
data += b"\x05"
# XXX: Using the absolute value of the CRC. The spec is unclear
# about the type of this data.
self.crc = int(math.fabs(binascii.crc32(frame_data +
(b"\x00" * padding))))
crc_data = self._syncsafeCRC()
if len(crc_data) < 5:
# pad if necessary
crc_data = (b"\x00" * (5 - len(crc_data))) + crc_data
assert(len(crc_data) == 5)
data += crc_data
if self.restrictions_bit:
data += b"\x01"
data += compat.chr(self._restrictions)
log.debug("Rendered extended header data (%d bytes)" % len(data))
# Extended header size.
size = bin2bytes(bin2synchsafe(dec2bin(len(data) + 6, 32)))
assert(len(size) == 4)
data = size + b"\x01" + bin2bytes(dec2bin(self._flags)) + data
log.debug("Rendered extended header of size %d" % len(data))
else:
# Version 2.3
size = 6 # Note, the 4 size bytes are not included in the size
# Extended flags.
f = [0] * 16
crc = None
if self.crc_bit:
f[0] = 1
# XXX: Using the absolute value of the CRC. The spec is unclear
# about the type of this value.
self.crc = int(math.fabs(binascii.crc32(frame_data +
(b"\x00" * padding))))
crc = bin2bytes(dec2bin(self.crc))
assert(len(crc) == 4)
size += 4
flags = bin2bytes(f)
assert(len(flags) == 2)
# Extended header size.
size = bin2bytes(dec2bin(size, 32))
assert(len(size) == 4)
# Padding size
padding_size = bin2bytes(dec2bin(padding, 32))
data = size + flags + padding_size
if crc:
data += crc
return data
# Only call this when you *know* there is an extened header.
def parse(self, fp, version):
'''Parse an ID3 v2 extended header starting at the current position
of ``fp`` and per the format defined by ``version``. This method
should only be called when the presence of an extended header is known
since it moves the file position. If a header is found but malformed
an ``eyed3.id3.tag.TagException`` is thrown. The return value is
``None``.
'''
from .tag import TagException
assert(version[0] == 2)
log.debug("Parsing extended header @ 0x%x" % fp.tell())
# First 4 bytes is the size of the extended header.
data = fp.read(4)
if version[1] == 4:
# sync-safe
sz = bin2dec(bytes2bin(data, 7))
self.size = sz
log.debug("Extended header size (includes the 4 size bytes): %d" %
sz)
data = fp.read(sz - 4)
# Number of flag bytes
if byteOrd(data[0]) != 1 or (byteOrd(data[1]) & 0x8f):
# As of 2.4 the first byte is 1 and the second can only have
# bits 6, 5, and 4 set.
raise TagException("Invalid Extended Header")
self._flags = byteOrd(data[1])
log.debug("Extended header flags: %x" % self._flags)
offset = 2
if self.update_bit:
log.debug("Extended header has update bit set")
assert(byteOrd(data[offset]) == 0)
offset += 1
if self.crc_bit:
log.debug("Extended header has CRC bit set")
assert(byteOrd(data[offset]) == 5)
offset += 1
crc_data = data[offset:offset + 5]
# This is sync-safe.
self.crc = bin2dec(bytes2bin(crc_data, 7))
log.debug("Extended header CRC: %d" % self.crc)
offset += 5
if self.restrictions_bit:
log.debug("Extended header has restrictions bit set")
assert(byteOrd(data[offset]) == 1)
offset += 1
self._restrictions = byteOrd(data[offset])
offset += 1
else:
# v2.3 is totally different... *sigh*
sz = bin2dec(bytes2bin(data))
self.size = sz
log.debug("Extended header size (not including 4 size bytes): %d" %
sz)
tmpFlags = fp.read(2)
# Read the padding size, but it'll be computed during the parse.
ps = fp.read(4)
log.debug("Extended header says there is %d bytes of padding" %
bin2dec(bytes2bin(ps)))
# Make this look like a v2.4 mask.
self._flags = byteOrd(tmpFlags[0]) >> 2
if self.crc_bit:
log.debug("Extended header has CRC bit set")
crc_data = fp.read(4)
self.crc = bin2dec(bytes2bin(crc_data))
log.debug("Extended header CRC: %d" % self.crc)
class FrameHeader(object):
'''A header for each and every ID3 frame in a tag.'''
# 2.4 not only added flag bits, but also reordered the previously defined
# flags. So these are mapped once the ID3 version is known. Access through
# 'self', always
TAG_ALTER = None
FILE_ALTER = None
READ_ONLY = None
COMPRESSED = None
ENCRYPTED = None
GROUPED = None
UNSYNC = None
DATA_LEN = None
# Constructor.
@requireBytes(1)
def __init__(self, fid, version):
self._version = version
self._setBitMask()
# _setBitMask will throw if the version is no good
# Correctly set size of header (v2.2 is smaller)
self.size = 10 if self.minor_version != 2 else 6
# The frame header itself...
self.id = fid # First 4 bytes, frame ID
self._flags = [0] * 16 # 16 bits, represented here as a list
self.data_size = 0 # 4 bytes, size of frame data
def copyFlags(self, rhs):
self.tag_alter = rhs._flags[rhs.TAG_ALTER]
self.file_alter = rhs._flags[rhs.FILE_ALTER]
self.read_only = rhs._flags[rhs.READ_ONLY]
self.compressed = rhs._flags[rhs.COMPRESSED]
self.encrypted = rhs._flags[rhs.ENCRYPTED]
self.grouped = rhs._flags[rhs.GROUPED]
self.unsync = rhs._flags[rhs.UNSYNC]
self.data_length_indicator = rhs._flags[rhs.DATA_LEN]
@property
def major_version(self):
return self._version[0]
@property
def minor_version(self):
return self._version[1]
@property
def version(self):
return self._version
@property
def tag_alter(self):
return self._flags[self.TAG_ALTER]
@tag_alter.setter
def tag_alter(self, b):
self._flags[self.TAG_ALTER] = int(bool(b))
@property
def file_alter(self):
return self._flags[self.FILE_ALTER]
@file_alter.setter
def file_alter(self, b):
self._flags[self.FILE_ALTER] = int(bool(b))
@property
def read_only(self):
return self._flags[self.READ_ONLY]
@read_only.setter
def read_only(self, b):
self._flags[self.READ_ONLY] = int(bool(b))
@property
def compressed(self):
return self._flags[self.COMPRESSED]
@compressed.setter
def compressed(self, b):
self._flags[self.COMPRESSED] = int(bool(b))
@property
def encrypted(self):
return self._flags[self.ENCRYPTED]
@encrypted.setter
def encrypted(self, b):
self._flags[self.ENCRYPTED] = int(bool(b))
@property
def grouped(self):
return self._flags[self.GROUPED]
@grouped.setter
def grouped(self, b):
self._flags[self.GROUPED] = int(bool(b))
@property
def unsync(self):
return self._flags[self.UNSYNC]
@unsync.setter
def unsync(self, b):
self._flags[self.UNSYNC] = int(bool(b))
@property
def data_length_indicator(self):
return self._flags[self.DATA_LEN]
@data_length_indicator.setter
def data_length_indicator(self, b):
self._flags[self.DATA_LEN] = int(bool(b))
def _setBitMask(self):
major = self.major_version
minor = self.minor_version
# 1.x tags are converted to 2.4 frames internally. These frames are
# created with frame flags \x00.
if (major == 2 and minor in (3, 2)):
# v2.2 does not contain flags, but set anyway, as long as the
# values remain 0 all is good
self.TAG_ALTER = 0
self.FILE_ALTER = 1
self.READ_ONLY = 2
self.COMPRESSED = 8
self.ENCRYPTED = 9
self.GROUPED = 10
# This is not in 2.3 frame header flags, map to unused
self.UNSYNC = 14
# This is not in 2.3 frame header flags, map to unused
self.DATA_LEN = 4
elif ((major == 2 and minor == 4) or (major == 1 and minor in (0, 1))):
self.TAG_ALTER = 1
self.FILE_ALTER = 2
self.READ_ONLY = 3
self.COMPRESSED = 12
self.ENCRYPTED = 13
self.GROUPED = 9
self.UNSYNC = 14
self.DATA_LEN = 15
else:
raise ValueError("ID3 v" + str(major) + "." + str(minor) +
" is not supported.")
def render(self, data_size):
data = b''
assert(type(self.id) is compat.BytesType)
data += self.id
self.data_size = data_size
if self.minor_version == 3:
data += bin2bytes(dec2bin(data_size, 32))
else:
data += bin2bytes(bin2synchsafe(dec2bin(data_size, 32)))
if self.unsync:
raise NotImplementedError("eyeD3 does not write (only reads) "
"unsync'd data")
data += bin2bytes(self._flags)
return data
@staticmethod
def _parse2_2(f, version):
from .frames import map2_2FrameId
from .frames import FrameException
frame_id_22 = f.read(3)
frame_id = map2_2FrameId(frame_id_22)
if FrameHeader._isValidFrameId(frame_id):
log.debug("FrameHeader [id]: %s (0x%x%x%x)" %
(frame_id_22, byteOrd(frame_id_22[0]),
byteOrd(frame_id_22[1]), byteOrd(frame_id_22[2])))
frame_header = FrameHeader(frame_id, version)
# data_size corresponds to the size of the data segment after
# encryption, compression, and unsynchronization.
sz = f.read(3)
frame_header.data_size = bin2dec(bytes2bin(sz, 8))
log.debug("FrameHeader [data size]: %d (0x%X)" %
(frame_header.data_size, frame_header.data_size))
return frame_header
elif frame_id == b'\x00\x00\x00':
log.debug("FrameHeader: Null frame id found at byte %d" % f.tell())
else:
core.parseError(FrameException("FrameHeader: Illegal Frame ID: %s" %
frame_id))
return None
@staticmethod
def parse(f, version):
from .frames import FrameException
log.debug("FrameHeader [start byte]: %d (0x%X)" % (f.tell(),
f.tell()))
major_version, minor_version = version[:2]
if minor_version == 2:
return FrameHeader._parse2_2(f, version)
frame_id = f.read(4)
if FrameHeader._isValidFrameId(frame_id):
log.debug("FrameHeader [id]: %s (0x%x%x%x%x)" %
(frame_id, byteOrd(frame_id[0]), byteOrd(frame_id[1]),
byteOrd(frame_id[2]), byteOrd(frame_id[3])))
frame_header = FrameHeader(frame_id, version)
# data_size corresponds to the size of the data segment after
# encryption, compression, and unsynchronization.
sz = f.read(4)
# In ID3 v2.4 this value became a synch-safe integer, meaning only
# the low 7 bits are used per byte.
if minor_version == 3:
frame_header.data_size = bin2dec(bytes2bin(sz, 8))
else:
frame_header.data_size = bin2dec(bytes2bin(sz, 7))
log.debug("FrameHeader [data size]: %d (0x%X)" %
(frame_header.data_size, frame_header.data_size))
# Frame flags.
flags = f.read(2)
frame_header._flags = bytes2bin(flags)
if log.getEffectiveLevel() <= logging.DEBUG:
log.debug("FrameHeader [flags]: ta(%d) fa(%d) ro(%d) co(%d) "
"en(%d) gr(%d) un(%d) dl(%d)" %
(frame_header.tag_alter,
frame_header.file_alter, frame_header.read_only,
frame_header.compressed, frame_header.encrypted,
frame_header.grouped, frame_header.unsync,
frame_header.data_length_indicator))
if (frame_header.minor_version >= 4 and frame_header.compressed and
not frame_header.data_length_indicator):
core.parseError(FrameException("Invalid frame; compressed with "
"no data length indicator"))
return frame_header
elif frame_id == b'\x00' * 4:
log.debug("FrameHeader: Null frame id found at byte %d" % f.tell())
else:
core.parseError(FrameException("FrameHeader: Illegal Frame ID: %s" %
frame_id))
return None
@staticmethod
def _isValidFrameId(id):
import re
return re.compile(b"^[A-Z0-9][A-Z0-9][A-Z0-9][A-Z0-9]$").match(id)
eyeD3-0.8.4/src/eyed3/id3/frames.py 0000644 0001750 0001750 00000206576 13203722311 017456 0 ustar travis travis 0000000 0000000 # -*- coding: utf-8 -*-
from io import BytesIO
from codecs import ascii_encode
from collections import namedtuple
from .. import core
from ..utils import requireUnicode, requireBytes
from ..utils.binfuncs import (bin2bytes, bin2dec, bytes2bin, dec2bin,
bytes2dec, dec2bytes)
from ..compat import unicode, UnicodeType, BytesType, byteiter
from .. import Error
from . import ID3_V2, ID3_V2_3, ID3_V2_4
from . import (LATIN1_ENCODING, UTF_8_ENCODING, UTF_16BE_ENCODING,
UTF_16_ENCODING, DEFAULT_LANG)
from .headers import FrameHeader
from ..utils.log import getLogger
log = getLogger(__name__)
class FrameException(Error):
pass
TITLE_FID = b"TIT2" # noqa
SUBTITLE_FID = b"TIT3" # noqa
ARTIST_FID = b"TPE1" # noqa
ALBUM_ARTIST_FID = b"TPE2" # noqa
COMPOSER_FID = b"TCOM" # noqa
ALBUM_FID = b"TALB" # noqa
TRACKNUM_FID = b"TRCK" # noqa
GENRE_FID = b"TCON" # noqa
COMMENT_FID = b"COMM" # noqa
USERTEXT_FID = b"TXXX" # noqa
OBJECT_FID = b"GEOB" # noqa
UNIQUE_FILE_ID_FID = b"UFID" # noqa
LYRICS_FID = b"USLT" # noqa
DISCNUM_FID = b"TPOS" # noqa
IMAGE_FID = b"APIC" # noqa
USERURL_FID = b"WXXX" # noqa
PLAYCOUNT_FID = b"PCNT" # noqa
BPM_FID = b"TBPM" # noqa
PUBLISHER_FID = b"TPUB" # noqa
CDID_FID = b"MCDI" # noqa
PRIVATE_FID = b"PRIV" # noqa
TOS_FID = b"USER" # noqa
POPULARITY_FID = b"POPM" # noqa
URL_COMMERCIAL_FID = b"WCOM" # noqa
URL_COPYRIGHT_FID = b"WCOP" # noqa
URL_AUDIOFILE_FID = b"WOAF" # noqa
URL_ARTIST_FID = b"WOAR" # noqa
URL_AUDIOSRC_FID = b"WOAS" # noqa
URL_INET_RADIO_FID = b"WORS" # noqa
URL_PAYMENT_FID = b"WPAY" # noqa
URL_PUBLISHER_FID = b"WPUB" # noqa
URL_FIDS = [URL_COMMERCIAL_FID, URL_COPYRIGHT_FID, # noqa
URL_AUDIOFILE_FID, URL_ARTIST_FID, URL_AUDIOSRC_FID,
URL_INET_RADIO_FID, URL_PAYMENT_FID,
URL_PUBLISHER_FID]
TOC_FID = b"CTOC" # noqa
CHAPTER_FID = b"CHAP" # noqa
DEPRECATED_DATE_FIDS = [b"TDAT", b"TYER", b"TIME", b"TORY", b"TRDA",
# Nonstandard v2.3 only
b"XDOR",
]
DATE_FIDS = [b"TDEN", b"TDOR", b"TDRC", b"TDRL", b"TDTG"]
class Frame(object):
@requireBytes(1)
def __init__(self, id):
self.id = id
self.header = None
self.decompressed_size = 0
self.group_id = None
self.encrypt_method = None
self.data = None
self.data_len = 0
self._encoding = None
@property
def header(self):
return self._header
@header.setter
def header(self, h):
self._header = h
@requireBytes(1)
def parse(self, data, frame_header):
self.id = frame_header.id
self.header = frame_header
self.data = self._disassembleFrame(data)
def render(self):
return self._assembleFrame(self.data)
def __lt__(self, other):
return self.id < other.id
@staticmethod
def decompress(data):
import zlib
log.debug("before decompression: %d bytes" % len(data))
data = zlib.decompress(data, 15)
log.debug("after decompression: %d bytes" % len(data))
return data
@staticmethod
def compress(data):
import zlib
log.debug("before compression: %d bytes" % len(data))
data = zlib.compress(data)
log.debug("after compression: %d bytes" % len(data))
return data
@staticmethod
def decrypt(data):
raise NotImplementedError("Frame decryption not yet supported")
@staticmethod
def encrypt(data):
raise NotImplementedError("Frame encryption not yet supported")
@requireBytes(1)
def _disassembleFrame(self, data):
assert(self.header)
header = self.header
# Format flags in the frame header may add extra data to the
# beginning of this data.
if header.minor_version <= 3:
# 2.3: compression(4), encryption(1), group(1)
if header.compressed:
self.decompressed_size = bin2dec(bytes2bin(data[:4]))
data = data[4:]
log.debug("Decompressed Size: %d" % self.decompressed_size)
if header.encrypted:
self.encrypt_method = bin2dec(bytes2bin(data[0:1]))
data = data[1:]
log.debug("Encryption Method: %d" % self.encrypt_method)
if header.grouped:
self.group_id = bin2dec(bytes2bin(data[0:1]))
data = data[1:]
log.debug("Group ID: %d" % self.group_id)
else:
# 2.4: group(1), encrypted(1), data_length_indicator (4,7)
if header.grouped:
self.group_id = bin2dec(bytes2bin(data[0:1]))
log.debug("Group ID: %d" % self.group_id)
data = data[1:]
if header.encrypted:
self.encrypt_method = bin2dec(bytes2bin(data[0:1]))
data = data[1:]
log.debug("Encryption Method: %d" % self.encrypt_method)
if header.data_length_indicator:
self.data_len = bin2dec(bytes2bin(data[:4], 7))
data = data[4:]
log.debug("Data Length: %d" % self.data_len)
if header.compressed:
self.decompressed_size = self.data_len
log.debug("Decompressed Size: %d" % self.decompressed_size)
if header.minor_version == 4 and header.unsync:
data = deunsyncData(data)
if header.encrypted:
data = self.decrypt(data)
if header.compressed:
data = self.decompress(data)
return data
@requireBytes(1)
def _assembleFrame(self, data):
assert(self.header)
header = self.header
# eyeD3 never writes unsync'd frames
header.unsync = False
format_data = b""
if header.minor_version == 3:
if header.compressed:
format_data += bin2bytes(dec2bin(len(data), 32))
if header.encrypted:
format_data += bin2bytes(dec2bin(self.encrypt_method, 8))
if header.grouped:
format_data += bin2bytes(dec2bin(self.group_id, 8))
else:
if header.grouped:
format_data += bin2bytes(dec2bin(self.group_id, 8))
if header.encrypted:
format_data += bin2bytes(dec2bin(self.encrypt_method, 8))
if header.compressed or header.data_length_indicator:
header.data_length_indicator = 1
format_data += bin2bytes(dec2bin(len(data), 32))
if header.compressed:
data = self.compress(data)
if header.encrypted:
data = self.encrypt(data)
self.data = format_data + data
return header.render(len(self.data)) + self.data
@property
def text_delim(self):
assert(self.encoding is not None)
return b"\x00\x00" if self.encoding in (UTF_16_ENCODING,
UTF_16BE_ENCODING) else b"\x00"
def _initEncoding(self):
assert(self.header.version and len(self.header.version) == 3)
if self.encoding is not None:
# Make sure the encoding is valid for this version
if self.header.version[:2] < (2, 4):
if self.header.version[0] == 1:
self.encoding = LATIN1_ENCODING
else:
if self.encoding > UTF_16_ENCODING:
# v2.3 cannot do utf16 BE or utf8
self.encoding = UTF_16_ENCODING
else:
if self.header.version[:2] < (2, 4):
if self.header.version[0] == 2:
self.encoding = UTF_16_ENCODING
else:
self.encoding = LATIN1_ENCODING
else:
self.encoding = UTF_8_ENCODING
@property
def encoding(self):
return self._encoding
@encoding.setter
def encoding(self, enc):
if not isinstance(enc, bytes):
raise TypeError("encoding argument must be a byte string.")
elif not (LATIN1_ENCODING <= enc <= UTF_8_ENCODING):
raise ValueError("Unknown encoding value {}".format(enc))
self._encoding = enc
class TextFrame(Frame):
"""Text frames.
Data string format: encoding (one byte) + text
"""
@requireUnicode("text")
def __init__(self, id, text=None):
super(TextFrame, self).__init__(id)
assert(self.id[0:1] == b'T' or self.id in [b"XSOA", b"XSOP", b"XSOT",
b"XDOR", b"WFED"])
self.text = text or u""
@property
def text(self):
return self._text
@text.setter
@requireUnicode(1)
def text(self, txt):
self._text = txt
def parse(self, data, frame_header):
super(TextFrame, self).parse(data, frame_header)
try:
self.encoding = self.data[0:1]
text_data = self.data[1:]
except ValueError as err:
log.warning("{err}; using latin1".format(err=err))
self.encoding = LATIN1_ENCODING
text_data = self.data[:]
try:
self.text = decodeUnicode(text_data, self.encoding)
except UnicodeDecodeError as err:
log.warning("Error decoding text frame {fid}: {err}"
.format(fid=self.id, err=err))
self.test = u""
log.debug("TextFrame text: %s" % self.text)
def render(self):
self._initEncoding()
self.data = (self.encoding +
self.text.encode(id3EncodingToString(self.encoding)))
assert(type(self.data) == BytesType)
return super(TextFrame, self).render()
class UserTextFrame(TextFrame):
@requireUnicode("description", "text")
def __init__(self, id=USERTEXT_FID, description=u"", text=u""):
super(UserTextFrame, self).__init__(id, text=text)
self.description = description
@property
def description(self):
return self._description
@description.setter
@requireUnicode(1)
def description(self, txt):
self._description = txt
def parse(self, data, frame_header):
"""Data string format:
encoding (one byte) + description + b"\x00" + text """
# Calling Frame, not TextFrame implementation here since TextFrame
# does not know about description
Frame.parse(self, data, frame_header)
self.encoding = encoding = self.data[0:1]
(d, t) = splitUnicode(self.data[1:], encoding)
self.description = decodeUnicode(d, encoding)
log.debug("UserTextFrame description: %s" % self.description)
self.text = decodeUnicode(t, encoding)
log.debug("UserTextFrame text: %s" % self.text)
def render(self):
self._initEncoding()
data = (self.encoding +
self.description.encode(id3EncodingToString(self.encoding)) +
self.text_delim +
self.text.encode(id3EncodingToString(self.encoding)))
self.data = data
# Calling Frame, not the base
return Frame.render(self)
class DateFrame(TextFrame):
def __init__(self, id, date=u""):
assert(id in DATE_FIDS or id in DEPRECATED_DATE_FIDS)
super(DateFrame, self).__init__(id, text=unicode(date))
self.date = self.text
self.encoding = LATIN1_ENCODING
def parse(self, data, frame_header):
super(DateFrame, self).parse(data, frame_header)
try:
if self.text:
_ = core.Date.parse(self.text) # noqa
except ValueError:
# Date is invalid, log it and reset.
core.parseError(FrameException(u"Invalid date: " + self.text))
self.text = u''
@property
def date(self):
return core.Date.parse(self.text.encode("latin1")) if self.text \
else None
# \a date Either an ISO 8601 date string or a eyed3.core.Date object.
@date.setter
def date(self, date):
if not date:
self.text = u""
return
try:
if type(date) is str:
date = core.Date.parse(date)
elif type(date) is unicode:
date = core.Date.parse(date.encode("latin1"))
elif not isinstance(date, core.Date):
raise TypeError("str, unicode, and eyed3.core.Date type "
"expected")
except ValueError:
log.warning("Invalid date text: %s" % date)
self.text = u""
return
self.text = unicode(str(date))
def _initEncoding(self):
# Dates are always latin1 since they are always represented in ISO 8601
self.encoding = LATIN1_ENCODING
class UrlFrame(Frame):
@requireBytes("url")
def __init__(self, id, url=b""):
assert(id in URL_FIDS or id == USERURL_FID)
super(UrlFrame, self).__init__(id)
self.encoding = LATIN1_ENCODING
self.url = url
@property
def url(self):
return self._url
@requireBytes(1)
@url.setter
def url(self, url):
self._url = url
def parse(self, data, frame_header):
super(UrlFrame, self).parse(data, frame_header)
# The URL is ascii, ensure
try:
self.url = unicode(self.data, "ascii").encode("ascii")
except UnicodeDecodeError:
log.warning("Non ascii url, clearing.")
self.url = ""
def render(self):
self.data = self.url
return super(UrlFrame, self).render()
class UserUrlFrame(UrlFrame):
"""
Data string format:
encoding (one byte) + description + b"\x00" + url (ascii)
"""
@requireUnicode("description")
def __init__(self, id=USERURL_FID, description=u"", url=b""):
UrlFrame.__init__(self, id, url=url)
assert(self.id == USERURL_FID)
self.description = description
@property
def description(self):
return self._description
@description.setter
@requireUnicode(1)
def description(self, desc):
self._description = desc
def parse(self, data, frame_header):
# Calling Frame and NOT UrlFrame to get the basic disassemble behavior
# UrlFrame would be confused by the encoding, desc, etc.
super(UserUrlFrame, self).parse(data, frame_header)
self.encoding = encoding = self.data[0:1]
(d, u) = splitUnicode(self.data[1:], encoding)
self.description = decodeUnicode(d, encoding)
log.debug("UserUrlFrame description: %s" % self.description)
# The URL is ascii, ensure
try:
self.url = unicode(u, "ascii").encode("ascii")
except UnicodeDecodeError:
log.warning("Non ascii url, clearing.")
self.url = ""
log.debug("UserUrlFrame text: %s" % self.url)
def render(self):
self._initEncoding()
data = (self.encoding +
self.description.encode(id3EncodingToString(self.encoding)) +
self.text_delim + self.url)
self.data = data
# Calling Frame, not the base.
return Frame.render(self)
##
# Data string format:
#
# Text encoding $xx
# MIME type $00
# Picture type $xx
# Description $00 (00)
# Picture data
class ImageFrame(Frame):
OTHER = 0x00 # noqa
ICON = 0x01 # 32x32 png only. # noqa
OTHER_ICON = 0x02 # noqa
FRONT_COVER = 0x03 # noqa
BACK_COVER = 0x04 # noqa
LEAFLET = 0x05 # noqa
MEDIA = 0x06 # label side of cd, vinyl, etc. # noqa
LEAD_ARTIST = 0x07 # noqa
ARTIST = 0x08 # noqa
CONDUCTOR = 0x09 # noqa
BAND = 0x0A # noqa
COMPOSER = 0x0B # noqa
LYRICIST = 0x0C # noqa
RECORDING_LOCATION = 0x0D # noqa
DURING_RECORDING = 0x0E # noqa
DURING_PERFORMANCE = 0x0F # noqa
VIDEO = 0x10 # noqa
BRIGHT_COLORED_FISH = 0x11 # There's always room for porno. # noqa
ILLUSTRATION = 0x12 # noqa
BAND_LOGO = 0x13 # noqa
PUBLISHER_LOGO = 0x14 # noqa
MIN_TYPE = OTHER # noqa
MAX_TYPE = PUBLISHER_LOGO # noqa
URL_MIME_TYPE = b"-->" # noqa
URL_MIME_TYPE_STR = u"-->" # noqa
URL_MIME_TYPE_VALUES = (URL_MIME_TYPE, URL_MIME_TYPE_STR)
@requireUnicode("description")
def __init__(self, id=IMAGE_FID, description=u"",
image_data=None, image_url=None,
picture_type=None, mime_type=None):
assert(id == IMAGE_FID)
super(ImageFrame, self).__init__(id)
self.description = description
self.image_data = image_data
self.image_url = image_url
self.picture_type = picture_type
self.mime_type = mime_type
@property
def description(self):
return self._description
@description.setter
@requireUnicode(1)
def description(self, d):
self._description = d
@property
def mime_type(self):
return unicode(self._mime_type, "ascii")
@mime_type.setter
def mime_type(self, m):
m = m or b''
self._mime_type = m if isinstance(m, BytesType) else m.encode('ascii')
@property
def picture_type(self):
return self._pic_type
@picture_type.setter
def picture_type(self, t):
if t is not None and (t < ImageFrame.MIN_TYPE or
t > ImageFrame.MAX_TYPE):
raise ValueError("Invalid picture_type: %d" % t)
self._pic_type = t
def parse(self, data, frame_header):
super(ImageFrame, self).parse(data, frame_header)
input = BytesIO(self.data)
log.debug("APIC frame data size: %d" % len(self.data))
self.encoding = encoding = input.read(1)
# Mime type
self._mime_type = b""
if frame_header.minor_version != 2:
ch = input.read(1)
while ch and ch != b"\x00":
self._mime_type += ch
ch = input.read(1)
else:
# v2.2 (OBSOLETE) special case
self._mime_type = input.read(3)
log.debug("APIC mime type: %s" % self._mime_type)
if not self._mime_type:
core.parseError(FrameException("APIC frame does not contain a mime "
"type"))
if (self._mime_type != self.URL_MIME_TYPE and
self._mime_type.find(b"/") == -1):
self._mime_type = b"image/" + self._mime_type
pt = ord(input.read(1))
log.debug("Initial APIC picture type: %d" % pt)
if pt < self.MIN_TYPE or pt > self.MAX_TYPE:
core.parseError(FrameException("Invalid APIC picture type: %d" %
pt))
self.picture_type = self.OTHER
else:
self.picture_type = pt
log.debug("APIC picture type: %d" % self.picture_type)
self.desciption = u""
# Remaining data is a NULL separated description and image data
buffer = input.read()
input.close()
(desc, img) = splitUnicode(buffer, encoding)
log.debug("description len: %d" % len(desc))
log.debug("image len: %d" % len(img))
self.description = decodeUnicode(desc, encoding)
log.debug("APIC description: %s" % self.description)
if self._mime_type.find(self.URL_MIME_TYPE) != -1:
self.image_data = None
self.image_url = img
log.debug("APIC image URL: %s" %
len(self.image_url.decode("ascii")))
else:
self.image_data = img
self.image_url = None
log.debug("APIC image data: %d bytes" % len(self.image_data))
if not self.image_data and not self.image_url:
core.parseError(FrameException("APIC frame does not contain image "
"data/url"))
def render(self):
# some code has problems with image descriptions encoded <> latin1
# namely mp3diags: work around the problem by forcing latin1 encoding
# for empty descriptions, which is by far the most common case anyway
if self.description:
self._initEncoding()
else:
self.encoding = LATIN1_ENCODING
if not self.image_data and self.image_url:
self._mime_type = self.URL_MIME_TYPE
data = (self.encoding + self._mime_type + b"\x00" +
bin2bytes(dec2bin(self.picture_type, 8)) +
self.description.encode(id3EncodingToString(self.encoding)) +
self.text_delim)
if self.image_data:
data += self.image_data
elif self.image_url:
data += self.image_url
self.data = data
return super(ImageFrame, self).render()
@staticmethod
def picTypeToString(t):
if t == ImageFrame.OTHER:
return "OTHER"
elif t == ImageFrame.ICON:
return "ICON"
elif t == ImageFrame.OTHER_ICON:
return "OTHER_ICON"
elif t == ImageFrame.FRONT_COVER:
return "FRONT_COVER"
elif t == ImageFrame.BACK_COVER:
return "BACK_COVER"
elif t == ImageFrame.LEAFLET:
return "LEAFLET"
elif t == ImageFrame.MEDIA:
return "MEDIA"
elif t == ImageFrame.LEAD_ARTIST:
return "LEAD_ARTIST"
elif t == ImageFrame.ARTIST:
return "ARTIST"
elif t == ImageFrame.CONDUCTOR:
return "CONDUCTOR"
elif t == ImageFrame.BAND:
return "BAND"
elif t == ImageFrame.COMPOSER:
return "COMPOSER"
elif t == ImageFrame.LYRICIST:
return "LYRICIST"
elif t == ImageFrame.RECORDING_LOCATION:
return "RECORDING_LOCATION"
elif t == ImageFrame.DURING_RECORDING:
return "DURING_RECORDING"
elif t == ImageFrame.DURING_PERFORMANCE:
return "DURING_PERFORMANCE"
elif t == ImageFrame.VIDEO:
return "VIDEO"
elif t == ImageFrame.BRIGHT_COLORED_FISH:
return "BRIGHT_COLORED_FISH"
elif t == ImageFrame.ILLUSTRATION:
return "ILLUSTRATION"
elif t == ImageFrame.BAND_LOGO:
return "BAND_LOGO"
elif t == ImageFrame.PUBLISHER_LOGO:
return "PUBLISHER_LOGO"
else:
raise ValueError("Invalid APIC picture type: %d" % t)
@staticmethod
def stringToPicType(s):
if s == "OTHER":
return ImageFrame.OTHER
elif s == "ICON":
return ImageFrame.ICON
elif s == "OTHER_ICON":
return ImageFrame.OTHER_ICON
elif s == "FRONT_COVER":
return ImageFrame.FRONT_COVER
elif s == "BACK_COVER":
return ImageFrame.BACK_COVER
elif s == "LEAFLET":
return ImageFrame.LEAFLET
elif s == "MEDIA":
return ImageFrame.MEDIA
elif s == "LEAD_ARTIST":
return ImageFrame.LEAD_ARTIST
elif s == "ARTIST":
return ImageFrame.ARTIST
elif s == "CONDUCTOR":
return ImageFrame.CONDUCTOR
elif s == "BAND":
return ImageFrame.BAND
elif s == "COMPOSER":
return ImageFrame.COMPOSER
elif s == "LYRICIST":
return ImageFrame.LYRICIST
elif s == "RECORDING_LOCATION":
return ImageFrame.RECORDING_LOCATION
elif s == "DURING_RECORDING":
return ImageFrame.DURING_RECORDING
elif s == "DURING_PERFORMANCE":
return ImageFrame.DURING_PERFORMANCE
elif s == "VIDEO":
return ImageFrame.VIDEO
elif s == "BRIGHT_COLORED_FISH":
return ImageFrame.BRIGHT_COLORED_FISH
elif s == "ILLUSTRATION":
return ImageFrame.ILLUSTRATION
elif s == "BAND_LOGO":
return ImageFrame.BAND_LOGO
elif s == "PUBLISHER_LOGO":
return ImageFrame.PUBLISHER_LOGO
else:
raise ValueError("Invalid APIC picture type: %s" % s)
def makeFileName(self, name=None):
name = ImageFrame.picTypeToString(self.picture_type) if not name \
else name
ext = self.mime_type.split("/")[1]
if ext == "jpeg":
ext = "jpg"
return ".".join([name, ext])
class ObjectFrame(Frame):
@requireUnicode("description", "filename")
def __init__(self, id=OBJECT_FID, description=u"", filename=u"",
object_data=None, mime_type=None):
super(ObjectFrame, self).__init__(OBJECT_FID)
self.description = description
self.filename = filename
self.mime_type = mime_type
self.object_data = object_data
@property
def description(self):
return self._description
@description.setter
@requireUnicode(1)
def description(self, txt):
self._description = txt
@property
def mime_type(self):
return unicode(self._mime_type, "ascii")
@mime_type.setter
def mime_type(self, m):
m = m or b''
self._mime_type = m if isinstance(m, BytesType) else m.encode('ascii')
@property
def filename(self):
return self._filename
@filename.setter
@requireUnicode(1)
def filename(self, txt):
self._filename = txt
def parse(self, data, frame_header):
"""Parse the frame from ``data`` bytes using details from
``frame_header``.
Data string format:
Text encoding $xx
MIME type $00
Filename $00 (00)
Content description $00 (00)
Encapsulated object
"""
super(ObjectFrame, self).parse(data, frame_header)
input = BytesIO(self.data)
log.debug("GEOB frame data size: " + str(len(self.data)))
self.encoding = encoding = input.read(1)
# Mime type
self._mime_type = b""
if self.header.minor_version != 2:
ch = input.read(1)
while ch != b"\x00":
self._mime_type += ch
ch = input.read(1)
else:
# v2.2 (OBSOLETE) special case
self._mime_type = input.read(3)
log.debug("GEOB mime type: %s" % self._mime_type)
if not self._mime_type:
core.parseError(FrameException("GEOB frame does not contain a "
"mime type"))
if self._mime_type.find(b"/") == -1:
core.parseError(FrameException("GEOB frame does not contain a "
"valid mime type"))
self.filename = u""
self.description = u""
# Remaining data is a NULL separated filename, description and object
# data
buffer = input.read()
input.close()
(filename, buffer) = splitUnicode(buffer, encoding)
(desc, obj) = splitUnicode(buffer, encoding)
self.filename = decodeUnicode(filename, encoding)
log.debug("GEOB filename: " + self.filename)
self.description = decodeUnicode(desc, encoding)
log.debug("GEOB description: " + self.description)
self.object_data = obj
log.debug("GEOB data: %d bytes " % len(self.object_data))
if not self.object_data:
core.parseError(FrameException("GEOB frame does not contain any "
"data"))
def render(self):
self._initEncoding()
data = (self.encoding + self._mime_type + b"\x00" +
self.filename.encode(id3EncodingToString(self.encoding)) +
self.text_delim +
self.description.encode(id3EncodingToString(self.encoding)) +
self.text_delim +
(self.object_data or b""))
self.data = data
return super(ObjectFrame, self).render()
class PrivateFrame(Frame):
"""PRIV"""
def __init__(self, id=PRIVATE_FID, owner_id=b"", owner_data=b""):
super(PrivateFrame, self).__init__(id)
assert(id == PRIVATE_FID)
self.owner_id = owner_id
self.owner_data = owner_data
def parse(self, data, frame_header):
super(PrivateFrame, self).parse(data, frame_header)
try:
self.owner_id, self.owner_data = self.data.split(b'\x00', 1)
except ValueError:
# If data doesn't contain required \x00
# all data is taken to be owner_id
self.owner_id = self.data
def render(self):
self.data = self.owner_id + b"\x00" + self.owner_data
return super(PrivateFrame, self).render()
class MusicCDIdFrame(Frame):
def __init__(self, id=CDID_FID, toc=b""):
super(MusicCDIdFrame, self).__init__(id)
assert(id == CDID_FID)
self.toc = toc
@property
def toc(self):
return self.data
@toc.setter
def toc(self, toc):
self.data = toc
def parse(self, data, frame_header):
super(MusicCDIdFrame, self).parse(data, frame_header)
self.toc = self.data
class PlayCountFrame(Frame):
def __init__(self, id=PLAYCOUNT_FID, count=0):
super(PlayCountFrame, self).__init__(id)
assert(self.id == PLAYCOUNT_FID)
if count is None or count < 0:
raise ValueError("Invalid count value: %s" % str(count))
self.count = count
def parse(self, data, frame_header):
super(PlayCountFrame, self).parse(data, frame_header)
# data of less then 4 bytes is handled with with 'sz' arg
if len(self.data) < 4:
log.warning("Fixing invalid PCNT frame: less than 32 bits")
self.count = bytes2dec(self.data)
def render(self):
self.data = dec2bytes(self.count, 32)
return super(PlayCountFrame, self).render()
class PopularityFrame(Frame):
"""Frame type for 'POPM' frames; popularity.
Frame format:
Email to user $00
Rating $xx
Counter $xx xx xx xx (xx ...)
"""
def __init__(self, id=POPULARITY_FID, email=b"", rating=0, count=0):
super(PopularityFrame, self).__init__(id)
assert(self.id == POPULARITY_FID)
self.email = email
self.rating = rating
if count is None or count < 0:
raise ValueError("Invalid count value: %s" % str(count))
self.count = count
@property
def rating(self):
return self._rating
@rating.setter
def rating(self, rating):
if rating < 0 or rating > 255:
raise ValueError("Popularity rating must be >= 0 and <=255")
self._rating = rating
@property
def email(self):
return self._email
@email.setter
def email(self, email):
# XXX: becoming a pattern?
if isinstance(email, UnicodeType):
self._email = email.encode(ascii_encode)
elif isinstance(email, BytesType):
_ = email.decode("ascii") # noqa
self._email = email
else:
raise TypeError("bytes, str, unicode email required")
@property
def count(self):
return self._count
@count.setter
def count(self, count):
if count < 0:
raise ValueError("Popularity count must be > 0")
self._count = count
def parse(self, data, frame_header):
super(PopularityFrame, self).parse(data, frame_header)
data = self.data
null_byte = data.find(b'\x00')
try:
self.email = data[:null_byte]
except UnicodeDecodeError:
core.parseError(FrameException("Invalid (non-ascii) POPM email "
"address. Setting to 'BOGUS'"))
self.email = b"BOGUS"
data = data[null_byte + 1:]
self.rating = bytes2dec(data[0:1])
data = data[1:]
if len(self.data) < 4:
core.parseError(FrameException(
"Invalid POPM play count: less than 32 bits."))
self.count = bytes2dec(data)
def render(self):
data = (self.email or b"") + b'\x00'
data += dec2bytes(self.rating)
data += dec2bytes(self.count, 32)
self.data = data
return super(PopularityFrame, self).render()
class UniqueFileIDFrame(Frame):
def __init__(self, id=UNIQUE_FILE_ID_FID, owner_id=None, uniq_id=None):
super(UniqueFileIDFrame, self).__init__(id)
assert(self.id == UNIQUE_FILE_ID_FID)
self.owner_id = owner_id
self.uniq_id = uniq_id
def parse(self, data, frame_header):
"""
Data format
Owner identifier $00
Identifier up to 64 bytes binary data>
"""
super(UniqueFileIDFrame, self).parse(data, frame_header)
split_data = self.data.split(b'\x00', 1)
if len(split_data) == 2:
(self.owner_id, self.uniq_id) = split_data
else:
self.owner_id, self.uniq_id = b"", split_data[0:1]
log.debug("UFID owner_id: %s" % self.owner_id)
log.debug("UFID id: %s" % self.uniq_id)
if len(self.owner_id) == 0:
dummy_owner_id = "http://www.id3.org/dummy/ufid.html"
self.owner_id = dummy_owner_id
core.parseError(FrameException("Invalid UFID, owner_id is empty. "
"Setting to '%s'" % dummy_owner_id))
elif 0 <= len(self.uniq_id) > 64:
core.parseError(FrameException("Invalid UFID, ID is empty or too "
"long: %s" % self.uniq_id))
def render(self):
self.data = self.owner_id + b"\x00" + self.uniq_id
return super(UniqueFileIDFrame, self).render()
class LanguageCodeMixin(object):
@property
def lang(self):
assert self._lang is not None
return self._lang
@lang.setter
@requireBytes(1)
def lang(self, lang):
if not lang:
self._lang = b""
return
lang = lang.strip(b"\00")
lang = lang[:3] if lang else DEFAULT_LANG
try:
if lang != DEFAULT_LANG:
lang.decode("ascii")
except UnicodeDecodeError:
lang = DEFAULT_LANG
assert len(lang) <= 3
self._lang = lang
def _renderLang(self):
lang = self.lang
if len(lang) < 3:
lang = lang + (b"\x00" * (3 - len(lang)))
return lang
class DescriptionLangTextFrame(Frame, LanguageCodeMixin):
@requireBytes(1, 3)
@requireUnicode(2, 4)
def __init__(self, id, description, lang, text):
super(DescriptionLangTextFrame,
self).__init__(id)
self.lang = lang
self.description = description
self.text = text
@property
def description(self):
return self._description
@description.setter
@requireUnicode(1)
def description(self, description):
self._description = description
@property
def text(self):
return self._text
@text.setter
@requireUnicode(1)
def text(self, text):
self._text = text
def parse(self, data, frame_header):
super(DescriptionLangTextFrame, self).parse(data, frame_header)
self.encoding = encoding = self.data[0:1]
self.lang = self.data[1:4]
log.debug("%s lang: %s" % (self.id, self.lang))
try:
(d, t) = splitUnicode(self.data[4:], encoding)
self.description = decodeUnicode(d, encoding)
log.debug("%s description: %s" % (self.id, self.description))
self.text = decodeUnicode(t, encoding)
log.debug("%s text: %s" % (self.id, self.text))
except ValueError:
log.warning("Invalid %s frame; no description/text" % self.id)
self.description = u""
self.text = u""
def render(self):
lang = self._renderLang()
self._initEncoding()
data = (self.encoding + lang +
self.description.encode(id3EncodingToString(self.encoding)) +
self.text_delim +
self.text.encode(id3EncodingToString(self.encoding)))
self.data = data
return super(DescriptionLangTextFrame, self).render()
class CommentFrame(DescriptionLangTextFrame):
def __init__(self, id=COMMENT_FID, description=u"", lang=DEFAULT_LANG,
text=u""):
super(CommentFrame, self).__init__(id, description, lang, text)
assert(self.id == COMMENT_FID)
class LyricsFrame(DescriptionLangTextFrame):
def __init__(self, id=LYRICS_FID, description=u"", lang=DEFAULT_LANG,
text=u""):
super(LyricsFrame, self).__init__(id, description, lang, text)
assert(self.id == LYRICS_FID)
class TermsOfUseFrame(Frame, LanguageCodeMixin):
@requireUnicode("text")
def __init__(self, id=b"USER", text=u"", lang=DEFAULT_LANG):
super(TermsOfUseFrame, self).__init__(id)
self.lang = lang
self.text = text
@property
def text(self):
return self._text
@text.setter
@requireUnicode(1)
def text(self, text):
self._text = text
def parse(self, data, frame_header):
super(TermsOfUseFrame, self).parse(data, frame_header)
self.encoding = encoding = self.data[0:1]
self.lang = self.data[1:4]
log.debug("%s lang: %s" % (self.id, self.lang))
self.text = decodeUnicode(self.data[4:], encoding)
log.debug("%s text: %s" % (self.id, self.text))
def render(self):
lang = self._renderLang()
self._initEncoding()
self.data = (self.encoding + lang +
self.text.encode(id3EncodingToString(self.encoding)))
return super(TermsOfUseFrame, self).render()
class TocFrame(Frame):
"""Table of content frame. There may be more than one, but only one may
have the top-level flag set.
Data format:
Element ID: \x00
TOC flags: %000000ab
Entry count: %xx
Child elem IDs: \x00 (... num entry count)
Description: TIT2 frame (optional)
"""
TOP_LEVEL_FLAG_BIT = 6
ORDERED_FLAG_BIT = 7
@requireBytes(1, 2)
def __init__(self, id=TOC_FID, element_id=None, toplevel=True, ordered=True,
child_ids=None, description=None):
assert(id == TOC_FID)
super(TocFrame, self).__init__(id)
self.element_id = element_id
self.toplevel = toplevel
self.ordered = ordered
self.child_ids = child_ids or []
self.description = description
def parse(self, data, frame_header):
super(TocFrame, self).parse(data, frame_header)
data = self.data
log.debug("CTOC frame data size: %d" % len(data))
null_byte = data.find(b'\x00')
self.element_id = data[0:null_byte]
data = data[null_byte + 1:]
flag_bits = bytes2bin(data[0:1])
self.toplevel = bool(flag_bits[self.TOP_LEVEL_FLAG_BIT])
self.ordered = bool(flag_bits[self.ORDERED_FLAG_BIT])
entry_count = bytes2dec(data[1:2])
data = data[2:]
self.child_ids = []
for i in range(entry_count):
null_byte = data.find(b'\x00')
self.child_ids.append(data[:null_byte])
data = data[null_byte + 1:]
# Any data remaining must be a TIT2 frame
self.description = None
if data and data[:4] != b"TIT2":
log.warning("Invalid toc data, TIT2 frame expected")
return
elif data:
data = BytesIO(data)
frame_header = FrameHeader.parse(data, self.header.version)
data = data.read()
description_frame = TextFrame(TITLE_FID)
description_frame.parse(data, frame_header)
self.description = description_frame.text
def render(self):
flags = [0] * 8
if self.toplevel:
flags[self.TOP_LEVEL_FLAG_BIT] = 1
if self.ordered:
flags[self.ORDERED_FLAG_BIT] = 1
data = (self.element_id + b'\x00' +
bin2bytes(flags) + dec2bytes(len(self.child_ids)))
for cid in self.child_ids:
data += cid + b'\x00'
if self.description is not None:
desc_frame = TextFrame(TITLE_FID, self.description)
desc_frame.header = FrameHeader(TITLE_FID, self.header.version)
data += desc_frame.render()
self.data = data
return super(TocFrame, self).render()
StartEndTuple = namedtuple("StartEndTuple", ["start", "end"])
"""A 2-tuple, with names 'start' and 'end'."""
class ChapterFrame(Frame):
"""Frame type for chapter/section of the audio file.
(10 bytes)
Element ID $00
Start time $xx xx xx xx
End time $xx xx xx xx
Start offset $xx xx xx xx
End offset $xx xx xx xx
"""
NO_OFFSET = 4294967295
"""No offset value, aka '0xff0xff0xff0xff'"""
def __init__(self, id=CHAPTER_FID, element_id=None, times=None,
offsets=None, sub_frames=None):
assert(id == CHAPTER_FID)
super(ChapterFrame, self).__init__(id)
self.element_id = element_id
self.times = times or StartEndTuple(None, None)
self.offsets = offsets or StartEndTuple(None, None)
self.sub_frames = sub_frames or FrameSet()
def parse(self, data, frame_header):
from .headers import TagHeader, ExtendedTagHeader
super(ChapterFrame, self).parse(data, frame_header)
data = self.data
log.debug("CTOC frame data size: %d" % len(data))
null_byte = data.find(b'\x00')
self.element_id = data[0:null_byte]
data = data[null_byte + 1:]
start = bytes2dec(data[:4])
data = data[4:]
end = bytes2dec(data[:4])
data = data[4:]
self.times = StartEndTuple(start, end)
start = bytes2dec(data[:4])
data = data[4:]
end = bytes2dec(data[:4])
data = data[4:]
self.offsets = StartEndTuple(start if start != self.NO_OFFSET else None,
end if end != self.NO_OFFSET else None)
if data:
dummy_tag_header = TagHeader(self.header.version)
dummy_tag_header.tag_size = len(data)
_ = self.sub_frames.parse(BytesIO(data), dummy_tag_header, # noqa
ExtendedTagHeader())
else:
self.sub_frames = FrameSet()
def render(self):
data = self.element_id + b'\x00'
for n in self.times + self.offsets:
if n is not None:
data += dec2bytes(n, 32)
else:
data += b'\xff\xff\xff\xff'
for f in self.sub_frames.getAllFrames():
f.header = FrameHeader(f.id, self.header.version)
data += f.render()
self.data = data
return super(ChapterFrame, self).render()
@property
def title(self):
if TITLE_FID in self.sub_frames:
return self.sub_frames[TITLE_FID][0].text
return None
@title.setter
def title(self, title):
self.sub_frames.setTextFrame(TITLE_FID, title)
@property
def subtitle(self):
if SUBTITLE_FID in self.sub_frames:
return self.sub_frames[SUBTITLE_FID][0].text
return None
@subtitle.setter
def subtitle(self, subtitle):
self.sub_frames.setTextFrame(SUBTITLE_FID, subtitle)
@property
def user_url(self):
if USERURL_FID in self.sub_frames:
frame = self.sub_frames[USERURL_FID][0]
# Not returning frame description, it is always the same since it
# allows only 1 URL.
return frame.url
return None
@user_url.setter
def user_url(self, url):
DESCRIPTION = u"chapter url"
if url is None:
del self.sub_frames[USERURL_FID]
else:
if USERURL_FID in self.sub_frames:
for frame in self.sub_frames[USERURL_FID]:
if frame.description == DESCRIPTION:
frame.url = url
return
self.sub_frames[USERURL_FID] = UserUrlFrame(USERURL_FID,
DESCRIPTION, url)
# XXX: This data structure pretty sucks, or it is beautiful anarchy
class FrameSet(dict):
def __init__(self):
dict.__init__(self)
def parse(self, f, tag_header, extended_header):
"""Read frames starting from the current read position of the file
object. Returns the amount of padding which occurs after the tag, but
before the audio content. A return valule of 0 does not mean error."""
self.clear()
padding_size = 0
size_left = tag_header.tag_size - extended_header.size
consumed_size = 0
# Handle a tag-level unsync. Some frames may have their own unsync bit
# set instead.
tag_data = f.read(size_left)
# If the tag is 2.3 and the tag header unsync bit is set then all the
# frame data is deunsync'd at once, otherwise it will happen on a per
# frame basis.
if tag_header.unsync and tag_header.version <= ID3_V2_3:
log.debug("De-unsynching %d bytes at once (<= 2.3 tag)" %
len(tag_data))
og_size = len(tag_data)
tag_data = deunsyncData(tag_data)
size_left = len(tag_data)
log.debug("De-unsynch'd %d bytes at once (<= 2.3 tag) to %d bytes" %
(og_size, size_left))
# Adding bytes to simulate the tag header(s) in the buffer. This keeps
# f.tell() values matching the file offsets for logging.
prepadding = b'\x00' * 10 # Tag header
prepadding += b'\x00' * extended_header.size
tag_buffer = BytesIO(prepadding + tag_data)
tag_buffer.seek(len(prepadding))
frame_count = 0
while size_left > 0:
log.debug("size_left: " + str(size_left))
if size_left < (10 + 1): # The size of the smallest frame.
log.debug("FrameSet: Implied padding (size_left