Merge branch 'master' into parallel-replaygain

This commit is contained in:
ybnd 2020-08-12 11:59:32 +02:00
commit 72710cd8c7
91 changed files with 5130 additions and 695 deletions

View file

@ -2,7 +2,6 @@
omit =
*/pyshared/*
*/python?.?/*
*/site-packages/nose/*
*/test/*
exclude_lines =
assert False

16
.github/flake8-problem-matcher.json vendored Normal file
View file

@ -0,0 +1,16 @@
{
"problemMatcher": [
{
"owner": "flake8",
"pattern": [
{
"regexp": "^(.*?):(\\d+):(\\d+): (.*)$",
"file": 1,
"line": 2,
"column": 3,
"message": 4
}
]
}
]
}

11
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,11 @@
## Description
Fixes #X. <!-- Insert issue number here if applicable. -->
(...)
## To Do
- [ ] Documentation. (If you've add a new command-line flag, for example, find the appropriate page under `docs/` to describe it.)
- [ ] Changelog. (Add an entry to `docs/changelog.rst` near the top of the document.)
- [ ] Tests. (Encouraged but not strictly required.)

18
.github/sphinx-problem-matcher.json vendored Normal file
View file

@ -0,0 +1,18 @@
{
"problemMatcher": [
{
"owner": "sphinx",
"pattern": [
{
"regexp": "^Warning, treated as error:$"
},
{
"regexp": "^(.*?):(\\d+):(.*)$",
"file": 1,
"line": 2,
"message": 3
}
]
}
]
}

28
.github/stale.yml vendored Normal file
View file

@ -0,0 +1,28 @@
# Configuration for probot-stale - https://github.com/probot/stale
daysUntilClose: 7
staleLabel: stale
issues:
daysUntilStale: 60
onlyLabels:
- needinfo
markComment: >
Is this still relevant? If so, what is blocking it?
Is there anything you can do to help move it forward?
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
pulls:
daysUntilStale: 120
markComment: >
Is this still relevant? If so, what is blocking it?
Is there anything you can do to help move it forward?
This pull request has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.

88
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,88 @@
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.platform }}
strategy:
matrix:
platform: [ubuntu-latest]
python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9-dev]
env:
PY_COLORS: 1
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install base dependencies
run: |
python -m pip install --upgrade pip
python -m pip install tox sphinx
- name: Test with tox
if: matrix.python-version != '3.8'
run: |
tox -e py-test
- name: Test with tox and get coverage
if: matrix.python-version == '3.8'
run: |
tox -vv -e py-cov
- name: Upload code coverage
if: matrix.python-version == '3.8'
run: |
pip install codecov || true
codecov || true
test-docs:
runs-on: ubuntu-latest
env:
PY_COLORS: 1
steps:
- uses: actions/checkout@v2
- name: Set up Python 2.7
uses: actions/setup-python@v2
with:
python-version: 2.7
- name: Install base dependencies
run: |
python -m pip install --upgrade pip
python -m pip install tox sphinx
- name: Add problem matcher
run: echo "::add-matcher::.github/sphinx-problem-matcher.json"
- name: Build and check docs using tox
run: tox -e docs
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
uses: actions/setup-python@v2
with:
python-version: 3.8
- name: Install base dependencies
run: |
python -m pip install --upgrade pip
python -m pip install tox sphinx
- name: Add problem matcher
run: echo "::add-matcher::.github/flake8-problem-matcher.json"
- name: Lint with flake8
run: tox -e py-lint

45
.github/workflows/integration_test.yaml vendored Normal file
View file

@ -0,0 +1,45 @@
name: integration tests
on:
workflow_dispatch:
schedule:
- cron: '0 0 * * SUN' # run every Sunday at midnight
jobs:
test_integration:
runs-on: ubuntu-latest
env:
PY_COLORS: 1
steps:
- uses: actions/checkout@v2
- name: Set up latest Python version
uses: actions/setup-python@v2
with:
python-version: 3.9-dev
- name: Install base dependencies
run: |
python -m pip install --upgrade pip
python -m pip install tox sphinx
- name: Test with tox
run: |
tox -e int
- name: Notify on failure
if: ${{ failure() }}
env:
ZULIP_BOT_CREDENTIALS: ${{ secrets.ZULIP_BOT_CREDENTIALS }}
run: |
if [ -z "${ZULIP_BOT_CREDENTIALS}" ]; then
echo "Skipping notify, ZULIP_BOT_CREDENTIALS is unset"
exit 0
fi
curl -X POST https://beets.zulipchat.com/api/v1/messages \
-u "${ZULIP_BOT_CREDENTIALS}" \
-d "type=stream" \
-d "to=github" \
-d "subject=${GITHUB_WORKFLOW} - $(date -u +%Y-%m-%d)" \
-d "content=[${GITHUB_WORKFLOW}#${GITHUB_RUN_NUMBER}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) failed."

3
.gitignore vendored
View file

@ -7,7 +7,6 @@
# Project Specific patterns
man
test/rsrc/lyrics/*
# The rest is from https://www.gitignore.io/api/python
@ -54,7 +53,6 @@ htmlcov/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
.hypothesis/
@ -77,6 +75,7 @@ target/
# virtualenv
venv/
.venv/
ENV/
# Spyder project settings

View file

@ -1,87 +0,0 @@
dist: trusty
sudo: required
language: python
env:
global:
# Undocumented feature of nose-show-skipped.
NOSE_SHOW_SKIPPED: 1
matrix:
include:
- python: 2.7.13
env: {TOX_ENV: py27-cov, COVERAGE: 1}
- python: 2.7.13
env: {TOX_ENV: py27-test}
# - python: 3.4
# env: {TOX_ENV: py34-test}
# - python: 3.4_with_system_site_packages
# env: {TOX_ENV: py34-test}
- python: 3.5
env: {TOX_ENV: py35-test}
- python: 3.6
env: {TOX_ENV: py36-test}
- python: 3.7
env: {TOX_ENV: py37-test}
dist: xenial
- python: 3.8
env: {TOX_ENV: py38-test}
dist: xenial
# - python: pypy
# - env: {TOX_ENV: pypy-test}
- python: 3.6
env: {TOX_ENV: py36-flake8}
- python: 2.7.13
env: {TOX_ENV: docs}
# Non-Python dependencies.
addons:
apt:
sources:
- sourceline: "deb https://archive.ubuntu.com/ubuntu/ trusty multiverse"
- sourceline: "deb https://archive.ubuntu.com/ubuntu/ trusty-updates multiverse"
packages:
- bash-completion
- gir1.2-gst-plugins-base-1.0
- gir1.2-gstreamer-1.0
- gstreamer1.0-plugins-good
- gstreamer1.0-plugins-bad
- imagemagick
- python-gi
- python-gst-1.0
- python3-gi
- python3-gst-1.0
- unrar
# To install dependencies, tell tox to do everything but actually running the
# test.
install:
- travis_retry pip install tox sphinx
# upgrade requests to satisfy sphinx linkcheck (for building man pages)
- if [[ $TRAVIS_PYTHON_VERSION == *_site_packages ]]; then pip install -U requests; fi
- travis_retry tox -e $TOX_ENV --notest
script:
# prevents "libdc1394 error: Failed to initialize libdc1394" errors
- sudo ln -s /dev/null /dev/raw1394
- if [[ $TRAVIS_PYTHON_VERSION == *_site_packages ]]; then SITE_PACKAGES=--sitepackages; fi
# pip in trusty breaks on packages prefixed with "_". See https://github.com/pypa/pip/issues/3681
- if [[ $TRAVIS_PYTHON_VERSION == 3.4_with_system_site_packages ]]; then sudo rm -rf /usr/lib/python3/dist-packages/_lxc-0.1.egg-info; fi
- tox -e $TOX_ENV $SITE_PACKAGES
# Report coverage to codecov.io.
before_install:
- "[ ! -z $COVERAGE ] && travis_retry pip install codecov || true"
after_success:
- "[ ! -z $COVERAGE ] && codecov || true"
cache:
pip: true
notifications:
irc:
channels:
- "irc.freenode.org#beets"
use_notice: true
skip_join: true
on_success: change
on_failure: always

366
CONTRIBUTING.rst Normal file
View file

@ -0,0 +1,366 @@
############
Contributing
############
.. contents::
:depth: 3
Thank you!
==========
First off, thank you for considering contributing to beets! Its people
like you that make beets continue to succeed.
These guidelines describe how you can help most effectively. By
following these guidelines, you can make life easier for the development
team as it indicates you respect the maintainers time; in return, the
maintainers will reciprocate by helping to address your issue, review
changes, and finalize pull requests.
Types of Contributions
======================
We love to get contributions from our community—you! There are many ways
to contribute, whether youre a programmer or not.
Non-Programming
---------------
- Promote beets! Help get the word out by telling your friends, writing
a blog post, or discussing it on a forum you frequent.
- Improve the `documentation <http://beets.readthedocs.org/>`__. Its
incredibly easy to contribute here: just find a page you want to
modify and hit the “Edit on GitHub” button in the upper-right. You
can automatically send us a pull request for your changes.
- GUI design. For the time being, beets is a command-line-only affair.
But thats mostly because we dont have any great ideas for what a
good GUI should look like. If you have those great ideas, please get
in touch.
- Benchmarks. Wed like to have a consistent way of measuring speed
improvements in beets tagger and other functionality as well as a
way of comparing beets performance to other tools. You can help by
compiling a library of freely-licensed music files (preferably with
incorrect metadata) for testing and measurement.
- Think you have a nice config or cool use-case for beets? Wed love to
hear about it! Submit a post to our `our
forums <https://discourse.beets.io/>`__ under the “Show and Tell”
category for a chance to get featured in `the
docs <https://beets.readthedocs.io/en/stable/guides/advanced.html>`__.
- Consider helping out in `our forums <https://discourse.beets.io/>`__
by responding to support requests or driving some new discussions.
Programming
-----------
- As a programmer (even if youre just a beginner!), you have a ton of
opportunities to get your feet wet with beets.
- For developing plugins, or hacking away at beets, theres some good
information in the `“For Developers” section of the
docs <https://beets.readthedocs.io/en/stable/dev/>`__.
Getting the Source
^^^^^^^^^^^^^^^^^^
The easiest way to get started with the latest beets source is to use
`pip <https://pip.pypa.io/>`__ to install an “editable” package. This
can be done with one command:
.. code-block:: bash
$ pip install -e git+https://github.com/beetbox/beets.git#egg=beets
Or, equivalently:
.. code-block:: bash
$ git clone https://github.com/beetbox/beets.git
$ cd beets
$ pip install -e .
If you already have a released version of beets installed, you may need
to remove it first by typing ``pip uninstall beets``. The pip command
above will put the beets source in a ``src/beets`` directory and install
the ``beet`` CLI script to a standard location on your system. You may
want to use the ``--src`` option to specify the parent directory where
the source will be checked out and the ``--user`` option such that the
package will be installed to your home directory (compare with the
output of ``pip install --help``).
Code Contribution Ideas
^^^^^^^^^^^^^^^^^^^^^^^
- We maintain a set of `issues marked as
“bite-sized” <https://github.com/beetbox/beets/labels/bitesize>`__.
These are issues that would serve as a good introduction to the
codebase. Claim one and start exploring!
- Like testing? Our `test
coverage <https://codecov.io/github/beetbox/beets>`__ is somewhat
low. You can help out by finding low-coverage modules or checking out
other `testing-related
issues <https://github.com/beetbox/beets/labels/testing>`__.
- There are several ways to improve the tests in general (see :ref:`testing` and some
places to think about performance optimization (see
`Optimization <https://github.com/beetbox/beets/wiki/Optimization>`__).
- Not all of our code is up to our coding conventions. In particular,
the `API
documentation <https://beets.readthedocs.io/en/stable/dev/api.html>`__
are currently quite sparse. You can help by adding to the docstrings
in the code and to the documentation pages themselves. beets follows
`PEP-257 <https://www.python.org/dev/peps/pep-0257/>`__ for
docstrings and in some places, we also sometimes use `ReST autodoc
syntax for
Sphinx <https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html>`__
to, for example, refer to a class name.
Your First Contribution
=======================
If this is your first time contributing to an open source project,
welcome! If you are confused at all about how to contribute or what to
contribute, take a look at `this great
tutorial <http://makeapullrequest.com/>`__, or stop by our
`forums <https://discourse.beets.io/>`__ if you have any questions.
We maintain a list of issues we reserved for those new to open source
labeled `“first timers
only” <https://github.com/beetbox/beets/issues?q=is%3Aopen+is%3Aissue+label%3A%22first+timers+only%22>`__.
Since the goal of these issues is to get users comfortable with
contributing to an open source project, please do not hesitate to ask
any questions.
How to Submit Your Work
=======================
Do you have a great bug fix, new feature, or documentation expansion
youd like to contribute? Follow these steps to create a GitHub pull
request and your code will ship in no time.
1. Fork the beets repository and clone it (see above) to create a
workspace.
2. Make your changes.
3. Add tests. If youve fixed a bug, write a test to ensure that youve
actually fixed it. If theres a new feature or plugin, please
contribute tests that show that your code does what it says.
4. Add documentation. If youve added a new command flag, for example,
find the appropriate page under ``docs/`` where it needs to be
listed.
5. Add a changelog entry to ``docs/changelog.rst`` near the top of the
document.
6. Run the tests and style checker. The easiest way to run the tests is
to use `tox <https://tox.readthedocs.org/en/latest/>`__. For more
information on running tests, see :ref:`testing`.
7. Push to your fork and open a pull request! Well be in touch shortly.
8. If you add commits to a pull request, please add a comment or
re-request a review after you push them since GitHub doesnt
automatically notify us when commits are added.
Remember, code contributions have four parts: the code, the tests, the
documentation, and the changelog entry. Thank you for contributing!
The Code
========
The documentation has an `API
section <https://beets.readthedocs.io/en/stable/dev/api.html>`__ that
serves as an introduction to beets design.
Coding Conventions
==================
General
-------
There are a few coding conventions we use in beets:
- Whenever you access the library database, do so through the provided
Library methods or via a Transaction object. Never call
``lib.conn.*`` directly. For example, do this:
.. code-block:: python
with g.lib.transaction() as tx:
rows = tx.query('SELECT DISTINCT "{0}" FROM "{1}" ORDER BY "{2}"'
.format(field, model._table, sort_field))
To fetch Item objects from the database, use lib.items(…) and supply
a query as an argument. Resist the urge to write raw SQL for your
query. If you must use lower-level queries into the database, do
this:
.. code-block:: python
with lib.transaction() as tx:
rows = tx.query('SELECT …')
Transaction objects help control concurrent access to the database
and assist in debugging conflicting accesses.
- Always use the `future
imports <http://docs.python.org/library/__future__.html>`__
``print_function``, ``division``, and ``absolute_import``, but *not*
``unicode_literals``. These help keep your code modern and will help
in the eventual move to Python 3.
- ``str.format()`` should be used instead of the ``%`` operator
- Never ``print`` informational messages; use the
`logging <http://docs.python.org/library/logging.html>`__ module
instead. In particular, we have our own logging shim, so youll see
``from beets import logging`` in most files.
- Always log Unicode strings (e.g., ``log.debug(u"hello world")``).
- The loggers use
`str.format <http://docs.python.org/library/stdtypes.html#str.format>`__-style
logging instead of ``%``-style, so you can type
``log.debug(u"{0}", obj)`` to do your formatting.
- Exception handlers must use ``except A as B:`` instead of
``except A, B:``.
Style
-----
We follow `PEP 8`_ and `google's docstring format`_.
You can use ``tox -e lint`` to check your code for any style errors.
.. _PEP 8: https://www.python.org/dev/peps/pep-0008/
.. _google's docstring format: https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings
Handling Paths
--------------
A great deal of convention deals with the handling of **paths**. Paths
are stored internally—in the database, for instance—as byte strings
(i.e., ``bytes`` instead of ``str`` in Python 3). This is because POSIX
operating systems path names are only reliably usable as byte
strings—operating systems typically recommend but do not require that
filenames use a given encoding, so violations of any reported encoding
are inevitable. On Windows, the strings are always encoded with UTF-8;
on Unix, the encoding is controlled by the filesystem. Here are some
guidelines to follow:
- If you have a Unicode path or youre not sure whether something is
Unicode or not, pass it through ``bytestring_path`` function in the
``beets.util`` module to convert it to bytes.
- Pass every path name trough the ``syspath`` function (also in
``beets.util``) before sending it to any *operating system* file
operation (``open``, for example). This is necessary to use long
filenames (which, maddeningly, must be Unicode) on Windows. This
allows us to consistently store bytes in the database but use the
native encoding rule on both POSIX and Windows.
- Similarly, the ``displayable_path`` utility function converts
bytestring paths to a Unicode string for displaying to the user.
Every time you want to print out a string to the terminal or log it
with the ``logging`` module, feed it through this function.
Editor Settings
---------------
Personally, I work on beets with `vim <http://www.vim.org/>`__. Here are
some ``.vimrc`` lines that might help with PEP 8-compliant Python
coding::
filetype indent on
autocmd FileType python setlocal shiftwidth=4 tabstop=4 softtabstop=4 expandtab shiftround autoindent
Consider installing `this alternative Python indentation
plugin <https://github.com/mitsuhiko/vim-python-combined>`__. I also
like `neomake <https://github.com/neomake/neomake>`__ with its flake8
checker.
.. _testing:
Testing
=======
Running the Tests
-----------------
To run the tests for multiple Python versions, compile the docs, and
check style, use `tox`_. Just type ``tox`` or use something like
``tox -e py27`` to test a specific configuration. `detox`_ makes this go
faster.
You can disable a hand-selected set of "slow" tests by setting the
environment variable SKIP_SLOW_TESTS before running them.
Other ways to run the tests:
- ``python testall.py`` (ditto)
- ``python -m unittest discover -p 'test_*'`` (ditto)
- `pytest`_
You can also see the latest test results on `Linux`_ and on `Windows`_.
Note, if you are on Windows and are seeing errors running tox, it may be related to `this issue`_,
in which case you may have to install tox v3.8.3 e.g. ``python -m pip install tox=3.8.3``
.. _this issue: https://github.com/tox-dev/tox/issues/1550
Coverage
^^^^^^^^
``tox -e cov`` will add coverage info for tests: Coverage is pretty low
still -- see the current status on `Codecov`_.
Red Flags
^^^^^^^^^
The `pytest-random`_ plugin makes it easy to randomize the order of
tests. ``py.test test --random`` will occasionally turn up failing tests
that reveal ordering dependencies—which are bad news!
Test Dependencies
^^^^^^^^^^^^^^^^^
The tests have a few more dependencies than beets itself. (The
additional dependencies consist of testing utilities and dependencies of
non-default plugins exercised by the test suite.) The dependencies are
listed under 'test' in ``extras_require`` in `setup.py`_.
To install the test dependencies, run ``python -m pip install .[test]``.
Or, just run a test suite with ``tox`` which will install them
automatically.
.. _setup.py: https://github.com/beetbox/beets/blob/master/setup.py#L99`
Writing Tests
-------------
Writing tests is done by adding or modifying files in folder `test`_.
Take a look at
`https://github.com/beetbox/beets/blob/master/test/test_template.py#L224`_
to get a basic view on how tests are written. We currently allow writing
tests with either `unittest`_ or `pytest`_.
Any tests that involve sending out network traffic e.g. an external API
call, should be skipped normally and run under our weekly `integration
test`_ suite. These tests can be useful in detecting external changes
that would affect ``beets``. In order to do this, simply add the
following snippet before the applicable test case:
.. code-block:: python
@unittest.skipUnless(
os.environ.get('INTEGRATION_TEST', '0') == '1',
'integration testing not enabled')
If you do this, it is also advised to create a similar test that 'mocks'
the network call and can be run under normal circumstances by our CI and
others. See `unittest.mock`_ for more info.
- **AVOID** using the ``start()`` and ``stop()`` methods of
``mock.patch``, as they require manual cleanup. Use the annotation or
context manager forms instead.
.. _Python unittest: https://docs.python.org/2/library/unittest.html
.. _Codecov: https://codecov.io/github/beetbox/beets
.. _pytest-random: https://github.com/klrmn/pytest-random
.. _tox: http://tox.readthedocs.org
.. _detox: https://pypi.python.org/pypi/detox/
.. _pytest: http://pytest.org
.. _Linux: https://github.com/beetbox/beets/actions
.. _Windows: https://ci.appveyor.com/project/beetbox/beets/
.. _`https://github.com/beetbox/beets/blob/master/setup.py#L99`: https://github.com/beetbox/beets/blob/master/setup.py#L99
.. _test: https://github.com/beetbox/beets/tree/master/test
.. _`https://github.com/beetbox/beets/blob/master/test/test_template.py#L224`: https://github.com/beetbox/beets/blob/master/test/test_template.py#L224
.. _unittest: https://docs.python.org/3.8/library/unittest.html
.. _integration test: https://github.com/beetbox/beets/actions?query=workflow%3A%22integration+tests%22
.. _unittest.mock: https://docs.python.org/3/library/unittest.mock.html
.. _Python unittest: https://docs.python.org/2/library/unittest.html

View file

@ -4,8 +4,8 @@
.. image:: https://img.shields.io/codecov/c/github/beetbox/beets.svg
:target: https://codecov.io/github/beetbox/beets
.. image:: https://travis-ci.org/beetbox/beets.svg?branch=master
:target: https://travis-ci.org/beetbox/beets
.. image:: https://github.com/beetbox/beets/workflows/ci/badge.svg?branch=master
:target: https://github.com/beetbox/beets/actions
.. image:: https://repology.org/badge/tiny-repos/beets.svg
:target: https://repology.org/project/beets/versions
@ -90,11 +90,9 @@ Check out the `Getting Started`_ guide for more information.
Contribute
----------
Check out the `Hacking`_ page on the wiki for tips on how to help out.
You might also be interested in the `For Developers`_ section in the docs.
Thank you for considering contributing to ``beets``! Whether you're a programmer or not, you should be able to find all the info you need at `CONTRIBUTING.rst`_.
.. _Hacking: https://github.com/beetbox/beets/wiki/Hacking
.. _For Developers: https://beets.readthedocs.io/en/stable/dev/
.. _CONTRIBUTING.rst: https://github.com/beetbox/beets/blob/master/CONTRIBUTING.rst
Read More
---------
@ -105,11 +103,18 @@ news and updates.
.. _its Web site: https://beets.io/
.. _@b33ts: https://twitter.com/b33ts/
Contact
-------
* Encountered a bug you'd like to report or have an idea for a new feature? Check out our `issue tracker`_! If your issue or feature hasn't already been reported, please `open a new ticket`_ and we'll be in touch with you shortly. If you'd like to vote on a feature/bug, simply give a :+1: on issues you'd like to see prioritized over others.
* Need help/support, would like to start a discussion, or would just like to introduce yourself to the team? Check out our `forums`_!
.. _issue tracker: https://github.com/beetbox/beets/issues
.. _open a new ticket: https://github.com/beetbox/beets/issues/new/choose
.. _forums: https://discourse.beets.io/
Authors
-------
Beets is by `Adrian Sampson`_ with a supporting cast of thousands. For help,
please visit our `forum`_.
Beets is by `Adrian Sampson`_ with a supporting cast of thousands.
.. _forum: https://discourse.beets.io
.. _Adrian Sampson: https://www.cs.cornell.edu/~asampson/

View file

@ -6,9 +6,6 @@ skip_commits:
message: /\[appveyor skip\]/
environment:
# Undocumented feature of nose-show-skipped.
NOSE_SHOW_SKIPPED: 1
matrix:
- PYTHON: C:\Python27
TOX_ENV: py27-test

View file

@ -15,9 +15,8 @@
from __future__ import division, absolute_import, print_function
import os
import confuse
from sys import stderr
__version__ = u'1.5.0'
__author__ = u'Adrian Sampson <adrian@radbox.org>'
@ -32,11 +31,12 @@ class IncludeLazyConfig(confuse.LazyConfig):
try:
for view in self['include']:
filename = view.as_filename()
if os.path.isfile(filename):
self.set_file(filename)
self.set_file(view.as_filename())
except confuse.NotFoundError:
pass
except confuse.ConfigReadError as err:
stderr.write("configuration `import` failed: {}"
.format(err.reason))
config = IncludeLazyConfig('beets', __name__)

View file

@ -51,8 +51,8 @@ def get_art(log, item):
def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
compare_threshold=0, ifempty=False, as_album=False,
id3v23=None):
compare_threshold=0, ifempty=False, as_album=False, id3v23=None,
quality=0):
"""Embed an image into the item's media file.
"""
# Conditions and filters.
@ -64,7 +64,7 @@ def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
log.info(u'media file already contained art')
return
if maxwidth and not as_album:
imagepath = resize_image(log, imagepath, maxwidth)
imagepath = resize_image(log, imagepath, maxwidth, quality)
# Get the `Image` object from the file.
try:
@ -84,8 +84,8 @@ def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
item.try_write(path=itempath, tags={'images': [image]}, id3v23=id3v23)
def embed_album(log, album, maxwidth=None, quiet=False,
compare_threshold=0, ifempty=False):
def embed_album(log, album, maxwidth=None, quiet=False, compare_threshold=0,
ifempty=False, quality=0):
"""Embed album art into all of the album's items.
"""
imagepath = album.artpath
@ -97,20 +97,23 @@ def embed_album(log, album, maxwidth=None, quiet=False,
displayable_path(imagepath), album)
return
if maxwidth:
imagepath = resize_image(log, imagepath, maxwidth)
imagepath = resize_image(log, imagepath, maxwidth, quality)
log.info(u'Embedding album art into {0}', album)
for item in album.items():
embed_item(log, item, imagepath, maxwidth, None,
compare_threshold, ifempty, as_album=True)
embed_item(log, item, imagepath, maxwidth, None, compare_threshold,
ifempty, as_album=True, quality=quality)
def resize_image(log, imagepath, maxwidth):
"""Returns path to an image resized to maxwidth.
def resize_image(log, imagepath, maxwidth, quality):
"""Returns path to an image resized to maxwidth and encoded with the
specified quality level.
"""
log.debug(u'Resizing album art to {0} pixels wide', maxwidth)
imagepath = ArtResizer.shared.resize(maxwidth, syspath(imagepath))
log.debug(u'Resizing album art to {0} pixels wide and encoding at quality \
level {1}', maxwidth, quality)
imagepath = ArtResizer.shared.resize(maxwidth, syspath(imagepath),
quality=quality)
return imagepath

View file

@ -35,6 +35,41 @@ from .match import Recommendation # noqa
# Global logger.
log = logging.getLogger('beets')
# Metadata fields that are already hardcoded, or where the tag name changes.
SPECIAL_FIELDS = {
'album': (
'va',
'releasegroup_id',
'artist_id',
'album_id',
'mediums',
'tracks',
'year',
'month',
'day',
'artist',
'artist_credit',
'artist_sort',
'data_url'
),
'track': (
'track_alt',
'artist_id',
'release_track_id',
'medium',
'index',
'medium_index',
'title',
'artist_credit',
'artist_sort',
'artist',
'track_id',
'medium_total',
'data_url',
'length'
)
}
# Additional utilities for the main interface.
@ -49,23 +84,14 @@ def apply_item_metadata(item, track_info):
item.mb_releasetrackid = track_info.release_track_id
if track_info.artist_id:
item.mb_artistid = track_info.artist_id
if track_info.data_source:
item.data_source = track_info.data_source
if track_info.lyricist is not None:
item.lyricist = track_info.lyricist
if track_info.composer is not None:
item.composer = track_info.composer
if track_info.composer_sort is not None:
item.composer_sort = track_info.composer_sort
if track_info.arranger is not None:
item.arranger = track_info.arranger
if track_info.work is not None:
item.work = track_info.work
if track_info.mb_workid is not None:
item.mb_workid = track_info.mb_workid
if track_info.work_disambig is not None:
item.work_disambig = track_info.work_disambig
for field, value in track_info.items():
# We only overwrite fields that are not already hardcoded.
if field in SPECIAL_FIELDS['track']:
continue
if value is None:
continue
item[field] = value
# At the moment, the other metadata is left intact (including album
# and track number). Perhaps these should be emptied?
@ -157,52 +183,19 @@ def apply_metadata(album_info, mapping):
# Track alt.
item.track_alt = track_info.track_alt
# Miscellaneous/nullable metadata.
misc_fields = {
'album': (
'albumtype',
'label',
'asin',
'catalognum',
'script',
'language',
'country',
'style',
'genre',
'discogs_albumid',
'discogs_artistid',
'discogs_labelid',
'albumstatus',
'albumdisambig',
'releasegroupdisambig',
'data_source',
),
'track': (
'disctitle',
'lyricist',
'media',
'composer',
'composer_sort',
'arranger',
'work',
'mb_workid',
'work_disambig',
'bpm',
'initial_key',
'genre'
)
}
# Don't overwrite fields with empty values unless the
# field is explicitly allowed to be overwritten
for field in misc_fields['album']:
for field, value in album_info.items():
if field in SPECIAL_FIELDS['album']:
continue
clobber = field in config['overwrite_null']['album'].as_str_seq()
value = getattr(album_info, field)
if value is None and not clobber:
continue
item[field] = value
for field in misc_fields['track']:
for field, value in track_info.items():
if field in SPECIAL_FIELDS['track']:
continue
clobber = field in config['overwrite_null']['track'].as_str_seq()
value = getattr(track_info, field)
if value is None and not clobber:

View file

@ -39,8 +39,25 @@ except AttributeError:
# Classes used to represent candidate options.
class AttrDict(dict):
"""A dictionary that supports attribute ("dot") access, so `d.field`
is equivalent to `d['field']`.
"""
class AlbumInfo(object):
def __getattr__(self, attr):
if attr in self:
return self.get(attr)
else:
raise AttributeError
def __setattr__(self, key, value):
self.__setitem__(key, value)
def __hash__(self):
return id(self)
class AlbumInfo(AttrDict):
"""Describes a canonical release that may be used to match a release
in the library. Consists of these data members:
@ -49,43 +66,21 @@ class AlbumInfo(object):
- ``artist``: name of the release's primary artist
- ``artist_id``
- ``tracks``: list of TrackInfo objects making up the release
- ``asin``: Amazon ASIN
- ``albumtype``: string describing the kind of release
- ``va``: boolean: whether the release has "various artists"
- ``year``: release year
- ``month``: release month
- ``day``: release day
- ``label``: music label responsible for the release
- ``mediums``: the number of discs in this release
- ``artist_sort``: name of the release's artist for sorting
- ``releasegroup_id``: MBID for the album's release group
- ``catalognum``: the label's catalog number for the release
- ``script``: character set used for metadata
- ``language``: human language of the metadata
- ``country``: the release country
- ``albumstatus``: MusicBrainz release status (Official, etc.)
- ``media``: delivery mechanism (Vinyl, etc.)
- ``albumdisambig``: MusicBrainz release disambiguation comment
- ``releasegroupdisambig``: MusicBrainz release group
disambiguation comment.
- ``artist_credit``: Release-specific artist name
- ``data_source``: The original data source (MusicBrainz, Discogs, etc.)
- ``data_url``: The data source release URL.
``mediums`` along with the fields up through ``tracks`` are required.
The others are optional and may be None.
"""
def __init__(self, album, album_id, artist, artist_id, tracks, asin=None,
albumtype=None, va=False, year=None, month=None, day=None,
label=None, mediums=None, artist_sort=None,
releasegroup_id=None, catalognum=None, script=None,
language=None, country=None, style=None, genre=None,
albumstatus=None, media=None, albumdisambig=None,
def __init__(self, tracks, album=None, album_id=None, artist=None,
artist_id=None, asin=None, albumtype=None, va=False,
year=None, month=None, day=None, label=None, mediums=None,
artist_sort=None, releasegroup_id=None, catalognum=None,
script=None, language=None, country=None, style=None,
genre=None, albumstatus=None, media=None, albumdisambig=None,
releasegroupdisambig=None, artist_credit=None,
original_year=None, original_month=None,
original_day=None, data_source=None, data_url=None,
discogs_albumid=None, discogs_labelid=None,
discogs_artistid=None):
discogs_artistid=None, **kwargs):
self.album = album
self.album_id = album_id
self.artist = artist
@ -120,6 +115,7 @@ class AlbumInfo(object):
self.discogs_albumid = discogs_albumid
self.discogs_labelid = discogs_labelid
self.discogs_artistid = discogs_artistid
self.update(kwargs)
# Work around a bug in python-musicbrainz-ngs that causes some
# strings to be bytes rather than Unicode.
@ -138,53 +134,36 @@ class AlbumInfo(object):
if isinstance(value, bytes):
setattr(self, fld, value.decode(codec, 'ignore'))
if self.tracks:
for track in self.tracks:
track.decode(codec)
for track in self.tracks:
track.decode(codec)
def copy(self):
dupe = AlbumInfo([])
dupe.update(self)
dupe.tracks = [track.copy() for track in self.tracks]
return dupe
class TrackInfo(object):
class TrackInfo(AttrDict):
"""Describes a canonical track present on a release. Appears as part
of an AlbumInfo's ``tracks`` list. Consists of these data members:
- ``title``: name of the track
- ``track_id``: MusicBrainz ID; UUID fragment only
- ``release_track_id``: MusicBrainz ID respective to a track on a
particular release; UUID fragment only
- ``artist``: individual track artist name
- ``artist_id``
- ``length``: float: duration of the track in seconds
- ``index``: position on the entire release
- ``media``: delivery mechanism (Vinyl, etc.)
- ``medium``: the disc number this track appears on in the album
- ``medium_index``: the track's position on the disc
- ``medium_total``: the number of tracks on the item's disc
- ``artist_sort``: name of the track artist for sorting
- ``disctitle``: name of the individual medium (subtitle)
- ``artist_credit``: Recording-specific artist name
- ``data_source``: The original data source (MusicBrainz, Discogs, etc.)
- ``data_url``: The data source release URL.
- ``lyricist``: individual track lyricist name
- ``composer``: individual track composer name
- ``composer_sort``: individual track composer sort name
- ``arranger`: individual track arranger name
- ``track_alt``: alternative track number (tape, vinyl, etc.)
- ``work`: individual track work title
- ``mb_workid`: individual track work id
- ``work_disambig`: individual track work diambiguation
Only ``title`` and ``track_id`` are required. The rest of the fields
may be None. The indices ``index``, ``medium``, and ``medium_index``
are all 1-based.
"""
def __init__(self, title, track_id, release_track_id=None, artist=None,
artist_id=None, length=None, index=None, medium=None,
medium_index=None, medium_total=None, artist_sort=None,
disctitle=None, artist_credit=None, data_source=None,
data_url=None, media=None, lyricist=None, composer=None,
composer_sort=None, arranger=None, track_alt=None,
work=None, mb_workid=None, work_disambig=None, bpm=None,
initial_key=None, genre=None):
def __init__(self, title=None, track_id=None, release_track_id=None,
artist=None, artist_id=None, length=None, index=None,
medium=None, medium_index=None, medium_total=None,
artist_sort=None, disctitle=None, artist_credit=None,
data_source=None, data_url=None, media=None, lyricist=None,
composer=None, composer_sort=None, arranger=None,
track_alt=None, work=None, mb_workid=None,
work_disambig=None, bpm=None, initial_key=None, genre=None,
**kwargs):
self.title = title
self.track_id = track_id
self.release_track_id = release_track_id
@ -212,6 +191,7 @@ class TrackInfo(object):
self.bpm = bpm
self.initial_key = initial_key
self.genre = genre
self.update(kwargs)
# As above, work around a bug in python-musicbrainz-ngs.
def decode(self, codec='utf-8'):
@ -224,6 +204,11 @@ class TrackInfo(object):
if isinstance(value, bytes):
setattr(self, fld, value.decode(codec, 'ignore'))
def copy(self):
dupe = TrackInfo()
dupe.update(self)
return dupe
# Candidate distance scoring.
@ -347,7 +332,7 @@ class Distance(object):
self._penalties = {}
@LazyClassProperty
def _weights(cls): # noqa
def _weights(cls): # noqa: N805
"""A dictionary from keys to floating-point weights.
"""
weights_view = config['match']['distance_weights']
@ -614,17 +599,21 @@ def tracks_for_id(track_id):
@plugins.notify_info_yielded(u'albuminfo_received')
def album_candidates(items, artist, album, va_likely):
def album_candidates(items, artist, album, va_likely, extra_tags):
"""Search for album matches. ``items`` is a list of Item objects
that make up the album. ``artist`` and ``album`` are the respective
names (strings), which may be derived from the item list or may be
entered by the user. ``va_likely`` is a boolean indicating whether
the album is likely to be a "various artists" release.
the album is likely to be a "various artists" release. ``extra_tags``
is an optional dictionary of additional tags used to further
constrain the search.
"""
# Base candidates if we have album and artist to match.
if artist and album:
try:
for candidate in mb.match_album(artist, album, len(items)):
for candidate in mb.match_album(artist, album, len(items),
extra_tags):
yield candidate
except mb.MusicBrainzAPIError as exc:
exc.log(log)
@ -632,13 +621,15 @@ def album_candidates(items, artist, album, va_likely):
# Also add VA matches from MusicBrainz where appropriate.
if va_likely and album:
try:
for candidate in mb.match_album(None, album, len(items)):
for candidate in mb.match_album(None, album, len(items),
extra_tags):
yield candidate
except mb.MusicBrainzAPIError as exc:
exc.log(log)
# Candidates from plugins.
for candidate in plugins.candidates(items, artist, album, va_likely):
for candidate in plugins.candidates(items, artist, album, va_likely,
extra_tags):
yield candidate

View file

@ -447,6 +447,12 @@ def tag_album(items, search_artist=None, search_album=None,
search_artist, search_album = cur_artist, cur_album
log.debug(u'Search terms: {0} - {1}', search_artist, search_album)
extra_tags = None
if config['musicbrainz']['extra_tags']:
tag_list = config['musicbrainz']['extra_tags'].get()
extra_tags = {k: v for (k, v) in likelies.items() if k in tag_list}
log.debug(u'Additional search terms: {0}', extra_tags)
# Is this album likely to be a "various artist" release?
va_likely = ((not consensus['artist']) or
(search_artist.lower() in VA_ARTISTS) or
@ -457,7 +463,8 @@ def tag_album(items, search_artist=None, search_album=None,
for matched_candidate in hooks.album_candidates(items,
search_artist,
search_album,
va_likely):
va_likely,
extra_tags):
_add_candidate(items, candidates, matched_candidate)
log.debug(u'Evaluating {0} candidates.', len(candidates))

View file

@ -38,6 +38,14 @@ else:
SKIPPED_TRACKS = ['[data track]']
FIELDS_TO_MB_KEYS = {
'catalognum': 'catno',
'country': 'country',
'label': 'label',
'media': 'format',
'year': 'date',
}
musicbrainzngs.set_useragent('beets', beets.__version__,
'https://beets.io/')
@ -185,8 +193,8 @@ def track_info(recording, index=None, medium=None, medium_index=None,
the number of tracks on the medium. Each number is a 1-based index.
"""
info = beets.autotag.hooks.TrackInfo(
recording['title'],
recording['id'],
title=recording['title'],
track_id=recording['id'],
index=index,
medium=medium,
medium_index=medium_index,
@ -333,11 +341,11 @@ def album_info(release):
track_infos.append(ti)
info = beets.autotag.hooks.AlbumInfo(
release['title'],
release['id'],
artist_name,
release['artist-credit'][0]['artist']['id'],
track_infos,
album=release['title'],
album_id=release['id'],
artist=artist_name,
artist_id=release['artist-credit'][0]['artist']['id'],
tracks=track_infos,
mediums=len(release['medium-list']),
artist_sort=artist_sort_name,
artist_credit=artist_credit_name,
@ -411,13 +419,13 @@ def album_info(release):
return info
def match_album(artist, album, tracks=None):
def match_album(artist, album, tracks=None, extra_tags=None):
"""Searches for a single album ("release" in MusicBrainz parlance)
and returns an iterator over AlbumInfo objects. May raise a
MusicBrainzAPIError.
The query consists of an artist name, an album name, and,
optionally, a number of tracks on the album.
optionally, a number of tracks on the album and any other extra tags.
"""
# Build search criteria.
criteria = {'release': album.lower().strip()}
@ -429,6 +437,16 @@ def match_album(artist, album, tracks=None):
if tracks is not None:
criteria['tracks'] = six.text_type(tracks)
# Additional search cues from existing metadata.
if extra_tags:
for tag in extra_tags:
key = FIELDS_TO_MB_KEYS[tag]
value = six.text_type(extra_tags.get(tag, '')).lower().strip()
if key == 'catno':
value = value.replace(u' ', '')
if value:
criteria[key] = value
# Abort if we have no search terms.
if not any(criteria.values()):
return

View file

@ -44,6 +44,7 @@ replace:
'^\s+': ''
'^-': _
path_sep_replace: _
drive_sep_replace: _
asciify_paths: false
art_filename: cover
max_filename_length: 0
@ -103,6 +104,7 @@ musicbrainz:
ratelimit: 1
ratelimit_interval: 1.0
searchlimit: 5
extra_tags: []
match:
strong_rec_thresh: 0.04

View file

@ -19,6 +19,7 @@ from __future__ import division, absolute_import, print_function
import time
import os
import re
from collections import defaultdict
import threading
import sqlite3
@ -84,6 +85,11 @@ class FormattedMapping(Mapping):
if self.for_path:
sep_repl = beets.config['path_sep_replace'].as_str()
sep_drive = beets.config['drive_sep_replace'].as_str()
if re.match(r'^\w:', value):
value = re.sub(r'(?<=^\w):', sep_drive, value)
for sep in (os.path.sep, os.path.altsep):
if sep:
value = value.replace(sep, sep_repl)
@ -189,7 +195,7 @@ class LazyConvertDict(object):
class Model(object):
"""An abstract object representing an object in the database. Model
objects act like dictionaries (i.e., the allow subscript access like
objects act like dictionaries (i.e., they allow subscript access like
``obj['field']``). The same field set is available via attribute
access as a shortcut (i.e., ``obj.field``). Three kinds of attributes are
available:

View file

@ -156,12 +156,8 @@ class NoneQuery(FieldQuery):
def col_clause(self):
return self.field + " IS NULL", ()
@classmethod
def match(cls, item):
try:
return item[cls.field] is None
except KeyError:
return True
def match(self, item):
return item.get(self.field) is None
def __repr__(self):
return "{0.__class__.__name__}({0.field!r}, {0.fast})".format(self)

View file

@ -131,6 +131,14 @@ class Integer(Type):
query = query.NumericQuery
model_type = int
def normalize(self, value):
try:
return self.model_type(round(float(value)))
except ValueError:
return self.null
except TypeError:
return self.null
class PaddedInt(Integer):
"""An integer field that is formatted with a given number of digits,

View file

@ -1034,8 +1034,8 @@ class ArchiveImportTask(SentinelImportTask):
cls._handlers = []
from zipfile import is_zipfile, ZipFile
cls._handlers.append((is_zipfile, ZipFile))
from tarfile import is_tarfile, TarFile
cls._handlers.append((is_tarfile, TarFile))
import tarfile
cls._handlers.append((tarfile.is_tarfile, tarfile.open))
try:
from rarfile import is_rarfile, RarFile
except ImportError:

View file

@ -410,7 +410,8 @@ class FormattedItemMapping(dbcore.db.FormattedMapping):
raise KeyError(key)
def __getitem__(self, key):
"""Get the value for a key. Certain unset values are remapped.
"""Get the value for a key. `artist` and `albumartist`
are fallback values for each other when not set.
"""
value = self._get(key)

View file

@ -172,7 +172,7 @@ class BeetsPlugin(object):
"""
return beets.autotag.hooks.Distance()
def candidates(self, items, artist, album, va_likely):
def candidates(self, items, artist, album, va_likely, extra_tags=None):
"""Should return a sequence of AlbumInfo objects that match the
album whose items are provided.
"""
@ -379,11 +379,12 @@ def album_distance(items, album_info, mapping):
return dist
def candidates(items, artist, album, va_likely):
def candidates(items, artist, album, va_likely, extra_tags=None):
"""Gets MusicBrainz candidates for an album from each plugin.
"""
for plugin in find_plugins():
for candidate in plugin.candidates(items, artist, album, va_likely):
for candidate in plugin.candidates(items, artist, album, va_likely,
extra_tags):
yield candidate
@ -714,7 +715,7 @@ class MetadataSourcePlugin(object):
return id_
return None
def candidates(self, items, artist, album, va_likely):
def candidates(self, items, artist, album, va_likely, extra_tags=None):
"""Returns a list of AlbumInfo objects for Search API results
matching an ``album`` and ``artist`` (if not various).

View file

@ -40,14 +40,19 @@ else:
log = logging.getLogger('beets')
def resize_url(url, maxwidth):
def resize_url(url, maxwidth, quality=0):
"""Return a proxied image URL that resizes the original image to
maxwidth (preserving aspect ratio).
"""
return '{0}?{1}'.format(PROXY_URL, urlencode({
params = {
'url': url.replace('http://', ''),
'w': maxwidth,
}))
}
if quality > 0:
params['q'] = quality
return '{0}?{1}'.format(PROXY_URL, urlencode(params))
def temp_file_for(path):
@ -59,7 +64,7 @@ def temp_file_for(path):
return util.bytestring_path(f.name)
def pil_resize(maxwidth, path_in, path_out=None):
def pil_resize(maxwidth, path_in, path_out=None, quality=0):
"""Resize using Python Imaging Library (PIL). Return the output path
of resized image.
"""
@ -72,7 +77,7 @@ def pil_resize(maxwidth, path_in, path_out=None):
im = Image.open(util.syspath(path_in))
size = maxwidth, maxwidth
im.thumbnail(size, Image.ANTIALIAS)
im.save(util.py3_path(path_out))
im.save(util.py3_path(path_out), quality=quality)
return path_out
except IOError:
log.error(u"PIL cannot create thumbnail for '{0}'",
@ -80,7 +85,7 @@ def pil_resize(maxwidth, path_in, path_out=None):
return path_in
def im_resize(maxwidth, path_in, path_out=None):
def im_resize(maxwidth, path_in, path_out=None, quality=0):
"""Resize using ImageMagick.
Use the ``magick`` program or ``convert`` on older versions. Return
@ -93,10 +98,15 @@ def im_resize(maxwidth, path_in, path_out=None):
# "-resize WIDTHx>" shrinks images with the width larger
# than the given width while maintaining the aspect ratio
# with regards to the height.
cmd = ArtResizer.shared.im_convert_cmd + \
[util.syspath(path_in, prefix=False),
'-resize', '{0}x>'.format(maxwidth),
util.syspath(path_out, prefix=False)]
cmd = ArtResizer.shared.im_convert_cmd + [
util.syspath(path_in, prefix=False),
'-resize', '{0}x>'.format(maxwidth),
]
if quality > 0:
cmd += ['-quality', '{0}'.format(quality)]
cmd.append(util.syspath(path_out, prefix=False))
try:
util.command_output(cmd)
@ -190,18 +200,19 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
self.im_convert_cmd = ['magick']
self.im_identify_cmd = ['magick', 'identify']
def resize(self, maxwidth, path_in, path_out=None):
def resize(self, maxwidth, path_in, path_out=None, quality=0):
"""Manipulate an image file according to the method, returning a
new path. For PIL or IMAGEMAGIC methods, resizes the image to a
temporary file. For WEBPROXY, returns `path_in` unmodified.
temporary file and encodes with the specified quality level.
For WEBPROXY, returns `path_in` unmodified.
"""
if self.local:
func = BACKEND_FUNCS[self.method[0]]
return func(maxwidth, path_in, path_out)
return func(maxwidth, path_in, path_out, quality=quality)
else:
return path_in
def proxy_url(self, maxwidth, url):
def proxy_url(self, maxwidth, url, quality=0):
"""Modifies an image URL according the method, returning a new
URL. For WEBPROXY, a URL on the proxy server is returned.
Otherwise, the URL is returned unmodified.
@ -209,7 +220,7 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
if self.local:
return url
else:
return resize_url(url, maxwidth)
return resize_url(url, maxwidth, quality)
@property
def local(self):

View file

@ -73,15 +73,26 @@ def ex_literal(val):
"""An int, float, long, bool, string, or None literal with the given
value.
"""
if val is None:
return ast.Name('None', ast.Load())
elif isinstance(val, six.integer_types):
return ast.Num(val)
elif isinstance(val, bool):
return ast.Name(bytes(val), ast.Load())
elif isinstance(val, six.string_types):
return ast.Str(val)
raise TypeError(u'no literal for {0}'.format(type(val)))
if sys.version_info[:2] < (3, 4):
if val is None:
return ast.Name('None', ast.Load())
elif isinstance(val, six.integer_types):
return ast.Num(val)
elif isinstance(val, bool):
return ast.Name(bytes(val), ast.Load())
elif isinstance(val, six.string_types):
return ast.Str(val)
raise TypeError(u'no literal for {0}'.format(type(val)))
elif sys.version_info[:2] < (3, 6):
if val in [None, True, False]:
return ast.NameConstant(val)
elif isinstance(val, six.integer_types):
return ast.Num(val)
elif isinstance(val, six.string_types):
return ast.Str(val)
raise TypeError(u'no literal for {0}'.format(type(val)))
else:
return ast.Constant(val)
def ex_varassign(name, expr):

View file

@ -355,7 +355,7 @@ class BeatportPlugin(BeetsPlugin):
config=self.config
)
def candidates(self, items, artist, release, va_likely):
def candidates(self, items, artist, release, va_likely, extra_tags=None):
"""Returns a list of AlbumInfo objects for beatport search results
matching release and artist (if not various).
"""

View file

@ -191,7 +191,7 @@ class AcoustidPlugin(plugins.BeetsPlugin):
dist.add_expr('track_id', info.track_id not in recording_ids)
return dist
def candidates(self, items, artist, album, va_likely):
def candidates(self, items, artist, album, va_likely, extra_tags=None):
albums = []
for relid in prefix(_all_releases(items), MAX_RELEASES):
album = hooks.album_for_mbid(relid)

View file

@ -148,6 +148,7 @@ class ConvertPlugin(BeetsPlugin):
u'never_convert_lossy_files': False,
u'copy_album_art': False,
u'album_art_maxwidth': 0,
u'delete_originals': False,
})
self.early_import_stages = [self.auto_convert]
@ -532,11 +533,16 @@ class ConvertPlugin(BeetsPlugin):
# Change the newly-imported database entry to point to the
# converted file.
source_path = item.path
item.path = dest
item.write()
item.read() # Load new audio information data.
item.store()
if self.config['delete_originals']:
self._log.info(u'Removing original file {0}', source_path)
util.remove(source_path, False)
def _cleanup(self, task, session):
for path in task.old_paths:
if path in _temp_files:

View file

@ -24,7 +24,7 @@ class CuePlugin(BeetsPlugin):
# self.register_listener('import_task_start', self.look_for_cues)
def candidates(self, items, artist, album, va_likely):
def candidates(self, items, artist, album, va_likely, extra_tags=None):
import pdb
pdb.set_trace()
@ -53,5 +53,6 @@ class CuePlugin(BeetsPlugin):
title = "dunno lol"
track_id = "wtf"
index = int(path.basename(t)[len("split-track"):-len(".wav")])
yield TrackInfo(title, track_id, index=index, artist=artist)
yield TrackInfo(title=title, track_id=track_id, index=index,
artist=artist)
# generate TrackInfo instances

View file

@ -38,7 +38,7 @@ from string import ascii_lowercase
USER_AGENT = u'beets/{0} +https://beets.io/'.format(beets.__version__)
API_KEY = 'rAzVUQYRaoFjeBjyWuWZ'
API_KEY = 'rAzVUQYRaoFjeBjyWuWZ'
API_SECRET = 'plxtUTqoCzwxZpqdPysCwGuBSmZNdZVy'
# Exceptions that discogs_client should really handle but does not.
@ -175,7 +175,7 @@ class DiscogsPlugin(BeetsPlugin):
config=self.config
)
def candidates(self, items, artist, album, va_likely):
def candidates(self, items, artist, album, va_likely, extra_tags=None):
"""Returns a list of AlbumInfo objects for discogs search results
matching an album and artist (if not various).
"""
@ -356,17 +356,14 @@ class DiscogsPlugin(BeetsPlugin):
# a master release, otherwise fetch the master release.
original_year = self.get_master_year(master_id) if master_id else year
return AlbumInfo(album, album_id, artist, artist_id, tracks, asin=None,
albumtype=albumtype, va=va, year=year, month=None,
day=None, label=label, mediums=len(set(mediums)),
artist_sort=None, releasegroup_id=master_id,
catalognum=catalogno, script=None, language=None,
return AlbumInfo(album=album, album_id=album_id, artist=artist,
artist_id=artist_id, tracks=tracks,
albumtype=albumtype, va=va, year=year,
label=label, mediums=len(set(mediums)),
releasegroup_id=master_id, catalognum=catalogno,
country=country, style=style, genre=genre,
albumstatus=None, media=media,
albumdisambig=None, artist_credit=None,
original_year=original_year, original_month=None,
original_day=None, data_source='Discogs',
data_url=data_url,
media=media, original_year=original_year,
data_source='Discogs', data_url=data_url,
discogs_albumid=discogs_albumid,
discogs_labelid=labelid, discogs_artistid=artist_id)
@ -567,10 +564,9 @@ class DiscogsPlugin(BeetsPlugin):
track.get('artists', [])
)
length = self.get_track_length(track['duration'])
return TrackInfo(title, track_id, artist=artist, artist_id=artist_id,
length=length, index=index,
medium=medium, medium_index=medium_index,
artist_sort=None, disctitle=None, artist_credit=None)
return TrackInfo(title=title, track_id=track_id, artist=artist,
artist_id=artist_id, length=length, index=index,
medium=medium, medium_index=medium_index)
def get_track_index(self, position):
"""Returns the medium, medium index and subtrack index for a discogs

View file

@ -59,7 +59,8 @@ class EmbedCoverArtPlugin(BeetsPlugin):
'auto': True,
'compare_threshold': 0,
'ifempty': False,
'remove_art_file': False
'remove_art_file': False,
'quality': 0,
})
if self.config['maxwidth'].get(int) and not ArtResizer.shared.local:
@ -86,6 +87,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
u"-y", u"--yes", action="store_true", help=u"skip confirmation"
)
maxwidth = self.config['maxwidth'].get(int)
quality = self.config['quality'].get(int)
compare_threshold = self.config['compare_threshold'].get(int)
ifempty = self.config['ifempty'].get(bool)
@ -104,8 +106,9 @@ class EmbedCoverArtPlugin(BeetsPlugin):
return
for item in items:
art.embed_item(self._log, item, imagepath, maxwidth, None,
compare_threshold, ifempty)
art.embed_item(self._log, item, imagepath, maxwidth,
None, compare_threshold, ifempty,
quality=quality)
else:
albums = lib.albums(decargs(args))
@ -114,8 +117,9 @@ class EmbedCoverArtPlugin(BeetsPlugin):
return
for album in albums:
art.embed_album(self._log, album, maxwidth, False,
compare_threshold, ifempty)
art.embed_album(self._log, album, maxwidth,
False, compare_threshold, ifempty,
quality=quality)
self.remove_artfile(album)
embed_cmd.func = embed_func

View file

@ -21,7 +21,7 @@ import sys
import codecs
import json
import csv
import xml.etree.ElementTree as ET
from xml.etree import ElementTree
from datetime import datetime, date
from beets.plugins import BeetsPlugin
@ -188,18 +188,18 @@ class XMLFormat(ExportFormat):
def export(self, data, **kwargs):
# Creates the XML file structure.
library = ET.Element(u'library')
tracks = ET.SubElement(library, u'tracks')
library = ElementTree.Element(u'library')
tracks = ElementTree.SubElement(library, u'tracks')
if data and isinstance(data[0], dict):
for index, item in enumerate(data):
track = ET.SubElement(tracks, u'track')
track = ElementTree.SubElement(tracks, u'track')
for key, value in item.items():
track_details = ET.SubElement(track, key)
track_details = ElementTree.SubElement(track, key)
track_details.text = value
# Depending on the version of python the encoding needs to change
try:
data = ET.tostring(library, encoding='unicode', **kwargs)
data = ElementTree.tostring(library, encoding='unicode', **kwargs)
except LookupError:
data = ET.tostring(library, encoding='utf-8', **kwargs)
data = ElementTree.tostring(library, encoding='utf-8', **kwargs)
self.out_stream.write(data)

View file

@ -21,6 +21,7 @@ from contextlib import closing
import os
import re
from tempfile import NamedTemporaryFile
from collections import OrderedDict
import requests
@ -135,7 +136,8 @@ class Candidate(object):
def resize(self, plugin):
if plugin.maxwidth and self.check == self.CANDIDATE_DOWNSCALE:
self.path = ArtResizer.shared.resize(plugin.maxwidth, self.path)
self.path = ArtResizer.shared.resize(plugin.maxwidth, self.path,
quality=plugin.quality)
def _logged_get(log, *args, **kwargs):
@ -164,9 +166,14 @@ def _logged_get(log, *args, **kwargs):
message = 'getting URL'
req = requests.Request('GET', *args, **req_kwargs)
with requests.Session() as s:
s.headers = {'User-Agent': 'beets'}
prepped = s.prepare_request(req)
settings = s.merge_environment_settings(
prepped.url, {}, None, None, None
)
send_kwargs.update(settings)
log.debug('{}: {}', message, prepped.url)
return s.send(prepped, **send_kwargs)
@ -203,6 +210,9 @@ class ArtSource(RequestMixin):
def fetch_image(self, candidate, plugin):
raise NotImplementedError()
def cleanup(self, candidate):
pass
class LocalArtSource(ArtSource):
IS_LOCAL = True
@ -284,10 +294,18 @@ class RemoteArtSource(ArtSource):
self._log.debug(u'error fetching art: {}', exc)
return
def cleanup(self, candidate):
if candidate.path:
try:
util.remove(path=candidate.path)
except util.FilesystemError as exc:
self._log.debug(u'error cleaning up tmp art: {}', exc)
class CoverArtArchive(RemoteArtSource):
NAME = u"Cover Art Archive"
VALID_MATCHING_CRITERIA = ['release', 'releasegroup']
VALID_THUMBNAIL_SIZES = [250, 500, 1200]
if util.SNI_SUPPORTED:
URL = 'https://coverartarchive.org/release/{mbid}/front'
@ -300,13 +318,31 @@ class CoverArtArchive(RemoteArtSource):
"""Return the Cover Art Archive and Cover Art Archive release group URLs
using album MusicBrainz release ID and release group ID.
"""
release_url = self.URL.format(mbid=album.mb_albumid)
release_group_url = self.GROUP_URL.format(mbid=album.mb_releasegroupid)
# Cover Art Archive API offers pre-resized thumbnails at several sizes.
# If the maxwidth config matches one of the already available sizes
# fetch it directly intead of fetching the full sized image and
# resizing it.
size_suffix = None
if plugin.maxwidth in self.VALID_THUMBNAIL_SIZES:
size_suffix = "-" + str(plugin.maxwidth)
if 'release' in self.match_by and album.mb_albumid:
yield self._candidate(url=self.URL.format(mbid=album.mb_albumid),
if size_suffix:
release_thumbnail_url = release_url + size_suffix
yield self._candidate(url=release_thumbnail_url,
match=Candidate.MATCH_EXACT)
yield self._candidate(url=release_url,
match=Candidate.MATCH_EXACT)
if 'releasegroup' in self.match_by and album.mb_releasegroupid:
yield self._candidate(
url=self.GROUP_URL.format(mbid=album.mb_releasegroupid),
match=Candidate.MATCH_FALLBACK)
if size_suffix:
release_group_thumbnail_url = release_group_url + size_suffix
yield self._candidate(url=release_group_thumbnail_url,
match=Candidate.MATCH_FALLBACK)
yield self._candidate(url=release_group_url,
match=Candidate.MATCH_FALLBACK)
class Amazon(RemoteArtSource):
@ -736,11 +772,72 @@ class FileSystem(LocalArtSource):
match=Candidate.MATCH_FALLBACK)
class LastFM(RemoteArtSource):
NAME = u"Last.fm"
# Sizes in priority order.
SIZES = OrderedDict([
('mega', (300, 300)),
('extralarge', (300, 300)),
('large', (174, 174)),
('medium', (64, 64)),
('small', (34, 34)),
])
if util.SNI_SUPPORTED:
API_URL = 'https://ws.audioscrobbler.com/2.0'
else:
API_URL = 'http://ws.audioscrobbler.com/2.0'
def __init__(self, *args, **kwargs):
super(LastFM, self).__init__(*args, **kwargs)
self.key = self._config['lastfm_key'].get(),
def get(self, album, plugin, paths):
if not album.mb_albumid:
return
try:
response = self.request(self.API_URL, params={
'method': 'album.getinfo',
'api_key': self.key,
'mbid': album.mb_albumid,
'format': 'json',
})
except requests.RequestException:
self._log.debug(u'lastfm: error receiving response')
return
try:
data = response.json()
if 'error' in data:
if data['error'] == 6:
self._log.debug('lastfm: no results for {}',
album.mb_albumid)
else:
self._log.error(
'lastfm: failed to get album info: {} ({})',
data['message'], data['error'])
else:
images = {image['size']: image['#text']
for image in data['album']['image']}
# Provide candidates in order of size.
for size in self.SIZES.keys():
if size in images:
yield self._candidate(url=images[size],
size=self.SIZES[size])
except ValueError:
self._log.debug(u'lastfm: error loading response: {}'
.format(response.text))
return
# Try each source in turn.
SOURCES_ALL = [u'filesystem',
u'coverart', u'itunes', u'amazon', u'albumart',
u'wikipedia', u'google', u'fanarttv']
u'wikipedia', u'google', u'fanarttv', u'lastfm']
ART_SOURCES = {
u'filesystem': FileSystem,
@ -751,6 +848,7 @@ ART_SOURCES = {
u'wikipedia': Wikipedia,
u'google': GoogleImages,
u'fanarttv': FanartTV,
u'lastfm': LastFM,
}
SOURCE_NAMES = {v: k for k, v in ART_SOURCES.items()}
@ -772,6 +870,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
'auto': True,
'minwidth': 0,
'maxwidth': 0,
'quality': 0,
'enforce_ratio': False,
'cautious': False,
'cover_names': ['cover', 'front', 'art', 'album', 'folder'],
@ -780,14 +879,17 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
'google_key': None,
'google_engine': u'001442825323518660753:hrh5ch1gjzm',
'fanarttv_key': None,
'lastfm_key': None,
'store_source': False,
'high_resolution': False,
})
self.config['google_key'].redact = True
self.config['fanarttv_key'].redact = True
self.config['lastfm_key'].redact = True
self.minwidth = self.config['minwidth'].get(int)
self.maxwidth = self.config['maxwidth'].get(int)
self.quality = self.config['quality'].get(int)
# allow both pixel and percentage-based margin specifications
self.enforce_ratio = self.config['enforce_ratio'].get(
@ -823,6 +925,9 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
if not self.config['google_key'].get() and \
u'google' in available_sources:
available_sources.remove(u'google')
if not self.config['lastfm_key'].get() and \
u'lastfm' in available_sources:
available_sources.remove(u'lastfm')
available_sources = [(s, c)
for s in available_sources
for c in ART_SOURCES[s].VALID_MATCHING_CRITERIA]
@ -903,7 +1008,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
cmd.parser.add_option(
u'-q', u'--quiet', dest='quiet',
action='store_true', default=False,
help=u'shows only quiet art'
help=u'quiet mode: do not output albums that already have artwork'
)
def func(lib, opts, args):
@ -917,9 +1022,10 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
def art_for_album(self, album, paths, local_only=False):
"""Given an Album object, returns a path to downloaded art for the
album (or None if no art is found). If `maxwidth`, then images are
resized to this maximum pixel size. If `local_only`, then only local
image files from the filesystem are returned; no network requests
are made.
resized to this maximum pixel size. If `quality` then resized images
are saved at the specified quality level. If `local_only`, then only
local image files from the filesystem are returned; no network
requests are made.
"""
out = None
@ -940,6 +1046,8 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
u'using {0.LOC_STR} image {1}'.format(
source, util.displayable_path(out.path)))
break
# Remove temporary files for invalid candidates.
source.cleanup(candidate)
if out:
break

276
beetsplug/fish.py Normal file
View file

@ -0,0 +1,276 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2015, winters jean-marie.
# Copyright 2020, Justin Mayer <https://justinmayer.com>
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""This plugin generates tab completions for Beets commands for the Fish shell
<https://fishshell.com/>, including completions for Beets commands, plugin
commands, and option flags. Also generated are completions for all the album
and track fields, suggesting for example `genre:` or `album:` when querying the
Beets database. Completions for the *values* of those fields are not generated
by default but can be added via the `-e` / `--extravalues` flag. For example:
`beet fish -e genre -e albumartist`
"""
from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
from beets import library, ui
from beets.ui import commands
from operator import attrgetter
import os
BL_NEED2 = """complete -c beet -n '__fish_beet_needs_command' {} {}\n"""
BL_USE3 = """complete -c beet -n '__fish_beet_using_command {}' {} {}\n"""
BL_SUBS = """complete -c beet -n '__fish_at_level {} ""' {} {}\n"""
BL_EXTRA3 = """complete -c beet -n '__fish_beet_use_extra {}' {} {}\n"""
HEAD = '''
function __fish_beet_needs_command
set cmd (commandline -opc)
if test (count $cmd) -eq 1
return 0
end
return 1
end
function __fish_beet_using_command
set cmd (commandline -opc)
set needle (count $cmd)
if test $needle -gt 1
if begin test $argv[1] = $cmd[2];
and not contains -- $cmd[$needle] $FIELDS; end
return 0
end
end
return 1
end
function __fish_beet_use_extra
set cmd (commandline -opc)
set needle (count $cmd)
if test $argv[2] = $cmd[$needle]
return 0
end
return 1
end
'''
class FishPlugin(BeetsPlugin):
def commands(self):
cmd = ui.Subcommand('fish', help='generate Fish shell tab completions')
cmd.func = self.run
cmd.parser.add_option('-f', '--noFields', action='store_true',
default=False,
help='omit album/track field completions')
cmd.parser.add_option(
'-e',
'--extravalues',
action='append',
type='choice',
choices=library.Item.all_keys() +
library.Album.all_keys(),
help='include specified field *values* in completions')
return [cmd]
def run(self, lib, opts, args):
# Gather the commands from Beets core and its plugins.
# Collect the album and track fields.
# If specified, also collect the values for these fields.
# Make a giant string of all the above, formatted in a way that
# allows Fish to do tab completion for the `beet` command.
home_dir = os.path.expanduser("~")
completion_dir = os.path.join(home_dir, '.config/fish/completions')
try:
os.makedirs(completion_dir)
except OSError:
if not os.path.isdir(completion_dir):
raise
completion_file_path = os.path.join(completion_dir, 'beet.fish')
nobasicfields = opts.noFields # Do not complete for album/track fields
extravalues = opts.extravalues # e.g., Also complete artists names
beetcmds = sorted(
(commands.default_commands +
commands.plugins.commands()),
key=attrgetter('name'))
fields = sorted(set(
library.Album.all_keys() + library.Item.all_keys()))
# Collect commands, their aliases, and their help text
cmd_names_help = []
for cmd in beetcmds:
names = [alias for alias in cmd.aliases]
names.append(cmd.name)
for name in names:
cmd_names_help.append((name, cmd.help))
# Concatenate the string
totstring = HEAD + "\n"
totstring += get_cmds_list([name[0] for name in cmd_names_help])
totstring += '' if nobasicfields else get_standard_fields(fields)
totstring += get_extravalues(lib, extravalues) if extravalues else ''
totstring += "\n" + "# ====== {} =====".format(
"setup basic beet completion") + "\n" * 2
totstring += get_basic_beet_options()
totstring += "\n" + "# ====== {} =====".format(
"setup field completion for subcommands") + "\n"
totstring += get_subcommands(
cmd_names_help, nobasicfields, extravalues)
# Set up completion for all the command options
totstring += get_all_commands(beetcmds)
with open(completion_file_path, 'w') as fish_file:
fish_file.write(totstring)
def get_cmds_list(cmds_names):
# Make a list of all Beets core & plugin commands
substr = ''
substr += (
"set CMDS " + " ".join(cmds_names) + ("\n" * 2)
)
return substr
def get_standard_fields(fields):
# Make a list of album/track fields and append with ':'
fields = (field + ":" for field in fields)
substr = ''
substr += (
"set FIELDS " + " ".join(fields) + ("\n" * 2)
)
return substr
def get_extravalues(lib, extravalues):
# Make a list of all values from an album/track field.
# 'beet ls albumartist: <TAB>' yields completions for ABBA, Beatles, etc.
word = ''
values_set = get_set_of_values_for_field(lib, extravalues)
for fld in extravalues:
extraname = fld.upper() + 'S'
word += (
"set " + extraname + " " + " ".join(sorted(values_set[fld]))
+ ("\n" * 2)
)
return word
def get_set_of_values_for_field(lib, fields):
# Get unique values from a specified album/track field
fields_dict = {}
for each in fields:
fields_dict[each] = set()
for item in lib.items():
for field in fields:
fields_dict[field].add(wrap(item[field]))
return fields_dict
def get_basic_beet_options():
word = (
BL_NEED2.format("-l format-item",
"-f -d 'print with custom format'") +
BL_NEED2.format("-l format-album",
"-f -d 'print with custom format'") +
BL_NEED2.format("-s l -l library",
"-f -r -d 'library database file to use'") +
BL_NEED2.format("-s d -l directory",
"-f -r -d 'destination music directory'") +
BL_NEED2.format("-s v -l verbose",
"-f -d 'print debugging information'") +
BL_NEED2.format("-s c -l config",
"-f -r -d 'path to configuration file'") +
BL_NEED2.format("-s h -l help",
"-f -d 'print this help message and exit'"))
return word
def get_subcommands(cmd_name_and_help, nobasicfields, extravalues):
# Formatting for Fish to complete our fields/values
word = ""
for cmdname, cmdhelp in cmd_name_and_help:
word += "\n" + "# ------ {} -------".format(
"fieldsetups for " + cmdname) + "\n"
word += (
BL_NEED2.format(
("-a " + cmdname),
("-f " + "-d " + wrap(clean_whitespace(cmdhelp)))))
if nobasicfields is False:
word += (
BL_USE3.format(
cmdname,
("-a " + wrap("$FIELDS")),
("-f " + "-d " + wrap("fieldname"))))
if extravalues:
for f in extravalues:
setvar = wrap("$" + f.upper() + "S")
word += " ".join(BL_EXTRA3.format(
(cmdname + " " + f + ":"),
('-f ' + '-A ' + '-a ' + setvar),
('-d ' + wrap(f))).split()) + "\n"
return word
def get_all_commands(beetcmds):
# Formatting for Fish to complete command options
word = ""
for cmd in beetcmds:
names = [alias for alias in cmd.aliases]
names.append(cmd.name)
for name in names:
word += "\n"
word += ("\n" * 2) + "# ====== {} =====".format(
"completions for " + name) + "\n"
for option in cmd.parser._get_all_options()[1:]:
cmd_l = (" -l " + option._long_opts[0].replace('--', '')
)if option._long_opts else ''
cmd_s = (" -s " + option._short_opts[0].replace('-', '')
) if option._short_opts else ''
cmd_need_arg = ' -r ' if option.nargs in [1] else ''
cmd_helpstr = (" -d " + wrap(' '.join(option.help.split()))
) if option.help else ''
cmd_arglist = (' -a ' + wrap(" ".join(option.choices))
) if option.choices else ''
word += " ".join(BL_USE3.format(
name,
(cmd_need_arg + cmd_s + cmd_l + " -f " + cmd_arglist),
cmd_helpstr).split()) + "\n"
word = (word + " ".join(BL_USE3.format(
name,
("-s " + "h " + "-l " + "help" + " -f "),
('-d ' + wrap("print help") + "\n")
).split()))
return word
def clean_whitespace(word):
# Remove excess whitespace and tabs in a string
return " ".join(word.split())
def wrap(word):
# Need " or ' around strings but watch out if they're in the string
sptoken = '\"'
if ('"') in word and ("'") in word:
word.replace('"', sptoken)
return '"' + word + '"'
tok = '"' if "'" in word else "'"
return tok + word + tok

View file

@ -151,6 +151,8 @@ class IPFSPlugin(BeetsPlugin):
def ipfs_get(self, lib, query):
query = query[0]
# Check if query is a hash
# TODO: generalize to other hashes; probably use a multihash
# implementation
if query.startswith("Qm") and len(query) == 46:
self.ipfs_get_from_hash(lib, query)
else:
@ -197,7 +199,7 @@ class IPFSPlugin(BeetsPlugin):
else:
lib_name = _hash
lib_root = os.path.dirname(lib.path)
remote_libs = lib_root + "/remotes"
remote_libs = os.path.join(lib_root, b"remotes")
if not os.path.exists(remote_libs):
try:
os.makedirs(remote_libs)
@ -205,7 +207,7 @@ class IPFSPlugin(BeetsPlugin):
msg = "Could not create {0}. Error: {1}".format(remote_libs, e)
self._log.error(msg)
return False
path = remote_libs + "/" + lib_name + ".db"
path = os.path.join(remote_libs, lib_name.encode() + b".db")
if not os.path.exists(path):
cmd = "ipfs get {0} -o".format(_hash).split()
cmd.append(path)
@ -216,7 +218,7 @@ class IPFSPlugin(BeetsPlugin):
return False
# add all albums from remotes into a combined library
jpath = remote_libs + "/joined.db"
jpath = os.path.join(remote_libs, b"joined.db")
jlib = library.Library(jpath)
nlib = library.Library(path)
for album in nlib.albums():
@ -244,7 +246,7 @@ class IPFSPlugin(BeetsPlugin):
return
for album in albums:
ui.print_(format(album, fmt), " : ", album.ipfs)
ui.print_(format(album, fmt), " : ", album.ipfs.decode())
def query(self, lib, args):
rlib = self.get_remote_lib(lib)
@ -253,8 +255,8 @@ class IPFSPlugin(BeetsPlugin):
def get_remote_lib(self, lib):
lib_root = os.path.dirname(lib.path)
remote_libs = lib_root + "/remotes"
path = remote_libs + "/joined.db"
remote_libs = os.path.join(lib_root, b"remotes")
path = os.path.join(remote_libs, b"joined.db")
if not os.path.isfile(path):
raise IOError
return library.Library(path)

View file

@ -648,35 +648,51 @@
- glam rock
- hard rock
- heavy metal:
- alternative metal
- alternative metal:
- funk metal
- black metal:
- viking metal
- christian metal
- death metal:
- death/doom
- goregrind
- melodic death metal
- technical death metal
- doom metal
- doom metal:
- epic doom metal
- funeral doom
- drone metal
- epic metal
- folk metal:
- celtic metal
- medieval metal
- pagan metal
- funk metal
- glam metal
- gothic metal
- industrial metal:
- industrial death metal
- metalcore:
- deathcore
- mathcore:
- djent
- power metal
- synthcore
- neoclassical metal
- post-metal
- power metal:
- progressive power metal
- progressive metal
- sludge metal
- speed metal
- stoner rock
- stoner rock:
- stoner metal
- symphonic metal
- thrash metal:
- crossover thrash
- groove metal
- progressive thrash metal
- teutonic thrash metal
- traditional heavy metal
- math rock
- new wave:
- world fusion
@ -719,6 +735,7 @@
- street punk
- thrashcore
- horror punk
- oi!
- pop punk
- psychobilly
- riot grrrl

View file

@ -450,6 +450,8 @@ emo rap
emocore
emotronic
enka
epic doom metal
epic metal
eremwu eu
ethereal pop
ethereal wave
@ -1024,6 +1026,7 @@ neo-medieval
neo-prog
neo-psychedelia
neoclassical
neoclassical metal
neoclassical music
neofolk
neotraditional country
@ -1176,8 +1179,10 @@ progressive folk
progressive folk music
progressive house
progressive metal
progressive power metal
progressive rock
progressive trance
progressive thrash metal
protopunk
psych folk
psychedelic music
@ -1396,6 +1401,7 @@ symphonic metal
symphonic poem
symphonic rock
symphony
synthcore
synthpop
synthpunk
t'ong guitar
@ -1428,6 +1434,7 @@ tejano
tejano music
tekno
tembang sunda
teutonic thrash metal
texas blues
thai pop
thillana
@ -1444,6 +1451,7 @@ toeshey
togaku
trad jazz
traditional bluegrass
traditional heavy metal
traditional pop music
trallalero
trance

View file

@ -187,6 +187,9 @@ def search_pairs(item):
In addition to the artist and title obtained from the `item` the
method tries to strip extra information like paranthesized suffixes
and featured artists from the strings and add them as candidates.
The artist sort name is added as a fallback candidate to help in
cases where artist name includes special characters or is in a
non-latin script.
The method also tries to split multiple titles separated with `/`.
"""
def generate_alternatives(string, patterns):
@ -200,12 +203,16 @@ def search_pairs(item):
alternatives.append(match.group(1))
return alternatives
title, artist = item.title, item.artist
title, artist, artist_sort = item.title, item.artist, item.artist_sort
patterns = [
# Remove any featuring artists from the artists name
r"(.*?) {0}".format(plugins.feat_tokens())]
artists = generate_alternatives(artist, patterns)
# Use the artist_sort as fallback only if it differs from artist to avoid
# repeated remote requests with the same search terms
if artist != artist_sort:
artists.append(artist_sort)
patterns = [
# Remove a parenthesized suffix from a title string. Common
@ -352,56 +359,86 @@ class Genius(Backend):
'User-Agent': USER_AGENT,
}
def lyrics_from_song_api_path(self, song_api_path):
song_url = self.base_url + song_api_path
response = requests.get(song_url, headers=self.headers)
json = response.json()
path = json["response"]["song"]["path"]
# Gotta go regular html scraping... come on Genius.
page_url = "https://genius.com" + path
try:
page = requests.get(page_url)
except requests.RequestException as exc:
self._log.debug(u'Genius page request for {0} failed: {1}',
page_url, exc)
return None
html = BeautifulSoup(page.text, "html.parser")
# Remove script tags that they put in the middle of the lyrics.
[h.extract() for h in html('script')]
# At least Genius is nice and has a tag called 'lyrics'!
# Updated css where the lyrics are based in HTML.
lyrics = html.find("div", class_="lyrics").get_text()
return lyrics
def fetch(self, artist, title):
"""Fetch lyrics from genius.com
Because genius doesn't allow accesssing lyrics via the api,
we first query the api for a url matching our artist & title,
then attempt to scrape that url for the lyrics.
"""
json = self._search(artist, title)
if not json:
self._log.debug(u'Genius API request returned invalid JSON')
return None
# find a matching artist in the json
for hit in json["response"]["hits"]:
hit_artist = hit["result"]["primary_artist"]["name"]
if slug(hit_artist) == slug(artist):
return self._scrape_lyrics_from_html(
self.fetch_url(hit["result"]["url"]))
self._log.debug(u'Genius failed to find a matching artist for \'{0}\'',
artist)
def _search(self, artist, title):
"""Searches the genius api for a given artist and title
https://docs.genius.com/#search-h2
:returns: json response
"""
search_url = self.base_url + "/search"
data = {'q': title}
data = {'q': title + " " + artist.lower()}
try:
response = requests.get(search_url, data=data,
headers=self.headers)
response = requests.get(
search_url, data=data, headers=self.headers)
except requests.RequestException as exc:
self._log.debug(u'Genius API request failed: {0}', exc)
return None
try:
json = response.json()
return response.json()
except ValueError:
self._log.debug(u'Genius API request returned invalid JSON')
return None
song_info = None
for hit in json["response"]["hits"]:
if hit["result"]["primary_artist"]["name"] == artist:
song_info = hit
break
def _scrape_lyrics_from_html(self, html):
"""Scrape lyrics from a given genius.com html"""
if song_info:
song_api_path = song_info["result"]["api_path"]
return self.lyrics_from_song_api_path(song_api_path)
html = BeautifulSoup(html, "html.parser")
# Remove script tags that they put in the middle of the lyrics.
[h.extract() for h in html('script')]
# Most of the time, the page contains a div with class="lyrics" where
# all of the lyrics can be found already correctly formatted
# Sometimes, though, it packages the lyrics into separate divs, most
# likely for easier ad placement
lyrics_div = html.find("div", class_="lyrics")
if not lyrics_div:
self._log.debug(u'Received unusual song page html')
verse_div = html.find("div",
class_=re.compile("Lyrics__Container"))
if not verse_div:
if html.find("div",
class_=re.compile("LyricsPlaceholder__Message"),
string="This song is an instrumental"):
self._log.debug('Detected instrumental')
return "[Instrumental]"
else:
self._log.debug("Couldn't scrape page using known layouts")
return None
lyrics_div = verse_div.parent
for br in lyrics_div.find_all("br"):
br.replace_with("\n")
ads = lyrics_div.find_all("div",
class_=re.compile("InreadAd__Container"))
for ad in ads:
ad.replace_with("\n")
return lyrics_div.get_text()
class LyricsWiki(SymbolsReplaced):
@ -526,7 +563,7 @@ class Google(Backend):
bad_triggers = ['lyrics', 'copyright', 'property', 'links']
if artist:
bad_triggers_occ += [artist]
bad_triggers += [artist]
for item in bad_triggers:
bad_triggers_occ += [item] * len(re.findall(r'\W%s\W' % item,
@ -744,7 +781,8 @@ class LyricsPlugin(plugins.BeetsPlugin):
write = ui.should_write()
if opts.writerest:
self.writerest_indexes(opts.writerest)
for item in lib.items(ui.decargs(args)):
items = lib.items(ui.decargs(args))
for item in items:
if not opts.local_only and not self.config['local']:
self.fetch_item_lyrics(
lib, item, write,
@ -754,10 +792,10 @@ class LyricsPlugin(plugins.BeetsPlugin):
if opts.printlyr:
ui.print_(item.lyrics)
if opts.writerest:
self.writerest(opts.writerest, item)
if opts.writerest:
# flush last artist
self.writerest(opts.writerest, None)
self.appendrest(opts.writerest, item)
if opts.writerest and items:
# flush last artist & write to ReST
self.writerest(opts.writerest)
ui.print_(u'ReST files generated. to build, use one of:')
ui.print_(u' sphinx-build -b html %s _build/html'
% opts.writerest)
@ -769,26 +807,21 @@ class LyricsPlugin(plugins.BeetsPlugin):
cmd.func = func
return [cmd]
def writerest(self, directory, item):
"""Write the item to an ReST file
def appendrest(self, directory, item):
"""Append the item to an ReST file
This will keep state (in the `rest` variable) in order to avoid
writing continuously to the same files.
"""
if item is None or slug(self.artist) != slug(item.albumartist):
if self.rest is not None:
path = os.path.join(directory, 'artists',
slug(self.artist) + u'.rst')
with open(path, 'wb') as output:
output.write(self.rest.encode('utf-8'))
self.rest = None
if item is None:
return
if slug(self.artist) != slug(item.albumartist):
# Write current file and start a new one ~ item.albumartist
self.writerest(directory)
self.artist = item.albumartist.strip()
self.rest = u"%s\n%s\n\n.. contents::\n :local:\n\n" \
% (self.artist,
u'=' * len(self.artist))
if self.album != item.album:
tmpalbum = self.album = item.album.strip()
if self.album == '':
@ -800,6 +833,15 @@ class LyricsPlugin(plugins.BeetsPlugin):
u'~' * len(title_str),
block)
def writerest(self, directory):
"""Write self.rest to a ReST file
"""
if self.rest is not None and self.artist is not None:
path = os.path.join(directory, 'artists',
slug(self.artist) + u'.rst')
with open(path, 'wb') as output:
output.write(self.rest.encode('utf-8'))
def writerest_indexes(self, directory):
"""Write conf.py and index.rst files necessary for Sphinx
@ -881,7 +923,7 @@ class LyricsPlugin(plugins.BeetsPlugin):
return _scrape_strip_cruft(lyrics, True)
def append_translation(self, text, to_lang):
import xml.etree.ElementTree as ET
from xml.etree import ElementTree
if not self.bing_auth_token:
self.bing_auth_token = self.get_bing_access_token()
@ -899,7 +941,8 @@ class LyricsPlugin(plugins.BeetsPlugin):
self.bing_auth_token = None
return self.append_translation(text, to_lang)
return text
lines_translated = ET.fromstring(r.text.encode('utf-8')).text
lines_translated = ElementTree.fromstring(
r.text.encode('utf-8')).text
# Use a translation mapping dict to build resulting lyrics
translations = dict(zip(text_lines, lines_translated.split('|')))
result = ''

View file

@ -49,7 +49,7 @@ class Amarok(MetaSource):
'amarok_lastplayed': DateType(),
}
queryXML = u'<query version="1.0"> \
query_xml = u'<query version="1.0"> \
<filters> \
<and><include field="filename" value=%s /></and> \
</filters> \
@ -72,7 +72,7 @@ class Amarok(MetaSource):
# of the result set. So query for the filename and then try to match
# the correct item from the results we get back
results = self.collection.Query(
self.queryXML % quoteattr(basename(path))
self.query_xml % quoteattr(basename(path))
)
for result in results:
if result['xesam:url'] != path:

View file

@ -108,8 +108,9 @@ class MPDClientWrapper(object):
return self.get(command, retries=retries - 1)
def currentsong(self):
"""Return the path to the currently playing song. Prefixes paths with the
music_directory, to get the absolute path.
"""Return the path to the currently playing song, along with its
songid. Prefixes paths with the music_directory, to get the absolute
path.
"""
result = None
entry = self.get('currentsong')
@ -118,7 +119,7 @@ class MPDClientWrapper(object):
result = os.path.join(self.music_directory, entry['file'])
else:
result = entry['file']
return result
return result, entry.get('id')
def status(self):
"""Return the current status of the MPD.
@ -240,7 +241,9 @@ class MPDStats(object):
def on_stop(self, status):
self._log.info(u'stop')
if self.now_playing:
# if the current song stays the same it means that we stopped on the
# current track and should not record a skip.
if self.now_playing and self.now_playing['id'] != status.get('songid'):
self.handle_song_change(self.now_playing)
self.now_playing = None
@ -251,7 +254,7 @@ class MPDStats(object):
def on_play(self, status):
path = self.mpd.currentsong()
path, songid = self.mpd.currentsong()
if not path:
return
@ -286,6 +289,7 @@ class MPDStats(object):
'started': time.time(),
'remaining': remaining,
'path': path,
'id': songid,
'beets_item': self.get_item(path),
}

View file

@ -89,10 +89,11 @@ class ParentWorkPlugin(BeetsPlugin):
write = ui.should_write()
for item in lib.items(ui.decargs(args)):
self.find_work(item, force_parent)
item.store()
if write:
item.try_write()
changed = self.find_work(item, force_parent)
if changed:
item.store()
if write:
item.try_write()
command = ui.Subcommand(
'parentwork',
help=u'fetche parent works, composers and dates')
@ -130,6 +131,8 @@ class ParentWorkPlugin(BeetsPlugin):
if artist['type'] == 'composer':
parent_composer.append(artist['artist']['name'])
parent_composer_sort.append(artist['artist']['sort-name'])
if 'end' in artist.keys():
parentwork_info["parentwork_date"] = artist['end']
parentwork_info['parent_composer'] = u', '.join(parent_composer)
parentwork_info['parent_composer_sort'] = u', '.join(
@ -172,13 +175,17 @@ add one at https://musicbrainz.org/recording/{}', item, item.mb_trackid)
return
hasparent = hasattr(item, 'parentwork')
if force or not hasparent:
work_changed = True
if hasattr(item, 'parentwork_workid_current'):
work_changed = item.parentwork_workid_current != item.mb_workid
if force or not hasparent or work_changed:
try:
work_info, work_date = find_parentwork_info(item.mb_workid)
except musicbrainzngs.musicbrainz.WebServiceError as e:
self._log.debug("error fetching work: {}", e)
return
parent_info = self.get_info(item, work_info)
parent_info['parentwork_workid_current'] = item.mb_workid
if 'parent_composer' in parent_info:
self._log.debug("Work fetched: {} - {}",
parent_info['parentwork'],
@ -198,7 +205,8 @@ add one at https://musicbrainz.org/recording/{}', item, item.mb_trackid)
if work_date:
item['work_date'] = work_date
ui.show_model_changes(
return ui.show_model_changes(
item, fields=['parentwork', 'parentwork_disambig',
'mb_parentworkid', 'parent_composer',
'parent_composer_sort', 'work_date'])
'parent_composer_sort', 'work_date',
'parentwork_workid_current', 'parentwork_date'])

View file

@ -71,7 +71,7 @@ class PlaylistQuery(beets.dbcore.Query):
if not self.paths:
# Playlist is empty
return '0', ()
clause = 'path IN ({0})'.format(', '.join('?' for path in self.paths))
clause = 'path IN ({0})'.format(', '.join('?' for path in self.paths))
return clause, (beets.library.BLOB_TYPE(p) for p in self.paths)
def match(self, item):

View file

@ -12,39 +12,49 @@ Put something like the following in your config.yaml to configure:
from __future__ import division, absolute_import, print_function
import requests
import xml.etree.ElementTree as ET
from xml.etree import ElementTree
from six.moves.urllib.parse import urljoin, urlencode
from beets import config
from beets.plugins import BeetsPlugin
def get_music_section(host, port, token, library_name):
def get_music_section(host, port, token, library_name, secure,
ignore_cert_errors):
"""Getting the section key for the music library in Plex.
"""
api_endpoint = append_token('library/sections', token)
url = urljoin('http://{0}:{1}'.format(host, port), api_endpoint)
url = urljoin('{0}://{1}:{2}'.format(get_protocol(secure), host,
port), api_endpoint)
# Sends request.
r = requests.get(url)
r = requests.get(url, verify=not ignore_cert_errors)
# Parse xml tree and extract music section key.
tree = ET.fromstring(r.content)
tree = ElementTree.fromstring(r.content)
for child in tree.findall('Directory'):
if child.get('title') == library_name:
return child.get('key')
def update_plex(host, port, token, library_name):
def update_plex(host, port, token, library_name, secure,
ignore_cert_errors):
"""Ignore certificate errors if configured to.
"""
if ignore_cert_errors:
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
"""Sends request to the Plex api to start a library refresh.
"""
# Getting section key and build url.
section_key = get_music_section(host, port, token, library_name)
section_key = get_music_section(host, port, token, library_name,
secure, ignore_cert_errors)
api_endpoint = 'library/sections/{0}/refresh'.format(section_key)
api_endpoint = append_token(api_endpoint, token)
url = urljoin('http://{0}:{1}'.format(host, port), api_endpoint)
url = urljoin('{0}://{1}:{2}'.format(get_protocol(secure), host,
port), api_endpoint)
# Sends request and returns requests object.
r = requests.get(url)
r = requests.get(url, verify=not ignore_cert_errors)
return r
@ -56,6 +66,13 @@ def append_token(url, token):
return url
def get_protocol(secure):
if secure:
return 'https'
else:
return 'http'
class PlexUpdate(BeetsPlugin):
def __init__(self):
super(PlexUpdate, self).__init__()
@ -65,7 +82,9 @@ class PlexUpdate(BeetsPlugin):
u'host': u'localhost',
u'port': 32400,
u'token': u'',
u'library_name': u'Music'})
u'library_name': u'Music',
u'secure': False,
u'ignore_cert_errors': False})
config['plex']['token'].redact = True
self.register_listener('database_change', self.listen_for_db_change)
@ -85,7 +104,9 @@ class PlexUpdate(BeetsPlugin):
config['plex']['host'].get(),
config['plex']['port'].get(),
config['plex']['token'].get(),
config['plex']['library_name'].get())
config['plex']['library_name'].get(),
config['plex']['secure'].get(bool),
config['plex']['ignore_cert_errors'].get(bool))
self._log.info(u'... started.')
except requests.exceptions.RequestException:

View file

@ -71,6 +71,11 @@ def call(args, **kwargs):
raise ReplayGainError(u"argument encoding failed")
def after_version(version_a, version_b):
return tuple(int(s) for s in version_a.split('.')) \
>= tuple(int(s) for s in version_b.split('.'))
def db_to_lufs(db):
"""Convert db to LUFS.
@ -156,8 +161,12 @@ class Bs1770gainBackend(Backend):
cmd = 'bs1770gain'
try:
call([cmd, "--help"])
version_out = call([cmd, '--version'])
self.command = cmd
self.version = re.search(
'bs1770gain ([0-9]+.[0-9]+.[0-9]+), ',
version_out.stdout.decode('utf-8')
).group(1)
except OSError:
raise FatalReplayGainError(
u'Is bs1770gain installed?'
@ -250,17 +259,23 @@ class Bs1770gainBackend(Backend):
if self.__method != "":
# backward compatibility to `method` option
method = self.__method
gain_adjustment = target_level \
- [k for k, v in self.methods.items() if v == method][0]
elif target_level in self.methods:
method = self.methods[target_level]
gain_adjustment = 0
else:
method = self.methods[-23]
gain_adjustment = target_level - lufs_to_db(-23)
lufs_target = -23
method = self.methods[lufs_target]
gain_adjustment = target_level - lufs_target
# Construct shell command.
cmd = [self.command]
cmd += ["--" + method]
cmd += ['--xml', '-p']
if after_version(self.version, '0.6.0'):
cmd += ['--unit=ebu'] # set units to LU
cmd += ['--suppress-progress'] # don't print % to XML output
# Workaround for Windows: the underlying tool fails on paths
# with the \\?\ prefix, so we don't use it here. This
@ -295,6 +310,7 @@ class Bs1770gainBackend(Backend):
album_gain = {} # mutable variable so it can be set from handlers
parser = xml.parsers.expat.ParserCreate(encoding='utf-8')
state = {'file': None, 'gain': None, 'peak': None}
album_state = {'gain': None, 'peak': None}
def start_element_handler(name, attrs):
if name == u'track':
@ -303,9 +319,13 @@ class Bs1770gainBackend(Backend):
raise ReplayGainError(
u'duplicate filename in bs1770gain output')
elif name == u'integrated':
state['gain'] = float(attrs[u'lu'])
if 'lu' in attrs:
state['gain'] = float(attrs[u'lu'])
elif name == u'sample-peak':
state['peak'] = float(attrs[u'factor'])
if 'factor' in attrs:
state['peak'] = float(attrs[u'factor'])
elif 'amplitude' in attrs:
state['peak'] = float(attrs[u'amplitude'])
def end_element_handler(name):
if name == u'track':
@ -321,6 +341,17 @@ class Bs1770gainBackend(Backend):
'the output of bs1770gain')
album_gain["album"] = Gain(state['gain'], state['peak'])
state['gain'] = state['peak'] = None
elif len(per_file_gain) == len(path_list):
if state['gain'] is not None:
album_state['gain'] = state['gain']
if state['peak'] is not None:
album_state['peak'] = state['peak']
if album_state['gain'] is not None \
and album_state['peak'] is not None:
album_gain["album"] = Gain(
album_state['gain'], album_state['peak'])
state['gain'] = state['peak'] = None
parser.StartElementHandler = start_element_handler
parser.EndElementHandler = end_element_handler
@ -592,7 +623,7 @@ class FfmpegBackend(Backend):
return float(value)
except ValueError:
raise ReplayGainError(
u"ffmpeg output: expected float value, found {1}"
u"ffmpeg output: expected float value, found {0}"
.format(value)
)
@ -1336,10 +1367,10 @@ class ReplayGainPlugin(BeetsPlugin):
if (any([self.should_use_r128(item) for item in album.items()]) and not
all(([self.should_use_r128(item) for item in album.items()]))):
raise ReplayGainError(
u"Mix of ReplayGain and EBU R128 detected"
u" for some tracks in album {0}".format(album)
)
self._log.error(
u"Cannot calculate gain for album {0} (incompatible formats)",
album)
return
self._log.info(u'analyzing {0}', album)

View file

@ -105,17 +105,18 @@ class SmartPlaylistPlugin(BeetsPlugin):
playlist_data = (playlist['name'],)
try:
for key, Model in (('query', Item), ('album_query', Album)):
for key, model_cls in (('query', Item),
('album_query', Album)):
qs = playlist.get(key)
if qs is None:
query_and_sort = None, None
elif isinstance(qs, six.string_types):
query_and_sort = parse_query_string(qs, Model)
query_and_sort = parse_query_string(qs, model_cls)
elif len(qs) == 1:
query_and_sort = parse_query_string(qs[0], Model)
query_and_sort = parse_query_string(qs[0], model_cls)
else:
# multiple queries and sorts
queries, sorts = zip(*(parse_query_string(q, Model)
queries, sorts = zip(*(parse_query_string(q, model_cls)
for q in qs))
query = OrQuery(queries)
final_sorts = []

View file

@ -0,0 +1,173 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2019, Joris Jensen
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
from __future__ import absolute_import, division, print_function
import random
import string
from xml.etree import ElementTree
from hashlib import md5
from urllib.parse import urlencode
import requests
from beets.dbcore import AndQuery
from beets.dbcore.query import MatchQuery
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
__author__ = 'https://github.com/MrNuggelz'
def filter_to_be_removed(items, keys):
if len(items) > len(keys):
dont_remove = []
for artist, album, title in keys:
for item in items:
if artist == item['artist'] and \
album == item['album'] and \
title == item['title']:
dont_remove.append(item)
return [item for item in items if item not in dont_remove]
else:
def to_be_removed(item):
for artist, album, title in keys:
if artist == item['artist'] and\
album == item['album'] and\
title == item['title']:
return False
return True
return [item for item in items if to_be_removed(item)]
class SubsonicPlaylistPlugin(BeetsPlugin):
def __init__(self):
super(SubsonicPlaylistPlugin, self).__init__()
self.config.add(
{
'delete': False,
'playlist_ids': [],
'playlist_names': [],
'username': '',
'password': ''
}
)
self.config['password'].redact = True
def update_tags(self, playlist_dict, lib):
with lib.transaction():
for query, playlist_tag in playlist_dict.items():
query = AndQuery([MatchQuery("artist", query[0]),
MatchQuery("album", query[1]),
MatchQuery("title", query[2])])
items = lib.items(query)
if not items:
self._log.warn(u"{} | track not found ({})", playlist_tag,
query)
continue
for item in items:
item.subsonic_playlist = playlist_tag
item.try_sync(write=True, move=False)
def get_playlist(self, playlist_id):
xml = self.send('getPlaylist', {'id': playlist_id}).text
playlist = ElementTree.fromstring(xml)[0]
if playlist.attrib.get('code', '200') != '200':
alt_error = 'error getting playlist, but no error message found'
self._log.warn(playlist.attrib.get('message', alt_error))
return
name = playlist.attrib.get('name', 'undefined')
tracks = [(t.attrib['artist'], t.attrib['album'], t.attrib['title'])
for t in playlist]
return name, tracks
def commands(self):
def build_playlist(lib, opts, args):
self.config.set_args(opts)
ids = self.config['playlist_ids'].as_str_seq()
if self.config['playlist_names'].as_str_seq():
playlists = ElementTree.fromstring(
self.send('getPlaylists').text)[0]
if playlists.attrib.get('code', '200') != '200':
alt_error = 'error getting playlists,' \
' but no error message found'
self._log.warn(
playlists.attrib.get('message', alt_error))
return
for name in self.config['playlist_names'].as_str_seq():
for playlist in playlists:
if name == playlist.attrib['name']:
ids.append(playlist.attrib['id'])
playlist_dict = self.get_playlists(ids)
# delete old tags
if self.config['delete']:
existing = list(lib.items('subsonic_playlist:";"'))
to_be_removed = filter_to_be_removed(
existing,
playlist_dict.keys())
for item in to_be_removed:
item['subsonic_playlist'] = ''
with lib.transaction():
item.try_sync(write=True, move=False)
self.update_tags(playlist_dict, lib)
subsonicplaylist_cmds = Subcommand(
'subsonicplaylist', help=u'import a subsonic playlist'
)
subsonicplaylist_cmds.parser.add_option(
u'-d',
u'--delete',
action='store_true',
help=u'delete tag from items not in any playlist anymore',
)
subsonicplaylist_cmds.func = build_playlist
return [subsonicplaylist_cmds]
def generate_token(self):
salt = ''.join(random.choices(string.ascii_lowercase + string.digits))
return md5(
(self.config['password'].get() + salt).encode()).hexdigest(), salt
def send(self, endpoint, params=None):
if params is None:
params = dict()
a, b = self.generate_token()
params['u'] = self.config['username']
params['t'] = a
params['s'] = b
params['v'] = '1.12.0'
params['c'] = 'beets'
resp = requests.get('{}/rest/{}?{}'.format(
self.config['base_url'].get(),
endpoint,
urlencode(params))
)
return resp
def get_playlists(self, ids):
output = dict()
for playlist_id in ids:
name, tracks = self.get_playlist(playlist_id)
for track in tracks:
if track not in output:
output[track] = ';'
output[track] += name + ';'
return output

View file

@ -17,11 +17,9 @@
Your Beets configuration file should contain
a "subsonic" section like the following:
subsonic:
host: 192.168.x.y (Subsonic server IP)
port: 4040 (default)
user: <your username>
pass: <your password>
contextpath: /subsonic
url: https://mydomain.com:443/subsonic
user: username
pass: password
"""
from __future__ import division, absolute_import, print_function
@ -37,68 +35,65 @@ from beets.plugins import BeetsPlugin
__author__ = 'https://github.com/maffo999'
def create_token():
"""Create salt and token from given password.
:return: The generated salt and hashed token
"""
password = config['subsonic']['pass'].as_str()
# Pick the random sequence and salt the password
r = string.ascii_letters + string.digits
salt = "".join([random.choice(r) for _ in range(6)])
salted_password = password + salt
token = hashlib.md5().update(salted_password.encode('utf-8')).hexdigest()
# Put together the payload of the request to the server and the URL
return salt, token
def format_url():
"""Get the Subsonic URL to trigger a scan. Uses either the url
config option or the deprecated host, port, and context_path config
options together.
:return: Endpoint for updating Subsonic
"""
url = config['subsonic']['url'].as_str()
if url and url.endsWith('/'):
url = url[:-1]
# @deprecated("Use url config option instead")
if not url:
host = config['subsonic']['host'].as_str()
port = config['subsonic']['port'].get(int)
context_path = config['subsonic']['contextpath'].as_str()
if context_path == '/':
context_path = ''
url = "http://{}:{}{}".format(host, port, context_path)
return url + '/rest/startScan'
class SubsonicUpdate(BeetsPlugin):
def __init__(self):
super(SubsonicUpdate, self).__init__()
# Set default configuration values
config['subsonic'].add({
'host': 'localhost',
'port': '4040',
'user': 'admin',
'pass': 'admin',
'contextpath': '/',
'url': 'http://localhost:4040',
})
config['subsonic']['pass'].redact = True
self.register_listener('import', self.start_scan)
@staticmethod
def __create_token():
"""Create salt and token from given password.
:return: The generated salt and hashed token
"""
password = config['subsonic']['pass'].as_str()
# Pick the random sequence and salt the password
r = string.ascii_letters + string.digits
salt = "".join([random.choice(r) for _ in range(6)])
salted_password = password + salt
token = hashlib.md5(salted_password.encode('utf-8')).hexdigest()
# Put together the payload of the request to the server and the URL
return salt, token
@staticmethod
def __format_url():
"""Get the Subsonic URL to trigger a scan. Uses either the url
config option or the deprecated host, port, and context_path config
options together.
:return: Endpoint for updating Subsonic
"""
url = config['subsonic']['url'].as_str()
if url and url.endswith('/'):
url = url[:-1]
# @deprecated("Use url config option instead")
if not url:
host = config['subsonic']['host'].as_str()
port = config['subsonic']['port'].get(int)
context_path = config['subsonic']['contextpath'].as_str()
if context_path == '/':
context_path = ''
url = "http://{}:{}{}".format(host, port, context_path)
return url + '/rest/startScan'
def start_scan(self):
user = config['subsonic']['user'].as_str()
url = format_url()
salt, token = create_token()
url = self.__format_url()
salt, token = self.__create_token()
payload = {
'u': user,

View file

@ -23,7 +23,7 @@ from beets.plugins import BeetsPlugin
__author__ = 'baobab@heresiarch.info'
__version__ = '1.1'
PATTERN_THE = u'^[the]{3}\\s'
PATTERN_THE = u'^the\\s'
PATTERN_A = u'^[a][n]?\\s'
FORMAT = u'{0}, {1}'

View file

@ -224,7 +224,7 @@ class PathlibURI(URIGetter):
name = "Python Pathlib"
def uri(self, path):
return PurePosixPath(path).as_uri()
return PurePosixPath(util.py3_path(path)).as_uri()
def copy_c_string(c_string):

View file

@ -169,7 +169,7 @@ class IdListConverter(BaseConverter):
return ids
def to_url(self, value):
return ','.join(value)
return ','.join(str(v) for v in value)
class QueryConverter(PathConverter):
@ -177,10 +177,11 @@ class QueryConverter(PathConverter):
"""
def to_python(self, value):
return value.split('/')
queries = value.split('/')
return [query.replace('\\', os.sep) for query in queries]
def to_url(self, value):
return ','.join(value)
return ','.join([v.replace(os.sep, '\\') for v in value])
class EverythingConverter(PathConverter):

3
docs/contributing.rst Normal file
View file

@ -0,0 +1,3 @@
.. contributing:
.. include:: ../CONTRIBUTING.rst

View file

@ -32,6 +32,7 @@ Contents
reference/index
plugins/index
faq
contributing
dev/index
.. toctree::

View file

@ -96,7 +96,9 @@ Usage
Once you have all the dependencies sorted out, enable the ``chroma`` plugin in
your configuration (see :ref:`using-plugins`) to benefit from fingerprinting
the next time you run ``beet import``.
the next time you run ``beet import``. (The plugin doesn't produce any obvious
output by default. If you want to confirm that it's enabled, you can try
running in verbose mode once with ``beet -v import``.)
You can also use the ``beet fingerprint`` command to generate fingerprints for
items already in your library. (Provide a query to fingerprint a subset of your

View file

@ -111,6 +111,8 @@ file. The available options are:
This option overrides ``link``. Only works when converting to a directory
on the same filesystem as the library.
Default: ``false``.
- **delete_originals**: Transcoded files will be copied or moved to their destination, depending on the import configuration. By default, the original files are not modified by the plugin. This option deletes the original files after the transcoding step has completed.
Default: ``false``.
You can also configure the format to use for transcoding (see the next
section):

View file

@ -58,6 +58,13 @@ file. The available options are:
the aspect ratio is preserved. See also :ref:`image-resizing` for further
caveats about image resizing.
Default: 0 (disabled).
- **quality**: The JPEG quality level to use when compressing images (when
``maxwidth`` is set). This should be either a number from 1 to 100 or 0 to
use the default quality. 6575 is usually a good starting point. The default
behavior depends on the imaging tool used for scaling: ImageMagick tries to
estimate the input image quality and uses 92 if it cannot be determined, and
PIL defaults to 75.
Default: 0 (disabled)
- **remove_art_file**: Automatically remove the album art file for the album
after it has been embedded. This option is best used alongside the
:doc:`FetchArt </plugins/fetchart>` plugin to download art with the purpose of

View file

@ -11,7 +11,7 @@ To use the ``fetchart`` plugin, first enable it in your configuration (see
The plugin uses `requests`_ to fetch album art from the Web.
.. _requests: https://docs.python-requests.org/en/latest/
.. _requests: https://requests.readthedocs.io/en/master/
Fetching Album Art During Import
--------------------------------
@ -42,6 +42,13 @@ file. The available options are:
- **maxwidth**: A maximum image width to downscale fetched images if they are
too big. The resize operation reduces image width to at most ``maxwidth``
pixels. The height is recomputed so that the aspect ratio is preserved.
- **quality**: The JPEG quality level to use when compressing images (when
``maxwidth`` is set). This should be either a number from 1 to 100 or 0 to
use the default quality. 6575 is usually a good starting point. The default
behavior depends on the imaging tool used for scaling: ImageMagick tries to
estimate the input image quality and uses 92 if it cannot be determined, and
PIL defaults to 75.
Default: 0 (disabled)
- **enforce_ratio**: Only images with a width:height ratio of 1:1 are
considered as valid album art candidates if set to ``yes``.
It is also possible to specify a certain deviation to the exact ratio to
@ -51,9 +58,9 @@ file. The available options are:
- **sources**: List of sources to search for images. An asterisk `*` expands
to all available sources.
Default: ``filesystem coverart itunes amazon albumart``, i.e., everything but
``wikipedia``, ``google`` and ``fanarttv``. Enable those sources for more
matches at the cost of some speed. They are searched in the given order,
thus in the default config, no remote (Web) art source are queried if
``wikipedia``, ``google``, ``fanarttv`` and ``lastfm``. Enable those sources
for more matches at the cost of some speed. They are searched in the given
order, thus in the default config, no remote (Web) art source are queried if
local art is found in the filesystem. To use a local image as fallback,
move it to the end of the list. For even more fine-grained control over
the search order, see the section on :ref:`album-art-sources` below.
@ -64,6 +71,8 @@ file. The available options are:
Default: The `beets custom search engine`_, which searches the entire web.
- **fanarttv_key**: The personal API key for requesting art from
fanart.tv. See below.
- **lastfm_key**: The personal API key for requesting art from Last.fm. See
below.
- **store_source**: If enabled, fetchart stores the artwork's source in a
flexible tag named ``art_source``. See below for the rationale behind this.
Default: ``no``.
@ -117,8 +126,9 @@ art::
$ beet fetchart [-q] [query]
By default the command will display all results, the ``-q`` or ``--quiet``
switch will only display results for album arts that are still missing.
By default the command will display all albums matching the ``query``. When the
``-q`` or ``--quiet`` switch is given, only albums for which artwork has been
fetched, or for which artwork could not be found will be printed.
.. _image-resizing:
@ -214,6 +224,15 @@ personal key will give you earlier access to new art.
.. _on their blog: https://fanart.tv/2015/01/personal-api-keys/
Last.fm
'''''''
To use the Last.fm backend, you need to `register for a Last.fm API key`_. Set
the ``lastfm_key`` configuration option to your API key, then add ``lastfm`` to
the list of sources in your configutation.
.. _register for a Last.fm API key: https://www.last.fm/api/account/create
Storing the Artwork's Source
----------------------------

52
docs/plugins/fish.rst Normal file
View file

@ -0,0 +1,52 @@
Fish Plugin
===========
The ``fish`` plugin adds a ``beet fish`` command that creates a `Fish shell`_
tab-completion file named ``beet.fish`` in ``~/.config/fish/completions``.
This enables tab-completion of ``beet`` commands for the `Fish shell`_.
.. _Fish shell: https://fishshell.com/
Configuration
-------------
Enable the ``fish`` plugin (see :ref:`using-plugins`) on a system running the
`Fish shell`_.
Usage
-----
Type ``beet fish`` to generate the ``beet.fish`` completions file at:
``~/.config/fish/completions/``. If you later install or disable plugins, run
``beet fish`` again to update the completions based on the enabled plugins.
For users not accustomed to tab completion… After you type ``beet`` followed by
a space in your shell prompt and then the ``TAB`` key, you should see a list of
the beets commands (and their abbreviated versions) that can be invoked in your
current environment. Similarly, typing ``beet -<TAB>`` will show you all the
option flags available to you, which also applies to subcommands such as
``beet import -<TAB>``. If you type ``beet ls`` followed by a space and then the
and the ``TAB`` key, you will see a list of all the album/track fields that can
be used in beets queries. For example, typing ``beet ls ge<TAB>`` will complete
to ``genre:`` and leave you ready to type the rest of your query.
Options
-------
In addition to beets commands, plugin commands, and option flags, the generated
completions also include by default all the album/track fields. If you only want
the former and do not want the album/track fields included in the generated
completions, use ``beet fish -f`` to only generate completions for beets/plugin
commands and option flags.
If you want generated completions to also contain album/track field *values* for
the items in your library, you can use the ``-e`` or ``--extravalues`` option.
For example: ``beet fish -e genre`` or ``beet fish -e genre -e albumartist``
In the latter case, subsequently typing ``beet list genre: <TAB>`` will display
a list of all the genres in your library and ``beet list albumartist: <TAB>``
will show a list of the album artists in your library. Keep in mind that all of
these values will be put into the generated completions file, so use this option
with care when specified fields contain a large number of values. Libraries with,
for example, very large numbers of genres/artists may result in higher memory
utilization, completion latency, et cetera. This option is not meant to replace
database queries altogether.

View file

@ -78,6 +78,7 @@ following to your configuration::
export
fetchart
filefilter
fish
freedesktop
fromfilename
ftintitle
@ -115,6 +116,7 @@ following to your configuration::
smartplaylist
sonosupdate
spotify
subsonicplaylist
subsonicupdate
the
thumbnails
@ -184,6 +186,7 @@ Interoperability
* :doc:`badfiles`: Check audio file integrity.
* :doc:`embyupdate`: Automatically notifies `Emby`_ whenever the beets library changes.
* :doc:`fish`: Adds `Fish shell`_ tab autocompletion to ``beet`` commands.
* :doc:`importfeeds`: Keep track of imported files via ``.m3u`` playlist file(s) or symlinks.
* :doc:`ipfs`: Import libraries from friends and get albums from them via ipfs.
* :doc:`kodiupdate`: Automatically notifies `Kodi`_ whenever the beets library
@ -203,6 +206,7 @@ Interoperability
.. _Emby: https://emby.media
.. _Fish shell: https://fishshell.com/
.. _Plex: https://plex.tv
.. _Kodi: https://kodi.tv
.. _Sonos: https://sonos.com
@ -297,7 +301,26 @@ Here are a few of the plugins written by the beets community:
* `beet-summarize`_ can compute lots of counts and statistics about your music
library.
* `beets-mosaic`_ generates a montage of a mosiac from cover art.
* `beets-mosaic`_ generates a montage of a mosaic from cover art.
* `beets-goingrunning`_ generates playlists to go with your running sessions.
* `beets-xtractor`_ extracts low- and high-level musical information from your songs.
* `beets-yearfixer`_ attempts to fix all missing ``original_year`` and ``year`` fields.
* `beets-autofix`_ automates repetitive tasks to keep your library in order.
* `beets-describe`_ gives you the full picture of a single attribute of your library items.
* `beets-bpmanalyser`_ analyses songs and calculates their tempo (BPM).
* `beets-originquery`_ augments MusicBrainz queries with locally-sourced data
to improve autotagger results.
* `drop2beets`_ automatically imports singles as soon as they are dropped in a
folder (using Linux's ``inotify``). You can also set a sub-folders
hierarchy to set flexible attributes by the way.
.. _beets-barcode: https://github.com/8h2a/beets-barcode
.. _beets-check: https://github.com/geigerzaehler/beets-check
@ -321,3 +344,11 @@ Here are a few of the plugins written by the beets community:
.. _beets-ydl: https://github.com/vmassuchetto/beets-ydl
.. _beet-summarize: https://github.com/steven-murray/beet-summarize
.. _beets-mosaic: https://github.com/SusannaMaria/beets-mosaic
.. _beets-goingrunning: https://pypi.org/project/beets-goingrunning
.. _beets-xtractor: https://github.com/adamjakab/BeetsPluginXtractor
.. _beets-yearfixer: https://github.com/adamjakab/BeetsPluginYearFixer
.. _beets-autofix: https://github.com/adamjakab/BeetsPluginAutofix
.. _beets-describe: https://github.com/adamjakab/BeetsPluginDescribe
.. _beets-bpmanalyser: https://github.com/adamjakab/BeetsPluginBpmAnalyser
.. _beets-originquery: https://github.com/x1ppy/beets-originquery
.. _drop2beets: https://github.com/martinkirch/drop2beets

View file

@ -35,8 +35,8 @@ configuration file. There is one option available:
- **auto**: Automatically amend your MusicBrainz collection whenever you
import a new album.
Default: ``no``.
- **collection**: Which MusicBrainz collection to update.
- **collection**: The MBID of which MusicBrainz collection to update.
Default: ``None``.
- **remove**: Remove albums from collections which are no longer
present in the beets database.
Default: ``None``.
Default: ``no``.

View file

@ -18,15 +18,27 @@ example, all the movements of a symphony. This plugin aims to solve this
problem by also fetching the parent work, which would be the whole symphony in
this example.
This plugin adds five tags:
The plugin can detect changes in ``mb_workid`` so it knows when to re-fetch
other metadata, such as ``parentwork``. To do this, when it runs, it stores a
copy of ``mb_workid`` in the bookkeeping field ``parentwork_workid_current``.
At any later run of ``beet parentwork`` it will check if the tags
``mb_workid`` and ``parentwork_workid_current`` are still identical. If it is
not the case, it means the work has changed and all the tags need to be
fetched again.
This plugin adds seven tags:
- **parentwork**: The title of the parent work.
- **mb_parentworkid**: The musicbrainz id of the parent work.
- **mb_parentworkid**: The MusicBrainz id of the parent work.
- **parentwork_disambig**: The disambiguation of the parent work title.
- **parent_composer**: The composer of the parent work.
- **parent_composer_sort**: The sort name of the parent work composer.
- **work_date**: The composition date of the work, or the first parent work
that has a composition date. Format: yyyy-mm-dd.
- **parentwork_workid_current**: The MusicBrainz id of the work as it was when
the parentwork was retrieved. This tag exists only for internal bookkeeping,
to keep track of recordings whose works have changed.
- **parentwork_date**: The composition date of the parent work.
To use the ``parentwork`` plugin, enable it in your configuration (see
:ref:`using-plugins`).
@ -38,10 +50,11 @@ To configure the plugin, make a ``parentwork:`` section in your
configuration file. The available options are:
- **force**: As a default, ``parentwork`` only fetches work info for
recordings that do not already have a ``parentwork`` tag. If ``force``
recordings that do not already have a ``parentwork`` tag or where
``mb_workid`` differs from ``parentwork_workid_current``. If ``force``
is enabled, it fetches it for all recordings.
Default: ``no``
- **auto**: If enabled, automatically fetches works at import. It takes quite
some time, because beets is restricted to one musicbrainz query per second.
some time, because beets is restricted to one MusicBrainz query per second.
Default: ``no``

View file

@ -41,3 +41,7 @@ The available options under the ``plex:`` section are:
Default: Empty.
- **library_name**: The name of the Plex library to update.
Default: ``Music``
- **secure**: Use secure connections to the Plex server.
Default: ``False``
- **ignore_cert_errors**: Ignore TLS certificate errors when using secure connections.
Default: ``False``

View file

@ -0,0 +1,43 @@
Subsonic Playlist Plugin
========================
The ``subsonicplaylist`` plugin allows to import playlists from a subsonic server.
This is done by retrieving the track info from the subsonic server, searching
for them in the beets library, and adding the playlist names to the
`subsonic_playlist` tag of the found items. The content of the tag has the format:
subsonic_playlist: ";first playlist;second playlist;"
To get all items in a playlist use the query `;playlist name;`.
Command Line Usage
------------------
To use the ``subsonicplaylist`` plugin, enable it in your configuration (see
:ref:`using-plugins`). Then use it by invoking the ``subsonicplaylist`` command.
Next, configure the plugin to connect to your Subsonic server, like this::
subsonicplaylist:
base_url: http://subsonic.example.com
username: someUser
password: somePassword
After this you can import your playlists by invoking the `subsonicplaylist` command.
By default only the tags of the items found for playlists will be updated.
This means that, if one imported a playlist, then delete one song from it and
imported the playlist again, the deleted song will still have the playlist set
in its `subsonic_playlist` tag. To solve this problem one can use the `-d/--delete`
flag. This resets all `subsonic_playlist` tag before importing playlists.
Here's an example configuration with all the available options and their default values::
subsonicplaylist:
base_url: "https://your.subsonic.server"
delete: no
playlist_ids: []
playlist_names: []
username: ''
password: ''
The `base_url`, `username`, and `password` options are required.

View file

@ -210,7 +210,8 @@ If the server runs UNIX, you'll need to include an extra leading slash:
``GET /item/query/querystring``
+++++++++++++++++++++++++++++++
Returns a list of tracks matching the query. The *querystring* must be a valid query as described in :doc:`/reference/query`. ::
Returns a list of tracks matching the query. The *querystring* must be a
valid query as described in :doc:`/reference/query`. ::
{
"results": [
@ -219,6 +220,11 @@ Returns a list of tracks matching the query. The *querystring* must be a valid q
]
}
Path elements are joined as parts of a query. For example,
``/item/query/foo/bar`` will be converted to the query ``foo,bar``.
To specify literal path separators in a query, use a backslash instead of a
slash.
``GET /item/6/file``
++++++++++++++++++++

View file

@ -212,7 +212,7 @@ The ``-p`` option makes beets print out filenames of matched items, which might
be useful for piping into other Unix commands (such as `xargs`_). Similarly, the
``-f`` option lets you specify a specific format with which to print every album
or track. This uses the same template syntax as beets' :doc:`path formats
<pathformat>`. For example, the command ``beet ls -af '$album: $tracktotal'
<pathformat>`. For example, the command ``beet ls -af '$album: $albumtotal'
beatles`` prints out the number of tracks on each Beatles album. In Unix shells,
remember to enclose the template argument in single quotes to avoid environment
variable expansion.

View file

@ -701,6 +701,26 @@ MusicBrainz server.
Default: ``5``.
.. _extra_tags:
extra_tags
~~~~~~~~~~
By default, beets will use only the artist, album, and track count to query
MusicBrainz. Additional tags to be queried can be supplied with the
``extra_tags`` setting. For example::
musicbrainz:
extra_tags: [year, catalognum, country, media, label]
This setting should improve the autotagger results if the metadata with the
given tags match the metadata returned by MusicBrainz.
Note that the only tags supported by this setting are the ones listed in the
above example.
Default: ``[]``
.. _match-config:
Autotagger Matching Options

225
setup.cfg
View file

@ -1,23 +1,204 @@
[nosetests]
verbosity=1
logging-clear-handlers=1
[flake8]
min-version=2.7
accept-encodings=utf-8
# Errors we ignore:
# - E121,E123,E126,E24,E704,W503,W504 flake8 default ignores, excluding E226 (have to be listed here to not be overridden)
# - E221: multiple spaces before operator (used to align visually)
# - E731: do not assign a lambda expression, use a def
# - F405 object may be undefined, or defined from star imports
# - C901: function/method complexity
# - E305: spacing after a declaration (might be nice to change eventually)
# `flake8-future-import` errors we ignore:
# - FI50: `__future__` import "division" present
# - FI51: `__future__` import "absolute_import" present
# - FI12: `__future__` import "with_statement" missing
# - FI53: `__future__` import "print_function" present
# - FI14: `__future__` import "unicode_literals" missing
# - FI15: `__future__` import "generator_stop" missing
# - E741: ambiguous variable name
ignore=E121,E123,E126,E24,E704,W503,W504,E305,C901,E221,E731,F405,FI50,FI51,FI12,FI53,FI14,FI15,E741
min-version = 2.7
accept-encodings = utf-8
docstring-convention = google
# errors we ignore; see https://www.flake8rules.com/ for more info
ignore =
# pycodestyle errors
E121, # continuation line under-indented for hanging indent
E123, # closing bracket does not match indentation of opening bracket's line
E126, # continuation line over-indented for hangin indent
E241, # multiple spaces after non-arithmetic operators (for vertical alignment)
E305, # expected 2 blank lines after end of function or class
E731, # do not assign a lamba expression, use a def
E741, # do not use variables name 'I', 'O', or 'l'
# pycodestyle warnings
W503, # line break occurred before a binary operator
W504, # line break occurred after a binary operator
# pyflakes errors
F405, # name be undefined, or defined from star imports: module
# mccabe error
C901, # function is too complex
# future-import errors
FI12, # `__future__` import "with_statement" missing
FI14, # `__future__` import "unicode_literals" missing
FI15, # `__future__` import "generator_stop" missing
FI50, # `__future__` import "division" present
FI51, # `__future__` import "absolute_import" present
FI53, # `__future__` import "print_function" present
per-file-ignores =
./beet:D
./docs/conf.py:D
./extra/release.py:D
./beetsplug/duplicates.py:D
./beetsplug/bpm.py:D
./beetsplug/convert.py:D
./beetsplug/info.py:D
./beetsplug/parentwork.py:D
./beetsplug/deezer.py:D
./beetsplug/smartplaylist.py:D
./beetsplug/absubmit.py:D
./beetsplug/subsonicupdate.py:D
./beetsplug/fromfilename.py:D
./beetsplug/mpdstats.py:D
./beetsplug/gmusic.py:D
./beetsplug/subsonicplaylist.py:D
./beetsplug/rewrite.py:D
./beetsplug/hook.py:D
./beetsplug/playlist.py:D
./beetsplug/ftintitle.py:D
./beetsplug/bpd/gstplayer.py:D
./beetsplug/bpd/__init__.py:D
./beetsplug/scrub.py:D
./beetsplug/sonosupdate.py:D
./beetsplug/embyupdate.py:D
./beetsplug/plexupdate.py:D
./beetsplug/mbsync.py:D
./beetsplug/lyrics.py:D
./beetsplug/inline.py:D
./beetsplug/freedesktop.py:D
./beetsplug/acousticbrainz.py:D
./beetsplug/beatport.py:D
./beetsplug/cue.py:D
./beetsplug/thumbnails.py:D
./beetsplug/random.py:D
./beetsplug/loadext.py:D
./beetsplug/replaygain.py:D
./beetsplug/export.py:D
./beetsplug/fuzzy.py:D
./beetsplug/importadded.py:D
./beetsplug/web/__init__.py:D
./beetsplug/bucket.py:D
./beetsplug/the.py:D
./beetsplug/ihate.py:D
./beetsplug/bench.py:D
./beetsplug/permissions.py:D
./beetsplug/spotify.py:D
./beetsplug/lastgenre/__init__.py:D
./beetsplug/mbcollection.py:D
./beetsplug/metasync/amarok.py:D
./beetsplug/metasync/itunes.py:D
./beetsplug/metasync/__init__.py:D
./beetsplug/importfeeds.py:D
./beetsplug/kodiupdate.py:D
./beetsplug/zero.py:D
./beetsplug/bpsync.py:D
./beetsplug/__init__.py:D
./beetsplug/edit.py:D
./beetsplug/types.py:D
./beetsplug/embedart.py:D
./beetsplug/mpdupdate.py:D
./beetsplug/ipfs.py:D
./beetsplug/discogs.py:D
./beetsplug/chroma.py:D
./beetsplug/fish.py:D
./beetsplug/missing.py:D
./beetsplug/fetchart.py:D
./beetsplug/mbsubmit.py:D
./beetsplug/filefilter.py:D
./beetsplug/badfiles.py:D
./beetsplug/play.py:D
./beetsplug/keyfinder.py:D
./beetsplug/unimported.py:D
./beetsplug/lastimport.py:D
./test/test_parentwork.py:D
./test/test_hook.py:D
./test/test_keyfinder.py:D
./test/test_util.py:D
./test/test_plexupdate.py:D
./test/test_importfeeds.py:D
./test/test_discogs.py:D
./test/test_acousticbrainz.py:D
./test/test_pipeline.py:D
./test/test_mb.py:D
./test/test_playlist.py:D
./test/helper.py:D
./test/test_player.py:D
./test/test_template.py:D
./test/test_web.py:D
./test/test_replaygain.py:D
./test/test_hidden.py:D
./test/test_info.py:D
./test/test_dbcore.py:D
./test/test_vfs.py:D
./test/test_subsonic.py:D
./test/test_play.py:D
./test/test_types_plugin.py:D
./test/test_plugins.py:D
./test/test_importer.py:D
./test/test_smartplaylist.py:D
./test/test_spotify.py:D
./test/test_metasync.py:D
./test/test_bucket.py:D
./test/test_ftintitle.py:D
./test/lyrics_download_samples.py:D
./test/test_convert.py:D
./test/test_mbsubmit.py:D
./test/testall.py:D
./test/test_fetchart.py:D
./test/test_ui_importer.py:D
./test/test_mbsync.py:D
./test/test_art.py:D
./test/test_permissions.py:D
./test/test_embedart.py:D
./test/test_the.py:D
./test/test_export.py:D
./test/rsrc/beetsplug/test.py:D
./test/rsrc/convert_stub.py:D
./test/test_ui_init.py:D
./test/test_filefilter.py:D
./test/test_logging.py:D
./test/test_thumbnails.py:D
./test/test_ipfs.py:D
./test/test_autotag.py:D
./test/__init__.py:D
./test/test_plugin_mediafield.py:D
./test/test_files.py:D
./test/test_lastgenre.py:D
./test/_common.py:D
./test/test_zero.py:D
./test/test_edit.py:D
./test/test_ihate.py:D
./test/test_ui.py:D
./test/test_mpdstats.py:D
./test/test_importadded.py:D
./test/test_query.py:D
./test/test_sort.py:D
./test/test_library.py:D
./test/test_ui_commands.py:D
./test/test_lyrics.py:D
./test/test_beatport.py:D
./test/test_random.py:D
./test/test_embyupdate.py:D
./test/test_datequery.py:D
./test/test_config_command.py:D
./setup.py:D
./beets/ui/__init__.py:D
./beets/ui/commands.py:D
./beets/autotag/mb.py:D
./beets/autotag/hooks.py:D
./beets/autotag/__init__.py:D
./beets/autotag/match.py:D
./beets/__main__.py:D
./beets/importer.py:D
./beets/plugins.py:D
./beets/util/bluelet.py:D
./beets/util/enumeration.py:D
./beets/util/artresizer.py:D
./beets/util/functemplate.py:D
./beets/util/confit.py:D
./beets/util/pipeline.py:D
./beets/util/hidden.py:D
./beets/util/__init__.py:D
./beets/library.py:D
./beets/random.py:D
./beets/art.py:D
./beets/logging.py:D
./beets/vfs.py:D
./beets/__init__.py:D
./beets/dbcore/query.py:D
./beets/dbcore/db.py:D
./beets/dbcore/__init__.py:D
./beets/dbcore/queryparse.py:D
./beets/dbcore/types.py:D
./beets/mediafile.py:D

View file

@ -109,23 +109,35 @@ setup(
['colorama'] if (sys.platform == 'win32') else []
),
tests_require=[
'beautifulsoup4',
'flask',
'mock',
'pylast',
'rarfile',
'responses',
'pyxdg',
'python-mpd2',
'discogs-client'
] + (
# Tests for the thumbnails plugin need pathlib on Python 2 too.
['pathlib'] if (sys.version_info < (3, 4, 0)) else []
),
# Plugin (optional) dependencies:
extras_require={
'test': [
'beautifulsoup4',
'coverage',
'discogs-client',
'flask',
'mock',
'pylast',
'pytest',
'python-mpd2',
'pyxdg',
'responses>=0.3.0',
'requests_oauthlib',
] + (
# Tests for the thumbnails plugin need pathlib on Python 2 too.
['pathlib'] if (sys.version_info < (3, 4, 0)) else []
) + [
'rarfile<4' if sys.version_info < (3, 6, 0) else 'rarfile',
],
'lint': [
'flake8',
'flake8-blind-except',
'flake8-coding',
'flake8-docstrings',
'flake8-future-import',
'pep8-naming',
],
# Plugin (optional) dependencies:
'absubmit': ['requests'],
'fetchart': ['requests', 'Pillow'],
'embedart': ['Pillow'],
@ -141,7 +153,9 @@ setup(
'mpdstats': ['python-mpd2>=0.4.2'],
'plexupdate': ['requests'],
'web': ['flask', 'flask-cors'],
'import': ['rarfile'],
'import': (
['rarfile<4' if (sys.version_info < (3, 6, 0)) else 'rarfile']
),
'thumbnails': ['pyxdg', 'Pillow'] +
(['pathlib'] if (sys.version_info < (3, 4, 0)) else []),
'metasync': ['dbus-python'],

View file

@ -44,7 +44,7 @@ beetsplug.__path__ = [os.path.abspath(
RSRC = util.bytestring_path(os.path.join(os.path.dirname(__file__), 'rsrc'))
PLUGINPATH = os.path.join(os.path.dirname(__file__), 'rsrc', 'beetsplug')
# Propagate to root logger so nosetest can capture it
# Propagate to root logger so the test runner can capture it
log = logging.getLogger('beets')
log.propagate = True
log.setLevel(logging.DEBUG)

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,270 @@
<!DOCTYPE html>
<html class="snarly apple_music_player--enabled bagon_song_page--enabled song_stories_public_launch--enabled react_forums--disabled" xmlns="http://www.w3.org/1999/xhtml" xmlns:fb="http://www.facebook.com/2008/fbml" lang="en" xml:lang="en">
<head>
<base target='_top' href="//g-example.com/">
<script type="text/javascript">
//<![CDATA[
var _sf_startpt=(new Date()).getTime();
if (window.performance && performance.mark) {
window.performance.mark('parse_start');
}
//]]>
</script>
<title>SAMPLE SONG Lyrics | g-example Lyrics</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta content='width=device-width,initial-scale=1' name='viewport'>
<meta property="og:site_name" content="g-example"/>
<link title="g-example" type="application/opensearchdescription+xml" rel="search" href="https://g-example.com/opensearch.xml">
<script async src="https://www.youtube.com/iframe_api"></script>
<script defer src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
<meta content="https://g-example.com/SAMPLE-SONG-lyrics" property="og:url" />
<link href="ios-app://#/g-example/songs/#" rel="alternate" />
<meta content="/songs/3113595" name="newrelic-resource-path" />
<link href="https://g-example.com/SAMPLE-SONG-lyrics" rel="canonical" />
<link href="https://g-example.com/amp/SAMPLE-SONG-lyrics" rel="amphtml" />
<script type="text/javascript">
var _qevents = _qevents || [];
(function() {
var elem = document.createElement('script');
elem.src = (document.location.protocol == 'https:' ? 'https://secure' : 'http://edge') + '.quantserve.com/quant.js';
elem.async = true;
elem.type = 'text/javascript';
var scpt = document.getElementsByTagName('script')[0];
scpt.parentNode.insertBefore(elem, scpt);
})();
</script>
<script type="text/javascript">
window.ga = window.ga || function() {
(window.ga.q = window.ga.q || []).push(arguments);
};
(function(g, e, n, i, u, s) {
g['GoogleAnalyticsObject'] = 'ga';
g.ga.l = Date.now();
u = e.createElement(n);
s = e.getElementsByTagName(n)[0];
u.async = true;
u.src = i;
s.parentNode.insertBefore(u, s);
})(window, document, 'script', 'https://www.google-analytics.com/analytics.js');
ga('create', "UA-10346621-1", 'auto', {'useAmpClientId': true});
ga('set', 'dimension1', "false");
ga('set', 'dimension2', "songs#show");
ga('set', 'dimension3', "r-b");
ga('set', 'dimension4', "true");
ga('set', 'dimension5', 'false');
ga('set', 'dimension6', "none");
ga('send', 'pageview');
</script>
</head>
<body>
<div class="header" ng-controller="HeaderALBUM as header_ALBUM" click-outside="close_mobile_subnav_menu()">
<div class="header-primary active">
<div class="header-expand_nav_menu" ng-click="toggle_mobile_subnav_menu()"><div class="header-expand_nav_menu-contents"></div></div>
<div class="logo_container">
<a href="https://g-example.com/" class="logo_link">g-example</a>
</div>
<header-actions></header-actions>
<search-form search-style="header"></search-form>
</div>
</div>
<routable-page>
<ng-non-bindable>
<div class="header_with_cover_art">
<div class="header_with_cover_art-inner column_layout">
<div class="column_layout-column_span column_layout-column_span--primary">
<div class="header_with_cover_art-cover_art ">
<div class="cover_art">
<img alt="#" class="cover_art-image" src="#" srcset="#" />
</div>
</div>
<div class="header_with_cover_art-primary_info_container">
<div class="header_with_cover_art-primary_info">
<h1 class="header_with_cover_art-primary_info-title ">SONG</h1>
<h2>
<a href="https://g-example.com/artists/SAMPLE" class="header_with_cover_art-primary_info-primary_artist">
SAMPLE
</a>
</h2>
<h3>
<div class="metadata_unit ">
<span class="metadata_unit-label">Produced by</span>
<span class="metadata_unit-info">
<a href="https://g-example.com/artists/Person1">Person 1</a> & <a href="https://g-example.com/artists/Person 2">Person 2</a>
</span>
</div>
</h3>
<h3>
<div class="metadata_unit ">
<span class="metadata_unit-label">Album</span>
<span class="metadata_unit-info"><a href="https://g-example.com/albums/SAMPLE/ALBUM">ALBUM</a></span>
</div>
</h3>
</div>
</div>
</div>
</div>
</div>
<div class="song_body column_layout" initial-content-for="song_body">
<div class="column_layout-column_span column_layout-column_span--primary">
<div class="song_body-lyrics">
<h2 class="text_label text_label--gray text_label--x_small_text_size u-top_margin">SONG Lyrics</h2>
<div initial-content-for="lyrics">
<div class="totally-not-the-lyrics-div">
!!!! MISSING LYRICS HERE !!!
</div>
</div>
<div initial-content-for="recirculated_content">
<div class="u-xx_large_vertical_margins">
<div class="text_label text_label--gray">More on g-example</div>
</div>
</div>
</div>
</div>
</div>
<div class="metadata_unit metadata_unit--table_row">
<span class="metadata_unit-label">Released by</span>
<span class="metadata_unit-info">
<a href="https://g-example.com/artists/records">Records</a> & <a href="https://g-example.com/artists/Top">Top</a>
</span>
</div>
<div class="metadata_unit metadata_unit--table_row">
<span class="metadata_unit-label">Mixing</span>
<span class="metadata_unit-info">
<a href="https://g-example.com/artists/Mixed-by-person">Mixed by Person</a>
</span>
</div>
<div class="metadata_unit metadata_unit--table_row">
<span class="metadata_unit-label">Recorded At</span>
<span class="metadata_unit-info metadata_unit-info--text_only">City, Place</span>
</div>
<div class="metadata_unit metadata_unit--table_row">
<span class="metadata_unit-label">Release Date</span>
<span class="metadata_unit-info metadata_unit-info--text_only">Feb 30, 1290</span>
</div>
<div class="metadata_unit metadata_unit--table_row">
<span class="metadata_unit-label">Interpolated By</span>
<span class="metadata_unit-info">
<div class="u-x_small_bottom_margin">
<a href="#"> # </a>
</div>
</span>
</div>
<div initial-content-for="album">
<div class="u-xx_large_vertical_margins">
<div class="song_album u-bottom_margin">
<a href="https://g-example.com/albums/SAMPLE/ALBUM" class="song_album-album_art" title="ALBUM">
<img alt="#" src="#" srcset="#"/>
</a>
<div class="song_album-info">
<a href="https://g-example.com/albums/SAMPLE/ALBUM" title="ALBUM" class="song_album-info-title">
ALBUM
</a>
<a href="https://g-example.com/artists/SAMPLE" class="song_album-info-artist" title="ALBUM">SAMPLE</a>
</div>
</div>
</ng-non-bindable>
</routable-page>
<div class="page_footer page_footer--padding-for-sticky-player">
<div class="footer">
<div>
<a href="/about">About g-example</a>
<a href="/contributor_guidelines">Contributor Guidelines</a>
</div>
<div>
<span>g-example</span>
</div>
</div>
</div>
<script type="text/javascript">_qevents.push({ qacct: "################"});</script>
<noscript>
<div style="display: none;">
<img src="#" height="1" width="1" alt="#"/>
</div>
</noscript>
<script type="text/javascript">
var _sf_async_config={};
_sf_async_config.uid = 3877;
_sf_async_config.domain = 'g-example.com';
_sf_async_config.title = 'SAMPLE SONG Lyrics | g-example Lyrics';
_sf_async_config.sections = 'songs,tag:r-b';
_sf_async_config.authors = 'SAMPLE';
var _cbq = window._cbq || [];
(function(){
function loadChartbeat() {
window._sf_endpt=(new Date()).getTime();
var e = document.createElement('script');
e.setAttribute('language', 'javascript');
e.setAttribute('type', 'text/javascript');
e.setAttribute('src', '#');
document.body.appendChild(e);
}
var oldonload = window.onload;
window.onload = (typeof window.onload != 'function') ?
loadChartbeat : function() { oldonload(); loadChartbeat(); };
})();
</script>
<!-- Begin comScore Tag -->
<script>
var _comscore = _comscore || [];
_comscore.push({ c1: "2", c2: "17151659" });
(function() {
var s = document.createElement("script"), el = document.getElementsByTagName("script")[0]; s.async = true;
s.src = (document.location.protocol == "https:" ? "https://sb" : "http://b") + ".scorecardresearch.com/beacon.js";
el.parentNode.insertBefore(s, el);
})();
</script>
<noscript>
<img src="#"/>
</noscript>
<!-- End comScore Tag -->
<noscript>
<img height="1" width="1" style="display:none" src="#"/>
</noscript>
</body>
</html>

View file

@ -18,7 +18,6 @@
from __future__ import division, absolute_import, print_function
import re
import copy
import unittest
from test import _common
@ -91,7 +90,10 @@ class PluralityTest(_common.TestCase):
for i in range(5)]
likelies, _ = match.current_metadata(items)
for f in fields:
self.assertEqual(likelies[f], '%s_1' % f)
if isinstance(likelies[f], int):
self.assertEqual(likelies[f], 0)
else:
self.assertEqual(likelies[f], '%s_1' % f)
def _make_item(title, track, artist=u'some artist'):
@ -103,9 +105,12 @@ def _make_item(title, track, artist=u'some artist'):
def _make_trackinfo():
return [
TrackInfo(u'one', None, artist=u'some artist', length=1, index=1),
TrackInfo(u'two', None, artist=u'some artist', length=1, index=2),
TrackInfo(u'three', None, artist=u'some artist', length=1, index=3),
TrackInfo(title=u'one', track_id=None, artist=u'some artist',
length=1, index=1),
TrackInfo(title=u'two', track_id=None, artist=u'some artist',
length=1, index=2),
TrackInfo(title=u'three', track_id=None, artist=u'some artist',
length=1, index=3),
]
@ -345,9 +350,7 @@ class AlbumDistanceTest(_common.TestCase):
artist=u'some artist',
album=u'some album',
tracks=_make_trackinfo(),
va=False,
album_id=None,
artist_id=None,
va=False
)
self.assertEqual(self._dist(items, info), 0)
@ -359,9 +362,7 @@ class AlbumDistanceTest(_common.TestCase):
artist=u'some artist',
album=u'some album',
tracks=_make_trackinfo(),
va=False,
album_id=None,
artist_id=None,
va=False
)
dist = self._dist(items, info)
self.assertNotEqual(dist, 0)
@ -377,9 +378,7 @@ class AlbumDistanceTest(_common.TestCase):
artist=u'someone else',
album=u'some album',
tracks=_make_trackinfo(),
va=False,
album_id=None,
artist_id=None,
va=False
)
self.assertNotEqual(self._dist(items, info), 0)
@ -392,9 +391,7 @@ class AlbumDistanceTest(_common.TestCase):
artist=u'should be ignored',
album=u'some album',
tracks=_make_trackinfo(),
va=True,
album_id=None,
artist_id=None,
va=True
)
self.assertEqual(self._dist(items, info), 0)
@ -408,9 +405,7 @@ class AlbumDistanceTest(_common.TestCase):
artist=u'should be ignored',
album=u'some album',
tracks=_make_trackinfo(),
va=True,
album_id=None,
artist_id=None,
va=True
)
info.tracks[0].artist = None
info.tracks[1].artist = None
@ -426,9 +421,7 @@ class AlbumDistanceTest(_common.TestCase):
artist=u'some artist',
album=u'some album',
tracks=_make_trackinfo(),
va=True,
album_id=None,
artist_id=None,
va=True
)
self.assertNotEqual(self._dist(items, info), 0)
@ -441,9 +434,7 @@ class AlbumDistanceTest(_common.TestCase):
artist=u'some artist',
album=u'some album',
tracks=_make_trackinfo(),
va=False,
album_id=None,
artist_id=None,
va=False
)
dist = self._dist(items, info)
self.assertTrue(0 < dist < 0.2)
@ -457,9 +448,7 @@ class AlbumDistanceTest(_common.TestCase):
artist=u'some artist',
album=u'some album',
tracks=_make_trackinfo(),
va=False,
album_id=None,
artist_id=None,
va=False
)
info.tracks[0].medium_index = 1
info.tracks[1].medium_index = 2
@ -476,9 +465,7 @@ class AlbumDistanceTest(_common.TestCase):
artist=u'some artist',
album=u'some album',
tracks=_make_trackinfo(),
va=False,
album_id=None,
artist_id=None,
va=False
)
info.tracks[0].medium_index = 1
info.tracks[1].medium_index = 2
@ -500,9 +487,9 @@ class AssignmentTest(unittest.TestCase):
items.append(self.item(u'three', 2))
items.append(self.item(u'two', 3))
trackinfo = []
trackinfo.append(TrackInfo(u'one', None))
trackinfo.append(TrackInfo(u'two', None))
trackinfo.append(TrackInfo(u'three', None))
trackinfo.append(TrackInfo(title=u'one'))
trackinfo.append(TrackInfo(title=u'two'))
trackinfo.append(TrackInfo(title=u'three'))
mapping, extra_items, extra_tracks = \
match.assign_items(items, trackinfo)
self.assertEqual(extra_items, [])
@ -519,9 +506,9 @@ class AssignmentTest(unittest.TestCase):
items.append(self.item(u'three', 1))
items.append(self.item(u'two', 1))
trackinfo = []
trackinfo.append(TrackInfo(u'one', None))
trackinfo.append(TrackInfo(u'two', None))
trackinfo.append(TrackInfo(u'three', None))
trackinfo.append(TrackInfo(title=u'one'))
trackinfo.append(TrackInfo(title=u'two'))
trackinfo.append(TrackInfo(title=u'three'))
mapping, extra_items, extra_tracks = \
match.assign_items(items, trackinfo)
self.assertEqual(extra_items, [])
@ -537,9 +524,9 @@ class AssignmentTest(unittest.TestCase):
items.append(self.item(u'one', 1))
items.append(self.item(u'three', 3))
trackinfo = []
trackinfo.append(TrackInfo(u'one', None))
trackinfo.append(TrackInfo(u'two', None))
trackinfo.append(TrackInfo(u'three', None))
trackinfo.append(TrackInfo(title=u'one'))
trackinfo.append(TrackInfo(title=u'two'))
trackinfo.append(TrackInfo(title=u'three'))
mapping, extra_items, extra_tracks = \
match.assign_items(items, trackinfo)
self.assertEqual(extra_items, [])
@ -555,8 +542,8 @@ class AssignmentTest(unittest.TestCase):
items.append(self.item(u'two', 2))
items.append(self.item(u'three', 3))
trackinfo = []
trackinfo.append(TrackInfo(u'one', None))
trackinfo.append(TrackInfo(u'three', None))
trackinfo.append(TrackInfo(title=u'one'))
trackinfo.append(TrackInfo(title=u'three'))
mapping, extra_items, extra_tracks = \
match.assign_items(items, trackinfo)
self.assertEqual(extra_items, [items[1]])
@ -592,7 +579,8 @@ class AssignmentTest(unittest.TestCase):
items.append(item(12, 186.45916150485752))
def info(index, title, length):
return TrackInfo(title, None, length=length, index=index)
return TrackInfo(title=title, length=length,
index=index)
trackinfo = []
trackinfo.append(info(1, u'Alone', 238.893))
trackinfo.append(info(2, u'The Woman in You', 341.44))
@ -635,8 +623,8 @@ class ApplyTest(_common.TestCase, ApplyTestUtil):
self.items.append(Item({}))
trackinfo = []
trackinfo.append(TrackInfo(
u'oneNew',
u'dfa939ec-118c-4d0f-84a0-60f3d1e6522c',
title=u'oneNew',
track_id=u'dfa939ec-118c-4d0f-84a0-60f3d1e6522c',
medium=1,
medium_index=1,
medium_total=1,
@ -645,8 +633,8 @@ class ApplyTest(_common.TestCase, ApplyTestUtil):
artist_sort='trackArtistSort',
))
trackinfo.append(TrackInfo(
u'twoNew',
u'40130ed1-a27c-42fd-a328-1ebefb6caef4',
title=u'twoNew',
track_id=u'40130ed1-a27c-42fd-a328-1ebefb6caef4',
medium=2,
medium_index=1,
index=2,
@ -746,13 +734,13 @@ class ApplyTest(_common.TestCase, ApplyTestUtil):
self.assertEqual(self.items[1].albumtype, 'album')
def test_album_artist_overrides_empty_track_artist(self):
my_info = copy.deepcopy(self.info)
my_info = self.info.copy()
self._apply(info=my_info)
self.assertEqual(self.items[0].artist, 'artistNew')
self.assertEqual(self.items[1].artist, 'artistNew')
def test_album_artist_overridden_by_nonempty_track_artist(self):
my_info = copy.deepcopy(self.info)
my_info = self.info.copy()
my_info.tracks[0].artist = 'artist1!'
my_info.tracks[1].artist = 'artist2!'
self._apply(info=my_info)
@ -774,7 +762,7 @@ class ApplyTest(_common.TestCase, ApplyTestUtil):
self.assertEqual(self.items[1].artist_sort, 'albumArtistSort')
def test_full_date_applied(self):
my_info = copy.deepcopy(self.info)
my_info = self.info.copy()
my_info.year = 2013
my_info.month = 12
my_info.day = 18
@ -789,7 +777,7 @@ class ApplyTest(_common.TestCase, ApplyTestUtil):
self.items.append(Item(year=1, month=2, day=3))
self.items.append(Item(year=4, month=5, day=6))
my_info = copy.deepcopy(self.info)
my_info = self.info.copy()
my_info.year = 2013
self._apply(info=my_info)
@ -809,7 +797,7 @@ class ApplyTest(_common.TestCase, ApplyTestUtil):
self.assertEqual(self.items[0].day, 3)
def test_data_source_applied(self):
my_info = copy.deepcopy(self.info)
my_info = self.info.copy()
my_info.data_source = 'MusicBrainz'
self._apply(info=my_info)
@ -825,15 +813,15 @@ class ApplyCompilationTest(_common.TestCase, ApplyTestUtil):
self.items.append(Item({}))
trackinfo = []
trackinfo.append(TrackInfo(
u'oneNew',
u'dfa939ec-118c-4d0f-84a0-60f3d1e6522c',
title=u'oneNew',
track_id=u'dfa939ec-118c-4d0f-84a0-60f3d1e6522c',
artist=u'artistOneNew',
artist_id=u'a05686fc-9db2-4c23-b99e-77f5db3e5282',
index=1,
))
trackinfo.append(TrackInfo(
u'twoNew',
u'40130ed1-a27c-42fd-a328-1ebefb6caef4',
title=u'twoNew',
track_id=u'40130ed1-a27c-42fd-a328-1ebefb6caef4',
artist=u'artistTwoNew',
artist_id=u'80b3cf5e-18fe-4c59-98c7-e5bb87210710',
index=2,
@ -871,7 +859,7 @@ class ApplyCompilationTest(_common.TestCase, ApplyTestUtil):
self.assertFalse(self.items[1].comp)
def test_va_flag_sets_comp(self):
va_info = copy.deepcopy(self.info)
va_info = self.info.copy()
va_info.va = True
self._apply(info=va_info)
self.assertTrue(self.items[0].comp)

View file

@ -15,6 +15,7 @@
from __future__ import division, absolute_import, print_function
import fnmatch
import sys
import re
import os.path
@ -121,6 +122,15 @@ class ImportConvertTest(unittest.TestCase, TestHelper):
self.assertIsNotNone(item)
self.assertTrue(os.path.isfile(item.path))
def test_delete_originals(self):
self.config['convert']['delete_originals'] = True
self.importer.run()
for path in self.importer.paths:
for root, dirnames, filenames in os.walk(path):
self.assertTrue(len(fnmatch.filter(filenames, '*.mp3')) == 0,
u'Non-empty import directory {0}'
.format(util.displayable_path(path)))
class ConvertCommand(object):
"""A mixin providing a utility method to run the `convert`command

View file

@ -53,6 +53,7 @@ class ModelFixture1(dbcore.Model):
_fields = {
'id': dbcore.types.PRIMARY_ID,
'field_one': dbcore.types.INTEGER,
'field_two': dbcore.types.STRING,
}
_types = {
'some_float_field': dbcore.types.FLOAT,
@ -355,7 +356,7 @@ class ModelTest(unittest.TestCase):
def test_items(self):
model = ModelFixture1(self.db)
model.id = 5
self.assertEqual({('id', 5), ('field_one', 0)},
self.assertEqual({('id', 5), ('field_one', 0), ('field_two', '')},
set(model.items()))
def test_delete_internal_field(self):
@ -370,10 +371,28 @@ class ModelTest(unittest.TestCase):
class FormatTest(unittest.TestCase):
def test_format_fixed_field(self):
def test_format_fixed_field_integer(self):
model = ModelFixture1()
model.field_one = u'caf\xe9'
model.field_one = 155
value = model.formatted().get('field_one')
self.assertEqual(value, u'155')
def test_format_fixed_field_integer_normalized(self):
"""The normalize method of the Integer class rounds floats
"""
model = ModelFixture1()
model.field_one = 142.432
value = model.formatted().get('field_one')
self.assertEqual(value, u'142')
model.field_one = 142.863
value = model.formatted().get('field_one')
self.assertEqual(value, u'143')
def test_format_fixed_field_string(self):
model = ModelFixture1()
model.field_two = u'caf\xe9'
value = model.formatted().get('field_two')
self.assertEqual(value, u'caf\xe9')
def test_format_flex_field(self):

View file

@ -23,7 +23,7 @@ from test.helper import TestHelper
import re # used to test csv format
import json
from xml.etree.ElementTree import Element
import xml.etree.ElementTree as ET
from xml.etree import ElementTree
class ExportPluginTest(unittest.TestCase, TestHelper):
@ -85,7 +85,7 @@ class ExportPluginTest(unittest.TestCase, TestHelper):
format_type='xml',
artist=item1.artist
)
library = ET.fromstring(out)
library = ElementTree.fromstring(out)
self.assertIsInstance(library, Element)
for track in library[0]:
for details in track:

View file

@ -102,6 +102,25 @@ class MoveTest(_common.TestCase):
self.i.move()
self.assertEqual(self.i.path, old_path)
def test_move_file_with_colon(self):
self.i.artist = u'C:DOS'
self.i.move()
self.assertIn('C_DOS', self.i.path.decode())
def test_move_file_with_multiple_colons(self):
print(beets.config['replace'])
self.i.artist = u'COM:DOS'
self.i.move()
self.assertIn('COM_DOS', self.i.path.decode())
def test_move_file_with_colon_alt_separator(self):
old = beets.config['drive_sep_replace']
beets.config["drive_sep_replace"] = '0'
self.i.artist = u'C:DOS'
self.i.move()
self.assertIn('C0DOS', self.i.path.decode())
beets.config["drive_sep_replace"] = old
def test_read_only_file_copied_writable(self):
# Make the source file read-only.
os.chmod(self.path, 0o444)

View file

@ -79,7 +79,7 @@ class AutotagStub(object):
autotag.mb.album_for_id = self.mb_album_for_id
autotag.mb.track_for_id = self.mb_track_for_id
def match_album(self, albumartist, album, tracks):
def match_album(self, albumartist, album, tracks, extra_tags):
if self.matching == self.IDENT:
yield self._make_album_match(albumartist, album, tracks)

View file

@ -17,28 +17,28 @@
from __future__ import absolute_import, division, print_function
import itertools
from io import open
import os
import re
import six
import sys
import unittest
from mock import patch
from test import _common
import confuse
from mock import MagicMock, patch
from beets import logging
from beets.library import Item
from beets.util import bytestring_path
import confuse
from beetsplug import lyrics
from mock import MagicMock
from test import _common
log = logging.getLogger('beets.test_lyrics')
raw_backend = lyrics.Backend({}, log)
google = lyrics.Google(MagicMock(), log)
genius = lyrics.Genius(MagicMock(), log)
class LyricsPluginTest(unittest.TestCase):
@ -94,6 +94,27 @@ class LyricsPluginTest(unittest.TestCase):
self.assertEqual(('Alice and Bob', ['song']),
list(lyrics.search_pairs(item))[0])
def test_search_artist_sort(self):
item = Item(artist='CHVRCHΞS', title='song', artist_sort='CHVRCHES')
self.assertIn(('CHVRCHΞS', ['song']),
lyrics.search_pairs(item))
self.assertIn(('CHVRCHES', ['song']),
lyrics.search_pairs(item))
# Make sure that the original artist name is still the first entry
self.assertEqual(('CHVRCHΞS', ['song']),
list(lyrics.search_pairs(item))[0])
item = Item(artist='横山克', title='song', artist_sort='Masaru Yokoyama')
self.assertIn(('横山克', ['song']),
lyrics.search_pairs(item))
self.assertIn(('Masaru Yokoyama', ['song']),
lyrics.search_pairs(item))
# Make sure that the original artist name is still the first entry
self.assertEqual(('横山克', ['song']),
list(lyrics.search_pairs(item))[0])
def test_search_pairs_multi_titles(self):
item = Item(title='1 / 2', artist='A')
self.assertIn(('A', ['1 / 2']), lyrics.search_pairs(item))
@ -209,7 +230,7 @@ class MockFetchUrl(object):
def __call__(self, url, filename=None):
self.fetched = url
fn = url_to_filename(url)
with open(fn, 'r') as f:
with open(fn, 'r', encoding="utf8") as f:
content = f.read()
return content
@ -248,8 +269,8 @@ class LyricsPluginSourcesTest(LyricsGoogleBaseTest):
DEFAULT_SOURCES = [
dict(DEFAULT_SONG, backend=lyrics.LyricsWiki),
dict(artist=u'Santana', title=u'Black magic woman',
backend=lyrics.MusiXmatch),
# dict(artist=u'Santana', title=u'Black magic woman',
# backend=lyrics.MusiXmatch),
dict(DEFAULT_SONG, backend=lyrics.Genius),
]
@ -263,9 +284,9 @@ class LyricsPluginSourcesTest(LyricsGoogleBaseTest):
dict(DEFAULT_SONG,
url=u'http://www.chartlyrics.com',
path=u'/_LsLsZ7P4EK-F-LD4dJgDQ/Lady+Madonna.aspx'),
dict(DEFAULT_SONG,
url=u'http://www.elyricsworld.com',
path=u'/lady_madonna_lyrics_beatles.html'),
# dict(DEFAULT_SONG,
# url=u'http://www.elyricsworld.com',
# path=u'/lady_madonna_lyrics_beatles.html'),
dict(url=u'http://www.lacoccinelle.net',
artist=u'Jacques Brel', title=u"Amsterdam",
path=u'/paroles-officielles/275679.html'),
@ -282,11 +303,11 @@ class LyricsPluginSourcesTest(LyricsGoogleBaseTest):
dict(url=u'http://www.lyricsontop.com',
artist=u'Amy Winehouse', title=u"Jazz'n'blues",
path=u'/amy-winehouse-songs/jazz-n-blues-lyrics.html'),
dict(DEFAULT_SONG,
url='http://www.metrolyrics.com/',
path='lady-madonna-lyrics-beatles.html'),
dict(url='http://www.musica.com/', path='letras.asp?letra=2738',
artist=u'Santana', title=u'Black magic woman'),
# dict(DEFAULT_SONG,
# url='http://www.metrolyrics.com/',
# path='lady-madonna-lyrics-beatles.html'),
# dict(url='http://www.musica.com/', path='letras.asp?letra=2738',
# artist=u'Santana', title=u'Black magic woman'),
dict(url=u'http://www.paroles.net/',
artist=u'Lilly Wood & the prick', title=u"Hey it's ok",
path=u'lilly-wood-the-prick/paroles-hey-it-s-ok'),
@ -302,23 +323,28 @@ class LyricsPluginSourcesTest(LyricsGoogleBaseTest):
LyricsGoogleBaseTest.setUp(self)
self.plugin = lyrics.LyricsPlugin()
@unittest.skipUnless(os.environ.get(
'BEETS_TEST_LYRICS_SOURCES', '0') == '1',
'lyrics sources testing not enabled')
@unittest.skipUnless(
os.environ.get('INTEGRATION_TEST', '0') == '1',
'integration testing not enabled')
def test_backend_sources_ok(self):
"""Test default backends with songs known to exist in respective databases.
"""
errors = []
for s in self.DEFAULT_SOURCES:
# GitHub actions seems to be on a Cloudflare blacklist, so we can't
# contact genius.
sources = [s for s in self.DEFAULT_SOURCES if
s['backend'] != lyrics.Genius or
os.environ.get('GITHUB_ACTIONS') != 'true']
for s in sources:
res = s['backend'](self.plugin.config, self.plugin._log).fetch(
s['artist'], s['title'])
if not is_lyrics_content_ok(s['title'], res):
errors.append(s['backend'].__name__)
self.assertFalse(errors)
@unittest.skipUnless(os.environ.get(
'BEETS_TEST_LYRICS_SOURCES', '0') == '1',
'lyrics sources testing not enabled')
@unittest.skipUnless(
os.environ.get('INTEGRATION_TEST', '0') == '1',
'integration testing not enabled')
def test_google_sources_ok(self):
"""Test if lyrics present on websites registered in beets google custom
search engine are correctly scraped.
@ -395,24 +421,133 @@ class LyricsGooglePluginMachineryTest(LyricsGoogleBaseTest):
google.is_page_candidate(url, url_title, s['title'], u'Sunn O)))')
# test Genius backend
class GeniusBaseTest(unittest.TestCase):
def setUp(self):
"""Set up configuration."""
try:
__import__('bs4')
except ImportError:
self.skipTest('Beautiful Soup 4 not available')
if sys.version_info[:3] < (2, 7, 3):
self.skipTest("Python's built-in HTML parser is not good enough")
class GeniusScrapeLyricsFromHtmlTest(GeniusBaseTest):
"""tests Genius._scrape_lyrics_from_html()"""
def setUp(self):
"""Set up configuration"""
GeniusBaseTest.setUp(self)
self.plugin = lyrics.LyricsPlugin()
def test_no_lyrics_div(self):
"""Ensure we don't crash when the scraping the html for a genius page
doesn't contain <div class="lyrics"></div>
"""
# https://github.com/beetbox/beets/issues/3535
# expected return value None
url = 'https://genius.com/sample'
mock = MockFetchUrl()
self.assertEqual(genius._scrape_lyrics_from_html(mock(url)), None)
def test_good_lyrics(self):
"""Ensure we are able to scrape a page with lyrics"""
url = 'https://genius.com/Wu-tang-clan-cream-lyrics'
mock = MockFetchUrl()
self.assertIsNotNone(genius._scrape_lyrics_from_html(mock(url)))
# TODO: find an example of a lyrics page with multiple divs and test it
class GeniusFetchTest(GeniusBaseTest):
"""tests Genius.fetch()"""
def setUp(self):
"""Set up configuration"""
GeniusBaseTest.setUp(self)
self.plugin = lyrics.LyricsPlugin()
@patch.object(lyrics.Genius, '_scrape_lyrics_from_html')
@patch.object(lyrics.Backend, 'fetch_url', return_value=True)
def test_json(self, mock_fetch_url, mock_scrape):
"""Ensure we're finding artist matches"""
with patch.object(
lyrics.Genius, '_search', return_value={
"response": {
"hits": [
{
"result": {
"primary_artist": {
"name": u"\u200Bblackbear",
},
"url": "blackbear_url"
}
},
{
"result": {
"primary_artist": {
"name": u"El\u002Dp"
},
"url": "El-p_url"
}
}
]
}
}
) as mock_json:
# genius uses zero-width-spaces (\u200B) for lowercase
# artists so we make sure we can match those
self.assertIsNotNone(genius.fetch('blackbear', 'Idfc'))
mock_fetch_url.assert_called_once_with("blackbear_url")
mock_scrape.assert_called_once_with(True)
# genius uses the hypen minus (\u002D) as their dash
self.assertIsNotNone(genius.fetch('El-p', 'Idfc'))
mock_fetch_url.assert_called_with('El-p_url')
mock_scrape.assert_called_with(True)
# test no matching artist
self.assertIsNone(genius.fetch('doesntexist', 'none'))
# test invalid json
mock_json.return_value = None
self.assertIsNone(genius.fetch('blackbear', 'Idfc'))
# TODO: add integration test hitting real api
# test utilties
class SlugTests(unittest.TestCase):
def test_slug(self):
# plain ascii passthrough
text = u"test"
self.assertEqual(lyrics.slug(text), 'test')
# german unicode and capitals
text = u"Mørdag"
self.assertEqual(lyrics.slug(text), 'mordag')
# more accents and quotes
text = u"l'été c'est fait pour jouer"
self.assertEqual(lyrics.slug(text), 'l-ete-c-est-fait-pour-jouer')
# accents, parens and spaces
text = u"\xe7afe au lait (boisson)"
self.assertEqual(lyrics.slug(text), 'cafe-au-lait-boisson')
text = u"Multiple spaces -- and symbols! -- merged"
self.assertEqual(lyrics.slug(text),
'multiple-spaces-and-symbols-merged')
text = u"\u200Bno-width-space"
self.assertEqual(lyrics.slug(text), 'no-width-space')
# variations of dashes should get standardized
dashes = [u'\u200D', u'\u2010']
for dash1, dash2 in itertools.combinations(dashes, 2):
self.assertEqual(lyrics.slug(dash1), lyrics.slug(dash2))
def suite():

View file

@ -62,10 +62,11 @@ class MPDStatsTest(unittest.TestCase, TestHelper):
{'state': u'stop'}]
EVENTS = [["player"]] * (len(STATUSES) - 1) + [KeyboardInterrupt]
item_path = util.normpath('/foo/bar.flac')
songid = 1
@patch("beetsplug.mpdstats.MPDClientWrapper", return_value=Mock(**{
"events.side_effect": EVENTS, "status.side_effect": STATUSES,
"currentsong.return_value": item_path}))
"currentsong.return_value": (item_path, songid)}))
def test_run_mpdstats(self, mpd_mock):
item = Item(title=u'title', path=self.item_path, id=1)
item.add(self.lib)

View file

@ -17,6 +17,7 @@
from __future__ import division, absolute_import, print_function
import os
import unittest
from test.helper import TestHelper
@ -34,6 +35,9 @@ class ParentWorkTest(unittest.TestCase, TestHelper):
self.unload_plugins()
self.teardown_beets()
@unittest.skipUnless(
os.environ.get('INTEGRATION_TEST', '0') == '1',
'integration testing not enabled')
def test_normal_case(self):
item = Item(path='/file',
mb_workid=u'e27bda6e-531e-36d3-9cd7-b8ebc18e8c53')
@ -45,6 +49,9 @@ class ParentWorkTest(unittest.TestCase, TestHelper):
self.assertEqual(item['mb_parentworkid'],
u'32c8943f-1b27-3a23-8660-4567f4847c94')
@unittest.skipUnless(
os.environ.get('INTEGRATION_TEST', '0') == '1',
'integration testing not enabled')
def test_force(self):
self.config['parentwork']['force'] = True
item = Item(path='/file',
@ -58,6 +65,9 @@ class ParentWorkTest(unittest.TestCase, TestHelper):
self.assertEqual(item['mb_parentworkid'],
u'32c8943f-1b27-3a23-8660-4567f4847c94')
@unittest.skipUnless(
os.environ.get('INTEGRATION_TEST', '0') == '1',
'integration testing not enabled')
def test_no_force(self):
self.config['parentwork']['force'] = True
item = Item(path='/file', mb_workid=u'e27bda6e-531e-36d3-9cd7-\
@ -72,6 +82,9 @@ class ParentWorkTest(unittest.TestCase, TestHelper):
# test different cases, still with Matthew Passion Ouverture or Mozart
# requiem
@unittest.skipUnless(
os.environ.get('INTEGRATION_TEST', '0') == '1',
'integration testing not enabled')
def test_direct_parent_work(self):
mb_workid = u'2e4a3668-458d-3b2a-8be2-0b08e0d8243a'
self.assertEqual(u'f04b42df-7251-4d86-a5ee-67cfa49580d1',

View file

@ -241,6 +241,8 @@ def implements(commands, expectedFailure=False): # noqa: N803
bluelet_listener = bluelet.Listener
@mock.patch("beets.util.bluelet.Listener")
def start_server(args, assigned_port, listener_patch):
"""Start the bpd server, writing the port to `assigned_port`.

View file

@ -120,7 +120,7 @@ class PlaylistQueryTestHelper(PlaylistTestHelper):
]))
def test_name_query_with_nonexisting_playlist(self):
q = u'playlist:nonexisting'.format(self.playlist_dir)
q = u'playlist:nonexisting'
results = self.lib.items(q)
self.assertEqual(set(results), set())

View file

@ -93,7 +93,9 @@ class PlexUpdateTest(unittest.TestCase, TestHelper):
self.config['plex']['host'],
self.config['plex']['port'],
self.config['plex']['token'],
self.config['plex']['library_name'].get()), '2')
self.config['plex']['library_name'].get(),
self.config['plex']['secure'],
self.config['plex']['ignore_cert_errors']), '2')
@responses.activate
def test_get_named_music_section(self):
@ -104,7 +106,9 @@ class PlexUpdateTest(unittest.TestCase, TestHelper):
self.config['plex']['host'],
self.config['plex']['port'],
self.config['plex']['token'],
'My Music Library'), '2')
'My Music Library',
self.config['plex']['secure'],
self.config['plex']['ignore_cert_errors']), '2')
@responses.activate
def test_update_plex(self):
@ -117,7 +121,9 @@ class PlexUpdateTest(unittest.TestCase, TestHelper):
self.config['plex']['host'],
self.config['plex']['port'],
self.config['plex']['token'],
self.config['plex']['library_name'].get()).status_code, 200)
self.config['plex']['library_name'].get(),
self.config['plex']['secure'],
self.config['plex']['ignore_cert_errors']).status_code, 200)
def suite():

View file

@ -772,6 +772,21 @@ class NoneQueryTest(unittest.TestCase, TestHelper):
matched = self.lib.items(NoneQuery(u'rg_track_gain'))
self.assertInResult(item, matched)
def test_match_slow(self):
item = self.add_item()
matched = self.lib.items(NoneQuery(u'rg_track_peak', fast=False))
self.assertInResult(item, matched)
def test_match_slow_after_set_none(self):
item = self.add_item(rg_track_gain=0)
matched = self.lib.items(NoneQuery(u'rg_track_gain', fast=False))
self.assertNotInResult(item, matched)
item['rg_track_gain'] = None
item.store()
matched = self.lib.items(NoneQuery(u'rg_track_gain', fast=False))
self.assertInResult(item, matched)
class NotQueryMatchTest(_common.TestCase):
"""Test `query.NotQuery` matching against a single item, using the same

View file

@ -58,6 +58,7 @@ def reset_replaygain(item):
class ReplayGainCliTestBase(TestHelper):
def setUp(self):
self.setup_beets()
self.config['replaygain']['backend'] = self.backend
@ -150,7 +151,9 @@ class ReplayGainCliTestBase(TestHelper):
self.assertEqual(max(gains), min(gains))
self.assertNotEqual(max(gains), 0.0)
self.assertNotEqual(max(peaks), 0.0)
if not self.backend == "bs1770gain":
# Actually produces peaks == 0.0 ~ self.add_album_fixture
self.assertNotEqual(max(peaks), 0.0)
def test_cli_writes_only_r128_tags(self):
if self.backend == "command":
@ -227,7 +230,9 @@ class ReplayGainLdnsCliMalformedTest(TestHelper, unittest.TestCase):
# Patch call to return nothing, bypassing the bs1770gain installation
# check.
call_patch.return_value = CommandOutput(stdout=b"", stderr=b"")
call_patch.return_value = CommandOutput(
stdout=b'bs1770gain 0.0.0, ', stderr=b''
)
try:
self.load_plugins('replaygain')
except Exception:
@ -249,7 +254,7 @@ class ReplayGainLdnsCliMalformedTest(TestHelper, unittest.TestCase):
@patch('beetsplug.replaygain.call')
def test_malformed_output(self, call_patch):
# Return malformed XML (the ampersand should be &amp;)
call_patch.return_value = CommandOutput(stdout="""
call_patch.return_value = CommandOutput(stdout=b"""
<album>
<track total="1" number="1" file="&">
<integrated lufs="0" lu="0" />

111
test/test_subsonic.py Normal file
View file

@ -0,0 +1,111 @@
# -*- coding: utf-8 -*-
"""Tests for the 'subsonic' plugin"""
from __future__ import division, absolute_import, print_function
import requests
import responses
import unittest
from test import _common
from beets import config
from beetsplug import subsonicupdate
from test.helper import TestHelper
from six.moves.urllib.parse import parse_qs, urlparse
class ArgumentsMock(object):
def __init__(self, mode, show_failures):
self.mode = mode
self.show_failures = show_failures
self.verbose = 1
def _params(url):
"""Get the query parameters from a URL."""
return parse_qs(urlparse(url).query)
class SubsonicPluginTest(_common.TestCase, TestHelper):
@responses.activate
def setUp(self):
config.clear()
self.setup_beets()
config["subsonic"]["user"] = "admin"
config["subsonic"]["pass"] = "admin"
config["subsonic"]["url"] = "http://localhost:4040"
self.subsonicupdate = subsonicupdate.SubsonicUpdate()
def tearDown(self):
self.teardown_beets()
@responses.activate
def test_start_scan(self):
responses.add(
responses.POST,
'http://localhost:4040/rest/startScan',
status=200
)
self.subsonicupdate.start_scan()
@responses.activate
def test_url_with_extra_forward_slash_url(self):
config["subsonic"]["url"] = "http://localhost:4040/contextPath"
responses.add(
responses.POST,
'http://localhost:4040/contextPath/rest/startScan',
status=200
)
self.subsonicupdate.start_scan()
@responses.activate
def test_url_with_context_path(self):
config["subsonic"]["url"] = "http://localhost:4040/"
responses.add(
responses.POST,
'http://localhost:4040/rest/startScan',
status=200
)
self.subsonicupdate.start_scan()
@responses.activate
def test_url_with_missing_port(self):
config["subsonic"]["url"] = "http://localhost/airsonic"
responses.add(
responses.POST,
'http://localhost:4040/rest/startScan',
status=200
)
with self.assertRaises(requests.exceptions.ConnectionError):
self.subsonicupdate.start_scan()
@responses.activate
def test_url_with_missing_schema(self):
config["subsonic"]["url"] = "localhost:4040/airsonic"
responses.add(
responses.POST,
'http://localhost:4040/rest/startScan',
status=200
)
with self.assertRaises(requests.exceptions.InvalidSchema):
self.subsonicupdate.start_scan()
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
if __name__ == '__main__':
unittest.main(defaultTest='suite')

View file

@ -36,6 +36,8 @@ class ThePluginTest(_common.TestCase):
u'A Thing, An')
self.assertEqual(ThePlugin().unthe(u'the An Arse', PATTERN_A),
u'the An Arse')
self.assertEqual(ThePlugin().unthe(u'TET - Travailleur', PATTERN_THE),
u'TET - Travailleur')
def test_unthe_with_strip(self):
config['the']['strip'] = True

View file

@ -284,6 +284,15 @@ class ThumbnailsTest(unittest.TestCase, TestHelper):
u'file:///music/%EC%8B%B8%EC%9D%B4')
class TestPathlibURI():
"""Test PathlibURI class"""
def test_uri(self):
test_uri = PathlibURI()
# test it won't break if we pass it bytes for a path
test_uri.uri(b'/')
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)

View file

@ -22,7 +22,6 @@ import shutil
import re
import subprocess
import platform
from copy import deepcopy
import six
import unittest
@ -1051,8 +1050,10 @@ class ShowChangeTest(_common.TestCase):
self.items[0].track = 1
self.items[0].path = b'/path/to/file.mp3'
self.info = autotag.AlbumInfo(
u'the album', u'album id', u'the artist', u'artist id', [
autotag.TrackInfo(u'the title', u'track id', index=1)
album=u'the album', album_id=u'album id', artist=u'the artist',
artist_id=u'artist id', tracks=[
autotag.TrackInfo(title=u'the title', track_id=u'track id',
index=1)
]
)
@ -1136,7 +1137,9 @@ class SummarizeItemsTest(_common.TestCase):
summary = commands.summarize_items([self.item], False)
self.assertEqual(summary, u"1 items, F, 4kbps, 10:54, 987.0 B")
i2 = deepcopy(self.item)
# make a copy of self.item
i2 = self.item.copy()
summary = commands.summarize_items([self.item, i2], False)
self.assertEqual(summary, u"2 items, F, 4kbps, 21:48, 1.9 KiB")

54
tox.ini
View file

@ -4,57 +4,31 @@
# and then run "tox" from this directory.
[tox]
envlist = py27-test, py37-test, py27-flake8, docs
# The exhaustive list of environments is:
# envlist = py{27,34,35}-{test,cov}, py{27,34,35}-flake8, docs
envlist = py27-test, py38-{cov,lint}, docs
[_test]
deps =
beautifulsoup4
flask
mock
nose
nose-show-skipped
pylast
rarfile
responses>=0.3.0
pyxdg
jellyfish
python-mpd2
coverage
discogs-client
requests_oauthlib
deps = .[test]
[_flake8]
deps =
flake8
flake8-coding
flake8-future-import
flake8-blind-except
pep8-naming~=0.7.0
[_lint]
deps = .[lint]
files = beets beetsplug beet test setup.py docs
[testenv]
passenv =
NOSE_SHOW_SKIPPED # Undocumented feature of nose-show-skipped.
deps =
{test,cov}: {[_test]deps}
py27: pathlib
py{27,34,35,36,37,38}-flake8: {[_flake8]deps}
lint: {[_lint]deps}
commands =
py27-cov: python -m nose --with-coverage {posargs}
py27-test: python -m nose {posargs}
py3{4,5,6,7,8}-cov: python -bb -m nose --with-coverage {posargs}
py3{4,5,6,7,8}-test: python -bb -m nose {posargs}
py27-flake8: flake8 --min-version 2.7 {posargs} {[_flake8]files}
py34-flake8: flake8 --min-version 3.4 {posargs} {[_flake8]files}
py35-flake8: flake8 --min-version 3.5 {posargs} {[_flake8]files}
py36-flake8: flake8 --min-version 3.6 {posargs} {[_flake8]files}
py37-flake8: flake8 --min-version 3.7 {posargs} {[_flake8]files}
py38-flake8: flake8 --min-version 3.8 {posargs} {[_flake8]files}
test: python -bb -m pytest {posargs}
cov: coverage run -m pytest {posargs}
lint: python -m flake8 {posargs} {[_lint]files}
[testenv:docs]
basepython = python2.7
deps = sphinx
commands = sphinx-build -W -q -b html docs {envtmpdir}/html {posargs}
[testenv:int]
deps = {[_test]deps}
setenv = INTEGRATION_TEST = 1
passenv = GITHUB_ACTIONS
commands = python -bb -m pytest {posargs}