Merge branch 'master' into tekstowo-lyrics

This commit is contained in:
Xavier Hocquet 2021-03-28 12:05:42 -06:00 committed by GitHub
commit 8c6530369d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
130 changed files with 8822 additions and 1525 deletions

View file

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

View file

@ -35,6 +35,12 @@ Here's a link to the music files that trigger the bug (if relevant):
* beets version:
* Turning off plugins made problem go away (yes/no):
<!--
You can turn off plugins temporarily by passing --plugins= on the command line:
$ beet --plugins= version
-->
My configuration (output of `beet config`) is:
```yaml

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.

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

@ -0,0 +1,101 @@
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, 3.10-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: Install optional dependencies
run: |
sudo apt-get install ffmpeg # For replaygain
- name: Test older Python versions with tox
if: matrix.python-version != '3.9' && matrix.python-version != '3.10-dev'
run: |
tox -e py-test
- name: Test latest Python version with tox and get coverage
if: matrix.python-version == '3.9'
run: |
tox -vv -e py-cov
- name: Test nightly Python version with tox
if: matrix.python-version == '3.10-dev'
# continue-on-error is not ideal since it doesn't give a visible
# warning, but there doesn't seem to be anything better:
# https://github.com/actions/toolkit/issues/399
continue-on-error: true
run: |
tox -e py-test
- name: Upload code coverage
if: matrix.python-version == '3.9'
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 3.9
uses: actions/setup-python@v2
with:
python-version: 3.9
- 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.9
uses: actions/setup-python@v2
with:
python-version: 3.9
- 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

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

@ -0,0 +1,49 @@
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: Check external links in docs
run: |
tox -e links
- 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

368
CONTRIBUTING.rst Normal file
View file

@ -0,0 +1,368 @@
############
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`_. 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`_ 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`_. 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`_. 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
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: https://tox.readthedocs.io/en/latest/
.. _detox: https://pypi.org/project/detox/
.. _pytest: https://docs.pytest.org/en/stable/
.. _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
.. _documentation: https://beets.readthedocs.io/en/stable/
.. _pip: https://pip.pypa.io/en/stable/
.. _vim: https://www.vim.org/

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,14 +6,9 @@ skip_commits:
message: /\[appveyor skip\]/
environment:
# Undocumented feature of nose-show-skipped.
NOSE_SHOW_SKIPPED: 1
matrix:
- PYTHON: C:\Python27
TOX_ENV: py27-test
- PYTHON: C:\Python35
TOX_ENV: py35-test
- PYTHON: C:\Python36
TOX_ENV: py36-test
- PYTHON: C:\Python37

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/')
@ -63,9 +71,17 @@ RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups',
'labels', 'artist-credits', 'aliases',
'recording-level-rels', 'work-rels',
'work-level-rels', 'artist-rels']
BROWSE_INCLUDES = ['artist-credits', 'work-rels',
'artist-rels', 'recording-rels', 'release-rels']
if "work-level-rels" in musicbrainzngs.VALID_BROWSE_INCLUDES['recording']:
BROWSE_INCLUDES.append("work-level-rels")
BROWSE_CHUNKSIZE = 100
BROWSE_MAXTRACKS = 500
TRACK_INCLUDES = ['artists', 'aliases']
if 'work-level-rels' in musicbrainzngs.VALID_INCLUDES['recording']:
TRACK_INCLUDES += ['work-level-rels', 'artist-rels']
if 'genres' in musicbrainzngs.VALID_INCLUDES['recording']:
RELEASE_INCLUDES += ['genres']
def track_url(trackid):
@ -185,8 +201,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,
@ -207,6 +223,8 @@ def track_info(recording, index=None, medium=None, medium_index=None,
if recording.get('length'):
info.length = int(recording['length']) / (1000.0)
info.trackdisambig = recording.get('disambiguation')
lyricist = []
composer = []
composer_sort = []
@ -275,6 +293,26 @@ def album_info(release):
artist_name, artist_sort_name, artist_credit_name = \
_flatten_artist_credit(release['artist-credit'])
ntracks = sum(len(m['track-list']) for m in release['medium-list'])
# The MusicBrainz API omits 'artist-relation-list' and 'work-relation-list'
# when the release has more than 500 tracks. So we use browse_recordings
# on chunks of tracks to recover the same information in this case.
if ntracks > BROWSE_MAXTRACKS:
log.debug(u'Album {} has too many tracks', release['id'])
recording_list = []
for i in range(0, ntracks, BROWSE_CHUNKSIZE):
log.debug(u'Retrieving tracks starting at {}', i)
recording_list.extend(musicbrainzngs.browse_recordings(
release=release['id'], limit=BROWSE_CHUNKSIZE,
includes=BROWSE_INCLUDES,
offset=i)['recording-list'])
track_map = {r['id']: r for r in recording_list}
for medium in release['medium-list']:
for recording in medium['track-list']:
recording_info = track_map[recording['recording']['id']]
recording['recording'] = recording_info
# Basic info.
track_infos = []
index = 0
@ -333,11 +371,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,
@ -407,17 +445,21 @@ def album_info(release):
first_medium = release['medium-list'][0]
info.media = first_medium.get('format')
genres = release.get('genre-list')
if config['musicbrainz']['genres'] and genres:
info.genre = ';'.join(g['name'] for g in genres)
info.decode()
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 +471,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

@ -7,6 +7,7 @@ import:
move: no
link: no
hardlink: no
reflink: no
delete: no
resume: ask
incremental: no
@ -44,6 +45,7 @@ replace:
'^\s+': ''
'^-': _
path_sep_replace: _
drive_sep_replace: _
asciify_paths: false
art_filename: cover
max_filename_length: 0
@ -103,6 +105,8 @@ musicbrainz:
ratelimit: 1
ratelimit_interval: 1.0
searchlimit: 5
extra_tags: []
genres: no
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
@ -55,10 +56,11 @@ class FormattedMapping(Mapping):
are replaced.
"""
def __init__(self, model, for_path=False):
def __init__(self, model, for_path=False, compute_keys=True):
self.for_path = for_path
self.model = model
self.model_keys = model.keys(True)
if compute_keys:
self.model_keys = model.keys(True)
def __getitem__(self, key):
if key in self.model_keys:
@ -84,6 +86,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)
@ -251,6 +258,11 @@ class Model(object):
value is the same as the old value (e.g., `o.f = o.f`).
"""
_revision = -1
"""A revision number from when the model was loaded from or written
to the database.
"""
@classmethod
def _getters(cls):
"""Return a mapping from field names to getter functions.
@ -303,9 +315,11 @@ class Model(object):
def clear_dirty(self):
"""Mark all fields as *clean* (i.e., not needing to be stored to
the database).
the database). Also update the revision.
"""
self._dirty = set()
if self._db:
self._revision = self._db.revision
def _check_db(self, need_id=True):
"""Ensure that this object is associated with a database row: it
@ -345,9 +359,9 @@ class Model(object):
"""
return cls._fields.get(key) or cls._types.get(key) or types.DEFAULT
def __getitem__(self, key):
"""Get the value for a field. Raise a KeyError if the field is
not available.
def _get(self, key, default=None, raise_=False):
"""Get the value for a field, or `default`. Alternatively,
raise a KeyError if the field is not available.
"""
getters = self._getters()
if key in getters: # Computed.
@ -359,8 +373,18 @@ class Model(object):
return self._type(key).null
elif key in self._values_flex: # Flexible.
return self._values_flex[key]
else:
elif raise_:
raise KeyError(key)
else:
return default
get = _get
def __getitem__(self, key):
"""Get the value for a field. Raise a KeyError if the field is
not available.
"""
return self._get(key, raise_=True)
def _setitem(self, key, value):
"""Assign the value for a field, return whether new and old value
@ -435,19 +459,10 @@ class Model(object):
for key in self:
yield key, self[key]
def get(self, key, default=None):
"""Get the value for a given key or `default` if it does not
exist.
"""
if key in self:
return self[key]
else:
return default
def __contains__(self, key):
"""Determine whether `key` is an attribute on this object.
"""
return key in self.keys(True)
return key in self.keys(computed=True)
def __iter__(self):
"""Iterate over the available field names (excluding computed
@ -532,8 +547,14 @@ class Model(object):
def load(self):
"""Refresh the object's metadata from the library database.
If check_revision is true, the database is only queried loaded when a
transaction has been committed since the item was last loaded.
"""
self._check_db()
if not self._dirty and self._db.revision == self._revision:
# Exit early
return
stored_obj = self._db._get(type(self), self.id)
assert stored_obj is not None, u"object {0} not in DB".format(self.id)
self._values_fixed = LazyConvertDict(self)
@ -708,10 +729,10 @@ class Results(object):
def _get_indexed_flex_attrs(self):
""" Index flexible attributes by the entity id they belong to
"""
flex_values = dict()
flex_values = {}
for row in self.flex_rows:
if row['entity_id'] not in flex_values:
flex_values[row['entity_id']] = dict()
flex_values[row['entity_id']] = {}
flex_values[row['entity_id']][row['key']] = row['value']
@ -788,6 +809,12 @@ class Transaction(object):
"""A context manager for safe, concurrent access to the database.
All SQL commands should be executed through a transaction.
"""
_mutated = False
"""A flag storing whether a mutation has been executed in the
current transaction.
"""
def __init__(self, db):
self.db = db
@ -809,12 +836,15 @@ class Transaction(object):
entered but not yet exited transaction. If it is the last active
transaction, the database updates are committed.
"""
# Beware of races; currently secured by db._db_lock
self.db.revision += self._mutated
with self.db._tx_stack() as stack:
assert stack.pop() is self
empty = not stack
if empty:
# Ending a "root" transaction. End the SQLite transaction.
self.db._connection().commit()
self._mutated = False
self.db._db_lock.release()
def query(self, statement, subvals=()):
@ -830,7 +860,6 @@ class Transaction(object):
"""
try:
cursor = self.db._connection().execute(statement, subvals)
return cursor.lastrowid
except sqlite3.OperationalError as e:
# In two specific cases, SQLite reports an error while accessing
# the underlying database file. We surface these exceptions as
@ -840,9 +869,14 @@ class Transaction(object):
raise DBAccessError(e.args[0])
else:
raise
else:
self._mutated = True
return cursor.lastrowid
def script(self, statements):
"""Execute a string containing multiple SQL statements."""
# We don't know whether this mutates, but quite likely it does.
self._mutated = True
self.db._connection().executescript(statements)
@ -858,6 +892,11 @@ class Database(object):
supports_extensions = hasattr(sqlite3.Connection, 'enable_load_extension')
"""Whether or not the current version of SQLite supports extensions"""
revision = 0
"""The current revision of the database. To be increased whenever
data is written in a transaction.
"""
def __init__(self, path, timeout=5.0):
self.path = path
self.timeout = timeout

View file

@ -207,6 +207,12 @@ class String(Type):
sql = u'TEXT'
query = query.SubstringQuery
def normalize(self, value):
if value is None:
return self.null
else:
return self.model_type(value)
class Boolean(Type):
"""A boolean type.

View file

@ -187,7 +187,7 @@ class ImportSession(object):
self.logger = self._setup_logging(loghandler)
self.paths = paths
self.query = query
self._is_resuming = dict()
self._is_resuming = {}
self._merged_items = set()
self._merged_dirs = set()
@ -222,19 +222,31 @@ class ImportSession(object):
iconfig['resume'] = False
iconfig['incremental'] = False
# Copy, move, link, and hardlink are mutually exclusive.
if iconfig['reflink']:
iconfig['reflink'] = iconfig['reflink'] \
.as_choice(['auto', True, False])
# Copy, move, reflink, link, and hardlink are mutually exclusive.
if iconfig['move']:
iconfig['copy'] = False
iconfig['link'] = False
iconfig['hardlink'] = False
iconfig['reflink'] = False
elif iconfig['link']:
iconfig['copy'] = False
iconfig['move'] = False
iconfig['hardlink'] = False
iconfig['reflink'] = False
elif iconfig['hardlink']:
iconfig['copy'] = False
iconfig['move'] = False
iconfig['link'] = False
iconfig['reflink'] = False
elif iconfig['reflink']:
iconfig['copy'] = False
iconfig['move'] = False
iconfig['link'] = False
iconfig['hardlink'] = False
# Only delete when copying.
if not iconfig['copy']:
@ -707,7 +719,7 @@ class ImportTask(BaseImportTask):
item.update(changes)
def manipulate_files(self, operation=None, write=False, session=None):
""" Copy, move, link or hardlink (depending on `operation`) the files
""" Copy, move, link, hardlink or reflink (depending on `operation`) the files
as well as write metadata.
`operation` should be an instance of `util.MoveOperation`.
@ -774,7 +786,7 @@ class ImportTask(BaseImportTask):
if (not dup_item.album_id or
dup_item.album_id in replaced_album_ids):
continue
replaced_album = dup_item.get_album()
replaced_album = dup_item._cached_album
if replaced_album:
replaced_album_ids.add(dup_item.album_id)
self.replaced_albums[replaced_album.path] = replaced_album
@ -1034,8 +1046,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:
@ -1536,6 +1548,8 @@ def manipulate_files(session, task):
operation = MoveOperation.LINK
elif session.config['hardlink']:
operation = MoveOperation.HARDLINK
elif session.config['reflink']:
operation = MoveOperation.REFLINK
else:
operation = None

View file

@ -375,7 +375,11 @@ class FormattedItemMapping(dbcore.db.FormattedMapping):
"""
def __init__(self, item, for_path=False):
super(FormattedItemMapping, self).__init__(item, for_path)
# We treat album and item keys specially here,
# so exclude transitive album keys from the model's keys.
super(FormattedItemMapping, self).__init__(item, for_path,
compute_keys=False)
self.model_keys = item.keys(computed=True, with_album=False)
self.item = item
@lazy_property
@ -386,15 +390,15 @@ class FormattedItemMapping(dbcore.db.FormattedMapping):
def album_keys(self):
album_keys = []
if self.album:
for key in self.album.keys(True):
for key in self.album.keys(computed=True):
if key in Album.item_keys \
or key not in self.item._fields.keys():
album_keys.append(key)
return album_keys
@lazy_property
@property
def album(self):
return self.item.get_album()
return self.item._cached_album
def _get(self, key):
"""Get the value for a key, either from the album or the item.
@ -410,7 +414,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)
@ -476,6 +481,7 @@ class Item(LibModel):
'mb_artistid': types.STRING,
'mb_albumartistid': types.STRING,
'mb_releasetrackid': types.STRING,
'trackdisambig': types.STRING,
'albumtype': types.STRING,
'label': types.STRING,
'acoustid_fingerprint': types.STRING,
@ -543,6 +549,29 @@ class Item(LibModel):
_format_config_key = 'format_item'
__album = None
"""Cached album object. Read-only."""
@property
def _cached_album(self):
"""The Album object that this item belongs to, if any, or
None if the item is a singleton or is not associated with a
library.
The instance is cached and refreshed on access.
DO NOT MODIFY!
If you want a copy to modify, use :meth:`get_album`.
"""
if not self.__album and self._db:
self.__album = self._db.get_album(self)
elif self.__album:
self.__album.load()
return self.__album
@_cached_album.setter
def _cached_album(self, album):
self.__album = album
@classmethod
def _getters(cls):
getters = plugins.item_field_getters()
@ -569,12 +598,57 @@ class Item(LibModel):
value = bytestring_path(value)
elif isinstance(value, BLOB_TYPE):
value = bytes(value)
elif key == 'album_id':
self._cached_album = None
changed = super(Item, self)._setitem(key, value)
if changed and key in MediaFile.fields():
self.mtime = 0 # Reset mtime on dirty.
def __getitem__(self, key):
"""Get the value for a field, falling back to the album if
necessary. Raise a KeyError if the field is not available.
"""
try:
return super(Item, self).__getitem__(key)
except KeyError:
if self._cached_album:
return self._cached_album[key]
raise
def __repr__(self):
# This must not use `with_album=True`, because that might access
# the database. When debugging, that is not guaranteed to succeed, and
# can even deadlock due to the database lock.
return '{0}({1})'.format(
type(self).__name__,
', '.join('{0}={1!r}'.format(k, self[k])
for k in self.keys(with_album=False)),
)
def keys(self, computed=False, with_album=True):
"""Get a list of available field names. `with_album`
controls whether the album's fields are included.
"""
keys = super(Item, self).keys(computed=computed)
if with_album and self._cached_album:
keys = set(keys)
keys.update(self._cached_album.keys(computed=computed))
keys = list(keys)
return keys
def get(self, key, default=None, with_album=True):
"""Get the value for a given key or `default` if it does not
exist. Set `with_album` to false to skip album fallback.
"""
try:
return self._get(key, default, raise_=with_album)
except KeyError:
if self._cached_album:
return self._cached_album.get(key, default)
return default
def update(self, values):
"""Set all key/value pairs in the mapping. If mtime is
specified, it is not reset (as it might otherwise be).
@ -746,6 +820,16 @@ class Item(LibModel):
util.hardlink(self.path, dest)
plugins.send("item_hardlinked", item=self, source=self.path,
destination=dest)
elif operation == MoveOperation.REFLINK:
util.reflink(self.path, dest, fallback=False)
plugins.send("item_reflinked", item=self, source=self.path,
destination=dest)
elif operation == MoveOperation.REFLINK_AUTO:
util.reflink(self.path, dest, fallback=True)
plugins.send("item_reflinked", item=self, source=self.path,
destination=dest)
else:
assert False, 'unknown MoveOperation'
# Either copying or moving succeeded, so update the stored path.
self.path = dest
@ -1086,6 +1170,12 @@ class Album(LibModel):
util.link(old_art, new_art)
elif operation == MoveOperation.HARDLINK:
util.hardlink(old_art, new_art)
elif operation == MoveOperation.REFLINK:
util.reflink(old_art, new_art, fallback=False)
elif operation == MoveOperation.REFLINK_AUTO:
util.reflink(old_art, new_art, fallback=True)
else:
assert False, 'unknown MoveOperation'
self.artpath = new_art
def move(self, operation=MoveOperation.MOVE, basedir=None, store=True):

View file

@ -130,29 +130,30 @@ class BeetsPlugin(object):
be sent for backwards-compatibility.
"""
if six.PY2:
func_args = inspect.getargspec(func).args
argspec = inspect.getargspec(func)
func_args = argspec.args
has_varkw = argspec.keywords is not None
else:
func_args = inspect.getfullargspec(func).args
argspec = inspect.getfullargspec(func)
func_args = argspec.args
has_varkw = argspec.varkw is not None
@wraps(func)
def wrapper(*args, **kwargs):
assert self._log.level == logging.NOTSET
verbosity = beets.config['verbose'].get(int)
log_level = max(logging.DEBUG, base_log_level - 10 * verbosity)
self._log.setLevel(log_level)
if not has_varkw:
kwargs = dict((k, v) for k, v in kwargs.items()
if k in func_args)
try:
try:
return func(*args, **kwargs)
except TypeError as exc:
if exc.args[0].startswith(func.__name__):
# caused by 'func' and not stuff internal to 'func'
kwargs = dict((arg, val) for arg, val in kwargs.items()
if arg in func_args)
return func(*args, **kwargs)
else:
raise
return func(*args, **kwargs)
finally:
self._log.setLevel(logging.NOTSET)
return wrapper
def queries(self):
@ -172,7 +173,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.
"""
@ -301,6 +302,11 @@ def find_plugins():
currently loaded beets plugins. Loads the default plugin set
first.
"""
if _instances:
# After the first call, use cached instances for performance reasons.
# See https://github.com/beetbox/beets/pull/3810
return list(_instances.values())
load_plugins()
plugins = []
for cls in _classes:
@ -379,11 +385,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 +721,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

@ -389,17 +389,19 @@ def input_yn(prompt, require=False):
return sel == u'y'
def input_select_objects(prompt, objs, rep):
def input_select_objects(prompt, objs, rep, prompt_all=None):
"""Prompt to user to choose all, none, or some of the given objects.
Return the list of selected objects.
`prompt` is the prompt string to use for each question (it should be
phrased as an imperative verb). `rep` is a function to call on each
object to print it out when confirming objects individually.
phrased as an imperative verb). If `prompt_all` is given, it is used
instead of `prompt` for the first (yes(/no/select) question.
`rep` is a function to call on each object to print it out when confirming
objects individually.
"""
choice = input_options(
(u'y', u'n', u's'), False,
u'%s? (Yes/no/select)' % prompt)
u'%s? (Yes/no/select)' % (prompt_all or prompt))
print() # Blank line.
if choice == u'y': # Yes.
@ -664,10 +666,10 @@ def term_width():
FLOAT_EPSILON = 0.01
def _field_diff(field, old, new):
"""Given two Model objects, format their values for `field` and
highlight changes among them. Return a human-readable string. If the
value has not changed, return None instead.
def _field_diff(field, old, old_fmt, new, new_fmt):
"""Given two Model objects and their formatted views, format their values
for `field` and highlight changes among them. Return a human-readable
string. If the value has not changed, return None instead.
"""
oldval = old.get(field)
newval = new.get(field)
@ -680,8 +682,8 @@ def _field_diff(field, old, new):
return None
# Get formatted values for output.
oldstr = old.formatted().get(field, u'')
newstr = new.formatted().get(field, u'')
oldstr = old_fmt.get(field, u'')
newstr = new_fmt.get(field, u'')
# For strings, highlight changes. For others, colorize the whole
# thing.
@ -706,6 +708,11 @@ def show_model_changes(new, old=None, fields=None, always=False):
"""
old = old or new._db._get(type(new), new.id)
# Keep the formatted views around instead of re-creating them in each
# iteration step
old_fmt = old.formatted()
new_fmt = new.formatted()
# Build up lines showing changed fields.
changes = []
for field in old:
@ -714,7 +721,7 @@ def show_model_changes(new, old=None, fields=None, always=False):
continue
# Detect and show difference for this field.
line = _field_diff(field, old, new)
line = _field_diff(field, old, old_fmt, new, new_fmt)
if line:
changes.append(u' {0}: {1}'.format(field, line))
@ -725,7 +732,7 @@ def show_model_changes(new, old=None, fields=None, always=False):
changes.append(u' {0}: {1}'.format(
field,
colorize('text_highlight', new.formatted()[field])
colorize('text_highlight', new_fmt[field])
))
# Print changes.
@ -789,11 +796,14 @@ def _store_dict(option, opt_str, value, parser):
if option_values is None:
# This is the first supplied ``key=value`` pair of option.
# Initialize empty dictionary and get a reference to it.
setattr(parser.values, dest, dict())
setattr(parser.values, dest, {})
option_values = getattr(parser.values, dest)
# Decode the argument using the platform's argument encoding.
value = util.text_string(value, util.arg_encoding())
try:
key, value = map(lambda s: util.text_string(s), value.split('='))
key, value = value.split('=', 1)
if not (key and value):
raise ValueError
except ValueError:
@ -1100,8 +1110,8 @@ optparse.Option.ALWAYS_TYPED_ACTIONS += ('callback',)
# The main entry point and bootstrapping.
def _load_plugins(config):
"""Load the plugins specified in the configuration.
def _load_plugins(options, config):
"""Load the plugins specified on the command line or in the configuration.
"""
paths = config['pluginpath'].as_str_seq(split=False)
paths = [util.normpath(p) for p in paths]
@ -1112,13 +1122,20 @@ def _load_plugins(config):
# Extend the `beetsplug` package to include the plugin paths.
import beetsplug
beetsplug.__path__ = paths + beetsplug.__path__
beetsplug.__path__ = paths + list(beetsplug.__path__)
# For backwards compatibility, also support plugin paths that
# *contain* a `beetsplug` package.
sys.path += paths
plugins.load_plugins(config['plugins'].as_str_seq())
# If we were given any plugins on the command line, use those.
if options.plugins is not None:
plugin_list = (options.plugins.split(',')
if len(options.plugins) > 0 else [])
else:
plugin_list = config['plugins'].as_str_seq()
plugins.load_plugins(plugin_list)
plugins.send("pluginload")
return plugins
@ -1133,7 +1150,7 @@ def _setup(options, lib=None):
config = _configure(options)
plugins = _load_plugins(config)
plugins = _load_plugins(options, config)
# Get the default subcommands.
from beets.ui.commands import default_commands
@ -1146,8 +1163,13 @@ def _setup(options, lib=None):
plugins.send("library_opened", lib=lib)
# Add types and queries defined by plugins.
library.Item._types.update(plugins.types(library.Item))
library.Album._types.update(plugins.types(library.Album))
plugin_types_album = plugins.types(library.Album)
library.Album._types.update(plugin_types_album)
item_types = plugin_types_album.copy()
item_types.update(library.Item._types)
item_types.update(plugins.types(library.Item))
library.Item._types = item_types
library.Item._queries.update(plugins.named_queries(library.Item))
library.Album._queries.update(plugins.named_queries(library.Album))
@ -1231,6 +1253,8 @@ def _raw_main(args, lib=None):
help=u'log more details (use twice for even more)')
parser.add_option('-c', '--config', dest='config',
help=u'path to configuration file')
parser.add_option('-p', '--plugins', dest='plugins',
help=u'a comma-separated list of plugins to load')
parser.add_option('-h', '--help', dest='help', action='store_true',
help=u'show this help message and exit')
parser.add_option('--version', dest='version', action='store_true',

View file

@ -468,6 +468,10 @@ def summarize_items(items, singleton):
total_duration = sum([item.length for item in items])
total_filesize = sum([item.filesize for item in items])
summary_parts.append(u'{0}kbps'.format(int(average_bitrate / 1000)))
if items[0].format == "FLAC":
sample_bits = u'{}kHz/{} bit'.format(
round(int(items[0].samplerate) / 1000, 1), items[0].bitdepth)
summary_parts.append(sample_bits)
summary_parts.append(ui.human_seconds_short(total_duration))
summary_parts.append(ui.human_bytes(total_filesize))
@ -803,7 +807,7 @@ class TerminalImportSession(importer.ImportSession):
))
sel = ui.input_options(
(u'Skip new', u'Keep both', u'Remove old', u'Merge all')
(u'Skip new', u'Keep all', u'Remove old', u'Merge all')
)
if sel == u's':
@ -1228,31 +1232,53 @@ def remove_items(lib, query, album, delete, force):
"""
# Get the matching items.
items, albums = _do_query(lib, query, album)
objs = albums if album else items
# Confirm file removal if not forcing removal.
if not force:
# Prepare confirmation with user.
print_()
album_str = u" in {} album{}".format(
len(albums), u's' if len(albums) > 1 else u''
) if album else ""
if delete:
fmt = u'$path - $title'
prompt = u'Really DELETE %i file%s (y/n)?' % \
(len(items), 's' if len(items) > 1 else '')
prompt = u'Really DELETE'
prompt_all = u'Really DELETE {} file{}{}'.format(
len(items), u's' if len(items) > 1 else u'', album_str
)
else:
fmt = u''
prompt = u'Really remove %i item%s from the library (y/n)?' % \
(len(items), 's' if len(items) > 1 else '')
prompt = u'Really remove from the library?'
prompt_all = u'Really remove {} item{}{} from the library?'.format(
len(items), u's' if len(items) > 1 else u'', album_str
)
# Helpers for printing affected items
def fmt_track(t):
ui.print_(format(t, fmt))
def fmt_album(a):
ui.print_()
for i in a.items():
fmt_track(i)
fmt_obj = fmt_album if album else fmt_track
# Show all the items.
for item in items:
ui.print_(format(item, fmt))
for o in objs:
fmt_obj(o)
# Confirm with user.
if not ui.input_yn(prompt, True):
return
objs = ui.input_select_objects(prompt, objs, fmt_obj,
prompt_all=prompt_all)
if not objs:
return
# Remove (and possibly delete) items.
with lib.transaction():
for obj in (albums if album else items):
for obj in objs:
obj.remove(delete)
@ -1665,7 +1691,10 @@ def config_func(lib, opts, args):
# Dump configuration.
else:
config_out = config.dump(full=opts.defaults, redact=opts.redact)
print_(util.text_string(config_out))
if config_out.strip() != '{}':
print_(util.text_string(config_out))
else:
print("Empty configuration")
def config_edit():

View file

@ -134,6 +134,8 @@ class MoveOperation(Enum):
COPY = 1
LINK = 2
HARDLINK = 3
REFLINK = 4
REFLINK_AUTO = 5
def normpath(path):
@ -197,6 +199,10 @@ def sorted_walk(path, ignore=(), ignore_hidden=False, logger=None):
skip = False
for pat in ignore:
if fnmatch.fnmatch(base, pat):
if logger:
logger.debug(u'ignoring {0} due to ignore rule {1}'.format(
base, pat
))
skip = True
break
if skip:
@ -545,6 +551,35 @@ def hardlink(path, dest, replace=False):
traceback.format_exc())
def reflink(path, dest, replace=False, fallback=False):
"""Create a reflink from `dest` to `path`.
Raise an `OSError` if `dest` already exists, unless `replace` is
True. If `path` == `dest`, then do nothing.
If reflinking fails and `fallback` is enabled, try copying the file
instead. Otherwise, raise an error without trying a plain copy.
May raise an `ImportError` if the `reflink` module is not available.
"""
import reflink as pyreflink
if samefile(path, dest):
return
if os.path.exists(syspath(dest)) and not replace:
raise FilesystemError(u'file exists', 'rename', (path, dest))
try:
pyreflink.reflink(path, dest)
except (NotImplementedError, pyreflink.ReflinkImpossibleError):
if fallback:
copy(path, dest, replace)
else:
raise FilesystemError(u'OS/filesystem does not support reflinks.',
'link', (path, dest), traceback.format_exc())
def unique_path(path):
"""Returns a version of ``path`` that does not exist on the
filesystem. Specifically, if ``path` itself already exists, then

View file

@ -64,12 +64,13 @@ def temp_file_for(path):
return util.bytestring_path(f.name)
def pil_resize(maxwidth, path_in, path_out=None, quality=0):
def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
"""Resize using Python Imaging Library (PIL). Return the output path
of resized image.
"""
path_out = path_out or temp_file_for(path_in)
from PIL import Image
log.debug(u'artresizer: PIL resizing {0} to {1}',
util.displayable_path(path_in), util.displayable_path(path_out))
@ -77,15 +78,49 @@ def pil_resize(maxwidth, path_in, path_out=None, quality=0):
im = Image.open(util.syspath(path_in))
size = maxwidth, maxwidth
im.thumbnail(size, Image.ANTIALIAS)
if quality == 0:
# Use PIL's default quality.
quality = -1
im.save(util.py3_path(path_out), quality=quality)
return path_out
if max_filesize > 0:
# If maximum filesize is set, we attempt to lower the quality of
# jpeg conversion by a proportional amount, up to 3 attempts
# First, set the maximum quality to either provided, or 95
if quality > 0:
lower_qual = quality
else:
lower_qual = 95
for i in range(5):
# 5 attempts is an abitrary choice
filesize = os.stat(util.syspath(path_out)).st_size
log.debug(u"PIL Pass {0} : Output size: {1}B", i, filesize)
if filesize <= max_filesize:
return path_out
# The relationship between filesize & quality will be
# image dependent.
lower_qual -= 10
# Restrict quality dropping below 10
if lower_qual < 10:
lower_qual = 10
# Use optimize flag to improve filesize decrease
im.save(
util.py3_path(path_out), quality=lower_qual, optimize=True
)
log.warning(u"PIL Failed to resize file to below {0}B",
max_filesize)
return path_out
else:
return path_out
except IOError:
log.error(u"PIL cannot create thumbnail for '{0}'",
util.displayable_path(path_in))
return path_in
def im_resize(maxwidth, path_in, path_out=None, quality=0):
def im_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
"""Resize using ImageMagick.
Use the ``magick`` program or ``convert`` on older versions. Return
@ -106,6 +141,11 @@ def im_resize(maxwidth, path_in, path_out=None, quality=0):
if quality > 0:
cmd += ['-quality', '{0}'.format(quality)]
# "-define jpeg:extent=SIZEb" sets the target filesize for imagemagick to
# SIZE in bytes.
if max_filesize > 0:
cmd += ['-define', 'jpeg:extent={0}b'.format(max_filesize)]
cmd.append(util.syspath(path_out, prefix=False))
try:
@ -126,6 +166,7 @@ BACKEND_FUNCS = {
def pil_getsize(path_in):
from PIL import Image
try:
im = Image.open(util.syspath(path_in))
return im.size
@ -166,6 +207,7 @@ class Shareable(type):
lazily-created shared instance of ``MyClass`` while calling
``MyClass()`` to construct a new object works as usual.
"""
def __init__(cls, name, bases, dict):
super(Shareable, cls).__init__(name, bases, dict)
cls._instance = None
@ -200,7 +242,9 @@ 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, quality=0):
def resize(
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=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 and encodes with the specified quality level.
@ -208,7 +252,8 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
"""
if self.local:
func = BACKEND_FUNCS[self.method[0]]
return func(maxwidth, path_in, path_out, quality=quality)
return func(maxwidth, path_in, path_out,
quality=quality, max_filesize=max_filesize)
else:
return path_in

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

@ -247,9 +247,6 @@ class FirstPipelineThread(PipelineThread):
self.out_queue = out_queue
self.out_queue.acquire()
self.abort_lock = Lock()
self.abort_flag = False
def run(self):
try:
while True:

966
beetsplug/aura.py Normal file
View file

@ -0,0 +1,966 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2020, Callum Brown.
#
# 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.
"""An AURA server using Flask."""
from __future__ import division, absolute_import, print_function
from mimetypes import guess_type
import re
from os.path import isfile, getsize
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand, _open_library
from beets import config
from beets.util import py3_path
from beets.library import Item, Album
from beets.dbcore.query import (
MatchQuery,
NotQuery,
RegexpQuery,
AndQuery,
FixedFieldSort,
SlowFieldSort,
MultipleSort,
)
from flask import (
Blueprint,
Flask,
current_app,
send_file,
make_response,
request,
)
# Constants
# AURA server information
# TODO: Add version information
SERVER_INFO = {
"aura-version": "0",
"server": "beets-aura",
"server-version": "0.1",
"auth-required": False,
"features": ["albums", "artists", "images"],
}
# Maps AURA Track attribute to beets Item attribute
TRACK_ATTR_MAP = {
# Required
"title": "title",
"artist": "artist",
# Optional
"album": "album",
"track": "track", # Track number on album
"tracktotal": "tracktotal",
"disc": "disc",
"disctotal": "disctotal",
"year": "year",
"month": "month",
"day": "day",
"bpm": "bpm",
"genre": "genre",
"recording-mbid": "mb_trackid", # beets trackid is MB recording
"track-mbid": "mb_releasetrackid",
"composer": "composer",
"albumartist": "albumartist",
"comments": "comments",
# Optional for Audio Metadata
# TODO: Support the mimetype attribute, format != mime type
# "mimetype": track.format,
"duration": "length",
"framerate": "samplerate",
# I don't think beets has a framecount field
# "framecount": ???,
"channels": "channels",
"bitrate": "bitrate",
"bitdepth": "bitdepth",
"size": "filesize",
}
# Maps AURA Album attribute to beets Album attribute
ALBUM_ATTR_MAP = {
# Required
"title": "album",
"artist": "albumartist",
# Optional
"tracktotal": "albumtotal",
"disctotal": "disctotal",
"year": "year",
"month": "month",
"day": "day",
"genre": "genre",
"release-mbid": "mb_albumid",
"release-group-mbid": "mb_releasegroupid",
}
# Maps AURA Artist attribute to beets Item field
# Artists are not first-class in beets, so information is extracted from
# beets Items.
ARTIST_ATTR_MAP = {
# Required
"name": "artist",
# Optional
"artist-mbid": "mb_artistid",
}
class AURADocument:
"""Base class for building AURA documents."""
@staticmethod
def error(status, title, detail):
"""Make a response for an error following the JSON:API spec.
Args:
status: An HTTP status code string, e.g. "404 Not Found".
title: A short, human-readable summary of the problem.
detail: A human-readable explanation specific to this
occurrence of the problem.
"""
document = {
"errors": [{"status": status, "title": title, "detail": detail}]
}
return make_response(document, status)
def translate_filters(self):
"""Translate filters from request arguments to a beets Query."""
# The format of each filter key in the request parameter is:
# filter[<attribute>]. This regex extracts <attribute>.
pattern = re.compile(r"filter\[(?P<attribute>[a-zA-Z0-9_-]+)\]")
queries = []
for key, value in request.args.items():
match = pattern.match(key)
if match:
# Extract attribute name from key
aura_attr = match.group("attribute")
# Get the beets version of the attribute name
beets_attr = self.attribute_map.get(aura_attr, aura_attr)
converter = self.get_attribute_converter(beets_attr)
value = converter(value)
# Add exact match query to list
# Use a slow query so it works with all fields
queries.append(MatchQuery(beets_attr, value, fast=False))
# NOTE: AURA doesn't officially support multiple queries
return AndQuery(queries)
def translate_sorts(self, sort_arg):
"""Translate an AURA sort parameter into a beets Sort.
Args:
sort_arg: The value of the 'sort' query parameter; a comma
separated list of fields to sort by, in order.
E.g. "-year,title".
"""
# Change HTTP query parameter to a list
aura_sorts = sort_arg.strip(",").split(",")
sorts = []
for aura_attr in aura_sorts:
if aura_attr[0] == "-":
ascending = False
# Remove leading "-"
aura_attr = aura_attr[1:]
else:
# JSON:API default
ascending = True
# Get the beets version of the attribute name
beets_attr = self.attribute_map.get(aura_attr, aura_attr)
# Use slow sort so it works with all fields (inc. computed)
sorts.append(SlowFieldSort(beets_attr, ascending=ascending))
return MultipleSort(sorts)
def paginate(self, collection):
"""Get a page of the collection and the URL to the next page.
Args:
collection: The raw data from which resource objects can be
built. Could be an sqlite3.Cursor object (tracks and
albums) or a list of strings (artists).
"""
# Pages start from zero
page = request.args.get("page", 0, int)
# Use page limit defined in config by default.
default_limit = config["aura"]["page_limit"].get(int)
limit = request.args.get("limit", default_limit, int)
# start = offset of first item to return
start = page * limit
# end = offset of last item + 1
end = start + limit
if end > len(collection):
end = len(collection)
next_url = None
else:
# Not the last page so work out links.next url
if not request.args:
# No existing arguments, so current page is 0
next_url = request.url + "?page=1"
elif not request.args.get("page", None):
# No existing page argument, so add one to the end
next_url = request.url + "&page=1"
else:
# Increment page token by 1
next_url = request.url.replace(
"page={}".format(page), "page={}".format(page + 1)
)
# Get only the items in the page range
data = [self.resource_object(collection[i]) for i in range(start, end)]
return data, next_url
def get_included(self, data, include_str):
"""Build a list of resource objects for inclusion.
Args:
data: An array of dicts in the form of resource objects.
include_str: A comma separated list of resource types to
include. E.g. "tracks,images".
"""
# Change HTTP query parameter to a list
to_include = include_str.strip(",").split(",")
# Build a list of unique type and id combinations
# For each resource object in the primary data, iterate over it's
# relationships. If a relationship matches one of the types
# requested for inclusion (e.g. "albums") then add each type-id pair
# under the "data" key to unique_identifiers, checking first that
# it has not already been added. This ensures that no resources are
# included more than once.
unique_identifiers = []
for res_obj in data:
for rel_name, rel_obj in res_obj["relationships"].items():
if rel_name in to_include:
# NOTE: Assumes relationship is to-many
for identifier in rel_obj["data"]:
if identifier not in unique_identifiers:
unique_identifiers.append(identifier)
# TODO: I think this could be improved
included = []
for identifier in unique_identifiers:
res_type = identifier["type"]
if res_type == "track":
track_id = int(identifier["id"])
track = current_app.config["lib"].get_item(track_id)
included.append(TrackDocument.resource_object(track))
elif res_type == "album":
album_id = int(identifier["id"])
album = current_app.config["lib"].get_album(album_id)
included.append(AlbumDocument.resource_object(album))
elif res_type == "artist":
artist_id = identifier["id"]
included.append(ArtistDocument.resource_object(artist_id))
elif res_type == "image":
image_id = identifier["id"]
included.append(ImageDocument.resource_object(image_id))
else:
raise ValueError("Invalid resource type: {}".format(res_type))
return included
def all_resources(self):
"""Build document for /tracks, /albums or /artists."""
query = self.translate_filters()
sort_arg = request.args.get("sort", None)
if sort_arg:
sort = self.translate_sorts(sort_arg)
# For each sort field add a query which ensures all results
# have a non-empty, non-zero value for that field.
for s in sort.sorts:
query.subqueries.append(
NotQuery(
# Match empty fields (^$) or zero fields, (^0$)
RegexpQuery(s.field, "(^$|^0$)", fast=False)
)
)
else:
sort = None
# Get information from the library
collection = self.get_collection(query=query, sort=sort)
# Convert info to AURA form and paginate it
data, next_url = self.paginate(collection)
document = {"data": data}
# If there are more pages then provide a way to access them
if next_url:
document["links"] = {"next": next_url}
# Include related resources for each element in "data"
include_str = request.args.get("include", None)
if include_str:
document["included"] = self.get_included(data, include_str)
return document
def single_resource_document(self, resource_object):
"""Build document for a specific requested resource.
Args:
resource_object: A dictionary in the form of a JSON:API
resource object.
"""
document = {"data": resource_object}
include_str = request.args.get("include", None)
if include_str:
# [document["data"]] is because arg needs to be list
document["included"] = self.get_included(
[document["data"]], include_str
)
return document
class TrackDocument(AURADocument):
"""Class for building documents for /tracks endpoints."""
attribute_map = TRACK_ATTR_MAP
def get_collection(self, query=None, sort=None):
"""Get Item objects from the library.
Args:
query: A beets Query object or a beets query string.
sort: A beets Sort object.
"""
return current_app.config["lib"].items(query, sort)
def get_attribute_converter(self, beets_attr):
"""Work out what data type an attribute should be for beets.
Args:
beets_attr: The name of the beets attribute, e.g. "title".
"""
# filesize is a special field (read from disk not db?)
if beets_attr == "filesize":
converter = int
else:
try:
# Look for field in list of Item fields
# and get python type of database type.
# See beets.library.Item and beets.dbcore.types
converter = Item._fields[beets_attr].model_type
except KeyError:
# Fall back to string (NOTE: probably not good)
converter = str
return converter
@staticmethod
def resource_object(track):
"""Construct a JSON:API resource object from a beets Item.
Args:
track: A beets Item object.
"""
attributes = {}
# Use aura => beets attribute map, e.g. size => filesize
for aura_attr, beets_attr in TRACK_ATTR_MAP.items():
a = getattr(track, beets_attr)
# Only set attribute if it's not None, 0, "", etc.
# NOTE: This could result in required attributes not being set
if a:
attributes[aura_attr] = a
# JSON:API one-to-many relationship to parent album
relationships = {
"artists": {"data": [{"type": "artist", "id": track.artist}]}
}
# Only add album relationship if not singleton
if not track.singleton:
relationships["albums"] = {
"data": [{"type": "album", "id": str(track.album_id)}]
}
return {
"type": "track",
"id": str(track.id),
"attributes": attributes,
"relationships": relationships,
}
def single_resource(self, track_id):
"""Get track from the library and build a document.
Args:
track_id: The beets id of the track (integer).
"""
track = current_app.config["lib"].get_item(track_id)
if not track:
return self.error(
"404 Not Found",
"No track with the requested id.",
"There is no track with an id of {} in the library.".format(
track_id
),
)
return self.single_resource_document(self.resource_object(track))
class AlbumDocument(AURADocument):
"""Class for building documents for /albums endpoints."""
attribute_map = ALBUM_ATTR_MAP
def get_collection(self, query=None, sort=None):
"""Get Album objects from the library.
Args:
query: A beets Query object or a beets query string.
sort: A beets Sort object.
"""
return current_app.config["lib"].albums(query, sort)
def get_attribute_converter(self, beets_attr):
"""Work out what data type an attribute should be for beets.
Args:
beets_attr: The name of the beets attribute, e.g. "title".
"""
try:
# Look for field in list of Album fields
# and get python type of database type.
# See beets.library.Album and beets.dbcore.types
converter = Album._fields[beets_attr].model_type
except KeyError:
# Fall back to string (NOTE: probably not good)
converter = str
return converter
@staticmethod
def resource_object(album):
"""Construct a JSON:API resource object from a beets Album.
Args:
album: A beets Album object.
"""
attributes = {}
# Use aura => beets attribute name map
for aura_attr, beets_attr in ALBUM_ATTR_MAP.items():
a = getattr(album, beets_attr)
# Only set attribute if it's not None, 0, "", etc.
# NOTE: This could mean required attributes are not set
if a:
attributes[aura_attr] = a
# Get beets Item objects for all tracks in the album sorted by
# track number. Sorting is not required but it's nice.
query = MatchQuery("album_id", album.id)
sort = FixedFieldSort("track", ascending=True)
tracks = current_app.config["lib"].items(query, sort)
# JSON:API one-to-many relationship to tracks on the album
relationships = {
"tracks": {
"data": [{"type": "track", "id": str(t.id)} for t in tracks]
}
}
# Add images relationship if album has associated images
if album.artpath:
path = py3_path(album.artpath)
filename = path.split("/")[-1]
image_id = "album-{}-{}".format(album.id, filename)
relationships["images"] = {
"data": [{"type": "image", "id": image_id}]
}
# Add artist relationship if artist name is same on tracks
# Tracks are used to define artists so don't albumartist
# Check for all tracks in case some have featured artists
if album.albumartist in [t.artist for t in tracks]:
relationships["artists"] = {
"data": [{"type": "artist", "id": album.albumartist}]
}
return {
"type": "album",
"id": str(album.id),
"attributes": attributes,
"relationships": relationships,
}
def single_resource(self, album_id):
"""Get album from the library and build a document.
Args:
album_id: The beets id of the album (integer).
"""
album = current_app.config["lib"].get_album(album_id)
if not album:
return self.error(
"404 Not Found",
"No album with the requested id.",
"There is no album with an id of {} in the library.".format(
album_id
),
)
return self.single_resource_document(self.resource_object(album))
class ArtistDocument(AURADocument):
"""Class for building documents for /artists endpoints."""
attribute_map = ARTIST_ATTR_MAP
def get_collection(self, query=None, sort=None):
"""Get a list of artist names from the library.
Args:
query: A beets Query object or a beets query string.
sort: A beets Sort object.
"""
# Gets only tracks with matching artist information
tracks = current_app.config["lib"].items(query, sort)
collection = []
for track in tracks:
# Do not add duplicates
if track.artist not in collection:
collection.append(track.artist)
return collection
def get_attribute_converter(self, beets_attr):
"""Work out what data type an attribute should be for beets.
Args:
beets_attr: The name of the beets attribute, e.g. "artist".
"""
try:
# Look for field in list of Item fields
# and get python type of database type.
# See beets.library.Item and beets.dbcore.types
converter = Item._fields[beets_attr].model_type
except KeyError:
# Fall back to string (NOTE: probably not good)
converter = str
return converter
@staticmethod
def resource_object(artist_id):
"""Construct a JSON:API resource object for the given artist.
Args:
artist_id: A string which is the artist's name.
"""
# Get tracks where artist field exactly matches artist_id
query = MatchQuery("artist", artist_id)
tracks = current_app.config["lib"].items(query)
if not tracks:
return None
# Get artist information from the first track
# NOTE: It could be that the first track doesn't have a
# MusicBrainz id but later tracks do, which isn't ideal.
attributes = {}
# Use aura => beets attribute map, e.g. artist => name
for aura_attr, beets_attr in ARTIST_ATTR_MAP.items():
a = getattr(tracks[0], beets_attr)
# Only set attribute if it's not None, 0, "", etc.
# NOTE: This could mean required attributes are not set
if a:
attributes[aura_attr] = a
relationships = {
"tracks": {
"data": [{"type": "track", "id": str(t.id)} for t in tracks]
}
}
album_query = MatchQuery("albumartist", artist_id)
albums = current_app.config["lib"].albums(query=album_query)
if len(albums) != 0:
relationships["albums"] = {
"data": [{"type": "album", "id": str(a.id)} for a in albums]
}
return {
"type": "artist",
"id": artist_id,
"attributes": attributes,
"relationships": relationships,
}
def single_resource(self, artist_id):
"""Get info for the requested artist and build a document.
Args:
artist_id: A string which is the artist's name.
"""
artist_resource = self.resource_object(artist_id)
if not artist_resource:
return self.error(
"404 Not Found",
"No artist with the requested id.",
"There is no artist with an id of {} in the library.".format(
artist_id
),
)
return self.single_resource_document(artist_resource)
class ImageDocument(AURADocument):
"""Class for building documents for /images/(id) endpoints."""
@staticmethod
def get_image_path(image_id):
"""Works out the full path to the image with the given id.
Returns None if there is no such image.
Args:
image_id: A string in the form
"<parent_type>-<parent_id>-<img_filename>".
"""
# Split image_id into its constituent parts
id_split = image_id.split("-")
if len(id_split) < 3:
# image_id is not in the required format
return None
parent_type = id_split[0]
parent_id = id_split[1]
img_filename = "-".join(id_split[2:])
# Get the path to the directory parent's images are in
if parent_type == "album":
album = current_app.config["lib"].get_album(int(parent_id))
if not album or not album.artpath:
return None
# Cut the filename off of artpath
# This is in preparation for supporting images in the same
# directory that are not tracked by beets.
artpath = py3_path(album.artpath)
dir_path = "/".join(artpath.split("/")[:-1])
else:
# Images for other resource types are not supported
return None
img_path = dir_path + "/" + img_filename
# Check the image actually exists
if isfile(img_path):
return img_path
else:
return None
@staticmethod
def resource_object(image_id):
"""Construct a JSON:API resource object for the given image.
Args:
image_id: A string in the form
"<parent_type>-<parent_id>-<img_filename>".
"""
# Could be called as a static method, so can't use
# self.get_image_path()
image_path = ImageDocument.get_image_path(image_id)
if not image_path:
return None
attributes = {
"role": "cover",
"mimetype": guess_type(image_path)[0],
"size": getsize(image_path),
}
try:
from PIL import Image
except ImportError:
pass
else:
im = Image.open(image_path)
attributes["width"] = im.width
attributes["height"] = im.height
relationships = {}
# Split id into [parent_type, parent_id, filename]
id_split = image_id.split("-")
relationships[id_split[0] + "s"] = {
"data": [{"type": id_split[0], "id": id_split[1]}]
}
return {
"id": image_id,
"type": "image",
# Remove attributes that are None, 0, "", etc.
"attributes": {k: v for k, v in attributes.items() if v},
"relationships": relationships,
}
def single_resource(self, image_id):
"""Get info for the requested image and build a document.
Args:
image_id: A string in the form
"<parent_type>-<parent_id>-<img_filename>".
"""
image_resource = self.resource_object(image_id)
if not image_resource:
return self.error(
"404 Not Found",
"No image with the requested id.",
"There is no image with an id of {} in the library.".format(
image_id
),
)
return self.single_resource_document(image_resource)
# Initialise flask blueprint
aura_bp = Blueprint("aura_bp", __name__)
@aura_bp.route("/server")
def server_info():
"""Respond with info about the server."""
return {"data": {"type": "server", "id": "0", "attributes": SERVER_INFO}}
# Track endpoints
@aura_bp.route("/tracks")
def all_tracks():
"""Respond with a list of all tracks and related information."""
doc = TrackDocument()
return doc.all_resources()
@aura_bp.route("/tracks/<int:track_id>")
def single_track(track_id):
"""Respond with info about the specified track.
Args:
track_id: The id of the track provided in the URL (integer).
"""
doc = TrackDocument()
return doc.single_resource(track_id)
@aura_bp.route("/tracks/<int:track_id>/audio")
def audio_file(track_id):
"""Supply an audio file for the specified track.
Args:
track_id: The id of the track provided in the URL (integer).
"""
track = current_app.config["lib"].get_item(track_id)
if not track:
return AURADocument.error(
"404 Not Found",
"No track with the requested id.",
"There is no track with an id of {} in the library.".format(
track_id
),
)
path = py3_path(track.path)
if not isfile(path):
return AURADocument.error(
"404 Not Found",
"No audio file for the requested track.",
(
"There is no audio file for track {} at the expected location"
).format(track_id),
)
file_mimetype = guess_type(path)[0]
if not file_mimetype:
return AURADocument.error(
"500 Internal Server Error",
"Requested audio file has an unknown mimetype.",
(
"The audio file for track {} has an unknown mimetype. "
"Its file extension is {}."
).format(track_id, path.split(".")[-1]),
)
# Check that the Accept header contains the file's mimetype
# Takes into account */* and audio/*
# Adding support for the bitrate parameter would require some effort so I
# left it out. This means the client could be sent an error even if the
# audio doesn't need transcoding.
if not request.accept_mimetypes.best_match([file_mimetype]):
return AURADocument.error(
"406 Not Acceptable",
"Unsupported MIME type or bitrate parameter in Accept header.",
(
"The audio file for track {} is only available as {} and "
"bitrate parameters are not supported."
).format(track_id, file_mimetype),
)
return send_file(
path,
mimetype=file_mimetype,
# Handles filename in Content-Disposition header
as_attachment=True,
# Tries to upgrade the stream to support range requests
conditional=True,
)
# Album endpoints
@aura_bp.route("/albums")
def all_albums():
"""Respond with a list of all albums and related information."""
doc = AlbumDocument()
return doc.all_resources()
@aura_bp.route("/albums/<int:album_id>")
def single_album(album_id):
"""Respond with info about the specified album.
Args:
album_id: The id of the album provided in the URL (integer).
"""
doc = AlbumDocument()
return doc.single_resource(album_id)
# Artist endpoints
# Artist ids are their names
@aura_bp.route("/artists")
def all_artists():
"""Respond with a list of all artists and related information."""
doc = ArtistDocument()
return doc.all_resources()
# Using the path converter allows slashes in artist_id
@aura_bp.route("/artists/<path:artist_id>")
def single_artist(artist_id):
"""Respond with info about the specified artist.
Args:
artist_id: The id of the artist provided in the URL. A string
which is the artist's name.
"""
doc = ArtistDocument()
return doc.single_resource(artist_id)
# Image endpoints
# Image ids are in the form <parent_type>-<parent_id>-<img_filename>
# For example: album-13-cover.jpg
@aura_bp.route("/images/<string:image_id>")
def single_image(image_id):
"""Respond with info about the specified image.
Args:
image_id: The id of the image provided in the URL. A string in
the form "<parent_type>-<parent_id>-<img_filename>".
"""
doc = ImageDocument()
return doc.single_resource(image_id)
@aura_bp.route("/images/<string:image_id>/file")
def image_file(image_id):
"""Supply an image file for the specified image.
Args:
image_id: The id of the image provided in the URL. A string in
the form "<parent_type>-<parent_id>-<img_filename>".
"""
img_path = ImageDocument.get_image_path(image_id)
if not img_path:
return AURADocument.error(
"404 Not Found",
"No image with the requested id.",
"There is no image with an id of {} in the library".format(
image_id
),
)
return send_file(img_path)
# WSGI app
def create_app():
"""An application factory for use by a WSGI server."""
config["aura"].add(
{
"host": u"127.0.0.1",
"port": 8337,
"cors": [],
"cors_supports_credentials": False,
"page_limit": 500,
}
)
app = Flask(__name__)
# Register AURA blueprint view functions under a URL prefix
app.register_blueprint(aura_bp, url_prefix="/aura")
# AURA specifies mimetype MUST be this
app.config["JSONIFY_MIMETYPE"] = "application/vnd.api+json"
# Disable auto-sorting of JSON keys
app.config["JSON_SORT_KEYS"] = False
# Provide a way to access the beets library
# The normal method of using the Library and config provided in the
# command function is not used because create_app() could be called
# by an external WSGI server.
# NOTE: this uses a 'private' function from beets.ui.__init__
app.config["lib"] = _open_library(config)
# Enable CORS if required
cors = config["aura"]["cors"].as_str_seq(list)
if cors:
from flask_cors import CORS
# "Accept" is the only header clients use
app.config["CORS_ALLOW_HEADERS"] = "Accept"
app.config["CORS_RESOURCES"] = {r"/aura/*": {"origins": cors}}
app.config["CORS_SUPPORTS_CREDENTIALS"] = config["aura"][
"cors_supports_credentials"
].get(bool)
CORS(app)
return app
# Beets Plugin Hook
class AURAPlugin(BeetsPlugin):
"""The BeetsPlugin subclass for the AURA server plugin."""
def __init__(self):
"""Add configuration options for the AURA plugin."""
super(AURAPlugin, self).__init__()
def commands(self):
"""Add subcommand used to run the AURA server."""
def run_aura(lib, opts, args):
"""Run the application using Flask's built in-server.
Args:
lib: A beets Library object (not used).
opts: Command line options. An optparse.Values object.
args: The list of arguments to process (not used).
"""
app = create_app()
# Start the built-in server (not intended for production)
app.run(
host=self.config["host"].get(str),
port=self.config["port"].get(int),
debug=opts.debug,
threaded=True,
)
run_aura_cmd = Subcommand("aura", help=u"run an AURA server")
run_aura_cmd.parser.add_option(
u"-d",
u"--debug",
action="store_true",
default=False,
help=u"use Flask debug mode",
)
run_aura_cmd.func = run_aura
return [run_aura_cmd]

85
beetsplug/bareasc.py Normal file
View file

@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Philippe Mongeau.
# Copyright 2021, Graham R. Cobb.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and ascociated 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 module is adapted from Fuzzy in accordance to the licence of
# that module
"""Provides a bare-ASCII matching query."""
from __future__ import division, absolute_import, print_function
from beets import ui
from beets.ui import print_, decargs
from beets.plugins import BeetsPlugin
from beets.dbcore.query import StringFieldQuery
from unidecode import unidecode
import six
class BareascQuery(StringFieldQuery):
"""Compare items using bare ASCII, without accents etc."""
@classmethod
def string_match(cls, pattern, val):
"""Convert both pattern and string to plain ASCII before matching.
If pattern is all lower case, also convert string to lower case so
match is also case insensitive
"""
# smartcase
if pattern.islower():
val = val.lower()
pattern = unidecode(pattern)
val = unidecode(val)
return pattern in val
class BareascPlugin(BeetsPlugin):
"""Plugin to provide bare-ASCII option for beets matching."""
def __init__(self):
"""Default prefix for selecting bare-ASCII matching is #."""
super(BareascPlugin, self).__init__()
self.config.add({
'prefix': '#',
})
def queries(self):
"""Register bare-ASCII matching."""
prefix = self.config['prefix'].as_str()
return {prefix: BareascQuery}
def commands(self):
"""Add bareasc command as unidecode version of 'list'."""
cmd = ui.Subcommand('bareasc',
help='unidecode version of beet list command')
cmd.parser.usage += u"\n" \
u'Example: %prog -f \'$album: $title\' artist:beatles'
cmd.parser.add_all_common_options()
cmd.func = self.unidecode_list
return [cmd]
def unidecode_list(self, lib, opts, args):
"""Emulate normal 'list' command but with unidecode output."""
query = decargs(args)
album = opts.album
# Copied from commands.py - list_items
if album:
for album in lib.albums(query):
bare = unidecode(six.ensure_text(str(album)))
print_(six.ensure_text(bare))
else:
for item in lib.items(query):
bare = unidecode(six.ensure_text(str(item)))
print_(six.ensure_text(bare))

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

@ -21,6 +21,7 @@ use of the wide range of MPD clients.
from __future__ import division, absolute_import, print_function
import re
import sys
from string import Template
import traceback
import random
@ -334,7 +335,7 @@ class BaseServer(object):
def cmd_kill(self, conn):
"""Exits the server process."""
exit(0)
sys.exit(0)
def cmd_close(self, conn):
"""Closes the connection."""

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)
@ -279,7 +279,7 @@ def submit_items(log, userkey, items, chunksize=64):
del data[:]
for item in items:
fp = fingerprint_item(log, item)
fp = fingerprint_item(log, item, write=ui.should_write())
# Construct a submission dictionary for this item.
item_data = {
@ -329,7 +329,7 @@ def fingerprint_item(log, item, write=False):
else:
log.info(u'{0}: using existing fingerprint',
util.displayable_path(item.path))
return item.acoustid_fingerprint
return item.acoustid_fingerprint
else:
log.info(u'{0}: fingerprinting',
util.displayable_path(item.path))

View file

@ -16,6 +16,7 @@
"""Converts tracks or albums to external directory
"""
from __future__ import division, absolute_import, print_function
from beets.util import par_map
import os
import threading
@ -148,6 +149,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]
@ -182,8 +184,8 @@ class ConvertPlugin(BeetsPlugin):
def auto_convert(self, config, task):
if self.config['auto']:
for item in task.imported_items():
self.convert_on_import(config.lib, item)
par_map(lambda item: self.convert_on_import(config.lib, item),
task.imported_items())
# Utilities converted from functions to methods on logging overhaul
@ -356,7 +358,7 @@ class ConvertPlugin(BeetsPlugin):
item.store() # Store new path and audio data.
if self.config['embed'] and not linked:
album = item.get_album()
album = item._cached_album
if album and album.artpath:
self._log.debug(u'embedding album art from {}',
util.displayable_path(album.artpath))
@ -532,11 +534,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

@ -1,57 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2016 Bruno Cauet
# Split an album-file in tracks thanks a cue file
from __future__ import division, absolute_import, print_function
import subprocess
from os import path
from glob import glob
from beets.util import command_output, displayable_path
from beets.plugins import BeetsPlugin
from beets.autotag import TrackInfo
class CuePlugin(BeetsPlugin):
def __init__(self):
super(CuePlugin, self).__init__()
# this does not seem supported by shnsplit
self.config.add({
'keep_before': .1,
'keep_after': .9,
})
# self.register_listener('import_task_start', self.look_for_cues)
def candidates(self, items, artist, album, va_likely):
import pdb
pdb.set_trace()
def item_candidates(self, item, artist, album):
dir = path.dirname(item.path)
cues = glob.glob(path.join(dir, "*.cue"))
if not cues:
return
if len(cues) > 1:
self._log.info(u"Found multiple cue files doing nothing: {0}",
list(map(displayable_path, cues)))
cue_file = cues[0]
self._log.info("Found {} for {}", displayable_path(cue_file), item)
try:
# careful: will ask for input in case of conflicts
command_output(['shnsplit', '-f', cue_file, item.path])
except (subprocess.CalledProcessError, OSError):
self._log.exception(u'shnsplit execution failed')
return
tracks = glob(path.join(dir, "*.wav"))
self._log.info("Generated {0} tracks", len(tracks))
for t in tracks:
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)
# generate TrackInfo instances

View file

@ -14,7 +14,7 @@
# included in all copies or substantial portions of the Software.
"""Adds Discogs album search support to the autotagger. Requires the
discogs-client library.
python3-discogs-client library.
"""
from __future__ import division, absolute_import, print_function
@ -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).
"""
@ -239,13 +239,10 @@ class DiscogsPlugin(BeetsPlugin):
# cause a query to return no results, even if they match the artist or
# album title. Use `re.UNICODE` flag to avoid stripping non-english
# word characters.
# FIXME: Encode as ASCII to work around a bug:
# https://github.com/beetbox/beets/issues/1051
# When the library is fixed, we should encode as UTF-8.
query = re.sub(r'(?u)\W+', ' ', query).encode('ascii', "replace")
query = re.sub(r'(?u)\W+', ' ', query)
# Strip medium information from query, Things like "CD1" and "disk 1"
# can also negate an otherwise positive result.
query = re.sub(br'(?i)\b(CD|disc)\s*\d+', b'', query)
query = re.sub(r'(?i)\b(CD|disc)\s*\d+', '', query)
self.request_start()
try:
@ -356,17 +353,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)
@ -508,6 +502,12 @@ class DiscogsPlugin(BeetsPlugin):
for subtrack in subtracks:
if not subtrack.get('artists'):
subtrack['artists'] = index_track['artists']
# Concatenate index with track title when index_tracks
# option is set
if self.config['index_tracks']:
for subtrack in subtracks:
subtrack['title'] = '{}: {}'.format(
index_track['title'], subtrack['title'])
tracklist.extend(subtracks)
else:
# Merge the subtracks, pick a title, and append the new track.
@ -560,17 +560,17 @@ class DiscogsPlugin(BeetsPlugin):
title = track['title']
if self.config['index_tracks']:
prefix = ', '.join(divisions)
title = ': '.join([prefix, title])
if prefix:
title = '{}: {}'.format(prefix, title)
track_id = None
medium, medium_index, _ = self.get_track_index(track['position'])
artist, artist_id = MetadataSourcePlugin.get_artist(
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

@ -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
@ -33,7 +33,7 @@ from beetsplug.info import make_key_filter, library_data, tag_data
class ExportEncoder(json.JSONEncoder):
"""Deals with dates because JSON doesn't have a standard"""
def default(self, o):
if isinstance(o, datetime) or isinstance(o, date):
if isinstance(o, (datetime, date)):
return o.isoformat()
return json.JSONEncoder.default(self, o)
@ -54,6 +54,14 @@ class ExportPlugin(BeetsPlugin):
'sort_keys': True
}
},
'jsonlines': {
# JSON Lines formatting options.
'formatting': {
'ensure_ascii': False,
'separators': (',', ': '),
'sort_keys': True
}
},
'csv': {
# CSV module formatting options.
'formatting': {
@ -95,7 +103,7 @@ class ExportPlugin(BeetsPlugin):
)
cmd.parser.add_option(
u'-f', u'--format', default='json',
help=u"the output format: json (default), csv, or xml"
help=u"the output format: json (default), jsonlines, csv, or xml"
)
return [cmd]
@ -103,6 +111,7 @@ class ExportPlugin(BeetsPlugin):
file_path = opts.output
file_mode = 'a' if opts.append else 'w'
file_format = opts.format or self.config['default_format'].get(str)
file_format_is_line_based = (file_format == 'jsonlines')
format_options = self.config[file_format]['formatting'].get(dict)
export_format = ExportFormat.factory(
@ -130,9 +139,14 @@ class ExportPlugin(BeetsPlugin):
continue
data = key_filter(data)
items += [data]
export_format.export(items, **format_options)
if file_format_is_line_based:
export_format.export(data, **format_options)
else:
items += [data]
if not file_format_is_line_based:
export_format.export(items, **format_options)
class ExportFormat(object):
@ -147,7 +161,7 @@ class ExportFormat(object):
@classmethod
def factory(cls, file_type, **kwargs):
if file_type == "json":
if file_type in ["json", "jsonlines"]:
return JsonFormat(**kwargs)
elif file_type == "csv":
return CSVFormat(**kwargs)
@ -167,6 +181,7 @@ class JsonFormat(ExportFormat):
def export(self, data, **kwargs):
json.dump(data, self.out_stream, cls=ExportEncoder, **kwargs)
self.out_stream.write('\n')
class CSVFormat(ExportFormat):
@ -188,18 +203,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
@ -50,6 +51,7 @@ class Candidate(object):
CANDIDATE_BAD = 0
CANDIDATE_EXACT = 1
CANDIDATE_DOWNSCALE = 2
CANDIDATE_DOWNSIZE = 3
MATCH_EXACT = 0
MATCH_FALLBACK = 1
@ -70,12 +72,15 @@ class Candidate(object):
Return `CANDIDATE_BAD` if the file is unusable.
Return `CANDIDATE_EXACT` if the file is usable as-is.
Return `CANDIDATE_DOWNSCALE` if the file must be resized.
Return `CANDIDATE_DOWNSCALE` if the file must be rescaled.
Return `CANDIDATE_DOWNSIZE` if the file must be resized, and possibly
also rescaled.
"""
if not self.path:
return self.CANDIDATE_BAD
if not (plugin.enforce_ratio or plugin.minwidth or plugin.maxwidth):
if (not (plugin.enforce_ratio or plugin.minwidth or plugin.maxwidth
or plugin.max_filesize)):
return self.CANDIDATE_EXACT
# get_size returns None if no local imaging backend is available
@ -86,14 +91,15 @@ class Candidate(object):
if not self.size:
self._log.warning(u'Could not get size of image (please see '
u'documentation for dependencies). '
u'The configuration options `minwidth` and '
u'`enforce_ratio` may be violated.')
u'The configuration options `minwidth`, '
u'`enforce_ratio` and `max_filesize` '
u'may be violated.')
return self.CANDIDATE_EXACT
short_edge = min(self.size)
long_edge = max(self.size)
# Check minimum size.
# Check minimum dimension.
if plugin.minwidth and self.size[0] < plugin.minwidth:
self._log.debug(u'image too small ({} < {})',
self.size[0], plugin.minwidth)
@ -121,22 +127,45 @@ class Candidate(object):
self.size[0], self.size[1])
return self.CANDIDATE_BAD
# Check maximum size.
# Check maximum dimension.
downscale = False
if plugin.maxwidth and self.size[0] > plugin.maxwidth:
self._log.debug(u'image needs resizing ({} > {})',
self._log.debug(u'image needs rescaling ({} > {})',
self.size[0], plugin.maxwidth)
return self.CANDIDATE_DOWNSCALE
downscale = True
return self.CANDIDATE_EXACT
# Check filesize.
downsize = False
if plugin.max_filesize:
filesize = os.stat(syspath(self.path)).st_size
if filesize > plugin.max_filesize:
self._log.debug(u'image needs resizing ({}B > {}B)',
filesize, plugin.max_filesize)
downsize = True
if downscale:
return self.CANDIDATE_DOWNSCALE
elif downsize:
return self.CANDIDATE_DOWNSIZE
else:
return self.CANDIDATE_EXACT
def validate(self, plugin):
self.check = self._validate(plugin)
return self.check
def resize(self, plugin):
if plugin.maxwidth and self.check == self.CANDIDATE_DOWNSCALE:
self.path = ArtResizer.shared.resize(plugin.maxwidth, self.path,
quality=plugin.quality)
if self.check == self.CANDIDATE_DOWNSCALE:
self.path = \
ArtResizer.shared.resize(plugin.maxwidth, self.path,
quality=plugin.quality,
max_filesize=plugin.max_filesize)
elif self.check == self.CANDIDATE_DOWNSIZE:
# dimensions are correct, so maxwidth is set to maximum dimension
self.path = \
ArtResizer.shared.resize(max(self.size), self.path,
quality=plugin.quality,
max_filesize=plugin.max_filesize)
def _logged_get(log, *args, **kwargs):
@ -209,6 +238,9 @@ class ArtSource(RequestMixin):
def fetch_image(self, candidate, plugin):
raise NotImplementedError()
def cleanup(self, candidate):
pass
class LocalArtSource(ArtSource):
IS_LOCAL = True
@ -290,29 +322,76 @@ 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'
GROUP_URL = 'https://coverartarchive.org/release-group/{mbid}/front'
URL = 'https://coverartarchive.org/release/{mbid}'
GROUP_URL = 'https://coverartarchive.org/release-group/{mbid}'
else:
URL = 'http://coverartarchive.org/release/{mbid}/front'
GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}/front'
URL = 'http://coverartarchive.org/release/{mbid}'
GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}'
def get(self, album, plugin, paths):
"""Return the Cover Art Archive and Cover Art Archive release group URLs
using album MusicBrainz release ID and release group ID.
"""
def get_image_urls(url, size_suffix=None):
try:
response = self.request(url)
except requests.RequestException:
self._log.debug(u'{0}: error receiving response'
.format(self.NAME))
return
try:
data = response.json()
except ValueError:
self._log.debug(u'{0}: error loading response: {1}'
.format(self.NAME, response.text))
return
for item in data.get('images', []):
try:
if 'Front' not in item['types']:
continue
if size_suffix:
yield item['thumbnails'][size_suffix]
else:
yield item['image']
except KeyError:
pass
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),
match=Candidate.MATCH_EXACT)
for url in get_image_urls(release_url, size_suffix):
yield self._candidate(url=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)
for url in get_image_urls(release_group_url):
yield self._candidate(url=url, match=Candidate.MATCH_FALLBACK)
class Amazon(RemoteArtSource):
@ -453,7 +532,7 @@ class FanartTV(RemoteArtSource):
matches = []
# can there be more than one releasegroupid per response?
for mbid, art in data.get(u'albums', dict()).items():
for mbid, art in data.get(u'albums', {}).items():
# there might be more art referenced, e.g. cdart, and an albumcover
# might not be present, even if the request was successful
if album.mb_releasegroupid == mbid and u'albumcover' in art:
@ -742,11 +821,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,
@ -757,6 +897,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()}
@ -779,6 +920,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
'minwidth': 0,
'maxwidth': 0,
'quality': 0,
'max_filesize': 0,
'enforce_ratio': False,
'cautious': False,
'cover_names': ['cover', 'front', 'art', 'album', 'folder'],
@ -787,14 +929,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.max_filesize = self.config['max_filesize'].get(int)
self.quality = self.config['quality'].get(int)
# allow both pixel and percentage-based margin specifications
@ -831,6 +976,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]
@ -911,7 +1059,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):
@ -949,6 +1097,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

View file

@ -110,7 +110,7 @@ class FishPlugin(BeetsPlugin):
# Collect commands, their aliases, and their help text
cmd_names_help = []
for cmd in beetcmds:
names = [alias for alias in cmd.aliases]
names = list(cmd.aliases)
names.append(cmd.name)
for name in names:
cmd_names_help.append((name, cmd.help))
@ -133,6 +133,13 @@ class FishPlugin(BeetsPlugin):
fish_file.write(totstring)
def _escape(name):
# Escape ? in fish
if name == "?":
name = "\\" + name
return name
def get_cmds_list(cmds_names):
# Make a list of all Beets core & plugin commands
substr = ''
@ -201,6 +208,8 @@ 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:
cmdname = _escape(cmdname)
word += "\n" + "# ------ {} -------".format(
"fieldsetups for " + cmdname) + "\n"
word += (
@ -229,9 +238,11 @@ 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 = list(cmd.aliases)
names.append(cmd.name)
for name in names:
name = _escape(name)
word += "\n"
word += ("\n" * 2) + "# ====== {} =====".format(
"completions for " + name) + "\n"

View file

@ -27,7 +27,7 @@ class ImportAddedPlugin(BeetsPlugin):
# album.path for old albums that were replaced by a reimported album
self.replaced_album_paths = None
# item path in the library to the mtime of the source file
self.item_mtime = dict()
self.item_mtime = {}
register = self.register_listener
register('import_task_created', self.check_config)

View file

@ -235,7 +235,7 @@ def make_key_filter(include):
matchers.append(re.compile(key + '$'))
def filter_(data):
filtered = dict()
filtered = {}
for key, value in data.items():
if any([m.match(key) for m in matchers]):
filtered[key] = value

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

@ -76,7 +76,14 @@ class KeyFinderPlugin(BeetsPlugin):
item.path)
continue
key_raw = output.rsplit(None, 1)[-1]
try:
key_raw = output.rsplit(None, 1)[-1]
except IndexError:
# Sometimes keyfinder-cli returns 0 but with no key, usually
# when the file is silent or corrupt, so we log and skip.
self._log.error(u'no key returned for path: {0}', item.path)
continue
try:
key = util.text_string(key_raw)
except UnicodeDecodeError:

View file

@ -111,6 +111,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
'auto': True,
'separator': u', ',
'prefer_specific': False,
'title_case': True,
})
self.setup()
@ -224,12 +225,17 @@ class LastGenrePlugin(plugins.BeetsPlugin):
# c14n only adds allowed genres but we may have had forbidden genres in
# the original tags list
tags = [x.title() for x in tags if self._is_allowed(x)]
tags = [self._format_tag(x) for x in tags if self._is_allowed(x)]
return self.config['separator'].as_str().join(
tags[:self.config['count'].get(int)]
)
def _format_tag(self, tag):
if self.config["title_case"]:
return tag.title()
return tag
def fetch_genre(self, lastfm_obj):
"""Return the genre for a pylast entity or None if no suitable genre
can be found. Ex. 'Electronic, House, Dance'

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

@ -55,7 +55,6 @@ except ImportError:
from beets import plugins
from beets import ui
from beets import util
import beets
DIV_RE = re.compile(r'<(/?)div>?', re.I)
@ -145,39 +144,6 @@ def extract_text_between(html, start_marker, end_marker):
return html
def extract_text_in(html, starttag):
"""Extract the text from a <DIV> tag in the HTML starting with
``starttag``. Returns None if parsing fails.
"""
# Strip off the leading text before opening tag.
try:
_, html = html.split(starttag, 1)
except ValueError:
return
# Walk through balanced DIV tags.
level = 0
parts = []
pos = 0
for match in DIV_RE.finditer(html):
if match.group(1): # Closing tag.
level -= 1
if level == 0:
pos = match.end()
else: # Opening tag.
if level == 0:
parts.append(html[pos:match.start()])
level += 1
if level == -1:
parts.append(html[pos:match.start()])
break
else:
print(u'no closing tag found!')
return
return u''.join(parts)
def search_pairs(item):
"""Yield a pairs of artists and titles to search for.
@ -187,6 +153,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 +169,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
@ -289,9 +262,9 @@ class Backend(object):
raise NotImplementedError()
class SymbolsReplaced(Backend):
class MusiXmatch(Backend):
REPLACEMENTS = {
r'\s+': '_',
r'\s+': '-',
'<': 'Less_Than',
'>': 'Greater_Than',
'#': 'Number_',
@ -299,20 +272,14 @@ class SymbolsReplaced(Backend):
r'[\]\}]': ')',
}
URL_PATTERN = 'https://www.musixmatch.com/lyrics/%s/%s'
@classmethod
def _encode(cls, s):
for old, new in cls.REPLACEMENTS.items():
s = re.sub(old, new, s)
return super(SymbolsReplaced, cls)._encode(s)
class MusiXmatch(SymbolsReplaced):
REPLACEMENTS = dict(SymbolsReplaced.REPLACEMENTS, **{
r'\s+': '-'
})
URL_PATTERN = 'https://www.musixmatch.com/lyrics/%s/%s'
return super(MusiXmatch, cls)._encode(s)
def fetch(self, artist, title):
url = self.build_url(artist, title)
@ -352,88 +319,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"]:
# Genius uses zero-width characters to denote lowercase
# artist names.
hit_artist = hit["result"]["primary_artist"]["name"]. \
strip(u'\u200b').lower()
def _scrape_lyrics_from_html(self, html):
"""Scrape lyrics from a given genius.com html"""
if hit_artist == artist.lower():
song_info = hit
break
html = BeautifulSoup(html, "html.parser")
if song_info:
self._log.debug(u'fetched: {0}', song_info["result"]["url"])
song_api_path = song_info["result"]["api_path"]
return self.lyrics_from_song_api_path(song_api_path)
else:
self._log.debug(u'genius: no matching artist')
# 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
class LyricsWiki(SymbolsReplaced):
"""Fetch lyrics from LyricsWiki."""
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")
if util.SNI_SUPPORTED:
URL_PATTERN = 'https://lyrics.wikia.com/%s:%s'
else:
URL_PATTERN = 'http://lyrics.wikia.com/%s:%s'
def fetch(self, artist, title):
url = self.build_url(artist, title)
html = self.fetch_url(url)
if not html:
return
# Get the HTML fragment inside the appropriate HTML element and then
# extract the text from it.
html_frag = extract_text_in(html, u"<div class='lyricbox'>")
if html_frag:
lyrics = _scrape_strip_cruft(html_frag, True)
if lyrics and 'Unfortunately, we are not licensed' not in lyrics:
return lyrics
return lyrics_div.get_text()
class Tekstowo(Backend):
@ -509,6 +474,7 @@ def _scrape_strip_cruft(html, plain_text_out=False):
html = re.sub(r' +', ' ', html) # Whitespaces collapse.
html = BREAK_RE.sub('\n', html) # <br> eats up surrounding '\n'.
html = re.sub(r'(?s)<(script).*?</\1>', '', html) # Strip script tags.
html = re.sub(u'\u2005', " ", html) # replace unicode with regular space
if plain_text_out: # Strip remaining HTML tags
html = COMMENT_RE.sub('', html)
@ -584,7 +550,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,
@ -677,11 +643,10 @@ class Google(Backend):
class LyricsPlugin(plugins.BeetsPlugin):
SOURCES = ['google', 'lyricwiki', 'musixmatch', 'genius', 'tekstowo']
SOURCES = ['google', 'musixmatch', 'genius', 'tekstowo']
BS_SOURCES = ['google', 'genius', 'tekstowo']
SOURCE_BACKENDS = {
'google': Google,
'lyricwiki': LyricsWiki,
'musixmatch': MusiXmatch,
'genius': Genius,
'tekstowo': Tekstowo,
@ -806,7 +771,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,
@ -816,10 +782,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)
@ -831,26 +797,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 == '':
@ -862,6 +823,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
@ -943,7 +913,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()
@ -961,7 +931,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

@ -123,7 +123,7 @@ class MBSyncPlugin(BeetsPlugin):
# Map release track and recording MBIDs to their information.
# Recordings can appear multiple times on a release, so each MBID
# maps to a list of TrackInfo objects.
releasetrack_index = dict()
releasetrack_index = {}
track_index = defaultdict(list)
for track_info in album_info.tracks:
releasetrack_index[track_info.release_track_id] = track_info

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

@ -216,7 +216,7 @@ class MissingPlugin(BeetsPlugin):
"""Query MusicBrainz to determine items missing from `album`.
"""
item_mbids = [x.mb_trackid for x in album.items()]
if len([i for i in album.items()]) < album.albumtotal:
if len(list(album.items())) < album.albumtotal:
# fetch missing items
# TODO: Implement caching that without breaking other stuff
album_info = hooks.album_for_mbid(album.mb_albumid)

View file

@ -18,6 +18,7 @@ from __future__ import division, absolute_import, print_function
import mpd
import socket
import select
import sys
import time
import os
@ -49,10 +50,23 @@ class MPDClientWrapper(object):
def __init__(self, log):
self._log = log
self.music_directory = (
mpd_config['music_directory'].as_str())
self.music_directory = mpd_config['music_directory'].as_str()
self.strip_path = mpd_config['strip_path'].as_str()
self.client = mpd.MPDClient(use_unicode=True)
# Ensure strip_path end with '/'
if not self.strip_path.endswith('/'):
self.strip_path += '/'
self._log.debug('music_directory: {0}', self.music_directory)
self._log.debug('strip_path: {0}', self.strip_path)
if sys.version_info < (3, 0):
# On Python 2, use_unicode will enable the utf-8 mode for
# python-mpd2
self.client = mpd.MPDClient(use_unicode=True)
else:
# On Python 3, python-mpd2 always uses Unicode
self.client = mpd.MPDClient()
def connect(self):
"""Connect to the MPD.
@ -108,17 +122,25 @@ 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.
In some cases, we need to remove the local path from MPD server,
we replace 'strip_path' with ''.
`strip_path` defaults to ''.
"""
result = None
entry = self.get('currentsong')
if 'file' in entry:
if not is_url(entry['file']):
result = os.path.join(self.music_directory, entry['file'])
file = entry['file']
if file.startswith(self.strip_path):
file = file[len(self.strip_path):]
result = os.path.join(self.music_directory, file)
else:
result = entry['file']
return result
self._log.debug('returning: {0}', result)
return result, entry.get('id')
def status(self):
"""Return the current status of the MPD.
@ -240,7 +262,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 +275,7 @@ class MPDStats(object):
def on_play(self, status):
path = self.mpd.currentsong()
path, songid = self.mpd.currentsong()
if not path:
return
@ -286,6 +310,7 @@ class MPDStats(object):
'started': time.time(),
'remaining': remaining,
'path': path,
'id': songid,
'beets_item': self.get_item(path),
}
@ -323,6 +348,7 @@ class MPDStatsPlugin(plugins.BeetsPlugin):
super(MPDStatsPlugin, self).__init__()
mpd_config.add({
'music_directory': config['directory'].as_filename(),
'strip_path': u'',
'rating': True,
'rating_mix': 0.75,
'host': os.environ.get('MPD_HOST', u'localhost'),

View file

@ -96,7 +96,7 @@ class ParentWorkPlugin(BeetsPlugin):
item.try_write()
command = ui.Subcommand(
'parentwork',
help=u'fetche parent works, composers and dates')
help=u'fetch parent works, composers and dates')
command.parser.add_option(
u'-f', u'--force', dest='force',
@ -129,8 +129,11 @@ class ParentWorkPlugin(BeetsPlugin):
if 'artist-relation-list' in work_info['work']:
for artist in work_info['work']['artist-relation-list']:
if artist['type'] == 'composer':
composer_exists = True
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(
@ -173,13 +176,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'],
@ -202,4 +209,5 @@ add one at https://musicbrainz.org/recording/{}', item, item.mb_trackid)
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

@ -15,21 +15,23 @@
from __future__ import division, absolute_import, print_function
import subprocess
import os
import collections
import enum
import math
import os
import signal
import six
import subprocess
import sys
import warnings
import enum
import re
import xml.parsers.expat
from six.moves import zip
from multiprocessing.pool import ThreadPool, RUN
from six.moves import zip, queue
from threading import Thread, Event
from beets import ui
from beets.plugins import BeetsPlugin
from beets.util import (syspath, command_output, bytestring_path,
displayable_path, py3_path)
from beets.util import (syspath, command_output, displayable_path,
py3_path, cpu_count)
# Utilities.
@ -110,6 +112,8 @@ class Backend(object):
"""An abstract class representing engine for calculating RG values.
"""
do_parallel = False
def __init__(self, config, log):
"""Initialize the backend with the configuration view for the
plugin.
@ -129,255 +133,13 @@ class Backend(object):
raise NotImplementedError()
# bsg1770gain backend
class Bs1770gainBackend(Backend):
"""bs1770gain is a loudness scanner compliant with ITU-R BS.1770 and
its flavors EBU R128, ATSC A/85 and Replaygain 2.0.
"""
methods = {
-24: "atsc",
-23: "ebu",
-18: "replaygain",
}
def __init__(self, config, log):
super(Bs1770gainBackend, self).__init__(config, log)
config.add({
'chunk_at': 5000,
'method': '',
})
self.chunk_at = config['chunk_at'].as_number()
# backward compatibility to `method` config option
self.__method = config['method'].as_str()
cmd = 'bs1770gain'
try:
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?'
)
if not self.command:
raise FatalReplayGainError(
u'no replaygain command found: install bs1770gain'
)
def compute_track_gain(self, items, target_level, peak):
"""Computes the track gain of the given tracks, returns a list
of TrackGain objects.
"""
output = self.compute_gain(items, target_level, False)
return output
def compute_album_gain(self, items, target_level, peak):
"""Computes the album gain of the given album, returns an
AlbumGain object.
"""
# TODO: What should be done when not all tracks in the album are
# supported?
output = self.compute_gain(items, target_level, True)
if not output:
raise ReplayGainError(u'no output from bs1770gain')
return AlbumGain(output[-1], output[:-1])
def isplitter(self, items, chunk_at):
"""Break an iterable into chunks of at most size `chunk_at`,
generating lists for each chunk.
"""
iterable = iter(items)
while True:
result = []
for i in range(chunk_at):
try:
a = next(iterable)
except StopIteration:
break
else:
result.append(a)
if result:
yield result
else:
break
def compute_gain(self, items, target_level, is_album):
"""Computes the track or album gain of a list of items, returns
a list of TrackGain objects.
When computing album gain, the last TrackGain object returned is
the album gain
"""
if len(items) == 0:
return []
albumgaintot = 0.0
albumpeaktot = 0.0
returnchunks = []
# In the case of very large sets of music, we break the tracks
# into smaller chunks and process them one at a time. This
# avoids running out of memory.
if len(items) > self.chunk_at:
i = 0
for chunk in self.isplitter(items, self.chunk_at):
i += 1
returnchunk = self.compute_chunk_gain(
chunk,
is_album,
target_level
)
albumgaintot += returnchunk[-1].gain
albumpeaktot = max(albumpeaktot, returnchunk[-1].peak)
returnchunks = returnchunks + returnchunk[0:-1]
returnchunks.append(Gain(albumgaintot / i, albumpeaktot))
return returnchunks
else:
return self.compute_chunk_gain(items, is_album, target_level)
def compute_chunk_gain(self, items, is_album, target_level):
"""Compute ReplayGain values and return a list of results
dictionaries as given by `parse_tool_output`.
"""
# choose method
target_level = db_to_lufs(target_level)
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:
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
# prevents the backend from working with long paths.
args = cmd + [syspath(i.path, prefix=False) for i in items]
path_list = [i.path for i in items]
# Invoke the command.
self._log.debug(
u'executing {0}', u' '.join(map(displayable_path, args))
)
output = call(args).stdout
self._log.debug(u'analysis finished: {0}', output)
results = self.parse_tool_output(output, path_list, is_album)
if gain_adjustment:
results = [
Gain(res.gain + gain_adjustment, res.peak)
for res in results
]
self._log.debug(u'{0} items, {1} results', len(items), len(results))
return results
def parse_tool_output(self, text, path_list, is_album):
"""Given the output from bs1770gain, parse the text and
return a list of dictionaries
containing information about each analyzed file.
"""
per_file_gain = {}
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':
state['file'] = bytestring_path(attrs[u'file'])
if state['file'] in per_file_gain:
raise ReplayGainError(
u'duplicate filename in bs1770gain output')
elif name == u'integrated':
if 'lu' in attrs:
state['gain'] = float(attrs[u'lu'])
elif name == u'sample-peak':
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':
if state['gain'] is None or state['peak'] is None:
raise ReplayGainError(u'could not parse gain or peak from '
'the output of bs1770gain')
per_file_gain[state['file']] = Gain(state['gain'],
state['peak'])
state['gain'] = state['peak'] = None
elif name == u'summary':
if state['gain'] is None or state['peak'] is None:
raise ReplayGainError(u'could not parse gain or peak from '
'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
try:
parser.Parse(text, True)
except xml.parsers.expat.ExpatError:
raise ReplayGainError(
u'The bs1770gain tool produced malformed XML. '
'Using version >=0.4.10 may solve this problem.'
)
if len(per_file_gain) != len(path_list):
raise ReplayGainError(
u'the number of results returned by bs1770gain does not match '
'the number of files passed to it')
# bs1770gain does not return the analysis results in the order that
# files are passed on the command line, because it is sorting the files
# internally. We must recover the order from the filenames themselves.
try:
out = [per_file_gain[os.path.basename(p)] for p in path_list]
except KeyError:
raise ReplayGainError(
u'unrecognized filename in bs1770gain output '
'(bs1770gain can only deal with utf-8 file names)')
if is_album:
out.append(album_gain["album"])
return out
# ffmpeg backend
class FfmpegBackend(Backend):
"""A replaygain backend using ffmpeg's ebur128 filter.
"""
do_parallel = True
def __init__(self, config, log):
super(FfmpegBackend, self).__init__(config, log)
self._ffmpeg_path = "ffmpeg"
@ -613,13 +375,14 @@ 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)
)
# mpgain/aacgain CLI tool backend.
class CommandBackend(Backend):
do_parallel = True
def __init__(self, config, log):
super(CommandBackend, self).__init__(config, log)
@ -748,7 +511,6 @@ class CommandBackend(Backend):
# GStreamer-based backend.
class GStreamerBackend(Backend):
def __init__(self, config, log):
super(GStreamerBackend, self).__init__(config, log)
self._import_gst()
@ -1168,6 +930,33 @@ class AudioToolsBackend(Backend):
)
class ExceptionWatcher(Thread):
"""Monitors a queue for exceptions asynchronously.
Once an exception occurs, raise it and execute a callback.
"""
def __init__(self, queue, callback):
self._queue = queue
self._callback = callback
self._stopevent = Event()
Thread.__init__(self)
def run(self):
while not self._stopevent.is_set():
try:
exc = self._queue.get_nowait()
self._callback()
six.reraise(exc[0], exc[1], exc[2])
except queue.Empty:
# No exceptions yet, loop back to check
# whether `_stopevent` is set
pass
def join(self, timeout=None):
self._stopevent.set()
Thread.join(self, timeout)
# Main plugin logic.
class ReplayGainPlugin(BeetsPlugin):
@ -1178,7 +967,6 @@ class ReplayGainPlugin(BeetsPlugin):
"command": CommandBackend,
"gstreamer": GStreamerBackend,
"audiotools": AudioToolsBackend,
"bs1770gain": Bs1770gainBackend,
"ffmpeg": FfmpegBackend,
}
@ -1195,6 +983,8 @@ class ReplayGainPlugin(BeetsPlugin):
'overwrite': False,
'auto': True,
'backend': u'command',
'threads': cpu_count(),
'parallel_on_import': False,
'per_disc': False,
'peak': 'true',
'targetlevel': 89,
@ -1204,12 +994,15 @@ class ReplayGainPlugin(BeetsPlugin):
self.overwrite = self.config['overwrite'].get(bool)
self.per_disc = self.config['per_disc'].get(bool)
backend_name = self.config['backend'].as_str()
if backend_name not in self.backends:
# Remember which backend is used for CLI feedback
self.backend_name = self.config['backend'].as_str()
if self.backend_name not in self.backends:
raise ui.UserError(
u"Selected ReplayGain backend {0} is not supported. "
u"Please select one of: {1}".format(
backend_name,
self.backend_name,
u', '.join(self.backends.keys())
)
)
@ -1226,13 +1019,15 @@ class ReplayGainPlugin(BeetsPlugin):
# On-import analysis.
if self.config['auto']:
self.register_listener('import_begin', self.import_begin)
self.register_listener('import', self.import_end)
self.import_stages = [self.imported]
# Formats to use R128.
self.r128_whitelist = self.config['r128'].as_str_seq()
try:
self.backend_instance = self.backends[backend_name](
self.backend_instance = self.backends[self.backend_name](
self.config, self._log
)
except (ReplayGainError, FatalReplayGainError) as e:
@ -1322,19 +1117,19 @@ class ReplayGainPlugin(BeetsPlugin):
self._log.info(u'Skipping album {0}', album)
return
self._log.info(u'analyzing {0}', album)
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)
tag_vals = self.tag_specific_values(album.items())
store_track_gain, store_album_gain, target_level, peak = tag_vals
discs = dict()
discs = {}
if self.per_disc:
for item in album.items():
if discs.get(item.disc) is None:
@ -1344,21 +1139,35 @@ class ReplayGainPlugin(BeetsPlugin):
discs[1] = album.items()
for discnumber, items in discs.items():
try:
album_gain = self.backend_instance.compute_album_gain(
items, target_level, peak
)
if len(album_gain.track_gains) != len(items):
def _store_album(album_gain):
if not album_gain or not album_gain.album_gain \
or len(album_gain.track_gains) != len(items):
# In some cases, backends fail to produce a valid
# `album_gain` without throwing FatalReplayGainError
# => raise non-fatal exception & continue
raise ReplayGainError(
u"ReplayGain backend failed "
u"for some tracks in album {0}".format(album)
u"ReplayGain backend `{}` failed "
u"for some tracks in album {}"
.format(self.backend_name, album)
)
for item, track_gain in zip(items, album_gain.track_gains):
for item, track_gain in zip(items,
album_gain.track_gains):
store_track_gain(item, track_gain)
store_album_gain(item, album_gain.album_gain)
if write:
item.try_write()
self._log.debug(u'done analyzing {0}', item)
try:
self._apply(
self.backend_instance.compute_album_gain, args=(),
kwds={
"items": list(items),
"target_level": target_level,
"peak": peak
},
callback=_store_album
)
except ReplayGainError as e:
self._log.info(u"ReplayGain error: {0}", e)
except FatalReplayGainError as e:
@ -1376,54 +1185,178 @@ class ReplayGainPlugin(BeetsPlugin):
self._log.info(u'Skipping track {0}', item)
return
self._log.info(u'analyzing {0}', item)
tag_vals = self.tag_specific_values([item])
store_track_gain, store_album_gain, target_level, peak = tag_vals
try:
track_gains = self.backend_instance.compute_track_gain(
[item], target_level, peak
)
if len(track_gains) != 1:
def _store_track(track_gains):
if not track_gains or len(track_gains) != 1:
# In some cases, backends fail to produce a valid
# `track_gains` without throwing FatalReplayGainError
# => raise non-fatal exception & continue
raise ReplayGainError(
u"ReplayGain backend failed for track {0}".format(item)
u"ReplayGain backend `{}` failed for track {}"
.format(self.backend_name, item)
)
store_track_gain(item, track_gains[0])
if write:
item.try_write()
self._log.debug(u'done analyzing {0}', item)
try:
self._apply(
self.backend_instance.compute_track_gain, args=(),
kwds={
"items": [item],
"target_level": target_level,
"peak": peak,
},
callback=_store_track
)
except ReplayGainError as e:
self._log.info(u"ReplayGain error: {0}", e)
except FatalReplayGainError as e:
raise ui.UserError(
u"Fatal replay gain error: {0}".format(e))
raise ui.UserError(u"Fatal replay gain error: {0}".format(e))
def _has_pool(self):
"""Check whether a `ThreadPool` is running instance in `self.pool`
"""
if hasattr(self, 'pool'):
if isinstance(self.pool, ThreadPool) and self.pool._state == RUN:
return True
return False
def open_pool(self, threads):
"""Open a `ThreadPool` instance in `self.pool`
"""
if not self._has_pool() and self.backend_instance.do_parallel:
self.pool = ThreadPool(threads)
self.exc_queue = queue.Queue()
signal.signal(signal.SIGINT, self._interrupt)
self.exc_watcher = ExceptionWatcher(
self.exc_queue, # threads push exceptions here
self.terminate_pool # abort once an exception occurs
)
self.exc_watcher.start()
def _apply(self, func, args, kwds, callback):
if self._has_pool():
def catch_exc(func, exc_queue, log):
"""Wrapper to catch raised exceptions in threads
"""
def wfunc(*args, **kwargs):
try:
return func(*args, **kwargs)
except ReplayGainError as e:
log.info(e.args[0]) # log non-fatal exceptions
except Exception:
exc_queue.put(sys.exc_info())
return wfunc
# Wrap function and callback to catch exceptions
func = catch_exc(func, self.exc_queue, self._log)
callback = catch_exc(callback, self.exc_queue, self._log)
self.pool.apply_async(func, args, kwds, callback)
else:
callback(func(*args, **kwds))
def terminate_pool(self):
"""Terminate the `ThreadPool` instance in `self.pool`
(e.g. stop execution in case of exception)
"""
# Don't call self._as_pool() here,
# self.pool._state may not be == RUN
if hasattr(self, 'pool') and isinstance(self.pool, ThreadPool):
self.pool.terminate()
self.pool.join()
# self.exc_watcher.join()
def _interrupt(self, signal, frame):
try:
self._log.info('interrupted')
self.terminate_pool()
sys.exit(0)
except SystemExit:
# Silence raised SystemExit ~ exit(0)
pass
def close_pool(self):
"""Close the `ThreadPool` instance in `self.pool` (if there is one)
"""
if self._has_pool():
self.pool.close()
self.pool.join()
self.exc_watcher.join()
def import_begin(self, session):
"""Handle `import_begin` event -> open pool
"""
threads = self.config['threads'].get(int)
if self.config['parallel_on_import'] \
and self.config['auto'] \
and threads:
self.open_pool(threads)
def import_end(self, paths):
"""Handle `import` event -> close pool
"""
self.close_pool()
def imported(self, session, task):
"""Add replay gain info to items or albums of ``task``.
"""
if task.is_album:
self.handle_album(task.album, False)
else:
self.handle_track(task.item, False)
if self.config['auto']:
if task.is_album:
self.handle_album(task.album, False)
else:
self.handle_track(task.item, False)
def command_func(self, lib, opts, args):
try:
write = ui.should_write(opts.write)
force = opts.force
# Bypass self.open_pool() if called with `--threads 0`
if opts.threads != 0:
threads = opts.threads or self.config['threads'].get(int)
self.open_pool(threads)
if opts.album:
albums = lib.albums(ui.decargs(args))
self._log.info(
"Analyzing {} albums ~ {} backend..."
.format(len(albums), self.backend_name)
)
for album in albums:
self.handle_album(album, write, force)
else:
items = lib.items(ui.decargs(args))
self._log.info(
"Analyzing {} tracks ~ {} backend..."
.format(len(items), self.backend_name)
)
for item in items:
self.handle_track(item, write, force)
self.close_pool()
except (SystemExit, KeyboardInterrupt):
# Silence interrupt exceptions
pass
def commands(self):
"""Return the "replaygain" ui subcommand.
"""
def func(lib, opts, args):
write = ui.should_write(opts.write)
force = opts.force
if opts.album:
for album in lib.albums(ui.decargs(args)):
self.handle_album(album, write, force)
else:
for item in lib.items(ui.decargs(args)):
self.handle_track(item, write, force)
cmd = ui.Subcommand('replaygain', help=u'analyze for ReplayGain')
cmd.parser.add_album_option()
cmd.parser.add_option(
"-t", "--threads", dest="threads", type=int,
help=u'change the number of threads, \
defaults to maximum available processors'
)
cmd.parser.add_option(
"-f", "--force", dest="force", action="store_true", default=False,
help=u"analyze all files, including those that "
@ -1434,5 +1367,5 @@ class ReplayGainPlugin(BeetsPlugin):
cmd.parser.add_option(
"-W", "--nowrite", dest="write", action="store_false",
help=u"don't write metadata (opposite of -w)")
cmd.func = func
cmd.func = self.command_func
return [cmd]

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 = {}
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 = {}
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
@ -31,90 +29,148 @@ import string
import requests
from binascii import hexlify
from beets import config
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'
AUTH_TOKEN_VERSION = (1, 12)
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._version = None
self._auth = None
self.register_listener('import', self.start_scan)
@property
def version(self):
if self._version is None:
self._version = self.__get_version()
return self._version
@property
def auth(self):
if self._auth is None:
if self.version is not None:
if self.version > AUTH_TOKEN_VERSION:
self._auth = "token"
else:
self._auth = "password"
self._log.info(
u"using '{}' authentication method".format(self._auth))
return self._auth
@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(endpoint):
"""Get the Subsonic URL to trigger the given endpoint.
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/{}'.format(endpoint)
def __get_version(self):
url = self.__format_url("ping.view")
payload = {
'c': 'beets',
'f': 'json'
}
try:
response = requests.get(url, params=payload)
if response.status_code == 200:
json = response.json()
version = json['subsonic-response']['version']
self._log.info(
u'subsonic version:{0} '.format(version))
return tuple(int(s) for s in version.split('.'))
else:
self._log.error(u'Error: {0}', json)
return None
except Exception as error:
self._log.error(u'Error: {0}'.format(error))
return None
def start_scan(self):
user = config['subsonic']['user'].as_str()
url = format_url()
salt, token = create_token()
url = self.__format_url("startScan.view")
payload = {
'u': user,
't': token,
's': salt,
'v': '1.15.0', # Subsonic 6.1 and newer.
'c': 'beets'
}
response = requests.post(url, params=payload)
if response.status_code == 403:
self._log.error(u'Server authentication failed')
elif response.status_code == 200:
self._log.debug(u'Updating Subsonic')
if self.auth == 'token':
salt, token = self.__create_token()
payload = {
'u': user,
't': token,
's': salt,
'v': self.version, # Subsonic 6.1 and newer.
'c': 'beets',
'f': 'json'
}
elif self.auth == 'password':
password = config['subsonic']['pass'].as_str()
encpass = hexlify(password.encode()).decode()
payload = {
'u': user,
'p': 'enc:{}'.format(encpass),
'v': self.version,
'c': 'beets',
'f': 'json'
}
else:
self._log.error(
u'Generic error, please try again later [Status Code: {}]'
.format(response.status_code))
return
try:
response = requests.get(url, params=payload)
json = response.json()
if response.status_code == 200 and \
json['subsonic-response']['status'] == "ok":
count = json['subsonic-response']['scanStatus']['count']
self._log.info(
u'Updating Subsonic; scanning {0} tracks'.format(count))
elif response.status_code == 200 and \
json['subsonic-response']['status'] == "failed":
error_message = json['subsonic-response']['error']['message']
self._log.error(u'Error: {0}'.format(error_message))
else:
self._log.error(u'Error: {0}', json)
except Exception as error:
self._log.error(u'Error: {0}'.format(error))

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

@ -21,7 +21,7 @@ from beets import ui
from beets import util
import beets.library
import flask
from flask import g
from flask import g, jsonify
from werkzeug.routing import BaseConverter, PathConverter
import os
from unidecode import unidecode
@ -59,7 +59,10 @@ def _rep(obj, expand=False):
return out
elif isinstance(obj, beets.library.Album):
del out['artpath']
if app.config.get('INCLUDE_PATHS', False):
out['artpath'] = util.displayable_path(out['artpath'])
else:
del out['artpath']
if expand:
out['items'] = [_rep(item) for item in obj.items()]
return out
@ -91,7 +94,20 @@ def is_expand():
return flask.request.args.get('expand') is not None
def resource(name):
def is_delete():
"""Returns whether the current delete request should remove the selected
files.
"""
return flask.request.args.get('delete') is not None
def get_method():
"""Returns the HTTP method of the current request."""
return flask.request.method
def resource(name, patchable=False):
"""Decorates a function to handle RESTful HTTP requests for a resource.
"""
def make_responder(retriever):
@ -99,34 +115,98 @@ def resource(name):
entities = [retriever(id) for id in ids]
entities = [entity for entity in entities if entity]
if len(entities) == 1:
return flask.jsonify(_rep(entities[0], expand=is_expand()))
elif entities:
return app.response_class(
json_generator(entities, root=name),
mimetype='application/json'
)
if get_method() == "DELETE":
if app.config.get('READONLY', True):
return flask.abort(405)
for entity in entities:
entity.remove(delete=is_delete())
return flask.make_response(jsonify({'deleted': True}), 200)
elif get_method() == "PATCH" and patchable:
if app.config.get('READONLY', True):
return flask.abort(405)
for entity in entities:
entity.update(flask.request.get_json())
entity.try_sync(True, False) # write, don't move
if len(entities) == 1:
return flask.jsonify(_rep(entities[0], expand=is_expand()))
elif entities:
return app.response_class(
json_generator(entities, root=name),
mimetype='application/json'
)
elif get_method() == "GET":
if len(entities) == 1:
return flask.jsonify(_rep(entities[0], expand=is_expand()))
elif entities:
return app.response_class(
json_generator(entities, root=name),
mimetype='application/json'
)
else:
return flask.abort(404)
else:
return flask.abort(404)
return flask.abort(405)
responder.__name__ = 'get_{0}'.format(name)
return responder
return make_responder
def resource_query(name):
def resource_query(name, patchable=False):
"""Decorates a function to handle RESTful HTTP queries for resources.
"""
def make_responder(query_func):
def responder(queries):
return app.response_class(
json_generator(
query_func(queries),
root='results', expand=is_expand()
),
mimetype='application/json'
)
entities = query_func(queries)
if get_method() == "DELETE":
if app.config.get('READONLY', True):
return flask.abort(405)
for entity in entities:
entity.remove(delete=is_delete())
return flask.make_response(jsonify({'deleted': True}), 200)
elif get_method() == "PATCH" and patchable:
if app.config.get('READONLY', True):
return flask.abort(405)
for entity in entities:
entity.update(flask.request.get_json())
entity.try_sync(True, False) # write, don't move
return app.response_class(
json_generator(entities, root=name),
mimetype='application/json'
)
elif get_method() == "GET":
return app.response_class(
json_generator(
entities,
root='results', expand=is_expand()
),
mimetype='application/json'
)
else:
return flask.abort(405)
responder.__name__ = 'query_{0}'.format(name)
return responder
return make_responder
@ -177,10 +257,13 @@ class QueryConverter(PathConverter):
"""
def to_python(self, value):
return value.split('/')
queries = value.split('/')
"""Do not do path substitution on regex value tests"""
return [query if '::' in query else 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):
@ -202,8 +285,8 @@ def before_request():
# Items.
@app.route('/item/<idlist:ids>')
@resource('items')
@app.route('/item/<idlist:ids>', methods=["GET", "DELETE", "PATCH"])
@resource('items', patchable=True)
def get_item(id):
return g.lib.get_item(id)
@ -249,8 +332,8 @@ def item_file(item_id):
return response
@app.route('/item/query/<query:queries>')
@resource_query('items')
@app.route('/item/query/<query:queries>', methods=["GET", "DELETE", "PATCH"])
@resource_query('items', patchable=True)
def item_query(queries):
return g.lib.items(queries)
@ -278,7 +361,7 @@ def item_unique_field_values(key):
# Albums.
@app.route('/album/<idlist:ids>')
@app.route('/album/<idlist:ids>', methods=["GET", "DELETE"])
@resource('albums')
def get_album(id):
return g.lib.get_album(id)
@ -291,7 +374,7 @@ def all_albums():
return g.lib.albums()
@app.route('/album/query/<query:queries>')
@app.route('/album/query/<query:queries>', methods=["GET", "DELETE"])
@resource_query('albums')
def album_query(queries):
return g.lib.albums(queries)
@ -359,6 +442,7 @@ class WebPlugin(BeetsPlugin):
'cors_supports_credentials': False,
'reverse_proxy': False,
'include_paths': False,
'readonly': True,
})
def commands(self):
@ -378,6 +462,7 @@ class WebPlugin(BeetsPlugin):
app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False
app.config['INCLUDE_PATHS'] = self.config['include_paths']
app.config['READONLY'] = self.config['readonly']
# Enable CORS if required.
if self.config['cors']:

View file

@ -4,66 +4,109 @@ Changelog
1.5.0 (in development)
----------------------
New features:
This long overdue release of beets includes far too many exciting and useful
features than could ever be satisfactorily enumerated.
As a technical detail, it also introduces two new external libraries:
`MediaFile`_ and `Confuse`_ used to be part of beets but are now reusable
dependencies---packagers, please take note.
Finally, this is the last version of beets where we intend to support Python
2.x and 3.5; future releases will soon require Python 3.6.
* A new :doc:`/plugins/fish` adds `Fish shell`_ tab autocompletion to beets
* :doc:`plugins/fetchart` and :doc:`plugins/embedart`: Added a new ``quality``
option that controls the quality of the image output when the image is
resized.
* :doc:`plugins/keyfinder`: Added support for `keyfinder-cli`_
Thanks to :user:`BrainDamage`.
* :doc:`plugins/fetchart`: Added a new ``high_resolution`` config option to
allow downloading of higher resolution iTunes artwork (at the expense of
file size).
:bug: `3391`
* :doc:`plugins/discogs` now adds two extra fields: `discogs_labelid` and
`discogs_artistid`
:bug: `3413`
* :doc:`/plugins/export`: Added new ``-f`` (``--format``) flag;
which allows for the ability to export in json, csv and xml.
Thanks to :user:`austinmm`.
:bug:`3402`
* :doc:`/plugins/unimported`: lets you find untracked files in your library directory.
Major new features:
* A new :ref:`reflink` config option instructs the importer to create fast,
copy-on-write file clones on filesystems that support them. Thanks to
:user:`rubdos`.
* A new :doc:`/plugins/unimported` lets you find untracked files in your
library directory.
* We now fetch information about `works`_ from MusicBrainz.
MusicBrainz matches provide the fields ``work`` (the title), ``mb_workid``
(the MBID), and ``work_disambig`` (the disambiguation string).
Thanks to :user:`dosoe`.
:bug:`2580` :bug:`3272`
* :doc:`/plugins/convert`: Added new ``-l`` (``--link``) flag and ``link``
option as well as the ``-H`` (``--hardlink``) flag and ``hardlink``
option which symlinks or hardlinks files that do not need to
be converted instead of copying them.
:bug:`2324`
* :doc:`/plugins/bpd`: BPD now supports most of the features of version 0.16
of the MPD protocol. This is enough to get it talking to more complicated
clients like ncmpcpp, but there are still some incompatibilities, largely due
to MPD commands we don't support yet. Let us know if you find an MPD client
that doesn't get along with BPD!
:bug:`3214` :bug:`800`
* :doc:`/plugins/replaygain`: The plugin now supports a ``per_disc`` option
which enables calculation of album ReplayGain on disc level instead of album
level.
Thanks to :user:`samuelnilsson`
:bug:`293`
* :doc:`/plugins/replaygain`: The new ``ffmpeg`` ReplayGain backend supports
``R128_`` tags, just like the ``bs1770gain`` backend.
:bug:`3056`
* :doc:`plugins/replaygain`: ``r128_targetlevel`` is a new configuration option
for the ReplayGain plugin: It defines the reference volume for files using
``R128_`` tags. ``targetlevel`` only configures the reference volume for
``REPLAYGAIN_`` files.
This also deprecates the ``bs1770gain`` ReplayGain backend's ``method``
option. Use ``targetlevel`` and ``r128_targetlevel`` instead.
:bug:`3065`
* A new :doc:`/plugins/parentwork` gets information about the original work,
which is useful for classical music.
Thanks to :user:`dosoe`.
:bug:`2580` :bug:`3279`
* :doc:`/plugins/discogs`: The field now collects the "style" field.
* :doc:`/plugins/bpd`: BPD now supports most of the features of version 0.16
of the MPD protocol. This is enough to get it talking to more complicated
clients like ncmpcpp, but there are still some incompatibilities, largely due
to MPD commands we don't support yet. (Let us know if you find an MPD client
that doesn't get along with BPD!)
:bug:`3214` :bug:`800`
* A new :doc:`/plugins/deezer` can autotag tracks and albums using the
`Deezer`_ database.
Thanks to :user:`rhlahuja`.
:bug:`3355`
* A new :doc:`/plugins/bareasc` provides a new query type: `bare ASCII`
which ignores accented characters, treating them as though they
were the base ASCII character. To perform `bare ASCII` searches, use
the ``#`` prefix with :ref:`list-cmd` or other commands.
:bug:`3882`
Other new things:
* :doc:`/plugins/mpdstats`: Add a new `strip_path` option to help build the
right local path from MPD information.
* :doc:`/plugins/convert`: Conversion can now parallelize conversion jobs on
Python 3.
* :doc:`/plugins/lastgenre`: Add a new `title_case` config option to make
title-case formatting optional.
* There's a new message when running ``beet config`` when there's no available
configuration file.
:bug:`3779`
* When importing a duplicate album, the prompt now says "keep all" instead of
"keep both" to reflect that there may be more than two albums involved.
:bug:`3569`
* :doc:`/plugins/chroma`: The plugin now updates file metadata after
generating fingerprints through the `submit` command.
* :doc:`/plugins/lastgenre`: Added more heavy metal genres to the built-in
genre filter lists.
* A new :doc:`/plugins/subsonicplaylist` can import playlists from a Subsonic
server.
* :doc:`/plugins/subsonicupdate`: The plugin now automatically chooses between
token- and password-based authentication based on server version
* A new :ref:`extra_tags` configuration option lets you use more metadata in
MusicBrainz queries to further narrow the search.
* A new :doc:`/plugins/fish` adds `Fish shell`_ tab autocompletion to beets.
* :doc:`plugins/fetchart` and :doc:`plugins/embedart`: Added a new ``quality``
option that controls the quality of the image output when the image is
resized.
* :doc:`plugins/keyfinder`: Added support for `keyfinder-cli`_.
Thanks to :user:`BrainDamage`.
* :doc:`plugins/fetchart`: Added a new ``high_resolution`` config option to
allow downloading of higher resolution iTunes artwork (at the expense of
file size).
:bug:`3391`
* :doc:`plugins/discogs`: The plugin applies two new fields: `discogs_labelid`
and `discogs_artistid`.
:bug:`3413`
* :doc:`/plugins/export`: Added a new ``-f`` (``--format``) flag,
which can export your data as JSON, JSON lines, CSV, or XML.
Thanks to :user:`austinmm`.
:bug:`3402`
* :doc:`/plugins/convert`: Added a new ``-l`` (``--link``) flag and ``link``
option as well as the ``-H`` (``--hardlink``) flag and ``hardlink``
option, which symlink or hardlink files that do not need to
be converted (instead of copying them).
:bug:`2324`
* :doc:`/plugins/replaygain`: The plugin now supports a ``per_disc`` option
that enables calculation of album ReplayGain on disc level instead of album
level.
Thanks to :user:`samuelnilsson`.
:bug:`293`
* :doc:`/plugins/replaygain`: The new ``ffmpeg`` ReplayGain backend supports
``R128_`` tags.
:bug:`3056`
* :doc:`plugins/replaygain`: A new ``r128_targetlevel`` configuration option
defines the reference volume for files using ``R128_`` tags. ``targetlevel``
only configures the reference volume for ``REPLAYGAIN_`` files.
:bug:`3065`
* :doc:`/plugins/discogs`: The plugin now collects the "style" field.
Thanks to :user:`thedevilisinthedetails`.
:bug:`2579` :bug:`3251`
* :doc:`/plugins/absubmit`: By default, the plugin now avoids re-analyzing
files that already have AB data.
files that already have AcousticBrainz data.
There are new ``force`` and ``pretend`` options to help control this new
behavior.
Thanks to :user:`SusannaMaria`.
@ -81,24 +124,21 @@ New features:
Windows.
Thanks to :user:`MartyLake`.
:bug:`3331` :bug:`3334`
* The 'data_source' field is now also applied as an album-level flexible
attribute during imports, allowing for more refined album level searches.
* The `data_source` field, which indicates which metadata source was used
during an autotagging import, is now also applied as an album-level flexible
attribute.
:bug:`3350` :bug:`1693`
* :doc:`/plugins/deezer`: Added Deezer plugin as an import metadata provider:
you can now match tracks and albums using the `Deezer`_ database.
Thanks to :user:`rhlahuja`.
:bug:`3355`
* :doc:`/plugins/beatport`: The plugin now gets the musical key, BPM and the
* :doc:`/plugins/beatport`: The plugin now gets the musical key, BPM, and
genre for each track.
:bug:`2080`
* :doc:`/plugins/beatport`: Fix default assignment of the musical key.
* :doc:`/plugins/beatport`: Fix the default assignment of the musical key.
:bug:`3377`
* :doc:`/plugins/bpsync`: Add `bpsync` plugin to sync metadata changes
from the Beatport database.
* :doc:`/plugins/beatport`: Fix assignment of `genre` and rename `musical_key`
to `initial_key`.
:bug:`3387`
* :doc:`/plugins/hook` now treats non-zero exit codes as errors.
* :doc:`/plugins/hook`: The plugin now treats non-zero exit codes as errors.
:bug:`3409`
* :doc:`/plugins/subsonicupdate`: A new ``url`` configuration replaces the
older (and now deprecated) separate ``host``, ``port``, and ``contextpath``
@ -111,12 +151,86 @@ New features:
titles.
Thanks to :user:`cole-miller`.
:bug:`3459`
* :doc:`/plugins/fetchart`: Album art can now be fetched from `last.fm`_.
:bug:`3530`
* :doc:`/plugins/web`: The query API now interprets backslashes as path
separators to support path queries.
Thanks to :user:`nmeum`.
:bug:`3567`
* ``beet import`` now handles tar archives with bzip2 or gzip compression.
:bug:`3606`
* :doc:`/plugins/plexupdate`: Added an option to use a secure connection to
Plex server, and to ignore certificate validation errors if necessary.
:bug:`2871`
* :doc:`/plugins/lyrics`: Improved searching on the Genius backend when the
artist contains special characters.
:bug:`3634`
* :doc:`/plugins/parentwork`: Also get the composition date of the parent work,
instead of just the child work.
Thanks to :user:`aereaux`.
:bug:`3650`
* :doc:`/plugins/lyrics`: Fix a bug in the heuristic for detecting valid
lyrics in the Google source.
:bug:`2969`
* :doc:`/plugins/thumbnails`: Fix a bug where pathlib expected a string instead
of bytes for a path.
:bug:`3360`
* :doc:`/plugins/convert`: If ``delete_originals`` is enabled, then the source files will
be deleted after importing.
Thanks to :user:`logan-arens`.
:bug:`2947`
* Added flac-specific reporting of samplerate and bitrate when importing duplicates.
* :doc:`/plugins/fetchart`: Cover Art Archive source now iterates over
all front images instead of blindly selecting the first one.
* ``beet remove`` now also allows interactive selection of items from the query
similar to ``beet modify``
* :doc:`/plugins/web`: add DELETE and PATCH methods for modifying items
* :doc:`/plugins/lyrics`: Removed LyricWiki source (shut down on 21/09/2020).
* Added a ``--plugins`` (or ``-p``) flag to specify a list of plugins at startup.
* Use the musicbrainz genre tag api to get genre information. This currently
depends on functionality that is currently unreleased in musicbrainzngs.
Once the functionality has been released, you can enable it with the
``genres`` option inside the ``musicbrainz`` config. See
https://github.com/alastair/python-musicbrainzngs/pull/247 and
https://github.com/alastair/python-musicbrainzngs/pull/266 .
Thanks to :user:`aereaux`.
* :doc:`/plugins/replaygain` now does its analysis in parallel when using
the ``command`` or ``ffmpeg`` backends.
:bug:`3478`
* Fields in queries now fall back to an item's album and check its fields too.
Notably, this allows querying items by an album flex attribute, also in path
configuration.
Thanks to :user:`FichteFoll`.
:bug:`2797` :bug:`2988`
* Removes usage of the bs1770gain replaygain backend.
Thanks to :user:`SamuelCook`.
* Added ``trackdisambig`` which stores the recording disambiguation from
MusicBrainz for each track.
:bug:`1904`
* The :doc:`/plugins/aura` has arrived!
* :doc:`plugins/fetchart`: The new ``max_filesize`` option for fetchart can be
used to target a maximum image filesize.
Fixes:
* :bug:`/plugins/web`: Allow use of backslash in regex web queries.
:bug:`3867`
* :bug:`/plugins/web`: Fixed a small bug which caused album artpath to be
redacted even when ``include_paths`` option is set.
:bug:`3866`
* :bug:`/plugins/discogs`: Fixed a bug with ``index_tracks`` options that
sometimes caused the index to be discarded. Also remove the extra semicolon
that was added when there is no index track.
* :doc:`/plugins/subsonicupdate`: REST was using `POST` method rather `GET` method.
Also includes better exception handling, response parsing, and tests.
* :doc:`/plugins/the`: Fixed incorrect regex for 'the' that matched any
3-letter combination of the letters t, h, e.
:bug:`3701`
* :doc:`/plugins/fetchart`: Fixed a bug that caused fetchart to not take
environment variables such as proxy servers into account when making requests
:bug:`3450`
* :doc:`/plugins/fetchart`: Temporary files for fetched album art that fail
validation are now removed
* :doc:`/plugins/inline`: In function-style field definitions that refer to
flexible attributes, values could stick around from one function invocation
to the next. This meant that, when displaying a list of objects, later
@ -153,15 +267,15 @@ Fixes:
* ``beet update`` will now confirm that the user still wants to update if
their library folder cannot be found, preventing the user from accidentally
wiping out their beets database.
Thanks to :user:`logan-arens`.
Thanks to user: `logan-arens`.
:bug:`1934`
* ``beet import`` now logs which files are ignored when in debug mode.
:bug:`3764`
* :doc:`/plugins/bpd`: Fix the transition to next track when in consume mode.
Thanks to :user:`aereaux`.
:bug:`3437`
* :doc:`/plugins/lyrics`: Fix a corner-case with Genius lowercase artist names
:bug:`3446`
* :doc:`/plugins/replaygain`: Support ``bs1770gain`` v0.6.0 and up
:bug:`3480`
* :doc:`/plugins/parentwork`: Don't save tracks when nothing has changed.
:bug:`3492`
* Added a warning when configuration files defined in the `include` directive
@ -176,6 +290,65 @@ Fixes:
:bug:`3516` :bug:`3517`
* :doc:`/plugins/lyrics`: Added Tekstowo.pl lyrics provider
:bug:`3344`
* :doc:`/plugins/lyrics`: Tolerate missing lyrics div in Genius scraper.
Thanks to :user:`thejli21`.
:bug:`3535` :bug:`3554`
* :doc:`/plugins/lyrics`: Use the artist sort name to search for lyrics, which
can help find matches when the artist name has special characters.
Thanks to :user:`hashhar`.
:bug:`3340` :bug:`3558`
* :doc:`/plugins/replaygain`: Trying to calculate volume gain for an album
consisting of some formats using ``ReplayGain`` and some using ``R128``
will no longer crash; instead it is skipped and and a message is logged.
The log message has also been rewritten for to improve clarity.
Thanks to :user:`autrimpo`.
:bug:`3533`
* :doc:`/plugins/lyrics`: Adapt the Genius backend to changes in markup to
reduce the scraping failure rate.
:bug:`3535` :bug:`3594`
* :doc:`/plugins/lyrics`: Fix crash when writing ReST files for a query without
results or fetched lyrics
:bug:`2805`
* Adapt to breaking changes in Python's ``ast`` module in 3.8
* :doc:`/plugins/fetchart`: Attempt to fetch pre-resized thumbnails from Cover
Art Archive if the ``maxwidth`` option matches one of the sizes supported by
the Cover Art Archive API.
Thanks to :user:`trolley`.
:bug:`3637`
* :doc:`/plugins/ipfs`: Fix Python 3 compatibility.
Thanks to :user:`musoke`.
:bug:`2554`
* Fix a bug that caused metadata starting with something resembling a drive
letter to be incorrectly split into an extra directory after the colon.
:bug:`3685`
* :doc:`/plugins/mpdstats`: Don't record a skip when stopping MPD, as MPD keeps
the current track in the queue.
Thanks to :user:`aereaux`.
:bug:`3722`
* String-typed fields are now normalized to string values, avoiding an
occasional crash when using both the :doc:`/plugins/fetchart` and the
:doc:`/plugins/discogs` together.
:bug:`3773` :bug:`3774`
* Fix a bug causing PIL to generate poor quality JPEGs when resizing artwork.
:bug:`3743`
* :doc:`plugins/keyfinder`: Catch output from ``keyfinder-cli`` that is missing key.
:bug:`2242`
* :doc:`plugins/replaygain`: Disable parallel analysis on import by default.
:bug:`3819`
* :doc:`/plugins/mpdstats`: Fix Python 2/3 compatibility
:bug:`3798`
* Fix :bug:`3308` by using browsing for big releases to retrieve additional
information. Thanks to :user:`dosoe`.
* :doc:`/plugins/discogs`: Replace deprecated discogs-client library with community
supported python3-discogs-client library. :bug:`3608`
* :doc:`/plugins/chroma`: Fixed submitting AcoustID information for tracks
that already have a fingerprint.
:bug:`3834`
* :doc:`/plugins/web`: DELETE and PATCH methods are disallowed by default.
Set ``readonly: no`` web config option to enable them.
:bug:`3870`
* Allow equals within ``--set`` value when importing.
:bug:`2984`
For plugin developers:
@ -210,6 +383,16 @@ For plugin developers:
:bug:`3355`
* The autotag hooks have been modified such that they now take 'bpm',
'musical_key' and a per-track based 'genre' as attributes.
* Item (and attribute) access on an item now falls back to the album's
attributes as well. If you specifically want to access an item's attributes,
use ``Item.get(key, with_album=False)``. :bug:`2988`
* ``Item.keys`` also has a ``with_album`` argument now, defaulting to ``True``.
* A ``revision`` attribute has been added to ``Database``. It is increased on
every transaction that mutates it. :bug:`2988`
* The classes ``AlbumInfo`` and ``TrackInfo`` now convey arbitrary attributes
instead of a fixed, built-in set of field names (which was important to
address :bug:`1547`).
Thanks to :user:`dosoe`.
For packagers:
@ -226,6 +409,7 @@ For packagers:
or `repair <https://build.opensuse.org/package/view_file/openSUSE:Factory/beets/fix_test_command_line_option_relative_to_working_dir.diff?expand=1>`_
the test may no longer be necessary.
* This version drops support for Python 3.4.
* Removes the optional dependency on bs1770gain.
.. _Fish shell: https://fishshell.com/
.. _MediaFile: https://github.com/beetbox/mediafile
@ -233,6 +417,7 @@ For packagers:
.. _works: https://musicbrainz.org/doc/Work
.. _Deezer: https://www.deezer.com
.. _keyfinder-cli: https://github.com/EvanPurkhiser/keyfinder-cli
.. _last.fm: https://last.fm
1.4.9 (May 30, 2019)
@ -1196,7 +1381,7 @@ And there are a few bug fixes too:
The last release, 1.3.19, also erroneously reported its version as "1.3.18"
when you typed ``beet version``. This has been corrected.
.. _six: https://pythonhosted.org/six/
.. _six: https://pypi.org/project/six/
1.3.19 (June 25, 2016)
@ -2042,7 +2227,7 @@ As usual, there are loads of little fixes and improvements:
* The :ref:`config-cmd` command can now use ``$EDITOR`` variables with
arguments.
.. _API changes: https://developer.echonest.com/forums/thread/3650
.. _API changes: https://web.archive.org/web/20160814092627/https://developer.echonest.com/forums/thread/3650
.. _Plex: https://plex.tv/
.. _musixmatch: https://www.musixmatch.com/
@ -2267,7 +2452,7 @@ The big new features are:
* A new :ref:`asciify-paths` configuration option replaces all non-ASCII
characters in paths.
.. _Mutagen: https://bitbucket.org/lazka/mutagen
.. _Mutagen: https://github.com/quodlibet/mutagen
.. _Spotify: https://www.spotify.com/
And the multitude of little improvements and fixes:
@ -2522,7 +2707,7 @@ Fixes:
* :doc:`/plugins/convert`: Display a useful error message when the FFmpeg
executable can't be found.
.. _requests: https://www.python-requests.org/
.. _requests: https://requests.readthedocs.io/en/master/
1.3.3 (February 26, 2014)
@ -2703,7 +2888,7 @@ As usual, there are also innumerable little fixes and improvements:
Bezman.
.. _Acoustic Attributes: http://developer.echonest.com/acoustic-attributes.html
.. _Acoustic Attributes: https://web.archive.org/web/20160701063109/http://developer.echonest.com/acoustic-attributes.html
.. _MPD: https://www.musicpd.org/
@ -3053,7 +3238,7 @@ will automatically migrate your configuration to the new system.
header. Thanks to Uwe L. Korn.
* :doc:`/plugins/lastgenre`: Fix an error when using genre canonicalization.
.. _Tomahawk: https://tomahawk-player.org/
.. _Tomahawk: https://github.com/tomahawk-player/tomahawk
1.1b3 (March 16, 2013)
----------------------
@ -3396,7 +3581,7 @@ begins today on features for version 1.1.
* Changed plugin loading so that modules can be imported without
unintentionally loading the plugins they contain.
.. _The Echo Nest: http://the.echonest.com/
.. _The Echo Nest: https://web.archive.org/web/20180329103558/http://the.echonest.com/
.. _Tomahawk resolver: https://beets.io/blog/tomahawk-resolver.html
.. _mp3gain: http://mp3gain.sourceforge.net/download.php
.. _aacgain: https://aacgain.altosdesign.com
@ -3834,7 +4019,7 @@ plugin.
* The :doc:`/plugins/web` encapsulates a simple **Web-based GUI for beets**. The
current iteration can browse the library and play music in browsers that
support `HTML5 Audio`_.
support HTML5 Audio.
* When moving items that are part of an album, the album art implicitly moves
too.
@ -3851,8 +4036,6 @@ plugin.
* Fix crash when "copying" an art file that's already in place.
.. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html
1.0b9 (July 9, 2011)
--------------------

View file

@ -28,6 +28,14 @@ extlinks = {
'stdlib': ('https://docs.python.org/3/library/%s.html', ''),
}
linkcheck_ignore = [
r'https://github.com/beetbox/beets/issues/',
r'https://github.com/[^/]+$', # ignore user pages
r'.*localhost.*',
r'https://www.musixmatch.com/', # blocks requests
r'https://genius.com/', # blocks requests
]
# Options for HTML output
htmlhelp_basename = 'beetsdoc'

3
docs/contributing.rst Normal file
View file

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

View file

@ -7,7 +7,7 @@ in hacking beets itself or creating plugins for it.
See also the documentation for `MediaFile`_, the library used by beets to read
and write metadata tags in media files.
.. _MediaFile: https://mediafile.readthedocs.io/
.. _MediaFile: https://mediafile.readthedocs.io/en/latest/
.. toctree::

View file

@ -45,7 +45,7 @@ responsible for handling queries to retrieve stored objects.
.. automethod:: transaction
.. _SQLite: https://sqlite.org/
.. _SQLite: https://sqlite.org/index.html
.. _ORM: https://en.wikipedia.org/wiki/Object-relational_mapping
@ -118,7 +118,7 @@ To make changes to either the database or the tags on a file, you
update an item's fields (e.g., ``item.title = "Let It Be"``) and then call
``item.write()``.
.. _MediaFile: https://mediafile.readthedocs.io/
.. _MediaFile: https://mediafile.readthedocs.io/en/latest/
Items also track their modification times (mtimes) to help detect when they
become out of sync with on-disk metadata, mainly to speed up the

View file

@ -164,6 +164,10 @@ The events currently available are:
created for a file.
Parameters: ``item``, ``source`` path, ``destination`` path
* `item_reflinked`: called with an ``Item`` object whenever a reflink is
created for a file.
Parameters: ``item``, ``source`` path, ``destination`` path
* `item_removed`: called with an ``Item`` object every time an item (singleton
or album's part) is removed from the library (even when its file is not
deleted from disk).
@ -301,7 +305,7 @@ To access this value, say ``self.config['foo'].get()`` at any point in your
plugin's code. The `self.config` object is a *view* as defined by the `Confuse`_
library.
.. _Confuse: https://confuse.readthedocs.org/
.. _Confuse: https://confuse.readthedocs.io/en/latest/
If you want to access configuration values *outside* of your plugin's section,
import the `config` object from the `beets` module. That is, just put ``from
@ -379,7 +383,7 @@ access to file tags. If you have created a descriptor you can add it through
your plugins ``add_media_field()`` method.
.. automethod:: beets.plugins.BeetsPlugin.add_media_field
.. _MediaFile: https://mediafile.readthedocs.io/
.. _MediaFile: https://mediafile.readthedocs.io/en/latest/
Here's an example plugin that provides a meaningless new field "foo"::

View file

@ -2,10 +2,9 @@ FAQ
###
Here are some answers to frequently-asked questions from IRC and elsewhere.
Got a question that isn't answered here? Try `IRC`_, the `discussion board`_, or
Got a question that isn't answered here? Try the `discussion board`_, or
:ref:`filing an issue <bugs>` in the bug tracker.
.. _IRC: irc://irc.freenode.net/beets
.. _mailing list: https://groups.google.com/group/beets-users
.. _discussion board: https://discourse.beets.io
@ -119,7 +118,7 @@ Run a command like this::
pip install -U beets
The ``-U`` flag tells `pip <https://pip.pypa.io/>`__ to upgrade
The ``-U`` flag tells `pip`_ to upgrade
beets to the latest version. If you want a specific version, you can
specify with using ``==`` like so::
@ -136,13 +135,13 @@ it's helpful to run on the "bleeding edge". To run the latest source:
1. Uninstall beets. If you installed using ``pip``, you can just run
``pip uninstall beets``.
2. Install from source. There are a few easy ways to do this:
2. Install from source. Choose one of these methods:
- Use ``pip`` to install the latest snapshot tarball: just type
``pip install https://github.com/beetbox/beets/tarball/master``.
- Grab the source using Git:
``git clone https://github.com/beetbox/beets.git``. Then
``cd beets`` and type ``python setup.py install``.
- Use ``pip`` to install the latest snapshot tarball. Type:
``pip install https://github.com/beetbox/beets/tarball/master``
- Grab the source using git. First, clone the repository:
``git clone https://github.com/beetbox/beets.git``.
Then, ``cd beets`` and ``python setup.py install``.
- Use ``pip`` to install an "editable" version of beets based on an
automatic source checkout. For example, run
``pip install -e git+https://github.com/beetbox/beets#egg=beets``
@ -188,7 +187,9 @@ there to report a bug. Please follow these guidelines when reporting an issue:
If you've never reported a bug before, Mozilla has some well-written
`general guidelines for good bug
reports <https://www.mozilla.org/bugs/>`__.
reports`_.
.. _general guidelines for good bug reports: https://developer.mozilla.org/en-US/docs/Mozilla/QA/Bug_writing_guidelines
.. _find-config:
@ -300,8 +301,7 @@ a flag. There is no simple way to remedy this.)
…not change my ID3 tags?
------------------------
Beets writes `ID3v2.4 <http://www.id3.org/id3v2.4.0-structure>`__ tags by
default.
Beets writes `ID3v2.4`_ tags by default.
Some software, including Windows (i.e., Windows Explorer and Windows
Media Player) and `id3lib/id3v2 <http://id3v2.sourceforge.net/>`__,
don't support v2.4 tags. When using 2.4-unaware software, it might look
@ -311,6 +311,7 @@ To enable ID3v2.3 tags, enable the :ref:`id3v23` config option.
.. _invalid:
.. _ID3v2.4: https://id3.org/id3v2.4.0-structure
…complain that a file is "unreadable"?
--------------------------------------
@ -379,3 +380,4 @@ installed using pip, the command ``pip show -f beets`` can show you where
try `this Super User answer`_.
.. _this Super User answer: https://superuser.com/a/284361/4569
.. _pip: https://pip.pypa.io/en/stable/

View file

@ -64,7 +64,7 @@ beets`` if you run into permissions problems).
To install without pip, download beets from `its PyPI page`_ and run ``python
setup.py install`` in the directory therein.
.. _its PyPI page: https://pypi.org/project/beets#downloads
.. _its PyPI page: https://pypi.org/project/beets/#files
.. _pip: https://pip.pypa.io
The best way to upgrade beets to a new version is by running ``pip install -U

View file

@ -234,7 +234,7 @@ If beets finds an album or item in your library that seems to be the same as the
one you're importing, you may see a prompt like this::
This album is already in the library!
[S]kip new, Keep both, Remove old, Merge all?
[S]kip new, Keep all, Remove old, Merge all?
Beets wants to keep you safe from duplicates, which can be a real pain, so you
have four choices in this situation. You can skip importing the new music,

View file

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

View file

@ -62,6 +62,6 @@ file. The available options are:
.. _streaming_extractor_music: https://acousticbrainz.org/download
.. _FAQ: https://acousticbrainz.org/faq
.. _pip: https://pip.pypa.io
.. _requests: https://docs.python-requests.org/en/master/
.. _requests: https://requests.readthedocs.io/en/master/
.. _github: https://github.com/MTG/essentia
.. _AcousticBrainz: https://acousticbrainz.org

198
docs/plugins/aura.rst Normal file
View file

@ -0,0 +1,198 @@
AURA Plugin
===========
This plugin is a server implementation of the `AURA`_ specification using the
`Flask`_ framework. AURA is still a work in progress and doesn't yet have a
stable version, but this server should be kept up to date. You are advised to
read the :ref:`aura-issues` section.
.. _AURA: https://auraspec.readthedocs.io
.. _Flask: https://palletsprojects.com/p/flask/
Install
-------
The ``aura`` plugin depends on `Flask`_, which can be installed using
``python -m pip install flask``. Then you can enable the ``aura`` plugin in
your configuration (see :ref:`using-plugins`).
It is likely that you will need to enable :ref:`aura-cors`, which introduces
an additional dependency: `flask-cors`_. This can be installed with
``python -m pip install flask-cors``.
If `Pillow`_ is installed (``python -m pip install Pillow``) then the optional
``width`` and ``height`` attributes are included in image resource objects.
.. _flask-cors: https://flask-cors.readthedocs.io
.. _Pillow: https://pillow.readthedocs.io
Usage
-----
Use ``beet aura`` to start the AURA server.
By default Flask's built-in server is used, which will give a warning about
using it in a production environment. It is safe to ignore this warning if the
server will have only a few users.
Alternatively, you can use ``beet aura -d`` to start the server in
`development mode`_, which will reload the server every time the AURA plugin
file is changed.
You can specify the hostname and port number used by the server in your
:doc:`configuration file </reference/config>`. For more detail see the
:ref:`configuration` section below.
If you would prefer to use a different WSGI server, such as gunicorn or uWSGI,
then see :ref:`aura-external-server`.
AURA is designed to separate the client and server functionality. This plugin
provides the server but not the client, so unless you like looking at JSON you
will need a separate client. Currently the only client is `AURA Web Client`_.
By default the API is served under http://127.0.0.1:8337/aura/. For example
information about the track with an id of 3 can be obtained at
http://127.0.0.1:8337/aura/tracks/3.
**Note the absence of a trailing slash**:
http://127.0.0.1:8337/aura/tracks/3/ returns a ``404 Not Found`` error.
.. _development mode: https://flask.palletsprojects.com/en/1.1.x/server
.. _AURA Web Client: https://sr.ht/~callum/aura-web-client/
.. _configuration:
Configuration
-------------
To configure the plugin, make an ``aura:`` section in your
configuration file. The available options are:
- **host**: The server hostname. Set this to ``0.0.0.0`` to bind to all
interfaces. Default: ``127.0.0.1``.
- **port**: The server port.
Default: ``8337``.
- **cors**: A YAML list of origins to allow CORS requests from (see
:ref:`aura-cors`, below).
Default: disabled.
- **cors_supports_credentials**: Allow authenticated requests when using CORS.
Default: disabled.
- **page_limit**: The number of items responses should be truncated to if the
client does not specify. Default ``500``.
.. _aura-cors:
Cross-Origin Resource Sharing (CORS)
------------------------------------
`CORS`_ allows browser clients to make requests to the AURA server. You should
set the ``cors`` configuration option to a YAML list of allowed origins.
For example::
aura:
cors:
- http://www.example.com
- https://aura.example.org
Alternatively you can set it to ``'*'`` to enable access from all origins.
Note that there are security implications if you set the origin to ``'*'``,
so please research this before using it. Note the use of quote marks when
allowing all origins. Quote marks are also required when the origin is
``null``, for example when using ``file:///``.
If the server is behind a proxy that uses credentials, you might want to set
the ``cors_supports_credentials`` configuration option to true to let
in-browser clients log in. Note that this option has not been tested, so it
may not work.
.. _CORS: https://en.wikipedia.org/wiki/Cross-origin_resource_sharing
.. _aura-external-server:
Using an External WSGI Server
-----------------------------
If you would like to use a different WSGI server (not Flask's built-in one),
then you can! The ``beetsplug.aura`` module provides a WSGI callable called
``create_app()`` which can be used by many WSGI servers.
For example to run the AURA server using `gunicorn`_ use
``gunicorn 'beetsplug.aura:create_app()'``, or for `uWSGI`_ use
``uwsgi --http :8337 --module 'beetsplug.aura:create_app()'``.
Note that these commands just show how to use the AURA app and you would
probably use something a bit different in a production environment. Read the
relevant server's documentation to figure out what you need.
.. _gunicorn: https://gunicorn.org
.. _uWSGI: https://uwsgi-docs.readthedocs.io
Reverse Proxy Support
---------------------
The plugin should work behind a reverse proxy without further configuration,
however this has not been tested extensively. For details of what headers must
be rewritten and a sample NGINX configuration see `Flask proxy setups`_.
It is (reportedly) possible to run the application under a URL prefix (for
example so you could have ``/foo/aura/server`` rather than ``/aura/server``),
but you'll have to work it out for yourself :-)
If using NGINX, do **not** add a trailing slash (``/``) to the URL where the
application is running, otherwise you will get a 404. However if you are using
Apache then you **should** add a trailing slash.
.. _Flask proxy setups: https://flask.palletsprojects.com/en/1.1.x/deploying/wsgi-standalone/#proxy-setups
.. _aura-issues:
Issues
------
As of writing there are some differences between the specification and this
implementation:
- Compound filters are not specified in AURA, but this server interprets
multiple ``filter`` parameters as AND. See `issue #19`_ for discussion.
- The ``bitrate`` parameter used for content negotiation is not supported.
Adding support for this is doable, but the way Flask handles acceptable MIME
types means it's a lot easier not to bother with it. This means an error
could be returned even if no transcoding was required.
It is possible that some attributes required by AURA could be absent from the
server's response if beets does not have a saved value for them. However, this
has not happened so far.
Beets fields (including flexible fields) that do not have an AURA equivalent
are not provided in any resource's attributes section, however these fields may
be used for filtering.
The ``mimetype`` and ``framecount`` attributes for track resources are not
supported. The first is due to beets storing the file type (e.g. ``MP3``), so
it is hard to filter by MIME type. The second is because there is no
corresponding beets field.
Artists are defined by the ``artist`` field on beets Items, which means some
albums have no ``artists`` relationship. Albums only have related artists
when their beets ``albumartist`` field is the same as the ``artist`` field on
at least one of it's constituent tracks.
The only art tracked by beets is a single cover image, so only albums have
related images at the moment. This could be expanded to looking in the same
directory for other images, and relating tracks to their album's image.
There are likely to be some performance issues, especially with larger
libraries. Sorting, pagination and inclusion (most notably of images) are
probably the main offenders. On a related note, the program attempts to import
Pillow every time it constructs an image resource object, which is not good.
The beets library is accessed using a so called private function (with a single
leading underscore) ``beets.ui.__init__._open_library()``. This shouldn't cause
any issues but it is probably not best practice.
.. _issue #19: https://github.com/beetbox/aura/issues/19

69
docs/plugins/bareasc.rst Normal file
View file

@ -0,0 +1,69 @@
Bare-ASCII Search Plugin
========================
The ``bareasc`` plugin provides a prefixed query that searches your library using
simple ASCII character matching, with accented characters folded to their base
ASCII character. This can be useful if you want to find a track with accented
characters in the title or artist, particularly if you are not confident
you have the accents correct. It is also not unknown for the accents
to not be correct in the database entry or wrong in the CD information.
First, enable the plugin named ``bareasc`` (see :ref:`using-plugins`).
You'll then be able to use the ``#`` prefix to use bare-ASCII matching::
$ beet ls '#dvorak'
István Kertész - REQUIEM - Dvořàk: Requiem, op.89 - Confutatis maledictis
Command
-------
In addition to the query prefix, the plugin provides a utility ``bareasc`` command.
This command is **exactly** the same as the ``beet list`` command except that
the output is passed through the bare-ASCII transformation before being printed.
This allows you to easily check what the library data looks like in bare ASCII,
which can be useful if you are trying to work out why a query is not matching.
Using the same example track as above::
$ beet bareasc 'Dvořàk'
Istvan Kertesz - REQUIEM - Dvorak: Requiem, op.89 - Confutatis maledictis
Note: the ``bareasc`` command does *not* automatically use bare-ASCII queries.
If you want a bare-ASCII query you still need to specify the ``#`` prefix.
Notes
-----
If the query string is all in lower case, the comparison ignores case as well as
accents.
The default ``bareasc`` prefix (``#``) is used as a comment character in some shells
so may need to be protected (for example in quotes) when typed into the command line.
The bare ASCII transliteration is quite simple. It may not give the expected output
for all languages. For example, German u-umlaut ``ü`` is transformed into ASCII ``u``,
not into ``ue``.
The bare ASCII transformation also changes Unicode punctuation like double quotes,
apostrophes and even some hyphens. It is often best to leave out punctuation
in the queries. Note that the punctuation changes are often not even visible
with normal terminal fonts. You can always use the ``bareasc`` command to print the
transformed entries and use a command like ``diff`` to compare with the output
from the ``list`` command.
Configuration
-------------
To configure the plugin, make a ``bareasc:`` section in your configuration
file. The only available option is:
- **prefix**: The character used to designate bare-ASCII queries.
Default: ``#``, which may need to be escaped in some shells.
Credits
-------
The hard work in this plugin is done in Sean Burke's
`Unidecode <https://pypi.org/project/Unidecode/>`__ library.
Thanks are due to Sean and to all the people who created the Python
version and the beets extensible query architecture.

View file

@ -41,6 +41,6 @@ Configuration
This plugin can be configured like other metadata source plugins as described in :ref:`metadata-source-plugin-configuration`.
.. _requests: https://docs.python-requests.org/en/latest/
.. _requests: https://requests.readthedocs.io/en/master/
.. _requests_oauthlib: https://github.com/requests/requests-oauthlib
.. _Beatport: https://beetport.com
.. _Beatport: https://www.beatport.com/

View file

@ -5,7 +5,7 @@ BPD is a music player using music from a beets library. It runs as a daemon and
implements the MPD protocol, so it's compatible with all the great MPD clients
out there. I'm using `Theremin`_, `gmpc`_, `Sonata`_, and `Ario`_ successfully.
.. _Theremin: https://theremin.sigterm.eu/
.. _Theremin: https://github.com/TheStalwart/Theremin
.. _gmpc: https://gmpc.wikia.com/wiki/Gnome_Music_Player_Client
.. _Sonata: http://sonata.berlios.de/
.. _Ario: http://ario-player.sourceforge.net/
@ -13,7 +13,7 @@ out there. I'm using `Theremin`_, `gmpc`_, `Sonata`_, and `Ario`_ successfully.
Dependencies
------------
Before you can use BPD, you'll need the media library called GStreamer (along
Before you can use BPD, you'll need the media library called `GStreamer`_ (along
with its Python bindings) on your system.
* On Mac OS X, you can use `Homebrew`_. Run ``brew install gstreamer
@ -22,14 +22,11 @@ with its Python bindings) on your system.
* On Linux, you need to install GStreamer 1.0 and the GObject bindings for
python. Under Ubuntu, they are called ``python-gi`` and ``gstreamer1.0``.
* On Windows, you may want to try `GStreamer WinBuilds`_ (caveat emptor: I
haven't tried this).
You will also need the various GStreamer plugin packages to make everything
work. See the :doc:`/plugins/chroma` documentation for more information on
installing GStreamer plugins.
.. _GStreamer WinBuilds: https://www.gstreamer-winbuild.ylatuya.es/
.. _GStreamer: https://gstreamer.freedesktop.org/download
.. _Homebrew: https://brew.sh
Usage

View file

@ -80,8 +80,8 @@ You will also need a mechanism for decoding audio files supported by the
.. _audioread: https://github.com/beetbox/audioread
.. _pyacoustid: https://github.com/beetbox/pyacoustid
.. _FFmpeg: https://ffmpeg.org/
.. _MAD: https://spacepants.org/src/pymad/
.. _pymad: https://www.underbit.com/products/mad/
.. _pymad: https://spacepants.org/src/pymad/
.. _MAD: https://www.underbit.com/products/mad/
.. _Core Audio: https://developer.apple.com/technologies/mac/audio-and-video.html
.. _Gstreamer: https://gstreamer.freedesktop.org/
.. _PyGObject: https://wiki.gnome.org/Projects/PyGObject

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):
@ -189,7 +191,7 @@ can use the :doc:`/plugins/replaygain` to do this analysis. See the LAME
`documentation`_ and the `HydrogenAudio wiki`_ for other LAME configuration
options and a thorough discussion of MP3 encoding.
.. _documentation: http://lame.sourceforge.net/using.php
.. _documentation: https://lame.sourceforge.io/index.php
.. _HydrogenAudio wiki: https://wiki.hydrogenaud.io/index.php?title=LAME
.. _gapless: https://wiki.hydrogenaud.io/index.php?title=Gapless_playback
.. _LAME: https://lame.sourceforge.net/
.. _LAME: https://lame.sourceforge.io/index.php

View file

@ -10,9 +10,9 @@ Installation
------------
To use the ``discogs`` plugin, first enable it in your configuration (see
:ref:`using-plugins`). Then, install the `discogs-client`_ library by typing::
:ref:`using-plugins`). Then, install the `python3-discogs-client`_ library by typing::
pip install discogs-client
pip install python3-discogs-client
You will also need to register for a `Discogs`_ account, and provide
authentication credentials via a personal access token or an OAuth2
@ -36,7 +36,7 @@ Authentication via Personal Access Token
As an alternative to OAuth, you can get a token from Discogs and add it to
your configuration.
To get a personal access token (called a "user token" in the `discogs-client`_
To get a personal access token (called a "user token" in the `python3-discogs-client`_
documentation), login to `Discogs`_, and visit the
`Developer settings page
<https://www.discogs.com/settings/developers>`_. Press the ``Generate new
@ -89,4 +89,4 @@ Here are two things you can try:
* Make sure that your system clock is accurate. The Discogs servers can reject
your request if your clock is too out of sync.
.. _discogs-client: https://github.com/discogs/discogs_client
.. _python3-discogs-client: https://github.com/joalla/discogs_client

View file

@ -18,7 +18,7 @@ To use the ``embyupdate`` plugin you need to install the `requests`_ library wit
With that all in place, you'll see beets send the "update" command to your Emby server every time you change your beets library.
.. _Emby: https://emby.media/
.. _requests: https://docs.python-requests.org/en/latest/
.. _requests: https://requests.readthedocs.io/en/master/
Configuration
-------------

View file

@ -39,14 +39,15 @@ The ``export`` command has these command-line options:
* ``--append``: Appends the data to the file instead of writing.
* ``--format`` or ``-f``: Specifies the format the data will be exported as. If not informed, JSON will be used by default. The format options include csv, json and xml.
* ``--format`` or ``-f``: Specifies the format the data will be exported as. If not informed, JSON will be used by default. The format options include csv, json, `jsonlines <https://jsonlines.org/>`_ and xml.
Configuration
-------------
To configure the plugin, make a ``export:`` section in your configuration
file.
For JSON export, these options are available under the ``json`` key:
For JSON export, these options are available under the ``json`` and
``jsonlines`` keys:
- **ensure_ascii**: Escape non-ASCII characters with ``\uXXXX`` entities.
- **indent**: The number of spaces for indentation.

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
--------------------------------
@ -49,6 +49,13 @@ file. The available options are:
estimate the input image quality and uses 92 if it cannot be determined, and
PIL defaults to 75.
Default: 0 (disabled)
- **max_filesize**: The maximum size of a target piece of cover art in bytes.
When using an ImageMagick backend this sets
``-define jpeg:extent=max_filesize``. Using PIL this will reduce JPG quality
by up to 50% to attempt to reach the target filesize. Neither method is
*guaranteed* to reach the target size, however in most cases it should
succeed.
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
@ -58,9 +65,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.
@ -71,6 +78,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``.
@ -124,8 +133,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:
@ -221,6 +231,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
----------------------------

View file

@ -61,7 +61,9 @@ following to your configuration::
absubmit
acousticbrainz
aura
badfiles
bareasc
beatport
bpd
bpm
@ -116,6 +118,7 @@ following to your configuration::
smartplaylist
sonosupdate
spotify
subsonicplaylist
subsonicupdate
the
thumbnails
@ -183,6 +186,7 @@ Path Formats
Interoperability
----------------
* :doc:`aura`: A server implementation of the `AURA`_ specification.
* :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.
@ -204,6 +208,7 @@ Interoperability
library changes.
.. _AURA: https://auraspec.readthedocs.io
.. _Emby: https://emby.media
.. _Fish shell: https://fishshell.com/
.. _Plex: https://plex.tv
@ -214,6 +219,7 @@ Interoperability
Miscellaneous
-------------
* :doc:`bareasc`: Search albums and tracks with bare ASCII string matching.
* :doc:`bpd`: A music player for your beets library that emulates `MPD`_ and is
compatible with `MPD clients`_.
* :doc:`convert`: Transcode music and embed album art while exporting to
@ -274,7 +280,7 @@ Here are a few of the plugins written by the beets community:
* `beet-amazon`_ adds Amazon.com as a tagger data source.
* `copyartifacts`_ helps bring non-music files along during import.
* `beets-copyartifacts`_ helps bring non-music files along during import.
* `beets-check`_ automatically checksums your files to detect corruption.
@ -282,6 +288,8 @@ Here are a few of the plugins written by the beets community:
* `beets-follow`_ lets you check for new albums from artists you like.
* `beets-ibroadcast`_ uploads tracks to the `iBroadcast`_ cloud service.
* `beets-setlister`_ generate playlists from the setlists of a given artist.
* `beets-noimport`_ adds and removes directories from the incremental import skip list.
@ -300,15 +308,30 @@ 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-goingrunning`_ copies songs to external device to go with your running session.
* `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
.. _copyartifacts: https://github.com/sbarakat/beets-copyartifacts
.. _beets-copyartifacts: https://github.com/adammillerio/beets-copyartifacts
.. _dsedivec: https://github.com/dsedivec/beets-plugins
.. _beets-artistcountry: https://github.com/agrausem/beets-artistcountry
.. _beetFs: https://github.com/jbaiter/beetfs
@ -320,6 +343,8 @@ Here are a few of the plugins written by the beets community:
.. _beet-amazon: https://github.com/jmwatte/beet-amazon
.. _beets-alternatives: https://github.com/geigerzaehler/beets-alternatives
.. _beets-follow: https://github.com/nolsto/beets-follow
.. _beets-ibroadcast: https://github.com/ctrueden/beets-ibroadcast
.. _iBroadcast: https://ibroadcast.com/
.. _beets-setlister: https://github.com/tomjaspers/beets-setlister
.. _beets-noimport: https://gitlab.com/tiago.dias/beets-noimport
.. _whatlastgenre: https://github.com/YetAnotherNerd/whatlastgenre/tree/master/plugin/beets
@ -328,5 +353,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-goingrunning: https://pypi.org/project/beets-goingrunning/
.. _beets-originquery: https://github.com/x1ppy/beets-originquery
.. _drop2beets: https://github.com/martinkirch/drop2beets

View file

@ -31,5 +31,5 @@ configuration file. The available options are:
`initial_key` value.
Default: ``no``.
.. _KeyFinder: https://www.ibrahimshaath.co.uk/keyfinder/
.. _KeyFinder: http://www.ibrahimshaath.co.uk/keyfinder/
.. _keyfinder-cli: https://github.com/EvanPurkhiser/keyfinder-cli/

View file

@ -27,7 +27,7 @@ With that all in place, you'll see beets send the "update" command to your Kodi
host every time you change your beets library.
.. _Kodi: https://kodi.tv/
.. _requests: https://docs.python-requests.org/en/latest/
.. _requests: https://requests.readthedocs.io/en/master/
Configuration
-------------

View file

@ -1,13 +1,10 @@
LastGenre Plugin
================
The MusicBrainz database `does not contain genre information`_. Therefore, when
importing and autotagging music, beets does not assign a genre. The
``lastgenre`` plugin fetches *tags* from `Last.fm`_ and assigns them as genres
The ``lastgenre`` plugin fetches *tags* from `Last.fm`_ and assigns them as genres
to your albums and items.
.. _does not contain genre information:
https://musicbrainz.org/doc/General_FAQ#Why_does_MusicBrainz_not_support_genre_information.3F
.. _Last.fm: https://last.fm/
Installation
@ -72,7 +69,7 @@ nothing would ever be matched to a more generic node since all the specific
subgenres are in the whitelist to begin with.
.. _YAML: https://www.yaml.org/
.. _YAML: https://yaml.org/
.. _tree of nested genre names: https://raw.githubusercontent.com/beetbox/beets/master/beetsplug/lastgenre/genres-tree.yaml
@ -149,6 +146,8 @@ configuration file. The available options are:
- **whitelist**: The filename of a custom genre list, ``yes`` to use
the internal whitelist, or ``no`` to consider all genres valid.
Default: ``yes``.
- **title_case**: Convert the new tags to TitleCase before saving.
Default: ``yes``.
Running Manually
----------------

View file

@ -2,10 +2,9 @@ Lyrics Plugin
=============
The ``lyrics`` plugin fetches and stores song lyrics from databases on the Web.
Namely, the current version of the plugin uses `Lyric Wiki`_,
`Musixmatch`_, `Genius.com`_, `Tekstowo.pl`_, and, optionally, the Google custom search API.
Namely, the current version of the plugin uses `Musixmatch`_, `Genius.com`_,
`Tekstowo.pl`_, and, optionally, the Google custom search API.
.. _Lyric Wiki: https://lyrics.wikia.com/
.. _Musixmatch: https://www.musixmatch.com/
.. _Genius.com: https://genius.com/
.. _Tekstowo.pl: https://www.tekstowo.pl/
@ -27,7 +26,7 @@ already have them. The lyrics will be stored in the beets database. If the
``import.write`` config option is on, then the lyrics will also be written to
the files' tags.
.. _requests: https://docs.python-requests.org/en/latest/
.. _requests: https://requests.readthedocs.io/en/master/
Configuration
@ -60,7 +59,7 @@ configuration file. The available options are:
sources known to be scrapeable.
- **sources**: List of sources to search for lyrics. An asterisk ``*`` expands
to all available sources.
Default: ``google lyricwiki musixmatch genius tekstowo``, i.e., all the
Default: ``google musixmatch genius tekstowo``, i.e., all the
available sources. The ``google`` source will be automatically
deactivated if no ``google_API_key`` is setup.
The following sources will only be enabled if BeatifulSoup is installed: ``[google, genius, tekstowo]``
@ -175,8 +174,7 @@ You also need to register for a Microsoft Azure Marketplace free account and
to the `Microsoft Translator API`_. Follow the four steps process, specifically
at step 3 enter ``beets`` as *Client ID* and copy/paste the generated
*Client secret* into your ``bing_client_secret`` configuration, alongside
``bing_lang_to`` target `language code`_.
``bing_lang_to`` target `language code`.
.. _langdetect: https://pypi.python.org/pypi/langdetect
.. _Microsoft Translator API: https://www.microsoft.com/en-us/translator/getstarted.aspx
.. _language code: https://msdn.microsoft.com/en-us/library/hh456380.aspx
.. _Microsoft Translator API: https://docs.microsoft.com/en-us/azure/cognitive-services/translator/translator-how-to-signup

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

@ -53,6 +53,9 @@ configuration file. The available options are:
- **music_directory**: If your MPD library is at a different location from the
beets library (e.g., because one is mounted on a NFS share), specify the path
here.
- **strip_path**: If your MPD library contains local path, specify the part to remove
here. Combining this with **music_directory** you can mangle MPD path to match the
beets library one.
Default: The beets library directory.
- **rating**: Enable rating updates.
Default: ``yes``.

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

@ -25,7 +25,7 @@ With that all in place, you'll see beets send the "update" command to your Plex
server every time you change your beets library.
.. _Plex: https://plex.tv/
.. _requests: https://docs.python-requests.org/en/latest/
.. _requests: https://requests.readthedocs.io/en/master/
.. _documentation about tokens: https://support.plex.tv/hc/en-us/articles/204059436-Finding-your-account-token-X-Plex-Token
Configuration
@ -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

@ -13,12 +13,16 @@ Installation
This plugin can use one of many backends to compute the ReplayGain values:
GStreamer, mp3gain (and its cousin, aacgain), Python Audio Tools or ffmpeg.
ffmpeg and mp3gain can be easier to install. mp3gain supports less audio formats
then the other backend.
than the other backend.
Once installed, this plugin analyzes all files during the import process. This
can be a slow process; to instead analyze after the fact, disable automatic
analysis and use the ``beet replaygain`` command (see below).
To speed up analysis with some of the avalaible backends, this plugin processes
tracks or albums (when using the ``-a`` option) in parallel. By default,
a single thread is used per logical core of your CPU.
GStreamer
`````````
@ -35,6 +39,8 @@ the GStreamer backend by adding this to your configuration file::
replaygain:
backend: gstreamer
The GStreamer backend does not support parallel analysis.
mp3gain and aacgain
```````````````````
@ -73,6 +79,8 @@ On OS X, most of the dependencies can be installed with `Homebrew`_::
brew install mpg123 mp3gain vorbisgain faad2 libvorbis
The Python Audio Tools backend does not support parallel analysis.
.. _Python Audio Tools: http://audiotools.sourceforge.net
ffmpeg
@ -92,6 +100,15 @@ configuration file. The available options are:
- **auto**: Enable ReplayGain analysis during import.
Default: ``yes``.
- **threads**: The number of parallel threads to run the analysis in. Overridden
by ``--threads`` at the command line.
Default: # of logical CPU cores
- **parallel_on_import**: Whether to enable parallel analysis during import.
As of now this ReplayGain data is not written to files properly, so this option
is disabled by default.
If you wish to enable it, remember to run ``beet write`` after importing to
actually write to the imported files.
Default: ``no``
- **backend**: The analysis backend; either ``gstreamer``, ``command``, ``audiotools``
or ``ffmpeg``.
Default: ``command``.
@ -143,8 +160,15 @@ whether ReplayGain tags are written into the music files, or stored in the
beets database only (the default is to use :ref:`the importer's configuration
<config-import-write>`).
To execute with a different number of threads, call ``beet replaygain --threads N``::
$ beet replaygain --threads N [-Waf] [QUERY]
with N any integer. To disable parallelism, use ``--threads 0``.
ReplayGain analysis is not fast, so you may want to disable it during import.
Use the ``auto`` config option to control this::
replaygain:
auto: no

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

@ -4,7 +4,7 @@ SubsonicUpdate Plugin
``subsonicupdate`` is a very simple plugin for beets that lets you automatically
update `Subsonic`_'s index whenever you change your beets library.
.. _Subsonic: https://www.subsonic.org
.. _Subsonic: http://www.subsonic.org/pages/index.jsp
To use ``subsonicupdate`` plugin, enable it in your configuration
(see :ref:`using-plugins`).

View file

@ -19,8 +19,6 @@ The Web interface depends on `Flask`_. To get it, just run ``pip install
flask``. Then enable the ``web`` plugin in your configuration (see
:ref:`using-plugins`).
.. _Flask: https://flask.pocoo.org/
If you need CORS (it's disabled by default---see :ref:`web-cors`, below), then
you also need `flask-cors`_. Just type ``pip install flask-cors``.
@ -47,9 +45,7 @@ Usage
-----
Type queries into the little search box. Double-click a track to play it with
`HTML5 Audio`_.
.. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html
HTML5 Audio.
Configuration
-------------
@ -70,6 +66,8 @@ configuration file. The available options are:
Default: false.
- **include_paths**: If true, includes paths in item objects.
Default: false.
- **readonly**: If true, DELETE and PATCH operations are not allowed. Only GET is permitted.
Default: true.
Implementation
--------------
@ -78,7 +76,7 @@ The Web backend is built using a simple REST+JSON API with the excellent
`Flask`_ library. The frontend is a single-page application written with
`Backbone.js`_. This allows future non-Web clients to use the same backend API.
.. _Flask: https://flask.pocoo.org/
.. _Backbone.js: https://backbonejs.org
Eventually, to make the Web player really viable, we should use a Flash fallback
@ -90,7 +88,7 @@ for unsupported formats/browsers. There are a number of options for this:
.. _audio.js: https://kolber.github.io/audiojs/
.. _html5media: https://html5media.info/
.. _MediaElement.js: https://mediaelementjs.com/
.. _MediaElement.js: https://www.mediaelementjs.com/
.. _web-cors:
@ -187,6 +185,29 @@ representation. ::
If there is no item with that id responds with a *404* status
code.
``DELETE /item/6``
++++++++++++++++++
Removes the item with id *6* from the beets library. If the *?delete* query string is included,
the matching file will be deleted from disk.
Only allowed if ``readonly`` configuration option is set to ``no``.
``PATCH /item/6``
++++++++++++++++++
Updates the item with id *6* and write the changes to the music file. The body should be a JSON object
containing the changes to the object.
Returns the updated JSON representation. ::
{
"id": 6,
"title": "A Song",
...
}
Only allowed if ``readonly`` configuration option is set to ``no``.
``GET /item/6,12,13``
+++++++++++++++++++++
@ -196,6 +217,8 @@ the response is the same as for `GET /item/`_. It is *not guaranteed* that the
response includes all the items requested. If a track is not found it is silently
dropped from the response.
This endpoint also supports *DELETE* and *PATCH* methods as above, to operate on all
items of the list.
``GET /item/path/...``
++++++++++++++++++++++
@ -210,7 +233,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 +243,13 @@ 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.
This endpoint also supports *DELETE* and *PATCH* methods as above, to operate on all
items returned by the query.
``GET /item/6/file``
++++++++++++++++++++
@ -236,16 +267,25 @@ For albums, the following endpoints are provided:
* ``GET /album/5``
* ``GET /album/5/art``
* ``DELETE /album/5``
* ``GET /album/5,7``
* ``DELETE /album/5,7``
* ``GET /album/query/querystring``
* ``DELETE /album/query/querystring``
The interface and response format is similar to the item API, except replacing
the encapsulation key ``"items"`` with ``"albums"`` when requesting ``/album/``
or ``/album/5,7``. In addition we can request the cover art of an album with
``GET /album/5/art``.
You can also add the '?expand' flag to get the individual items of an album.
``DELETE`` is only allowed if ``readonly`` configuration option is set to ``no``.
``GET /stats``
++++++++++++++
@ -256,3 +296,5 @@ Responds with the number of tracks and albums in the database. ::
"items": 5,
"albums": 3
}
.. _Flask: https://flask.palletsprojects.com/en/1.1.x/

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.
@ -230,10 +230,21 @@ remove
Remove music from your library.
This command uses the same :doc:`query <query>` syntax as the ``list`` command.
You'll be shown a list of the files that will be removed and asked to confirm.
By default, this just removes entries from the library database; it doesn't
touch the files on disk. To actually delete the files, use ``beet remove -d``.
If you do not want to be prompted to remove the files, use ``beet remove -f``.
By default, it just removes entries from the library database; it doesn't
touch the files on disk. To actually delete the files, use the ``-d`` flag.
When the ``-a`` flag is given, the command operates on albums instead of
individual tracks.
When you run the ``remove`` command, it prints a list of all
affected items in the library and asks for your permission before removing
them. You can then choose to abort (type `n`), confirm (`y`), or interactively
choose some of the items (`s`). In the latter case, the command will prompt you
for every matching item or album and invite you to type `y` to remove the
item/album, `n` to keep it or `q` to exit and only remove the items/albums
selected up to this point.
This option lets you choose precisely which tracks/albums to remove without
spending too much time to carefully craft a query.
If you do not want to be prompted at all, use the ``-f`` option.
.. _modify-cmd:
@ -429,6 +440,10 @@ import ...``.
configuration options entirely, the two are merged. Any individual options set
in this config file will override the corresponding settings in your base
configuration.
* ``-p plugins``: specify a comma-separated list of plugins to enable. If
specified, the plugin list in your configuration is ignored. The long form
of this argument also allows specifying no plugins, effectively disabling
all plugins: ``--plugins=``.
Beets also uses the ``BEETSDIR`` environment variable to look for
configuration and data.

View file

@ -356,7 +356,6 @@ Sets the albumartist for various-artist compilations. Defaults to ``'Various
Artists'`` (the MusicBrainz standard). Affects other sources, such as
:doc:`/plugins/discogs`, too.
UI Options
----------
@ -476,13 +475,35 @@ hardlink
~~~~~~~~
Either ``yes`` or ``no``, indicating whether to use hard links instead of
moving or copying or symlinking files. (It conflicts with the ``move``,
moving, copying, or symlinking files. (It conflicts with the ``move``,
``copy``, and ``link`` options.) Defaults to ``no``.
As with symbolic links (see :ref:`link`, above), this will not work on Windows
and you will want to set ``write`` to ``no``. Otherwise, metadata on the
original file will be modified.
.. _reflink:
reflink
~~~~~~~
Either ``yes``, ``no``, or ``auto``, indicating whether to use copy-on-write
`file clones`_ (a.k.a. "reflinks") instead of copying or moving files.
The ``auto`` option uses reflinks when possible and falls back to plain
copying when necessary.
Defaults to ``no``.
This kind of clone is only available on certain filesystems: for example,
btrfs and APFS. For more details on filesystem support, see the `pyreflink`_
documentation. Note that you need to install ``pyreflink``, either through
``python -m pip install beets[reflink]`` or ``python -m pip install reflink``.
The option is ignored if ``move`` is enabled (i.e., beets can move or
copy files but it doesn't make sense to do both).
.. _file clones: https://blogs.oracle.com/otn/save-disk-space-on-linux-by-cloning-files-on-btrfs-and-ocfs2
.. _pyreflink: https://reflink.readthedocs.io/en/latest/
resume
~~~~~~
@ -689,7 +710,7 @@ to one request per second.
.. _your own MusicBrainz database: https://musicbrainz.org/doc/MusicBrainz_Server/Setup
.. _main server: https://musicbrainz.org/
.. _limited: https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting
.. _Building search indexes: https://musicbrainz.org/doc/MusicBrainz_Server/Setup#Building_search_indexes
.. _Building search indexes: https://musicbrainz.org/doc/Development/Search_server_setup
.. _searchlimit:
@ -701,6 +722,37 @@ 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: ``[]``
.. _genres:
genres
~~~~~~
Use MusicBrainz genre tags to populate the ``genre`` tag. This will make it a
semicolon-separated list of all the genres tagged for the release on
MusicBrainz.
Default: ``no``
.. _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,31 +109,47 @@ setup(
['colorama'] if (sys.platform == 'win32') else []
),
tests_require=[
'beautifulsoup4',
'flask',
'mock',
'pylast',
'rarfile',
'responses',
'pyxdg',
'python-mpd2',
'discogs-client',
'requests_oauthlib'
] + (
# 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',
'flask',
'mock',
'pylast',
'pytest',
'python-mpd2',
'pyxdg',
'responses>=0.3.0',
'requests_oauthlib',
'reflink',
] + (
# 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',
] + [
'discogs-client' if (sys.version_info < (3, 0, 0))
else 'python3-discogs-client'
],
'lint': [
'flake8',
'flake8-coding',
'flake8-docstrings',
'flake8-future-import',
'pep8-naming',
],
# Plugin (optional) dependencies:
'absubmit': ['requests'],
'fetchart': ['requests', 'Pillow'],
'embedart': ['Pillow'],
'embyupdate': ['requests'],
'chroma': ['pyacoustid'],
'gmusic': ['gmusicapi'],
'discogs': ['discogs-client>=2.2.1'],
'discogs': (
['discogs-client' if (sys.version_info < (3, 0, 0))
else 'python3-discogs-client']
),
'beatport': ['requests-oauthlib>=0.6.1'],
'kodiupdate': ['requests'],
'lastgenre': ['pylast'],
@ -142,7 +158,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'],
@ -150,6 +168,7 @@ setup(
'scrub': ['mutagen>=1.33'],
'bpd': ['PyGObject'],
'replaygain': ['PyGObject'],
'reflink': ['reflink'],
},
# Non-Python/non-PyPI plugin dependencies:
# chroma: chromaprint or fpcalc
@ -159,8 +178,10 @@ setup(
# embedart: ImageMagick
# absubmit: extractor binary from https://acousticbrainz.org/download
# keyfinder: KeyFinder
# replaygain: python-gi and GStreamer 1.0+ or mp3gain/aacgain
# replaygain: python-gi and GStreamer 1.0+
# or mp3gain/aacgain
# or Python Audio Tools
# or ffmpeg
# ipfs: go-ipfs
classifiers=[
@ -177,6 +198,7 @@ setup(
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: Implementation :: CPython',
],
)

Some files were not shown because too many files have changed in this diff Show more