diff --git a/.coveragerc b/.coveragerc
index 7d111e74c..bcc99c18f 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -2,7 +2,6 @@
omit =
*/pyshared/*
*/python?.?/*
- */site-packages/nose/*
*/test/*
exclude_lines =
assert False
diff --git a/.github/flake8-problem-matcher.json b/.github/flake8-problem-matcher.json
new file mode 100644
index 000000000..52f94e05e
--- /dev/null
+++ b/.github/flake8-problem-matcher.json
@@ -0,0 +1,16 @@
+{
+ "problemMatcher": [
+ {
+ "owner": "flake8",
+ "pattern": [
+ {
+ "regexp": "^(.*?):(\\d+):(\\d+): (.*)$",
+ "file": 1,
+ "line": 2,
+ "column": 3,
+ "message": 4
+ }
+ ]
+ }
+ ]
+}
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..42cd8169b
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,11 @@
+## Description
+
+Fixes #X.
+
+(...)
+
+## 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.)
diff --git a/.github/sphinx-problem-matcher.json b/.github/sphinx-problem-matcher.json
new file mode 100644
index 000000000..d33eb09b6
--- /dev/null
+++ b/.github/sphinx-problem-matcher.json
@@ -0,0 +1,18 @@
+{
+ "problemMatcher": [
+ {
+ "owner": "sphinx",
+ "pattern": [
+ {
+ "regexp": "^Warning, treated as error:$"
+ },
+ {
+ "regexp": "^(.*?):(\\d+):(.*)$",
+ "file": 1,
+ "line": 2,
+ "message": 3
+ }
+ ]
+ }
+ ]
+}
diff --git a/.github/stale.yml b/.github/stale.yml
new file mode 100644
index 000000000..2f3dbe041
--- /dev/null
+++ b/.github/stale.yml
@@ -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.
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 000000000..ecb7e03dd
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,88 @@
+name: ci
+on: [push, pull_request]
+jobs:
+ test:
+ runs-on: ${{ matrix.platform }}
+ strategy:
+ matrix:
+ platform: [ubuntu-latest]
+ python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9-dev]
+
+ env:
+ PY_COLORS: 1
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v2
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install base dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install tox sphinx
+
+ - name: Test with tox
+ if: matrix.python-version != '3.8'
+ run: |
+ tox -e py-test
+
+ - name: Test with tox and get coverage
+ if: matrix.python-version == '3.8'
+ run: |
+ tox -vv -e py-cov
+
+ - name: Upload code coverage
+ if: matrix.python-version == '3.8'
+ run: |
+ pip install codecov || true
+ codecov || true
+
+ test-docs:
+ runs-on: ubuntu-latest
+
+ env:
+ PY_COLORS: 1
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Set up Python 2.7
+ uses: actions/setup-python@v2
+ with:
+ python-version: 2.7
+
+ - name: Install base dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install tox sphinx
+
+ - name: Add problem matcher
+ run: echo "::add-matcher::.github/sphinx-problem-matcher.json"
+
+ - name: Build and check docs using tox
+ run: tox -e docs
+
+ lint:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Set up Python 3.8
+ uses: actions/setup-python@v2
+ with:
+ python-version: 3.8
+
+ - name: Install base dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install tox sphinx
+
+ - name: Add problem matcher
+ run: echo "::add-matcher::.github/flake8-problem-matcher.json"
+
+ - name: Lint with flake8
+ run: tox -e py-lint
diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml
new file mode 100644
index 000000000..386571e5a
--- /dev/null
+++ b/.github/workflows/integration_test.yaml
@@ -0,0 +1,45 @@
+name: integration tests
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: '0 0 * * SUN' # run every Sunday at midnight
+jobs:
+ test_integration:
+ runs-on: ubuntu-latest
+
+ env:
+ PY_COLORS: 1
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Set up latest Python version
+ uses: actions/setup-python@v2
+ with:
+ python-version: 3.9-dev
+
+ - name: Install base dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install tox sphinx
+
+ - name: Test with tox
+ run: |
+ tox -e int
+
+ - name: Notify on failure
+ if: ${{ failure() }}
+ env:
+ ZULIP_BOT_CREDENTIALS: ${{ secrets.ZULIP_BOT_CREDENTIALS }}
+ run: |
+ if [ -z "${ZULIP_BOT_CREDENTIALS}" ]; then
+ echo "Skipping notify, ZULIP_BOT_CREDENTIALS is unset"
+ exit 0
+ fi
+
+ curl -X POST https://beets.zulipchat.com/api/v1/messages \
+ -u "${ZULIP_BOT_CREDENTIALS}" \
+ -d "type=stream" \
+ -d "to=github" \
+ -d "subject=${GITHUB_WORKFLOW} - $(date -u +%Y-%m-%d)" \
+ -d "content=[${GITHUB_WORKFLOW}#${GITHUB_RUN_NUMBER}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) failed."
diff --git a/.gitignore b/.gitignore
index 64f08abe5..370776197 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index b889f698d..000000000
--- a/.travis.yml
+++ /dev/null
@@ -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
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
new file mode 100644
index 000000000..d86c490b9
--- /dev/null
+++ b/CONTRIBUTING.rst
@@ -0,0 +1,366 @@
+############
+Contributing
+############
+
+.. contents::
+ :depth: 3
+
+Thank you!
+==========
+
+First off, thank you for considering contributing to beets! It’s 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 you’re 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 `__. It’s
+ 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 that’s mostly because we don’t have any great ideas for what a
+ good GUI should look like. If you have those great ideas, please get
+ in touch.
+- Benchmarks. We’d 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? We’d love to
+ hear about it! Submit a post to our `our
+ forums `__ under the “Show and Tell”
+ category for a chance to get featured in `the
+ docs `__.
+- Consider helping out in `our forums `__
+ by responding to support requests or driving some new discussions.
+
+Programming
+-----------
+
+- As a programmer (even if you’re just a beginner!), you have a ton of
+ opportunities to get your feet wet with beets.
+- For developing plugins, or hacking away at beets, there’s some good
+ information in the `“For Developers” section of the
+ docs `__.
+
+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” `__.
+ These are issues that would serve as a good introduction to the
+ codebase. Claim one and start exploring!
+- Like testing? Our `test
+ coverage `__ is somewhat
+ low. You can help out by finding low-coverage modules or checking out
+ other `testing-related
+ issues `__.
+- There are several ways to improve the tests in general (see :ref:`testing` and some
+ places to think about performance optimization (see
+ `Optimization `__).
+- Not all of our code is up to our coding conventions. In particular,
+ the `API
+ documentation `__
+ 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 `__ for
+ docstrings and in some places, we also sometimes use `ReST autodoc
+ syntax for
+ Sphinx `__
+ 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 `__, or stop by our
+`forums `__ if you have any questions.
+
+We maintain a list of issues we reserved for those new to open source
+labeled `“first timers
+only” `__.
+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
+you’d 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 you’ve fixed a bug, write a test to ensure that you’ve
+ actually fixed it. If there’s a new feature or plugin, please
+ contribute tests that show that your code does what it says.
+4. Add documentation. If you’ve 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! We’ll 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 doesn’t
+ 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 `__ 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 `__
+ ``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 `__ module
+ instead. In particular, we have our own logging shim, so you’ll see
+ ``from beets import logging`` in most files.
+
+ - Always log Unicode strings (e.g., ``log.debug(u"hello world")``).
+ - The loggers use
+ `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 you’re 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 `__. I also
+like `neomake `__ with its flake8
+checker.
+
+.. _testing:
+
+Testing
+=======
+
+Running the Tests
+-----------------
+
+To run the tests for multiple Python versions, compile the docs, and
+check style, use `tox`_. Just type ``tox`` or use something like
+``tox -e py27`` to test a specific configuration. `detox`_ makes this go
+faster.
+
+You can disable a hand-selected set of "slow" tests by setting the
+environment variable SKIP_SLOW_TESTS before running them.
+
+Other ways to run the tests:
+
+- ``python testall.py`` (ditto)
+- ``python -m unittest discover -p 'test_*'`` (ditto)
+- `pytest`_
+
+You can also see the latest test results on `Linux`_ and on `Windows`_.
+
+Note, if you are on Windows and are seeing errors running tox, it may be related to `this issue`_,
+in which case you may have to install tox v3.8.3 e.g. ``python -m pip install tox=3.8.3``
+
+.. _this issue: https://github.com/tox-dev/tox/issues/1550
+
+Coverage
+^^^^^^^^
+
+``tox -e cov`` will add coverage info for tests: Coverage is pretty low
+still -- see the current status on `Codecov`_.
+
+Red Flags
+^^^^^^^^^
+
+The `pytest-random`_ plugin makes it easy to randomize the order of
+tests. ``py.test test --random`` will occasionally turn up failing tests
+that reveal ordering dependencies—which are bad news!
+
+Test Dependencies
+^^^^^^^^^^^^^^^^^
+
+The tests have a few more dependencies than beets itself. (The
+additional dependencies consist of testing utilities and dependencies of
+non-default plugins exercised by the test suite.) The dependencies are
+listed under 'test' in ``extras_require`` in `setup.py`_.
+To install the test dependencies, run ``python -m pip install .[test]``.
+Or, just run a test suite with ``tox`` which will install them
+automatically.
+
+.. _setup.py: https://github.com/beetbox/beets/blob/master/setup.py#L99`
+
+Writing Tests
+-------------
+
+Writing tests is done by adding or modifying files in folder `test`_.
+Take a look at
+`https://github.com/beetbox/beets/blob/master/test/test_template.py#L224`_
+to get a basic view on how tests are written. We currently allow writing
+tests with either `unittest`_ or `pytest`_.
+
+Any tests that involve sending out network traffic e.g. an external API
+call, should be skipped normally and run under our weekly `integration
+test`_ suite. These tests can be useful in detecting external changes
+that would affect ``beets``. In order to do this, simply add the
+following snippet before the applicable test case:
+
+.. code-block:: python
+
+ @unittest.skipUnless(
+ os.environ.get('INTEGRATION_TEST', '0') == '1',
+ 'integration testing not enabled')
+
+If you do this, it is also advised to create a similar test that 'mocks'
+the network call and can be run under normal circumstances by our CI and
+others. See `unittest.mock`_ for more info.
+
+- **AVOID** using the ``start()`` and ``stop()`` methods of
+ ``mock.patch``, as they require manual cleanup. Use the annotation or
+ context manager forms instead.
+
+.. _Python unittest: https://docs.python.org/2/library/unittest.html
+.. _Codecov: https://codecov.io/github/beetbox/beets
+.. _pytest-random: https://github.com/klrmn/pytest-random
+.. _tox: http://tox.readthedocs.org
+.. _detox: https://pypi.python.org/pypi/detox/
+.. _pytest: http://pytest.org
+.. _Linux: https://github.com/beetbox/beets/actions
+.. _Windows: https://ci.appveyor.com/project/beetbox/beets/
+.. _`https://github.com/beetbox/beets/blob/master/setup.py#L99`: https://github.com/beetbox/beets/blob/master/setup.py#L99
+.. _test: https://github.com/beetbox/beets/tree/master/test
+.. _`https://github.com/beetbox/beets/blob/master/test/test_template.py#L224`: https://github.com/beetbox/beets/blob/master/test/test_template.py#L224
+.. _unittest: https://docs.python.org/3.8/library/unittest.html
+.. _integration test: https://github.com/beetbox/beets/actions?query=workflow%3A%22integration+tests%22
+.. _unittest.mock: https://docs.python.org/3/library/unittest.mock.html
+.. _Python unittest: https://docs.python.org/2/library/unittest.html
diff --git a/README.rst b/README.rst
index 0f653ac02..4a2d8cb10 100644
--- a/README.rst
+++ b/README.rst
@@ -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/
diff --git a/appveyor.yml b/appveyor.yml
index 00a3eb189..5a0f32135 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -6,9 +6,6 @@ skip_commits:
message: /\[appveyor skip\]/
environment:
- # Undocumented feature of nose-show-skipped.
- NOSE_SHOW_SKIPPED: 1
-
matrix:
- PYTHON: C:\Python27
TOX_ENV: py27-test
diff --git a/beets/__init__.py b/beets/__init__.py
index 20075073c..e3e5fdf83 100644
--- a/beets/__init__.py
+++ b/beets/__init__.py
@@ -15,9 +15,8 @@
from __future__ import division, absolute_import, print_function
-import os
-
import confuse
+from sys import stderr
__version__ = u'1.5.0'
__author__ = u'Adrian Sampson '
@@ -32,11 +31,12 @@ class IncludeLazyConfig(confuse.LazyConfig):
try:
for view in self['include']:
- filename = view.as_filename()
- if os.path.isfile(filename):
- self.set_file(filename)
+ self.set_file(view.as_filename())
except confuse.NotFoundError:
pass
+ except confuse.ConfigReadError as err:
+ stderr.write("configuration `import` failed: {}"
+ .format(err.reason))
config = IncludeLazyConfig('beets', __name__)
diff --git a/beets/art.py b/beets/art.py
index e7a087a05..20b0e96d2 100644
--- a/beets/art.py
+++ b/beets/art.py
@@ -51,8 +51,8 @@ def get_art(log, item):
def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
- compare_threshold=0, ifempty=False, as_album=False,
- id3v23=None):
+ compare_threshold=0, ifempty=False, as_album=False, id3v23=None,
+ quality=0):
"""Embed an image into the item's media file.
"""
# Conditions and filters.
@@ -64,7 +64,7 @@ def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
log.info(u'media file already contained art')
return
if maxwidth and not as_album:
- imagepath = resize_image(log, imagepath, maxwidth)
+ imagepath = resize_image(log, imagepath, maxwidth, quality)
# Get the `Image` object from the file.
try:
@@ -84,8 +84,8 @@ def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
item.try_write(path=itempath, tags={'images': [image]}, id3v23=id3v23)
-def embed_album(log, album, maxwidth=None, quiet=False,
- compare_threshold=0, ifempty=False):
+def embed_album(log, album, maxwidth=None, quiet=False, compare_threshold=0,
+ ifempty=False, quality=0):
"""Embed album art into all of the album's items.
"""
imagepath = album.artpath
@@ -97,20 +97,23 @@ def embed_album(log, album, maxwidth=None, quiet=False,
displayable_path(imagepath), album)
return
if maxwidth:
- imagepath = resize_image(log, imagepath, maxwidth)
+ imagepath = resize_image(log, imagepath, maxwidth, quality)
log.info(u'Embedding album art into {0}', album)
for item in album.items():
- embed_item(log, item, imagepath, maxwidth, None,
- compare_threshold, ifempty, as_album=True)
+ embed_item(log, item, imagepath, maxwidth, None, compare_threshold,
+ ifempty, as_album=True, quality=quality)
-def resize_image(log, imagepath, maxwidth):
- """Returns path to an image resized to maxwidth.
+def resize_image(log, imagepath, maxwidth, quality):
+ """Returns path to an image resized to maxwidth and encoded with the
+ specified quality level.
"""
- log.debug(u'Resizing album art to {0} pixels wide', maxwidth)
- imagepath = ArtResizer.shared.resize(maxwidth, syspath(imagepath))
+ log.debug(u'Resizing album art to {0} pixels wide and encoding at quality \
+ level {1}', maxwidth, quality)
+ imagepath = ArtResizer.shared.resize(maxwidth, syspath(imagepath),
+ quality=quality)
return imagepath
diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py
index f9e38413e..7ab0d57fd 100644
--- a/beets/autotag/__init__.py
+++ b/beets/autotag/__init__.py
@@ -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:
diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py
index 030f371ba..065d88170 100644
--- a/beets/autotag/hooks.py
+++ b/beets/autotag/hooks.py
@@ -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
diff --git a/beets/autotag/match.py b/beets/autotag/match.py
index 71b62adb7..f57cac739 100644
--- a/beets/autotag/match.py
+++ b/beets/autotag/match.py
@@ -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))
diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py
index 1a6e0b1f1..ea8ef24da 100644
--- a/beets/autotag/mb.py
+++ b/beets/autotag/mb.py
@@ -38,6 +38,14 @@ else:
SKIPPED_TRACKS = ['[data track]']
+FIELDS_TO_MB_KEYS = {
+ 'catalognum': 'catno',
+ 'country': 'country',
+ 'label': 'label',
+ 'media': 'format',
+ 'year': 'date',
+}
+
musicbrainzngs.set_useragent('beets', beets.__version__,
'https://beets.io/')
@@ -185,8 +193,8 @@ def track_info(recording, index=None, medium=None, medium_index=None,
the number of tracks on the medium. Each number is a 1-based index.
"""
info = beets.autotag.hooks.TrackInfo(
- recording['title'],
- recording['id'],
+ title=recording['title'],
+ track_id=recording['id'],
index=index,
medium=medium,
medium_index=medium_index,
@@ -333,11 +341,11 @@ def album_info(release):
track_infos.append(ti)
info = beets.autotag.hooks.AlbumInfo(
- release['title'],
- release['id'],
- artist_name,
- release['artist-credit'][0]['artist']['id'],
- track_infos,
+ album=release['title'],
+ album_id=release['id'],
+ artist=artist_name,
+ artist_id=release['artist-credit'][0]['artist']['id'],
+ tracks=track_infos,
mediums=len(release['medium-list']),
artist_sort=artist_sort_name,
artist_credit=artist_credit_name,
@@ -411,13 +419,13 @@ def album_info(release):
return info
-def match_album(artist, album, tracks=None):
+def match_album(artist, album, tracks=None, extra_tags=None):
"""Searches for a single album ("release" in MusicBrainz parlance)
and returns an iterator over AlbumInfo objects. May raise a
MusicBrainzAPIError.
The query consists of an artist name, an album name, and,
- optionally, a number of tracks on the album.
+ optionally, a number of tracks on the album and any other extra tags.
"""
# Build search criteria.
criteria = {'release': album.lower().strip()}
@@ -429,6 +437,16 @@ def match_album(artist, album, tracks=None):
if tracks is not None:
criteria['tracks'] = six.text_type(tracks)
+ # Additional search cues from existing metadata.
+ if extra_tags:
+ for tag in extra_tags:
+ key = FIELDS_TO_MB_KEYS[tag]
+ value = six.text_type(extra_tags.get(tag, '')).lower().strip()
+ if key == 'catno':
+ value = value.replace(u' ', '')
+ if value:
+ criteria[key] = value
+
# Abort if we have no search terms.
if not any(criteria.values()):
return
diff --git a/beets/config_default.yaml b/beets/config_default.yaml
index cf9ae6bf9..c75778b80 100644
--- a/beets/config_default.yaml
+++ b/beets/config_default.yaml
@@ -44,6 +44,7 @@ replace:
'^\s+': ''
'^-': _
path_sep_replace: _
+drive_sep_replace: _
asciify_paths: false
art_filename: cover
max_filename_length: 0
@@ -103,6 +104,7 @@ musicbrainz:
ratelimit: 1
ratelimit_interval: 1.0
searchlimit: 5
+ extra_tags: []
match:
strong_rec_thresh: 0.04
diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py
index 3195b52c9..46b47a2e1 100755
--- a/beets/dbcore/db.py
+++ b/beets/dbcore/db.py
@@ -19,6 +19,7 @@ from __future__ import division, absolute_import, print_function
import time
import os
+import re
from collections import defaultdict
import threading
import sqlite3
@@ -84,6 +85,11 @@ class FormattedMapping(Mapping):
if self.for_path:
sep_repl = beets.config['path_sep_replace'].as_str()
+ sep_drive = beets.config['drive_sep_replace'].as_str()
+
+ if re.match(r'^\w:', value):
+ value = re.sub(r'(?<=^\w):', sep_drive, value)
+
for sep in (os.path.sep, os.path.altsep):
if sep:
value = value.replace(sep, sep_repl)
@@ -189,7 +195,7 @@ class LazyConvertDict(object):
class Model(object):
"""An abstract object representing an object in the database. Model
- objects act like dictionaries (i.e., the allow subscript access like
+ objects act like dictionaries (i.e., they allow subscript access like
``obj['field']``). The same field set is available via attribute
access as a shortcut (i.e., ``obj.field``). Three kinds of attributes are
available:
diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py
index 8fb64e206..4f19f4f8d 100644
--- a/beets/dbcore/query.py
+++ b/beets/dbcore/query.py
@@ -156,12 +156,8 @@ class NoneQuery(FieldQuery):
def col_clause(self):
return self.field + " IS NULL", ()
- @classmethod
- def match(cls, item):
- try:
- return item[cls.field] is None
- except KeyError:
- return True
+ def match(self, item):
+ return item.get(self.field) is None
def __repr__(self):
return "{0.__class__.__name__}({0.field!r}, {0.fast})".format(self)
diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py
index 521a5a1ee..5aa2b9812 100644
--- a/beets/dbcore/types.py
+++ b/beets/dbcore/types.py
@@ -131,6 +131,14 @@ class Integer(Type):
query = query.NumericQuery
model_type = int
+ def normalize(self, value):
+ try:
+ return self.model_type(round(float(value)))
+ except ValueError:
+ return self.null
+ except TypeError:
+ return self.null
+
class PaddedInt(Integer):
"""An integer field that is formatted with a given number of digits,
diff --git a/beets/importer.py b/beets/importer.py
index e97b0a75c..68d5f3d5d 100644
--- a/beets/importer.py
+++ b/beets/importer.py
@@ -1034,8 +1034,8 @@ class ArchiveImportTask(SentinelImportTask):
cls._handlers = []
from zipfile import is_zipfile, ZipFile
cls._handlers.append((is_zipfile, ZipFile))
- from tarfile import is_tarfile, TarFile
- cls._handlers.append((is_tarfile, TarFile))
+ import tarfile
+ cls._handlers.append((tarfile.is_tarfile, tarfile.open))
try:
from rarfile import is_rarfile, RarFile
except ImportError:
diff --git a/beets/library.py b/beets/library.py
index 5d90ae43b..e22d4edc0 100644
--- a/beets/library.py
+++ b/beets/library.py
@@ -410,7 +410,8 @@ class FormattedItemMapping(dbcore.db.FormattedMapping):
raise KeyError(key)
def __getitem__(self, key):
- """Get the value for a key. Certain unset values are remapped.
+ """Get the value for a key. `artist` and `albumartist`
+ are fallback values for each other when not set.
"""
value = self._get(key)
diff --git a/beets/plugins.py b/beets/plugins.py
index 73d85cdd3..695725cb8 100644
--- a/beets/plugins.py
+++ b/beets/plugins.py
@@ -172,7 +172,7 @@ class BeetsPlugin(object):
"""
return beets.autotag.hooks.Distance()
- def candidates(self, items, artist, album, va_likely):
+ def candidates(self, items, artist, album, va_likely, extra_tags=None):
"""Should return a sequence of AlbumInfo objects that match the
album whose items are provided.
"""
@@ -379,11 +379,12 @@ def album_distance(items, album_info, mapping):
return dist
-def candidates(items, artist, album, va_likely):
+def candidates(items, artist, album, va_likely, extra_tags=None):
"""Gets MusicBrainz candidates for an album from each plugin.
"""
for plugin in find_plugins():
- for candidate in plugin.candidates(items, artist, album, va_likely):
+ for candidate in plugin.candidates(items, artist, album, va_likely,
+ extra_tags):
yield candidate
@@ -714,7 +715,7 @@ class MetadataSourcePlugin(object):
return id_
return None
- def candidates(self, items, artist, album, va_likely):
+ def candidates(self, items, artist, album, va_likely, extra_tags=None):
"""Returns a list of AlbumInfo objects for Search API results
matching an ``album`` and ``artist`` (if not various).
diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py
index 99e28c0cc..8f14c8baf 100644
--- a/beets/util/artresizer.py
+++ b/beets/util/artresizer.py
@@ -40,14 +40,19 @@ else:
log = logging.getLogger('beets')
-def resize_url(url, maxwidth):
+def resize_url(url, maxwidth, quality=0):
"""Return a proxied image URL that resizes the original image to
maxwidth (preserving aspect ratio).
"""
- return '{0}?{1}'.format(PROXY_URL, urlencode({
+ params = {
'url': url.replace('http://', ''),
'w': maxwidth,
- }))
+ }
+
+ if quality > 0:
+ params['q'] = quality
+
+ return '{0}?{1}'.format(PROXY_URL, urlencode(params))
def temp_file_for(path):
@@ -59,7 +64,7 @@ def temp_file_for(path):
return util.bytestring_path(f.name)
-def pil_resize(maxwidth, path_in, path_out=None):
+def pil_resize(maxwidth, path_in, path_out=None, quality=0):
"""Resize using Python Imaging Library (PIL). Return the output path
of resized image.
"""
@@ -72,7 +77,7 @@ def pil_resize(maxwidth, path_in, path_out=None):
im = Image.open(util.syspath(path_in))
size = maxwidth, maxwidth
im.thumbnail(size, Image.ANTIALIAS)
- im.save(util.py3_path(path_out))
+ im.save(util.py3_path(path_out), quality=quality)
return path_out
except IOError:
log.error(u"PIL cannot create thumbnail for '{0}'",
@@ -80,7 +85,7 @@ def pil_resize(maxwidth, path_in, path_out=None):
return path_in
-def im_resize(maxwidth, path_in, path_out=None):
+def im_resize(maxwidth, path_in, path_out=None, quality=0):
"""Resize using ImageMagick.
Use the ``magick`` program or ``convert`` on older versions. Return
@@ -93,10 +98,15 @@ def im_resize(maxwidth, path_in, path_out=None):
# "-resize WIDTHx>" shrinks images with the width larger
# than the given width while maintaining the aspect ratio
# with regards to the height.
- cmd = ArtResizer.shared.im_convert_cmd + \
- [util.syspath(path_in, prefix=False),
- '-resize', '{0}x>'.format(maxwidth),
- util.syspath(path_out, prefix=False)]
+ cmd = ArtResizer.shared.im_convert_cmd + [
+ util.syspath(path_in, prefix=False),
+ '-resize', '{0}x>'.format(maxwidth),
+ ]
+
+ if quality > 0:
+ cmd += ['-quality', '{0}'.format(quality)]
+
+ cmd.append(util.syspath(path_out, prefix=False))
try:
util.command_output(cmd)
@@ -190,18 +200,19 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
self.im_convert_cmd = ['magick']
self.im_identify_cmd = ['magick', 'identify']
- def resize(self, maxwidth, path_in, path_out=None):
+ def resize(self, maxwidth, path_in, path_out=None, quality=0):
"""Manipulate an image file according to the method, returning a
new path. For PIL or IMAGEMAGIC methods, resizes the image to a
- temporary file. For WEBPROXY, returns `path_in` unmodified.
+ temporary file and encodes with the specified quality level.
+ For WEBPROXY, returns `path_in` unmodified.
"""
if self.local:
func = BACKEND_FUNCS[self.method[0]]
- return func(maxwidth, path_in, path_out)
+ return func(maxwidth, path_in, path_out, quality=quality)
else:
return path_in
- def proxy_url(self, maxwidth, url):
+ def proxy_url(self, maxwidth, url, quality=0):
"""Modifies an image URL according the method, returning a new
URL. For WEBPROXY, a URL on the proxy server is returned.
Otherwise, the URL is returned unmodified.
@@ -209,7 +220,7 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
if self.local:
return url
else:
- return resize_url(url, maxwidth)
+ return resize_url(url, maxwidth, quality)
@property
def local(self):
diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py
index af22b7908..266534a9b 100644
--- a/beets/util/functemplate.py
+++ b/beets/util/functemplate.py
@@ -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):
diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py
index 6a45ab93a..df0abb2fc 100644
--- a/beetsplug/beatport.py
+++ b/beetsplug/beatport.py
@@ -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).
"""
diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py
index c4230b069..54ae90098 100644
--- a/beetsplug/chroma.py
+++ b/beetsplug/chroma.py
@@ -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)
diff --git a/beetsplug/convert.py b/beetsplug/convert.py
index e7ac4f3ac..70363f6eb 100644
--- a/beetsplug/convert.py
+++ b/beetsplug/convert.py
@@ -148,6 +148,7 @@ class ConvertPlugin(BeetsPlugin):
u'never_convert_lossy_files': False,
u'copy_album_art': False,
u'album_art_maxwidth': 0,
+ u'delete_originals': False,
})
self.early_import_stages = [self.auto_convert]
@@ -532,11 +533,16 @@ class ConvertPlugin(BeetsPlugin):
# Change the newly-imported database entry to point to the
# converted file.
+ source_path = item.path
item.path = dest
item.write()
item.read() # Load new audio information data.
item.store()
+ if self.config['delete_originals']:
+ self._log.info(u'Removing original file {0}', source_path)
+ util.remove(source_path, False)
+
def _cleanup(self, task, session):
for path in task.old_paths:
if path in _temp_files:
diff --git a/beetsplug/cue.py b/beetsplug/cue.py
index fd564b55c..1ff817b2b 100644
--- a/beetsplug/cue.py
+++ b/beetsplug/cue.py
@@ -24,7 +24,7 @@ class CuePlugin(BeetsPlugin):
# self.register_listener('import_task_start', self.look_for_cues)
- def candidates(self, items, artist, album, va_likely):
+ def candidates(self, items, artist, album, va_likely, extra_tags=None):
import pdb
pdb.set_trace()
@@ -53,5 +53,6 @@ class CuePlugin(BeetsPlugin):
title = "dunno lol"
track_id = "wtf"
index = int(path.basename(t)[len("split-track"):-len(".wav")])
- yield TrackInfo(title, track_id, index=index, artist=artist)
+ yield TrackInfo(title=title, track_id=track_id, index=index,
+ artist=artist)
# generate TrackInfo instances
diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py
index 0ba27d7dd..3b2595585 100644
--- a/beetsplug/discogs.py
+++ b/beetsplug/discogs.py
@@ -38,7 +38,7 @@ from string import ascii_lowercase
USER_AGENT = u'beets/{0} +https://beets.io/'.format(beets.__version__)
-API_KEY = 'rAzVUQYRaoFjeBjyWuWZ'
+API_KEY = 'rAzVUQYRaoFjeBjyWuWZ'
API_SECRET = 'plxtUTqoCzwxZpqdPysCwGuBSmZNdZVy'
# Exceptions that discogs_client should really handle but does not.
@@ -175,7 +175,7 @@ class DiscogsPlugin(BeetsPlugin):
config=self.config
)
- def candidates(self, items, artist, album, va_likely):
+ def candidates(self, items, artist, album, va_likely, extra_tags=None):
"""Returns a list of AlbumInfo objects for discogs search results
matching an album and artist (if not various).
"""
@@ -356,17 +356,14 @@ class DiscogsPlugin(BeetsPlugin):
# a master release, otherwise fetch the master release.
original_year = self.get_master_year(master_id) if master_id else year
- return AlbumInfo(album, album_id, artist, artist_id, tracks, asin=None,
- albumtype=albumtype, va=va, year=year, month=None,
- day=None, label=label, mediums=len(set(mediums)),
- artist_sort=None, releasegroup_id=master_id,
- catalognum=catalogno, script=None, language=None,
+ return AlbumInfo(album=album, album_id=album_id, artist=artist,
+ artist_id=artist_id, tracks=tracks,
+ albumtype=albumtype, va=va, year=year,
+ label=label, mediums=len(set(mediums)),
+ releasegroup_id=master_id, catalognum=catalogno,
country=country, style=style, genre=genre,
- albumstatus=None, media=media,
- albumdisambig=None, artist_credit=None,
- original_year=original_year, original_month=None,
- original_day=None, data_source='Discogs',
- data_url=data_url,
+ media=media, original_year=original_year,
+ data_source='Discogs', data_url=data_url,
discogs_albumid=discogs_albumid,
discogs_labelid=labelid, discogs_artistid=artist_id)
@@ -567,10 +564,9 @@ class DiscogsPlugin(BeetsPlugin):
track.get('artists', [])
)
length = self.get_track_length(track['duration'])
- return TrackInfo(title, track_id, artist=artist, artist_id=artist_id,
- length=length, index=index,
- medium=medium, medium_index=medium_index,
- artist_sort=None, disctitle=None, artist_credit=None)
+ return TrackInfo(title=title, track_id=track_id, artist=artist,
+ artist_id=artist_id, length=length, index=index,
+ medium=medium, medium_index=medium_index)
def get_track_index(self, position):
"""Returns the medium, medium index and subtrack index for a discogs
diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py
index 71681f024..61a4d798f 100644
--- a/beetsplug/embedart.py
+++ b/beetsplug/embedart.py
@@ -59,7 +59,8 @@ class EmbedCoverArtPlugin(BeetsPlugin):
'auto': True,
'compare_threshold': 0,
'ifempty': False,
- 'remove_art_file': False
+ 'remove_art_file': False,
+ 'quality': 0,
})
if self.config['maxwidth'].get(int) and not ArtResizer.shared.local:
@@ -86,6 +87,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
u"-y", u"--yes", action="store_true", help=u"skip confirmation"
)
maxwidth = self.config['maxwidth'].get(int)
+ quality = self.config['quality'].get(int)
compare_threshold = self.config['compare_threshold'].get(int)
ifempty = self.config['ifempty'].get(bool)
@@ -104,8 +106,9 @@ class EmbedCoverArtPlugin(BeetsPlugin):
return
for item in items:
- art.embed_item(self._log, item, imagepath, maxwidth, None,
- compare_threshold, ifempty)
+ art.embed_item(self._log, item, imagepath, maxwidth,
+ None, compare_threshold, ifempty,
+ quality=quality)
else:
albums = lib.albums(decargs(args))
@@ -114,8 +117,9 @@ class EmbedCoverArtPlugin(BeetsPlugin):
return
for album in albums:
- art.embed_album(self._log, album, maxwidth, False,
- compare_threshold, ifempty)
+ art.embed_album(self._log, album, maxwidth,
+ False, compare_threshold, ifempty,
+ quality=quality)
self.remove_artfile(album)
embed_cmd.func = embed_func
diff --git a/beetsplug/export.py b/beetsplug/export.py
index f7e84a570..8d98d0ba2 100644
--- a/beetsplug/export.py
+++ b/beetsplug/export.py
@@ -21,7 +21,7 @@ import sys
import codecs
import json
import csv
-import xml.etree.ElementTree as ET
+from xml.etree import ElementTree
from datetime import datetime, date
from beets.plugins import BeetsPlugin
@@ -188,18 +188,18 @@ class XMLFormat(ExportFormat):
def export(self, data, **kwargs):
# Creates the XML file structure.
- library = ET.Element(u'library')
- tracks = ET.SubElement(library, u'tracks')
+ library = ElementTree.Element(u'library')
+ tracks = ElementTree.SubElement(library, u'tracks')
if data and isinstance(data[0], dict):
for index, item in enumerate(data):
- track = ET.SubElement(tracks, u'track')
+ track = ElementTree.SubElement(tracks, u'track')
for key, value in item.items():
- track_details = ET.SubElement(track, key)
+ track_details = ElementTree.SubElement(track, key)
track_details.text = value
# Depending on the version of python the encoding needs to change
try:
- data = ET.tostring(library, encoding='unicode', **kwargs)
+ data = ElementTree.tostring(library, encoding='unicode', **kwargs)
except LookupError:
- data = ET.tostring(library, encoding='utf-8', **kwargs)
+ data = ElementTree.tostring(library, encoding='utf-8', **kwargs)
self.out_stream.write(data)
diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py
index 6b9fa375e..86c5b958f 100644
--- a/beetsplug/fetchart.py
+++ b/beetsplug/fetchart.py
@@ -21,6 +21,7 @@ from contextlib import closing
import os
import re
from tempfile import NamedTemporaryFile
+from collections import OrderedDict
import requests
@@ -135,7 +136,8 @@ class Candidate(object):
def resize(self, plugin):
if plugin.maxwidth and self.check == self.CANDIDATE_DOWNSCALE:
- self.path = ArtResizer.shared.resize(plugin.maxwidth, self.path)
+ self.path = ArtResizer.shared.resize(plugin.maxwidth, self.path,
+ quality=plugin.quality)
def _logged_get(log, *args, **kwargs):
@@ -164,9 +166,14 @@ def _logged_get(log, *args, **kwargs):
message = 'getting URL'
req = requests.Request('GET', *args, **req_kwargs)
+
with requests.Session() as s:
s.headers = {'User-Agent': 'beets'}
prepped = s.prepare_request(req)
+ settings = s.merge_environment_settings(
+ prepped.url, {}, None, None, None
+ )
+ send_kwargs.update(settings)
log.debug('{}: {}', message, prepped.url)
return s.send(prepped, **send_kwargs)
@@ -203,6 +210,9 @@ class ArtSource(RequestMixin):
def fetch_image(self, candidate, plugin):
raise NotImplementedError()
+ def cleanup(self, candidate):
+ pass
+
class LocalArtSource(ArtSource):
IS_LOCAL = True
@@ -284,10 +294,18 @@ class RemoteArtSource(ArtSource):
self._log.debug(u'error fetching art: {}', exc)
return
+ def cleanup(self, candidate):
+ if candidate.path:
+ try:
+ util.remove(path=candidate.path)
+ except util.FilesystemError as exc:
+ self._log.debug(u'error cleaning up tmp art: {}', exc)
+
class CoverArtArchive(RemoteArtSource):
NAME = u"Cover Art Archive"
VALID_MATCHING_CRITERIA = ['release', 'releasegroup']
+ VALID_THUMBNAIL_SIZES = [250, 500, 1200]
if util.SNI_SUPPORTED:
URL = 'https://coverartarchive.org/release/{mbid}/front'
@@ -300,13 +318,31 @@ class CoverArtArchive(RemoteArtSource):
"""Return the Cover Art Archive and Cover Art Archive release group URLs
using album MusicBrainz release ID and release group ID.
"""
+ release_url = self.URL.format(mbid=album.mb_albumid)
+ release_group_url = self.GROUP_URL.format(mbid=album.mb_releasegroupid)
+
+ # Cover Art Archive API offers pre-resized thumbnails at several sizes.
+ # If the maxwidth config matches one of the already available sizes
+ # fetch it directly intead of fetching the full sized image and
+ # resizing it.
+ size_suffix = None
+ if plugin.maxwidth in self.VALID_THUMBNAIL_SIZES:
+ size_suffix = "-" + str(plugin.maxwidth)
+
if 'release' in self.match_by and album.mb_albumid:
- yield self._candidate(url=self.URL.format(mbid=album.mb_albumid),
+ if size_suffix:
+ release_thumbnail_url = release_url + size_suffix
+ yield self._candidate(url=release_thumbnail_url,
+ match=Candidate.MATCH_EXACT)
+ yield self._candidate(url=release_url,
match=Candidate.MATCH_EXACT)
if 'releasegroup' in self.match_by and album.mb_releasegroupid:
- yield self._candidate(
- url=self.GROUP_URL.format(mbid=album.mb_releasegroupid),
- match=Candidate.MATCH_FALLBACK)
+ if size_suffix:
+ release_group_thumbnail_url = release_group_url + size_suffix
+ yield self._candidate(url=release_group_thumbnail_url,
+ match=Candidate.MATCH_FALLBACK)
+ yield self._candidate(url=release_group_url,
+ match=Candidate.MATCH_FALLBACK)
class Amazon(RemoteArtSource):
@@ -736,11 +772,72 @@ class FileSystem(LocalArtSource):
match=Candidate.MATCH_FALLBACK)
+class LastFM(RemoteArtSource):
+ NAME = u"Last.fm"
+
+ # Sizes in priority order.
+ SIZES = OrderedDict([
+ ('mega', (300, 300)),
+ ('extralarge', (300, 300)),
+ ('large', (174, 174)),
+ ('medium', (64, 64)),
+ ('small', (34, 34)),
+ ])
+
+ if util.SNI_SUPPORTED:
+ API_URL = 'https://ws.audioscrobbler.com/2.0'
+ else:
+ API_URL = 'http://ws.audioscrobbler.com/2.0'
+
+ def __init__(self, *args, **kwargs):
+ super(LastFM, self).__init__(*args, **kwargs)
+ self.key = self._config['lastfm_key'].get(),
+
+ def get(self, album, plugin, paths):
+ if not album.mb_albumid:
+ return
+
+ try:
+ response = self.request(self.API_URL, params={
+ 'method': 'album.getinfo',
+ 'api_key': self.key,
+ 'mbid': album.mb_albumid,
+ 'format': 'json',
+ })
+ except requests.RequestException:
+ self._log.debug(u'lastfm: error receiving response')
+ return
+
+ try:
+ data = response.json()
+
+ if 'error' in data:
+ if data['error'] == 6:
+ self._log.debug('lastfm: no results for {}',
+ album.mb_albumid)
+ else:
+ self._log.error(
+ 'lastfm: failed to get album info: {} ({})',
+ data['message'], data['error'])
+ else:
+ images = {image['size']: image['#text']
+ for image in data['album']['image']}
+
+ # Provide candidates in order of size.
+ for size in self.SIZES.keys():
+ if size in images:
+ yield self._candidate(url=images[size],
+ size=self.SIZES[size])
+ except ValueError:
+ self._log.debug(u'lastfm: error loading response: {}'
+ .format(response.text))
+ return
+
# Try each source in turn.
SOURCES_ALL = [u'filesystem',
u'coverart', u'itunes', u'amazon', u'albumart',
- u'wikipedia', u'google', u'fanarttv']
+ u'wikipedia', u'google', u'fanarttv', u'lastfm']
ART_SOURCES = {
u'filesystem': FileSystem,
@@ -751,6 +848,7 @@ ART_SOURCES = {
u'wikipedia': Wikipedia,
u'google': GoogleImages,
u'fanarttv': FanartTV,
+ u'lastfm': LastFM,
}
SOURCE_NAMES = {v: k for k, v in ART_SOURCES.items()}
@@ -772,6 +870,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
'auto': True,
'minwidth': 0,
'maxwidth': 0,
+ 'quality': 0,
'enforce_ratio': False,
'cautious': False,
'cover_names': ['cover', 'front', 'art', 'album', 'folder'],
@@ -780,14 +879,17 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
'google_key': None,
'google_engine': u'001442825323518660753:hrh5ch1gjzm',
'fanarttv_key': None,
+ 'lastfm_key': None,
'store_source': False,
'high_resolution': False,
})
self.config['google_key'].redact = True
self.config['fanarttv_key'].redact = True
+ self.config['lastfm_key'].redact = True
self.minwidth = self.config['minwidth'].get(int)
self.maxwidth = self.config['maxwidth'].get(int)
+ self.quality = self.config['quality'].get(int)
# allow both pixel and percentage-based margin specifications
self.enforce_ratio = self.config['enforce_ratio'].get(
@@ -823,6 +925,9 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
if not self.config['google_key'].get() and \
u'google' in available_sources:
available_sources.remove(u'google')
+ if not self.config['lastfm_key'].get() and \
+ u'lastfm' in available_sources:
+ available_sources.remove(u'lastfm')
available_sources = [(s, c)
for s in available_sources
for c in ART_SOURCES[s].VALID_MATCHING_CRITERIA]
@@ -903,7 +1008,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
cmd.parser.add_option(
u'-q', u'--quiet', dest='quiet',
action='store_true', default=False,
- help=u'shows only quiet art'
+ help=u'quiet mode: do not output albums that already have artwork'
)
def func(lib, opts, args):
@@ -917,9 +1022,10 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
def art_for_album(self, album, paths, local_only=False):
"""Given an Album object, returns a path to downloaded art for the
album (or None if no art is found). If `maxwidth`, then images are
- resized to this maximum pixel size. If `local_only`, then only local
- image files from the filesystem are returned; no network requests
- are made.
+ resized to this maximum pixel size. If `quality` then resized images
+ are saved at the specified quality level. If `local_only`, then only
+ local image files from the filesystem are returned; no network
+ requests are made.
"""
out = None
@@ -940,6 +1046,8 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
u'using {0.LOC_STR} image {1}'.format(
source, util.displayable_path(out.path)))
break
+ # Remove temporary files for invalid candidates.
+ source.cleanup(candidate)
if out:
break
diff --git a/beetsplug/fish.py b/beetsplug/fish.py
new file mode 100644
index 000000000..b842ac70f
--- /dev/null
+++ b/beetsplug/fish.py
@@ -0,0 +1,276 @@
+# -*- coding: utf-8 -*-
+# This file is part of beets.
+# Copyright 2015, winters jean-marie.
+# Copyright 2020, Justin Mayer
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""This plugin generates tab completions for Beets commands for the Fish shell
+, including completions for Beets commands, plugin
+commands, and option flags. Also generated are completions for all the album
+and track fields, suggesting for example `genre:` or `album:` when querying the
+Beets database. Completions for the *values* of those fields are not generated
+by default but can be added via the `-e` / `--extravalues` flag. For example:
+`beet fish -e genre -e albumartist`
+"""
+
+from __future__ import division, absolute_import, print_function
+
+from beets.plugins import BeetsPlugin
+from beets import library, ui
+from beets.ui import commands
+from operator import attrgetter
+import os
+BL_NEED2 = """complete -c beet -n '__fish_beet_needs_command' {} {}\n"""
+BL_USE3 = """complete -c beet -n '__fish_beet_using_command {}' {} {}\n"""
+BL_SUBS = """complete -c beet -n '__fish_at_level {} ""' {} {}\n"""
+BL_EXTRA3 = """complete -c beet -n '__fish_beet_use_extra {}' {} {}\n"""
+
+HEAD = '''
+function __fish_beet_needs_command
+ set cmd (commandline -opc)
+ if test (count $cmd) -eq 1
+ return 0
+ end
+ return 1
+end
+
+function __fish_beet_using_command
+ set cmd (commandline -opc)
+ set needle (count $cmd)
+ if test $needle -gt 1
+ if begin test $argv[1] = $cmd[2];
+ and not contains -- $cmd[$needle] $FIELDS; end
+ return 0
+ end
+ end
+ return 1
+end
+
+function __fish_beet_use_extra
+ set cmd (commandline -opc)
+ set needle (count $cmd)
+ if test $argv[2] = $cmd[$needle]
+ return 0
+ end
+ return 1
+end
+'''
+
+
+class FishPlugin(BeetsPlugin):
+
+ def commands(self):
+ cmd = ui.Subcommand('fish', help='generate Fish shell tab completions')
+ cmd.func = self.run
+ cmd.parser.add_option('-f', '--noFields', action='store_true',
+ default=False,
+ help='omit album/track field completions')
+ cmd.parser.add_option(
+ '-e',
+ '--extravalues',
+ action='append',
+ type='choice',
+ choices=library.Item.all_keys() +
+ library.Album.all_keys(),
+ help='include specified field *values* in completions')
+ return [cmd]
+
+ def run(self, lib, opts, args):
+ # Gather the commands from Beets core and its plugins.
+ # Collect the album and track fields.
+ # If specified, also collect the values for these fields.
+ # Make a giant string of all the above, formatted in a way that
+ # allows Fish to do tab completion for the `beet` command.
+ home_dir = os.path.expanduser("~")
+ completion_dir = os.path.join(home_dir, '.config/fish/completions')
+ try:
+ os.makedirs(completion_dir)
+ except OSError:
+ if not os.path.isdir(completion_dir):
+ raise
+ completion_file_path = os.path.join(completion_dir, 'beet.fish')
+ nobasicfields = opts.noFields # Do not complete for album/track fields
+ extravalues = opts.extravalues # e.g., Also complete artists names
+ beetcmds = sorted(
+ (commands.default_commands +
+ commands.plugins.commands()),
+ key=attrgetter('name'))
+ fields = sorted(set(
+ library.Album.all_keys() + library.Item.all_keys()))
+ # Collect commands, their aliases, and their help text
+ cmd_names_help = []
+ for cmd in beetcmds:
+ names = [alias for alias in cmd.aliases]
+ names.append(cmd.name)
+ for name in names:
+ cmd_names_help.append((name, cmd.help))
+ # Concatenate the string
+ totstring = HEAD + "\n"
+ totstring += get_cmds_list([name[0] for name in cmd_names_help])
+ totstring += '' if nobasicfields else get_standard_fields(fields)
+ totstring += get_extravalues(lib, extravalues) if extravalues else ''
+ totstring += "\n" + "# ====== {} =====".format(
+ "setup basic beet completion") + "\n" * 2
+ totstring += get_basic_beet_options()
+ totstring += "\n" + "# ====== {} =====".format(
+ "setup field completion for subcommands") + "\n"
+ totstring += get_subcommands(
+ cmd_names_help, nobasicfields, extravalues)
+ # Set up completion for all the command options
+ totstring += get_all_commands(beetcmds)
+
+ with open(completion_file_path, 'w') as fish_file:
+ fish_file.write(totstring)
+
+
+def get_cmds_list(cmds_names):
+ # Make a list of all Beets core & plugin commands
+ substr = ''
+ substr += (
+ "set CMDS " + " ".join(cmds_names) + ("\n" * 2)
+ )
+ return substr
+
+
+def get_standard_fields(fields):
+ # Make a list of album/track fields and append with ':'
+ fields = (field + ":" for field in fields)
+ substr = ''
+ substr += (
+ "set FIELDS " + " ".join(fields) + ("\n" * 2)
+ )
+ return substr
+
+
+def get_extravalues(lib, extravalues):
+ # Make a list of all values from an album/track field.
+ # 'beet ls albumartist: ' yields completions for ABBA, Beatles, etc.
+ word = ''
+ values_set = get_set_of_values_for_field(lib, extravalues)
+ for fld in extravalues:
+ extraname = fld.upper() + 'S'
+ word += (
+ "set " + extraname + " " + " ".join(sorted(values_set[fld]))
+ + ("\n" * 2)
+ )
+ return word
+
+
+def get_set_of_values_for_field(lib, fields):
+ # Get unique values from a specified album/track field
+ fields_dict = {}
+ for each in fields:
+ fields_dict[each] = set()
+ for item in lib.items():
+ for field in fields:
+ fields_dict[field].add(wrap(item[field]))
+ return fields_dict
+
+
+def get_basic_beet_options():
+ word = (
+ BL_NEED2.format("-l format-item",
+ "-f -d 'print with custom format'") +
+ BL_NEED2.format("-l format-album",
+ "-f -d 'print with custom format'") +
+ BL_NEED2.format("-s l -l library",
+ "-f -r -d 'library database file to use'") +
+ BL_NEED2.format("-s d -l directory",
+ "-f -r -d 'destination music directory'") +
+ BL_NEED2.format("-s v -l verbose",
+ "-f -d 'print debugging information'") +
+
+ BL_NEED2.format("-s c -l config",
+ "-f -r -d 'path to configuration file'") +
+ BL_NEED2.format("-s h -l help",
+ "-f -d 'print this help message and exit'"))
+ return word
+
+
+def get_subcommands(cmd_name_and_help, nobasicfields, extravalues):
+ # Formatting for Fish to complete our fields/values
+ word = ""
+ for cmdname, cmdhelp in cmd_name_and_help:
+ word += "\n" + "# ------ {} -------".format(
+ "fieldsetups for " + cmdname) + "\n"
+ word += (
+ BL_NEED2.format(
+ ("-a " + cmdname),
+ ("-f " + "-d " + wrap(clean_whitespace(cmdhelp)))))
+
+ if nobasicfields is False:
+ word += (
+ BL_USE3.format(
+ cmdname,
+ ("-a " + wrap("$FIELDS")),
+ ("-f " + "-d " + wrap("fieldname"))))
+
+ if extravalues:
+ for f in extravalues:
+ setvar = wrap("$" + f.upper() + "S")
+ word += " ".join(BL_EXTRA3.format(
+ (cmdname + " " + f + ":"),
+ ('-f ' + '-A ' + '-a ' + setvar),
+ ('-d ' + wrap(f))).split()) + "\n"
+ return word
+
+
+def get_all_commands(beetcmds):
+ # Formatting for Fish to complete command options
+ word = ""
+ for cmd in beetcmds:
+ names = [alias for alias in cmd.aliases]
+ names.append(cmd.name)
+ for name in names:
+ word += "\n"
+ word += ("\n" * 2) + "# ====== {} =====".format(
+ "completions for " + name) + "\n"
+
+ for option in cmd.parser._get_all_options()[1:]:
+ cmd_l = (" -l " + option._long_opts[0].replace('--', '')
+ )if option._long_opts else ''
+ cmd_s = (" -s " + option._short_opts[0].replace('-', '')
+ ) if option._short_opts else ''
+ cmd_need_arg = ' -r ' if option.nargs in [1] else ''
+ cmd_helpstr = (" -d " + wrap(' '.join(option.help.split()))
+ ) if option.help else ''
+ cmd_arglist = (' -a ' + wrap(" ".join(option.choices))
+ ) if option.choices else ''
+
+ word += " ".join(BL_USE3.format(
+ name,
+ (cmd_need_arg + cmd_s + cmd_l + " -f " + cmd_arglist),
+ cmd_helpstr).split()) + "\n"
+
+ word = (word + " ".join(BL_USE3.format(
+ name,
+ ("-s " + "h " + "-l " + "help" + " -f "),
+ ('-d ' + wrap("print help") + "\n")
+ ).split()))
+ return word
+
+
+def clean_whitespace(word):
+ # Remove excess whitespace and tabs in a string
+ return " ".join(word.split())
+
+
+def wrap(word):
+ # Need " or ' around strings but watch out if they're in the string
+ sptoken = '\"'
+ if ('"') in word and ("'") in word:
+ word.replace('"', sptoken)
+ return '"' + word + '"'
+
+ tok = '"' if "'" in word else "'"
+ return tok + word + tok
diff --git a/beetsplug/ipfs.py b/beetsplug/ipfs.py
index f2408c259..40a17d756 100644
--- a/beetsplug/ipfs.py
+++ b/beetsplug/ipfs.py
@@ -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)
diff --git a/beetsplug/lastgenre/genres-tree.yaml b/beetsplug/lastgenre/genres-tree.yaml
index a09f7e6b3..c8ae42478 100644
--- a/beetsplug/lastgenre/genres-tree.yaml
+++ b/beetsplug/lastgenre/genres-tree.yaml
@@ -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
diff --git a/beetsplug/lastgenre/genres.txt b/beetsplug/lastgenre/genres.txt
index 914ee1290..7ccd7ad3b 100644
--- a/beetsplug/lastgenre/genres.txt
+++ b/beetsplug/lastgenre/genres.txt
@@ -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
diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py
index 16699d9d3..16696d425 100644
--- a/beetsplug/lyrics.py
+++ b/beetsplug/lyrics.py
@@ -187,6 +187,9 @@ def search_pairs(item):
In addition to the artist and title obtained from the `item` the
method tries to strip extra information like paranthesized suffixes
and featured artists from the strings and add them as candidates.
+ The artist sort name is added as a fallback candidate to help in
+ cases where artist name includes special characters or is in a
+ non-latin script.
The method also tries to split multiple titles separated with `/`.
"""
def generate_alternatives(string, patterns):
@@ -200,12 +203,16 @@ def search_pairs(item):
alternatives.append(match.group(1))
return alternatives
- title, artist = item.title, item.artist
+ title, artist, artist_sort = item.title, item.artist, item.artist_sort
patterns = [
# Remove any featuring artists from the artists name
r"(.*?) {0}".format(plugins.feat_tokens())]
artists = generate_alternatives(artist, patterns)
+ # Use the artist_sort as fallback only if it differs from artist to avoid
+ # repeated remote requests with the same search terms
+ if artist != artist_sort:
+ artists.append(artist_sort)
patterns = [
# Remove a parenthesized suffix from a title string. Common
@@ -352,56 +359,86 @@ class Genius(Backend):
'User-Agent': USER_AGENT,
}
- def lyrics_from_song_api_path(self, song_api_path):
- song_url = self.base_url + song_api_path
- response = requests.get(song_url, headers=self.headers)
- json = response.json()
- path = json["response"]["song"]["path"]
-
- # Gotta go regular html scraping... come on Genius.
- page_url = "https://genius.com" + path
- try:
- page = requests.get(page_url)
- except requests.RequestException as exc:
- self._log.debug(u'Genius page request for {0} failed: {1}',
- page_url, exc)
- return None
- html = BeautifulSoup(page.text, "html.parser")
-
- # Remove script tags that they put in the middle of the lyrics.
- [h.extract() for h in html('script')]
-
- # At least Genius is nice and has a tag called 'lyrics'!
- # Updated css where the lyrics are based in HTML.
- lyrics = html.find("div", class_="lyrics").get_text()
-
- return lyrics
-
def fetch(self, artist, title):
+ """Fetch lyrics from genius.com
+
+ Because genius doesn't allow accesssing lyrics via the api,
+ we first query the api for a url matching our artist & title,
+ then attempt to scrape that url for the lyrics.
+ """
+ json = self._search(artist, title)
+ if not json:
+ self._log.debug(u'Genius API request returned invalid JSON')
+ return None
+
+ # find a matching artist in the json
+ for hit in json["response"]["hits"]:
+ hit_artist = hit["result"]["primary_artist"]["name"]
+
+ if slug(hit_artist) == slug(artist):
+ return self._scrape_lyrics_from_html(
+ self.fetch_url(hit["result"]["url"]))
+
+ self._log.debug(u'Genius failed to find a matching artist for \'{0}\'',
+ artist)
+
+ def _search(self, artist, title):
+ """Searches the genius api for a given artist and title
+
+ https://docs.genius.com/#search-h2
+
+ :returns: json response
+ """
search_url = self.base_url + "/search"
- data = {'q': title}
+ data = {'q': title + " " + artist.lower()}
try:
- response = requests.get(search_url, data=data,
- headers=self.headers)
+ response = requests.get(
+ search_url, data=data, headers=self.headers)
except requests.RequestException as exc:
self._log.debug(u'Genius API request failed: {0}', exc)
return None
try:
- json = response.json()
+ return response.json()
except ValueError:
- self._log.debug(u'Genius API request returned invalid JSON')
return None
- song_info = None
- for hit in json["response"]["hits"]:
- if hit["result"]["primary_artist"]["name"] == artist:
- song_info = hit
- break
+ def _scrape_lyrics_from_html(self, html):
+ """Scrape lyrics from a given genius.com html"""
- if song_info:
- song_api_path = song_info["result"]["api_path"]
- return self.lyrics_from_song_api_path(song_api_path)
+ html = BeautifulSoup(html, "html.parser")
+
+ # Remove script tags that they put in the middle of the lyrics.
+ [h.extract() for h in html('script')]
+
+ # Most of the time, the page contains a div with class="lyrics" where
+ # all of the lyrics can be found already correctly formatted
+ # Sometimes, though, it packages the lyrics into separate divs, most
+ # likely for easier ad placement
+ lyrics_div = html.find("div", class_="lyrics")
+ if not lyrics_div:
+ self._log.debug(u'Received unusual song page html')
+ verse_div = html.find("div",
+ class_=re.compile("Lyrics__Container"))
+ if not verse_div:
+ if html.find("div",
+ class_=re.compile("LyricsPlaceholder__Message"),
+ string="This song is an instrumental"):
+ self._log.debug('Detected instrumental')
+ return "[Instrumental]"
+ else:
+ self._log.debug("Couldn't scrape page using known layouts")
+ return None
+
+ lyrics_div = verse_div.parent
+ for br in lyrics_div.find_all("br"):
+ br.replace_with("\n")
+ ads = lyrics_div.find_all("div",
+ class_=re.compile("InreadAd__Container"))
+ for ad in ads:
+ ad.replace_with("\n")
+
+ return lyrics_div.get_text()
class LyricsWiki(SymbolsReplaced):
@@ -526,7 +563,7 @@ class Google(Backend):
bad_triggers = ['lyrics', 'copyright', 'property', 'links']
if artist:
- bad_triggers_occ += [artist]
+ bad_triggers += [artist]
for item in bad_triggers:
bad_triggers_occ += [item] * len(re.findall(r'\W%s\W' % item,
@@ -744,7 +781,8 @@ class LyricsPlugin(plugins.BeetsPlugin):
write = ui.should_write()
if opts.writerest:
self.writerest_indexes(opts.writerest)
- for item in lib.items(ui.decargs(args)):
+ items = lib.items(ui.decargs(args))
+ for item in items:
if not opts.local_only and not self.config['local']:
self.fetch_item_lyrics(
lib, item, write,
@@ -754,10 +792,10 @@ class LyricsPlugin(plugins.BeetsPlugin):
if opts.printlyr:
ui.print_(item.lyrics)
if opts.writerest:
- self.writerest(opts.writerest, item)
- if opts.writerest:
- # flush last artist
- self.writerest(opts.writerest, None)
+ self.appendrest(opts.writerest, item)
+ if opts.writerest and items:
+ # flush last artist & write to ReST
+ self.writerest(opts.writerest)
ui.print_(u'ReST files generated. to build, use one of:')
ui.print_(u' sphinx-build -b html %s _build/html'
% opts.writerest)
@@ -769,26 +807,21 @@ class LyricsPlugin(plugins.BeetsPlugin):
cmd.func = func
return [cmd]
- def writerest(self, directory, item):
- """Write the item to an ReST file
+ def appendrest(self, directory, item):
+ """Append the item to an ReST file
This will keep state (in the `rest` variable) in order to avoid
writing continuously to the same files.
"""
- if item is None or slug(self.artist) != slug(item.albumartist):
- if self.rest is not None:
- path = os.path.join(directory, 'artists',
- slug(self.artist) + u'.rst')
- with open(path, 'wb') as output:
- output.write(self.rest.encode('utf-8'))
- self.rest = None
- if item is None:
- return
+ if slug(self.artist) != slug(item.albumartist):
+ # Write current file and start a new one ~ item.albumartist
+ self.writerest(directory)
self.artist = item.albumartist.strip()
self.rest = u"%s\n%s\n\n.. contents::\n :local:\n\n" \
% (self.artist,
u'=' * len(self.artist))
+
if self.album != item.album:
tmpalbum = self.album = item.album.strip()
if self.album == '':
@@ -800,6 +833,15 @@ class LyricsPlugin(plugins.BeetsPlugin):
u'~' * len(title_str),
block)
+ def writerest(self, directory):
+ """Write self.rest to a ReST file
+ """
+ if self.rest is not None and self.artist is not None:
+ path = os.path.join(directory, 'artists',
+ slug(self.artist) + u'.rst')
+ with open(path, 'wb') as output:
+ output.write(self.rest.encode('utf-8'))
+
def writerest_indexes(self, directory):
"""Write conf.py and index.rst files necessary for Sphinx
@@ -881,7 +923,7 @@ class LyricsPlugin(plugins.BeetsPlugin):
return _scrape_strip_cruft(lyrics, True)
def append_translation(self, text, to_lang):
- import xml.etree.ElementTree as ET
+ from xml.etree import ElementTree
if not self.bing_auth_token:
self.bing_auth_token = self.get_bing_access_token()
@@ -899,7 +941,8 @@ class LyricsPlugin(plugins.BeetsPlugin):
self.bing_auth_token = None
return self.append_translation(text, to_lang)
return text
- lines_translated = ET.fromstring(r.text.encode('utf-8')).text
+ lines_translated = ElementTree.fromstring(
+ r.text.encode('utf-8')).text
# Use a translation mapping dict to build resulting lyrics
translations = dict(zip(text_lines, lines_translated.split('|')))
result = ''
diff --git a/beetsplug/metasync/amarok.py b/beetsplug/metasync/amarok.py
index 0622fc17a..1d28eb3c9 100644
--- a/beetsplug/metasync/amarok.py
+++ b/beetsplug/metasync/amarok.py
@@ -49,7 +49,7 @@ class Amarok(MetaSource):
'amarok_lastplayed': DateType(),
}
- queryXML = u' \
+ query_xml = u' \
\
\
\
@@ -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:
diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py
index f232d87e9..39b045f9b 100644
--- a/beetsplug/mpdstats.py
+++ b/beetsplug/mpdstats.py
@@ -108,8 +108,9 @@ class MPDClientWrapper(object):
return self.get(command, retries=retries - 1)
def currentsong(self):
- """Return the path to the currently playing song. Prefixes paths with the
- music_directory, to get the absolute path.
+ """Return the path to the currently playing song, along with its
+ songid. Prefixes paths with the music_directory, to get the absolute
+ path.
"""
result = None
entry = self.get('currentsong')
@@ -118,7 +119,7 @@ class MPDClientWrapper(object):
result = os.path.join(self.music_directory, entry['file'])
else:
result = entry['file']
- return result
+ return result, entry.get('id')
def status(self):
"""Return the current status of the MPD.
@@ -240,7 +241,9 @@ class MPDStats(object):
def on_stop(self, status):
self._log.info(u'stop')
- if self.now_playing:
+ # if the current song stays the same it means that we stopped on the
+ # current track and should not record a skip.
+ if self.now_playing and self.now_playing['id'] != status.get('songid'):
self.handle_song_change(self.now_playing)
self.now_playing = None
@@ -251,7 +254,7 @@ class MPDStats(object):
def on_play(self, status):
- path = self.mpd.currentsong()
+ path, songid = self.mpd.currentsong()
if not path:
return
@@ -286,6 +289,7 @@ class MPDStats(object):
'started': time.time(),
'remaining': remaining,
'path': path,
+ 'id': songid,
'beets_item': self.get_item(path),
}
diff --git a/beetsplug/parentwork.py b/beetsplug/parentwork.py
index eaa8abb30..b3e464e60 100644
--- a/beetsplug/parentwork.py
+++ b/beetsplug/parentwork.py
@@ -89,10 +89,11 @@ class ParentWorkPlugin(BeetsPlugin):
write = ui.should_write()
for item in lib.items(ui.decargs(args)):
- self.find_work(item, force_parent)
- item.store()
- if write:
- item.try_write()
+ changed = self.find_work(item, force_parent)
+ if changed:
+ item.store()
+ if write:
+ item.try_write()
command = ui.Subcommand(
'parentwork',
help=u'fetche parent works, composers and dates')
@@ -130,6 +131,8 @@ class ParentWorkPlugin(BeetsPlugin):
if artist['type'] == 'composer':
parent_composer.append(artist['artist']['name'])
parent_composer_sort.append(artist['artist']['sort-name'])
+ if 'end' in artist.keys():
+ parentwork_info["parentwork_date"] = artist['end']
parentwork_info['parent_composer'] = u', '.join(parent_composer)
parentwork_info['parent_composer_sort'] = u', '.join(
@@ -172,13 +175,17 @@ add one at https://musicbrainz.org/recording/{}', item, item.mb_trackid)
return
hasparent = hasattr(item, 'parentwork')
- if force or not hasparent:
+ work_changed = True
+ if hasattr(item, 'parentwork_workid_current'):
+ work_changed = item.parentwork_workid_current != item.mb_workid
+ if force or not hasparent or work_changed:
try:
work_info, work_date = find_parentwork_info(item.mb_workid)
except musicbrainzngs.musicbrainz.WebServiceError as e:
self._log.debug("error fetching work: {}", e)
return
parent_info = self.get_info(item, work_info)
+ parent_info['parentwork_workid_current'] = item.mb_workid
if 'parent_composer' in parent_info:
self._log.debug("Work fetched: {} - {}",
parent_info['parentwork'],
@@ -198,7 +205,8 @@ add one at https://musicbrainz.org/recording/{}', item, item.mb_trackid)
if work_date:
item['work_date'] = work_date
- ui.show_model_changes(
+ return ui.show_model_changes(
item, fields=['parentwork', 'parentwork_disambig',
'mb_parentworkid', 'parent_composer',
- 'parent_composer_sort', 'work_date'])
+ 'parent_composer_sort', 'work_date',
+ 'parentwork_workid_current', 'parentwork_date'])
diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py
index 302ddb56d..3f8ba2b0e 100644
--- a/beetsplug/playlist.py
+++ b/beetsplug/playlist.py
@@ -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):
diff --git a/beetsplug/plexupdate.py b/beetsplug/plexupdate.py
index 17fd8208d..860757cfb 100644
--- a/beetsplug/plexupdate.py
+++ b/beetsplug/plexupdate.py
@@ -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:
diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py
index 2be09dac5..076ca0f5c 100644
--- a/beetsplug/replaygain.py
+++ b/beetsplug/replaygain.py
@@ -71,6 +71,11 @@ def call(args, **kwargs):
raise ReplayGainError(u"argument encoding failed")
+def after_version(version_a, version_b):
+ return tuple(int(s) for s in version_a.split('.')) \
+ >= tuple(int(s) for s in version_b.split('.'))
+
+
def db_to_lufs(db):
"""Convert db to LUFS.
@@ -156,8 +161,12 @@ class Bs1770gainBackend(Backend):
cmd = 'bs1770gain'
try:
- call([cmd, "--help"])
+ version_out = call([cmd, '--version'])
self.command = cmd
+ self.version = re.search(
+ 'bs1770gain ([0-9]+.[0-9]+.[0-9]+), ',
+ version_out.stdout.decode('utf-8')
+ ).group(1)
except OSError:
raise FatalReplayGainError(
u'Is bs1770gain installed?'
@@ -250,17 +259,23 @@ class Bs1770gainBackend(Backend):
if self.__method != "":
# backward compatibility to `method` option
method = self.__method
+ gain_adjustment = target_level \
+ - [k for k, v in self.methods.items() if v == method][0]
elif target_level in self.methods:
method = self.methods[target_level]
gain_adjustment = 0
else:
- method = self.methods[-23]
- gain_adjustment = target_level - lufs_to_db(-23)
+ lufs_target = -23
+ method = self.methods[lufs_target]
+ gain_adjustment = target_level - lufs_target
# Construct shell command.
cmd = [self.command]
cmd += ["--" + method]
cmd += ['--xml', '-p']
+ if after_version(self.version, '0.6.0'):
+ cmd += ['--unit=ebu'] # set units to LU
+ cmd += ['--suppress-progress'] # don't print % to XML output
# Workaround for Windows: the underlying tool fails on paths
# with the \\?\ prefix, so we don't use it here. This
@@ -295,6 +310,7 @@ class Bs1770gainBackend(Backend):
album_gain = {} # mutable variable so it can be set from handlers
parser = xml.parsers.expat.ParserCreate(encoding='utf-8')
state = {'file': None, 'gain': None, 'peak': None}
+ album_state = {'gain': None, 'peak': None}
def start_element_handler(name, attrs):
if name == u'track':
@@ -303,9 +319,13 @@ class Bs1770gainBackend(Backend):
raise ReplayGainError(
u'duplicate filename in bs1770gain output')
elif name == u'integrated':
- state['gain'] = float(attrs[u'lu'])
+ if 'lu' in attrs:
+ state['gain'] = float(attrs[u'lu'])
elif name == u'sample-peak':
- state['peak'] = float(attrs[u'factor'])
+ if 'factor' in attrs:
+ state['peak'] = float(attrs[u'factor'])
+ elif 'amplitude' in attrs:
+ state['peak'] = float(attrs[u'amplitude'])
def end_element_handler(name):
if name == u'track':
@@ -321,6 +341,17 @@ class Bs1770gainBackend(Backend):
'the output of bs1770gain')
album_gain["album"] = Gain(state['gain'], state['peak'])
state['gain'] = state['peak'] = None
+ elif len(per_file_gain) == len(path_list):
+ if state['gain'] is not None:
+ album_state['gain'] = state['gain']
+ if state['peak'] is not None:
+ album_state['peak'] = state['peak']
+ if album_state['gain'] is not None \
+ and album_state['peak'] is not None:
+ album_gain["album"] = Gain(
+ album_state['gain'], album_state['peak'])
+ state['gain'] = state['peak'] = None
+
parser.StartElementHandler = start_element_handler
parser.EndElementHandler = end_element_handler
@@ -592,7 +623,7 @@ class FfmpegBackend(Backend):
return float(value)
except ValueError:
raise ReplayGainError(
- u"ffmpeg output: expected float value, found {1}"
+ u"ffmpeg output: expected float value, found {0}"
.format(value)
)
@@ -1336,10 +1367,10 @@ class ReplayGainPlugin(BeetsPlugin):
if (any([self.should_use_r128(item) for item in album.items()]) and not
all(([self.should_use_r128(item) for item in album.items()]))):
- raise ReplayGainError(
- u"Mix of ReplayGain and EBU R128 detected"
- u" for some tracks in album {0}".format(album)
- )
+ self._log.error(
+ u"Cannot calculate gain for album {0} (incompatible formats)",
+ album)
+ return
self._log.info(u'analyzing {0}', album)
diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py
index 4ffdd21e7..700b0c76a 100644
--- a/beetsplug/smartplaylist.py
+++ b/beetsplug/smartplaylist.py
@@ -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 = []
diff --git a/beetsplug/subsonicplaylist.py b/beetsplug/subsonicplaylist.py
new file mode 100644
index 000000000..732b51c2f
--- /dev/null
+++ b/beetsplug/subsonicplaylist.py
@@ -0,0 +1,173 @@
+# -*- coding: utf-8 -*-
+# This file is part of beets.
+# Copyright 2019, Joris Jensen
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+from __future__ import absolute_import, division, print_function
+
+import random
+import string
+from xml.etree import ElementTree
+from hashlib import md5
+from urllib.parse import urlencode
+
+import requests
+
+from beets.dbcore import AndQuery
+from beets.dbcore.query import MatchQuery
+from beets.plugins import BeetsPlugin
+from beets.ui import Subcommand
+
+__author__ = 'https://github.com/MrNuggelz'
+
+
+def filter_to_be_removed(items, keys):
+ if len(items) > len(keys):
+ dont_remove = []
+ for artist, album, title in keys:
+ for item in items:
+ if artist == item['artist'] and \
+ album == item['album'] and \
+ title == item['title']:
+ dont_remove.append(item)
+ return [item for item in items if item not in dont_remove]
+ else:
+ def to_be_removed(item):
+ for artist, album, title in keys:
+ if artist == item['artist'] and\
+ album == item['album'] and\
+ title == item['title']:
+ return False
+ return True
+
+ return [item for item in items if to_be_removed(item)]
+
+
+class SubsonicPlaylistPlugin(BeetsPlugin):
+
+ def __init__(self):
+ super(SubsonicPlaylistPlugin, self).__init__()
+ self.config.add(
+ {
+ 'delete': False,
+ 'playlist_ids': [],
+ 'playlist_names': [],
+ 'username': '',
+ 'password': ''
+ }
+ )
+ self.config['password'].redact = True
+
+ def update_tags(self, playlist_dict, lib):
+ with lib.transaction():
+ for query, playlist_tag in playlist_dict.items():
+ query = AndQuery([MatchQuery("artist", query[0]),
+ MatchQuery("album", query[1]),
+ MatchQuery("title", query[2])])
+ items = lib.items(query)
+ if not items:
+ self._log.warn(u"{} | track not found ({})", playlist_tag,
+ query)
+ continue
+ for item in items:
+ item.subsonic_playlist = playlist_tag
+ item.try_sync(write=True, move=False)
+
+ def get_playlist(self, playlist_id):
+ xml = self.send('getPlaylist', {'id': playlist_id}).text
+ playlist = ElementTree.fromstring(xml)[0]
+ if playlist.attrib.get('code', '200') != '200':
+ alt_error = 'error getting playlist, but no error message found'
+ self._log.warn(playlist.attrib.get('message', alt_error))
+ return
+
+ name = playlist.attrib.get('name', 'undefined')
+ tracks = [(t.attrib['artist'], t.attrib['album'], t.attrib['title'])
+ for t in playlist]
+ return name, tracks
+
+ def commands(self):
+ def build_playlist(lib, opts, args):
+ self.config.set_args(opts)
+ ids = self.config['playlist_ids'].as_str_seq()
+ if self.config['playlist_names'].as_str_seq():
+ playlists = ElementTree.fromstring(
+ self.send('getPlaylists').text)[0]
+ if playlists.attrib.get('code', '200') != '200':
+ alt_error = 'error getting playlists,' \
+ ' but no error message found'
+ self._log.warn(
+ playlists.attrib.get('message', alt_error))
+ return
+ for name in self.config['playlist_names'].as_str_seq():
+ for playlist in playlists:
+ if name == playlist.attrib['name']:
+ ids.append(playlist.attrib['id'])
+
+ playlist_dict = self.get_playlists(ids)
+
+ # delete old tags
+ if self.config['delete']:
+ existing = list(lib.items('subsonic_playlist:";"'))
+ to_be_removed = filter_to_be_removed(
+ existing,
+ playlist_dict.keys())
+ for item in to_be_removed:
+ item['subsonic_playlist'] = ''
+ with lib.transaction():
+ item.try_sync(write=True, move=False)
+
+ self.update_tags(playlist_dict, lib)
+
+ subsonicplaylist_cmds = Subcommand(
+ 'subsonicplaylist', help=u'import a subsonic playlist'
+ )
+ subsonicplaylist_cmds.parser.add_option(
+ u'-d',
+ u'--delete',
+ action='store_true',
+ help=u'delete tag from items not in any playlist anymore',
+ )
+ subsonicplaylist_cmds.func = build_playlist
+ return [subsonicplaylist_cmds]
+
+ def generate_token(self):
+ salt = ''.join(random.choices(string.ascii_lowercase + string.digits))
+ return md5(
+ (self.config['password'].get() + salt).encode()).hexdigest(), salt
+
+ def send(self, endpoint, params=None):
+ if params is None:
+ params = dict()
+ a, b = self.generate_token()
+ params['u'] = self.config['username']
+ params['t'] = a
+ params['s'] = b
+ params['v'] = '1.12.0'
+ params['c'] = 'beets'
+ resp = requests.get('{}/rest/{}?{}'.format(
+ self.config['base_url'].get(),
+ endpoint,
+ urlencode(params))
+ )
+ return resp
+
+ def get_playlists(self, ids):
+ output = dict()
+ for playlist_id in ids:
+ name, tracks = self.get_playlist(playlist_id)
+ for track in tracks:
+ if track not in output:
+ output[track] = ';'
+ output[track] += name + ';'
+ return output
diff --git a/beetsplug/subsonicupdate.py b/beetsplug/subsonicupdate.py
index 469a9d142..45fc3a8cb 100644
--- a/beetsplug/subsonicupdate.py
+++ b/beetsplug/subsonicupdate.py
@@ -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:
- pass:
- contextpath: /subsonic
+ url: https://mydomain.com:443/subsonic
+ user: username
+ pass: password
"""
from __future__ import division, absolute_import, print_function
@@ -37,68 +35,65 @@ from beets.plugins import BeetsPlugin
__author__ = 'https://github.com/maffo999'
-def create_token():
- """Create salt and token from given password.
-
- :return: The generated salt and hashed token
- """
- password = config['subsonic']['pass'].as_str()
-
- # Pick the random sequence and salt the password
- r = string.ascii_letters + string.digits
- salt = "".join([random.choice(r) for _ in range(6)])
- salted_password = password + salt
- token = hashlib.md5().update(salted_password.encode('utf-8')).hexdigest()
-
- # Put together the payload of the request to the server and the URL
- return salt, token
-
-
-def format_url():
- """Get the Subsonic URL to trigger a scan. Uses either the url
- config option or the deprecated host, port, and context_path config
- options together.
-
- :return: Endpoint for updating Subsonic
- """
-
- url = config['subsonic']['url'].as_str()
- if url and url.endsWith('/'):
- url = url[:-1]
-
- # @deprecated("Use url config option instead")
- if not url:
- host = config['subsonic']['host'].as_str()
- port = config['subsonic']['port'].get(int)
- context_path = config['subsonic']['contextpath'].as_str()
- if context_path == '/':
- context_path = ''
- url = "http://{}:{}{}".format(host, port, context_path)
-
- return url + '/rest/startScan'
-
-
class SubsonicUpdate(BeetsPlugin):
def __init__(self):
super(SubsonicUpdate, self).__init__()
# Set default configuration values
config['subsonic'].add({
- 'host': 'localhost',
- 'port': '4040',
'user': 'admin',
'pass': 'admin',
- 'contextpath': '/',
'url': 'http://localhost:4040',
})
config['subsonic']['pass'].redact = True
self.register_listener('import', self.start_scan)
+ @staticmethod
+ def __create_token():
+ """Create salt and token from given password.
+
+ :return: The generated salt and hashed token
+ """
+ password = config['subsonic']['pass'].as_str()
+
+ # Pick the random sequence and salt the password
+ r = string.ascii_letters + string.digits
+ salt = "".join([random.choice(r) for _ in range(6)])
+ salted_password = password + salt
+ token = hashlib.md5(salted_password.encode('utf-8')).hexdigest()
+
+ # Put together the payload of the request to the server and the URL
+ return salt, token
+
+ @staticmethod
+ def __format_url():
+ """Get the Subsonic URL to trigger a scan. Uses either the url
+ config option or the deprecated host, port, and context_path config
+ options together.
+
+ :return: Endpoint for updating Subsonic
+ """
+
+ url = config['subsonic']['url'].as_str()
+ if url and url.endswith('/'):
+ url = url[:-1]
+
+ # @deprecated("Use url config option instead")
+ if not url:
+ host = config['subsonic']['host'].as_str()
+ port = config['subsonic']['port'].get(int)
+ context_path = config['subsonic']['contextpath'].as_str()
+ if context_path == '/':
+ context_path = ''
+ url = "http://{}:{}{}".format(host, port, context_path)
+
+ return url + '/rest/startScan'
+
def start_scan(self):
user = config['subsonic']['user'].as_str()
- url = format_url()
- salt, token = create_token()
+ url = self.__format_url()
+ salt, token = self.__create_token()
payload = {
'u': user,
diff --git a/beetsplug/the.py b/beetsplug/the.py
index 238aec32f..dfc58817d 100644
--- a/beetsplug/the.py
+++ b/beetsplug/the.py
@@ -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}'
diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py
index fe36fbd13..1b262eca5 100644
--- a/beetsplug/thumbnails.py
+++ b/beetsplug/thumbnails.py
@@ -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):
diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py
index f53fb3a95..49149772d 100644
--- a/beetsplug/web/__init__.py
+++ b/beetsplug/web/__init__.py
@@ -169,7 +169,7 @@ class IdListConverter(BaseConverter):
return ids
def to_url(self, value):
- return ','.join(value)
+ return ','.join(str(v) for v in value)
class QueryConverter(PathConverter):
@@ -177,10 +177,11 @@ class QueryConverter(PathConverter):
"""
def to_python(self, value):
- return value.split('/')
+ queries = value.split('/')
+ return [query.replace('\\', os.sep) for query in queries]
def to_url(self, value):
- return ','.join(value)
+ return ','.join([v.replace(os.sep, '\\') for v in value])
class EverythingConverter(PathConverter):
diff --git a/docs/contributing.rst b/docs/contributing.rst
new file mode 100644
index 000000000..6af7deaef
--- /dev/null
+++ b/docs/contributing.rst
@@ -0,0 +1,3 @@
+.. contributing:
+
+.. include:: ../CONTRIBUTING.rst
diff --git a/docs/index.rst b/docs/index.rst
index 62c87461b..1d03a0f27 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -32,6 +32,7 @@ Contents
reference/index
plugins/index
faq
+ contributing
dev/index
.. toctree::
diff --git a/docs/plugins/chroma.rst b/docs/plugins/chroma.rst
index 1b86073b8..a6b60e6d8 100644
--- a/docs/plugins/chroma.rst
+++ b/docs/plugins/chroma.rst
@@ -96,7 +96,9 @@ Usage
Once you have all the dependencies sorted out, enable the ``chroma`` plugin in
your configuration (see :ref:`using-plugins`) to benefit from fingerprinting
-the next time you run ``beet import``.
+the next time you run ``beet import``. (The plugin doesn't produce any obvious
+output by default. If you want to confirm that it's enabled, you can try
+running in verbose mode once with ``beet -v import``.)
You can also use the ``beet fingerprint`` command to generate fingerprints for
items already in your library. (Provide a query to fingerprint a subset of your
diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst
index 6e9d00a11..9581e24a4 100644
--- a/docs/plugins/convert.rst
+++ b/docs/plugins/convert.rst
@@ -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):
diff --git a/docs/plugins/embedart.rst b/docs/plugins/embedart.rst
index cc2fe6fc8..defd3fa4b 100644
--- a/docs/plugins/embedart.rst
+++ b/docs/plugins/embedart.rst
@@ -58,6 +58,13 @@ file. The available options are:
the aspect ratio is preserved. See also :ref:`image-resizing` for further
caveats about image resizing.
Default: 0 (disabled).
+- **quality**: The JPEG quality level to use when compressing images (when
+ ``maxwidth`` is set). This should be either a number from 1 to 100 or 0 to
+ use the default quality. 65–75 is usually a good starting point. The default
+ behavior depends on the imaging tool used for scaling: ImageMagick tries to
+ estimate the input image quality and uses 92 if it cannot be determined, and
+ PIL defaults to 75.
+ Default: 0 (disabled)
- **remove_art_file**: Automatically remove the album art file for the album
after it has been embedded. This option is best used alongside the
:doc:`FetchArt ` plugin to download art with the purpose of
diff --git a/docs/plugins/fetchart.rst b/docs/plugins/fetchart.rst
index 68212a582..168ca0fa0 100644
--- a/docs/plugins/fetchart.rst
+++ b/docs/plugins/fetchart.rst
@@ -11,7 +11,7 @@ To use the ``fetchart`` plugin, first enable it in your configuration (see
The plugin uses `requests`_ to fetch album art from the Web.
-.. _requests: https://docs.python-requests.org/en/latest/
+.. _requests: https://requests.readthedocs.io/en/master/
Fetching Album Art During Import
--------------------------------
@@ -42,6 +42,13 @@ file. The available options are:
- **maxwidth**: A maximum image width to downscale fetched images if they are
too big. The resize operation reduces image width to at most ``maxwidth``
pixels. The height is recomputed so that the aspect ratio is preserved.
+- **quality**: The JPEG quality level to use when compressing images (when
+ ``maxwidth`` is set). This should be either a number from 1 to 100 or 0 to
+ use the default quality. 65–75 is usually a good starting point. The default
+ behavior depends on the imaging tool used for scaling: ImageMagick tries to
+ estimate the input image quality and uses 92 if it cannot be determined, and
+ PIL defaults to 75.
+ Default: 0 (disabled)
- **enforce_ratio**: Only images with a width:height ratio of 1:1 are
considered as valid album art candidates if set to ``yes``.
It is also possible to specify a certain deviation to the exact ratio to
@@ -51,9 +58,9 @@ file. The available options are:
- **sources**: List of sources to search for images. An asterisk `*` expands
to all available sources.
Default: ``filesystem coverart itunes amazon albumart``, i.e., everything but
- ``wikipedia``, ``google`` and ``fanarttv``. Enable those sources for more
- matches at the cost of some speed. They are searched in the given order,
- thus in the default config, no remote (Web) art source are queried if
+ ``wikipedia``, ``google``, ``fanarttv`` and ``lastfm``. Enable those sources
+ for more matches at the cost of some speed. They are searched in the given
+ order, thus in the default config, no remote (Web) art source are queried if
local art is found in the filesystem. To use a local image as fallback,
move it to the end of the list. For even more fine-grained control over
the search order, see the section on :ref:`album-art-sources` below.
@@ -64,6 +71,8 @@ file. The available options are:
Default: The `beets custom search engine`_, which searches the entire web.
- **fanarttv_key**: The personal API key for requesting art from
fanart.tv. See below.
+- **lastfm_key**: The personal API key for requesting art from Last.fm. See
+ below.
- **store_source**: If enabled, fetchart stores the artwork's source in a
flexible tag named ``art_source``. See below for the rationale behind this.
Default: ``no``.
@@ -117,8 +126,9 @@ art::
$ beet fetchart [-q] [query]
-By default the command will display all results, the ``-q`` or ``--quiet``
-switch will only display results for album arts that are still missing.
+By default the command will display all albums matching the ``query``. When the
+``-q`` or ``--quiet`` switch is given, only albums for which artwork has been
+fetched, or for which artwork could not be found will be printed.
.. _image-resizing:
@@ -214,6 +224,15 @@ personal key will give you earlier access to new art.
.. _on their blog: https://fanart.tv/2015/01/personal-api-keys/
+Last.fm
+'''''''
+
+To use the Last.fm backend, you need to `register for a Last.fm API key`_. Set
+the ``lastfm_key`` configuration option to your API key, then add ``lastfm`` to
+the list of sources in your configutation.
+
+.. _register for a Last.fm API key: https://www.last.fm/api/account/create
+
Storing the Artwork's Source
----------------------------
diff --git a/docs/plugins/fish.rst b/docs/plugins/fish.rst
new file mode 100644
index 000000000..b2cb096ee
--- /dev/null
+++ b/docs/plugins/fish.rst
@@ -0,0 +1,52 @@
+Fish Plugin
+===========
+
+The ``fish`` plugin adds a ``beet fish`` command that creates a `Fish shell`_
+tab-completion file named ``beet.fish`` in ``~/.config/fish/completions``.
+This enables tab-completion of ``beet`` commands for the `Fish shell`_.
+
+.. _Fish shell: https://fishshell.com/
+
+Configuration
+-------------
+
+Enable the ``fish`` plugin (see :ref:`using-plugins`) on a system running the
+`Fish shell`_.
+
+Usage
+-----
+
+Type ``beet fish`` to generate the ``beet.fish`` completions file at:
+``~/.config/fish/completions/``. If you later install or disable plugins, run
+``beet fish`` again to update the completions based on the enabled plugins.
+
+For users not accustomed to tab completion… After you type ``beet`` followed by
+a space in your shell prompt and then the ``TAB`` key, you should see a list of
+the beets commands (and their abbreviated versions) that can be invoked in your
+current environment. Similarly, typing ``beet -`` will show you all the
+option flags available to you, which also applies to subcommands such as
+``beet import -``. If you type ``beet ls`` followed by a space and then the
+and the ``TAB`` key, you will see a list of all the album/track fields that can
+be used in beets queries. For example, typing ``beet ls ge`` will complete
+to ``genre:`` and leave you ready to type the rest of your query.
+
+Options
+-------
+
+In addition to beets commands, plugin commands, and option flags, the generated
+completions also include by default all the album/track fields. If you only want
+the former and do not want the album/track fields included in the generated
+completions, use ``beet fish -f`` to only generate completions for beets/plugin
+commands and option flags.
+
+If you want generated completions to also contain album/track field *values* for
+the items in your library, you can use the ``-e`` or ``--extravalues`` option.
+For example: ``beet fish -e genre`` or ``beet fish -e genre -e albumartist``
+In the latter case, subsequently typing ``beet list genre: `` will display
+a list of all the genres in your library and ``beet list albumartist: ``
+will show a list of the album artists in your library. Keep in mind that all of
+these values will be put into the generated completions file, so use this option
+with care when specified fields contain a large number of values. Libraries with,
+for example, very large numbers of genres/artists may result in higher memory
+utilization, completion latency, et cetera. This option is not meant to replace
+database queries altogether.
diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst
index 7dc152bd1..aab922fcd 100644
--- a/docs/plugins/index.rst
+++ b/docs/plugins/index.rst
@@ -78,6 +78,7 @@ following to your configuration::
export
fetchart
filefilter
+ fish
freedesktop
fromfilename
ftintitle
@@ -115,6 +116,7 @@ following to your configuration::
smartplaylist
sonosupdate
spotify
+ subsonicplaylist
subsonicupdate
the
thumbnails
@@ -184,6 +186,7 @@ Interoperability
* :doc:`badfiles`: Check audio file integrity.
* :doc:`embyupdate`: Automatically notifies `Emby`_ whenever the beets library changes.
+* :doc:`fish`: Adds `Fish shell`_ tab autocompletion to ``beet`` commands.
* :doc:`importfeeds`: Keep track of imported files via ``.m3u`` playlist file(s) or symlinks.
* :doc:`ipfs`: Import libraries from friends and get albums from them via ipfs.
* :doc:`kodiupdate`: Automatically notifies `Kodi`_ whenever the beets library
@@ -203,6 +206,7 @@ Interoperability
.. _Emby: https://emby.media
+.. _Fish shell: https://fishshell.com/
.. _Plex: https://plex.tv
.. _Kodi: https://kodi.tv
.. _Sonos: https://sonos.com
@@ -297,7 +301,26 @@ Here are a few of the plugins written by the beets community:
* `beet-summarize`_ can compute lots of counts and statistics about your music
library.
-* `beets-mosaic`_ generates a montage of a mosiac from cover art.
+* `beets-mosaic`_ generates a montage of a mosaic from cover art.
+
+* `beets-goingrunning`_ generates playlists to go with your running sessions.
+
+* `beets-xtractor`_ extracts low- and high-level musical information from your songs.
+
+* `beets-yearfixer`_ attempts to fix all missing ``original_year`` and ``year`` fields.
+
+* `beets-autofix`_ automates repetitive tasks to keep your library in order.
+
+* `beets-describe`_ gives you the full picture of a single attribute of your library items.
+
+* `beets-bpmanalyser`_ analyses songs and calculates their tempo (BPM).
+
+* `beets-originquery`_ augments MusicBrainz queries with locally-sourced data
+ to improve autotagger results.
+
+* `drop2beets`_ automatically imports singles as soon as they are dropped in a
+ folder (using Linux's ``inotify``). You can also set a sub-folders
+ hierarchy to set flexible attributes by the way.
.. _beets-barcode: https://github.com/8h2a/beets-barcode
.. _beets-check: https://github.com/geigerzaehler/beets-check
@@ -321,3 +344,11 @@ Here are a few of the plugins written by the beets community:
.. _beets-ydl: https://github.com/vmassuchetto/beets-ydl
.. _beet-summarize: https://github.com/steven-murray/beet-summarize
.. _beets-mosaic: https://github.com/SusannaMaria/beets-mosaic
+.. _beets-goingrunning: https://pypi.org/project/beets-goingrunning
+.. _beets-xtractor: https://github.com/adamjakab/BeetsPluginXtractor
+.. _beets-yearfixer: https://github.com/adamjakab/BeetsPluginYearFixer
+.. _beets-autofix: https://github.com/adamjakab/BeetsPluginAutofix
+.. _beets-describe: https://github.com/adamjakab/BeetsPluginDescribe
+.. _beets-bpmanalyser: https://github.com/adamjakab/BeetsPluginBpmAnalyser
+.. _beets-originquery: https://github.com/x1ppy/beets-originquery
+.. _drop2beets: https://github.com/martinkirch/drop2beets
diff --git a/docs/plugins/mbcollection.rst b/docs/plugins/mbcollection.rst
index 113855bce..00acd4604 100644
--- a/docs/plugins/mbcollection.rst
+++ b/docs/plugins/mbcollection.rst
@@ -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``.
diff --git a/docs/plugins/parentwork.rst b/docs/plugins/parentwork.rst
index 9707650b4..fb15af9f1 100644
--- a/docs/plugins/parentwork.rst
+++ b/docs/plugins/parentwork.rst
@@ -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``
diff --git a/docs/plugins/plexupdate.rst b/docs/plugins/plexupdate.rst
index f9312280a..92fc949d2 100644
--- a/docs/plugins/plexupdate.rst
+++ b/docs/plugins/plexupdate.rst
@@ -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``
diff --git a/docs/plugins/subsonicplaylist.rst b/docs/plugins/subsonicplaylist.rst
new file mode 100644
index 000000000..98c83ebe1
--- /dev/null
+++ b/docs/plugins/subsonicplaylist.rst
@@ -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.
diff --git a/docs/plugins/web.rst b/docs/plugins/web.rst
index d416b1b7d..65d4743fb 100644
--- a/docs/plugins/web.rst
+++ b/docs/plugins/web.rst
@@ -210,7 +210,8 @@ If the server runs UNIX, you'll need to include an extra leading slash:
``GET /item/query/querystring``
+++++++++++++++++++++++++++++++
-Returns a list of tracks matching the query. The *querystring* must be a valid query as described in :doc:`/reference/query`. ::
+Returns a list of tracks matching the query. The *querystring* must be a
+valid query as described in :doc:`/reference/query`. ::
{
"results": [
@@ -219,6 +220,11 @@ Returns a list of tracks matching the query. The *querystring* must be a valid q
]
}
+Path elements are joined as parts of a query. For example,
+``/item/query/foo/bar`` will be converted to the query ``foo,bar``.
+To specify literal path separators in a query, use a backslash instead of a
+slash.
+
``GET /item/6/file``
++++++++++++++++++++
diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst
index b66920d96..724afc80a 100644
--- a/docs/reference/cli.rst
+++ b/docs/reference/cli.rst
@@ -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
-`. For example, the command ``beet ls -af '$album: $tracktotal'
+`. 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.
diff --git a/docs/reference/config.rst b/docs/reference/config.rst
index 7dcd53801..46f14f2c5 100644
--- a/docs/reference/config.rst
+++ b/docs/reference/config.rst
@@ -701,6 +701,26 @@ MusicBrainz server.
Default: ``5``.
+.. _extra_tags:
+
+extra_tags
+~~~~~~~~~~
+
+By default, beets will use only the artist, album, and track count to query
+MusicBrainz. Additional tags to be queried can be supplied with the
+``extra_tags`` setting. For example::
+
+ musicbrainz:
+ extra_tags: [year, catalognum, country, media, label]
+
+This setting should improve the autotagger results if the metadata with the
+given tags match the metadata returned by MusicBrainz.
+
+Note that the only tags supported by this setting are the ones listed in the
+above example.
+
+Default: ``[]``
+
.. _match-config:
Autotagger Matching Options
diff --git a/setup.cfg b/setup.cfg
index 0660b2721..761ab1de3 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -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
diff --git a/setup.py b/setup.py
index 544721937..2c3cb2b55 100755
--- a/setup.py
+++ b/setup.py
@@ -109,23 +109,35 @@ setup(
['colorama'] if (sys.platform == 'win32') else []
),
- tests_require=[
- 'beautifulsoup4',
- 'flask',
- 'mock',
- 'pylast',
- 'rarfile',
- 'responses',
- 'pyxdg',
- 'python-mpd2',
- 'discogs-client'
- ] + (
- # Tests for the thumbnails plugin need pathlib on Python 2 too.
- ['pathlib'] if (sys.version_info < (3, 4, 0)) else []
- ),
-
- # Plugin (optional) dependencies:
extras_require={
+ 'test': [
+ 'beautifulsoup4',
+ 'coverage',
+ 'discogs-client',
+ 'flask',
+ 'mock',
+ 'pylast',
+ 'pytest',
+ 'python-mpd2',
+ 'pyxdg',
+ 'responses>=0.3.0',
+ 'requests_oauthlib',
+ ] + (
+ # Tests for the thumbnails plugin need pathlib on Python 2 too.
+ ['pathlib'] if (sys.version_info < (3, 4, 0)) else []
+ ) + [
+ 'rarfile<4' if sys.version_info < (3, 6, 0) else 'rarfile',
+ ],
+ 'lint': [
+ 'flake8',
+ 'flake8-blind-except',
+ 'flake8-coding',
+ 'flake8-docstrings',
+ 'flake8-future-import',
+ 'pep8-naming',
+ ],
+
+ # Plugin (optional) dependencies:
'absubmit': ['requests'],
'fetchart': ['requests', 'Pillow'],
'embedart': ['Pillow'],
@@ -141,7 +153,9 @@ setup(
'mpdstats': ['python-mpd2>=0.4.2'],
'plexupdate': ['requests'],
'web': ['flask', 'flask-cors'],
- 'import': ['rarfile'],
+ 'import': (
+ ['rarfile<4' if (sys.version_info < (3, 6, 0)) else 'rarfile']
+ ),
'thumbnails': ['pyxdg', 'Pillow'] +
(['pathlib'] if (sys.version_info < (3, 4, 0)) else []),
'metasync': ['dbus-python'],
diff --git a/test/_common.py b/test/_common.py
index 5412ab650..8e3b1dd18 100644
--- a/test/_common.py
+++ b/test/_common.py
@@ -44,7 +44,7 @@ beetsplug.__path__ = [os.path.abspath(
RSRC = util.bytestring_path(os.path.join(os.path.dirname(__file__), 'rsrc'))
PLUGINPATH = os.path.join(os.path.dirname(__file__), 'rsrc', 'beetsplug')
-# Propagate to root logger so nosetest can capture it
+# Propagate to root logger so the test runner can capture it
log = logging.getLogger('beets')
log.propagate = True
log.setLevel(logging.DEBUG)
diff --git a/test/rsrc/lyrics/geniuscom/Wutangclancreamlyrics.txt b/test/rsrc/lyrics/geniuscom/Wutangclancreamlyrics.txt
new file mode 100644
index 000000000..08518f8ee
--- /dev/null
+++ b/test/rsrc/lyrics/geniuscom/Wutangclancreamlyrics.txt
@@ -0,0 +1,2227 @@
+
+
+
+
+
+
+
+Wu-Tang Clan – C.R.E.A.M. Lyrics | Genius Lyrics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{:: 'cloud_flare_always_on_short_message' | i18n }}
+ Check @genius for updates. We'll have things fixed soon.
+
+
Although it was released as an official single in 1994, “C.R.E.A.M.” was first recorded in 1991, around the same time as RZA’s assault case, and featured himself and Ghostface Killah. The track went through several revisions and was later re-recorded by Raekwon and Inspectah Deck in 1993—an early title of the song was “Lifestyles of the Mega-Rich.”
Once we got to the studio, I decided that this track had to be on the Wu-Tang album. I reminded Rae and Deck of their verses—their verses were long. […] Method Man, the master of hooks at the time, came in with this hook right here: ‘cash rules everything around me, cream, get the money.’ Once he added that element, I knew it was going to be a smash.
‘C.R.E.A.M.’ did a lot for my career personally. It gave me an opportunity to revisit the times where that cream meant that much to us. So, yeah, when I think of this record it just automatically puts me back into ‘87/’88 where we were standing in front of the building. It’s cold outside. We didn’t care. We’re out there, all black on trying to make dollars. Just trying to make some money and trying to eat. Survive.
+
+
This song, I remember writing to the beat a long time ago before we actually came out. That beat is old. That was probably like a ‘89 beat. RZA had it that long because he had a bunch of breaks. He had all kind of things and he was making beats back then, but we was just picking and that beat happened to always sit around and I would be like, ‘I want that beat, so don’t give that beat to nobody.’ And he kept his word and let me have it.
+
+
Meth came up with the hook but our dude named Raider Ruckus, this was like Meth’s homeboy back then, like they was real close, he came up with the phrase ‘cash rules everything around me.’ So when he showed Meth what it was and was like, ‘Cash rules everything around me,’ Meth was like, ‘Word, you right!’ And turned it into a movie, and I came in later that day and heard it and co-signed it.
“C.R.E.A.M.” is a true song. Everything Inspectah Deck and Raekwon said is 100 percent true. Not one line in that entire song is a lie, or even a slight exaggeration. Deck did sell base, and he did go to jail at the age of fifteen. Rae was sticking up white boys on ball courts, rocking the same damn ’Lo sweater. And of “course, Meth on the hook was like butter on the popcorn. Meth knew the hard times, too, being out there smoking woolies and pumping crack, etc. That raspy shit he was kicking just echoed in everyone’s head long after the song was done playing.
The realism on “C.R.E.A.M.” is what resonates with so many people all over the world. People everywhere know that sentiment of being slaves to the dollar. Cash is king, and we are its lowly subjects. That’s pretty much the case in every nation around the world, the desperation to put your life and your freedom on the line to make a couple dollars. Whether you’re working, stripping, hustling, or slinging, whether you’re a business owner or homeless, cash rules everything around us.
‘C.R.E.A.M.’ was the one that really put us on the map if you wanna be technical. I wasn’t there when they recorded ‘C.R.E.A.M.’ I came in after the fact. RZA was like, ‘Put a hook on this song’ and I put a hook on it. That’s how it always went. I liked doing hooks.
+
+
The hook for that was done by my man Raider Ruckus. We used to work at the Statue of Liberty and when we were coming home we used to come up with all these made-up words that were acronyms.
+
+
We had words like ‘BIBWAM’ which meant, ‘Bitches Is Busted Without A Man’ and all this other crazy shit. Raider Ruckus was so ill with the way he put the words together. We would call money ‘cream’ so he took each letter and made a word out of it and killed it the way he did it.
+
+
Something like that had never been done before as far as a hook or even a way of speaking. This is just showing and proving that we paid attention in class when we was kids. You can’t do shit like that unless you got a brain in your fucking head! You got to have some level of intelligence to do something like that.
+
+
The best acronym for a word that I heard was ‘P.R.O.J.E.C.T.S.’ by Killah Priest. He said ‘People Relying On Just Enough Cash To Survive.’ And he’s the one that came up with ‘Basic Instructions Before Leaving Earth,’ the acronym for B.I.B.L.E. This ain’t no fluke shit man.
+
+
There’s a reason you got millions upon millions of fucking kids running around with Wu-Tang tattoos. You don’t just put something on your body permanently unless it’s official. At that time, when you’re coming out brand new and representing where you come from, everybody from that area wants you to win because they win. That’s what it was like for us.
+
+
We were the only dudes from Staten Island doing it so everybody from Staten Island wanted us to win. Not just dudes from Staten Island, but dudes from Brooklyn too because they had peoples in the group too. Then it was just grimy niggas who loved to see real shit, saying, ‘We riding with them Wu-Tang niggas. Fuck all that shiny suit shit!’ That ain’t no take on Puff, a lot of niggas was wearing suits and shit man, but that ain’t us.