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/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/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..08e7548f2
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,85 @@
+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: Build and check docs using tox
+ run: tox -e docs
+
+ lint:
+ runs-on: ubuntu-latest
+
+ env:
+ PY_COLORS: 1
+
+ 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: 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..1e8b3a773
--- /dev/null
+++ b/.github/workflows/integration_test.yaml
@@ -0,0 +1,27 @@
+name: integration tests
+on:
+ 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
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 8376be522..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-dev
- # 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 http://archive.ubuntu.com/ubuntu/ trusty multiverse"
- - sourceline: "deb http://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..dc8617712
--- /dev/null
+++ b/CONTRIBUTING.rst
@@ -0,0 +1,377 @@
+############
+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 `__ for
+style. You can use ``tox -e lint`` to check your code for any style
+errors.
+
+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. Despite using ``pytest``
+as a test runner, we prefer to write tests using the standard
+`unittest`_ testing framework.
+
+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.
+
+Basics
+^^^^^^
+
+- Your file should contain a class derived from unittest.TestCase
+- Each method in this class which name starts with the letters *test*
+ will be executed to test functionality
+- Errors are raised with these methods:
+
+ - ``self.assertEqual``
+ - ``self.assertTrue``
+ - ``self.assertFalse``
+ - ``self.assertRaises``
+
+- For detailed information see `Python unittest`_
+- **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 a3ea6302f..4a2d8cb10 100644
--- a/README.rst
+++ b/README.rst
@@ -1,18 +1,20 @@
-.. image:: http://img.shields.io/pypi/v/beets.svg
+.. image:: https://img.shields.io/pypi/v/beets.svg
:target: https://pypi.python.org/pypi/beets
-.. image:: http://img.shields.io/codecov/c/github/beetbox/beets.svg
+.. 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
beets
=====
-Beets is the media library management system for obsessive-compulsive music
-geeks.
+Beets is the media library management system for obsessive music geeks.
The purpose of beets is to get your music collection right once and for all.
It catalogs your collection, automatically improving its metadata as it goes.
@@ -51,46 +53,46 @@ imagine for your music collection. Via `plugins`_, beets becomes a panacea:
If beets doesn't do what you want yet, `writing your own plugin`_ is
shockingly simple if you know a little Python.
-.. _plugins: http://beets.readthedocs.org/page/plugins/
-.. _MPD: http://www.musicpd.org/
-.. _MusicBrainz music collection: http://musicbrainz.org/doc/Collections/
+.. _plugins: https://beets.readthedocs.org/page/plugins/
+.. _MPD: https://www.musicpd.org/
+.. _MusicBrainz music collection: https://musicbrainz.org/doc/Collections/
.. _writing your own plugin:
- http://beets.readthedocs.org/page/dev/plugins.html
+ https://beets.readthedocs.org/page/dev/plugins.html
.. _HTML5 Audio:
http://www.w3.org/TR/html-markup/audio.html
.. _albums that are missing tracks:
- http://beets.readthedocs.org/page/plugins/missing.html
+ https://beets.readthedocs.org/page/plugins/missing.html
.. _duplicate tracks and albums:
- http://beets.readthedocs.org/page/plugins/duplicates.html
+ https://beets.readthedocs.org/page/plugins/duplicates.html
.. _Transcode audio:
- http://beets.readthedocs.org/page/plugins/convert.html
-.. _Discogs: http://www.discogs.com/
+ https://beets.readthedocs.org/page/plugins/convert.html
+.. _Discogs: https://www.discogs.com/
.. _acoustic fingerprints:
- http://beets.readthedocs.org/page/plugins/chroma.html
-.. _ReplayGain: http://beets.readthedocs.org/page/plugins/replaygain.html
-.. _tempos: http://beets.readthedocs.org/page/plugins/acousticbrainz.html
-.. _genres: http://beets.readthedocs.org/page/plugins/lastgenre.html
-.. _album art: http://beets.readthedocs.org/page/plugins/fetchart.html
-.. _lyrics: http://beets.readthedocs.org/page/plugins/lyrics.html
-.. _MusicBrainz: http://musicbrainz.org/
+ https://beets.readthedocs.org/page/plugins/chroma.html
+.. _ReplayGain: https://beets.readthedocs.org/page/plugins/replaygain.html
+.. _tempos: https://beets.readthedocs.org/page/plugins/acousticbrainz.html
+.. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html
+.. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html
+.. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html
+.. _MusicBrainz: https://musicbrainz.org/
.. _Beatport: https://www.beatport.com
Install
-------
-You can install beets by typing ``pip install beets``. Then check out the
-`Getting Started`_ guide.
+You can install beets by typing ``pip install beets``.
+Beets has also been packaged in the `software repositories`_ of several distributions.
+Check out the `Getting Started`_ guide for more information.
-.. _Getting Started: http://beets.readthedocs.org/page/guides/main.html
+.. _Getting Started: https://beets.readthedocs.org/page/guides/main.html
+.. _software repositories: https://repology.org/project/beets/versions
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: http://docs.beets.io/page/dev/
+.. _CONTRIBUTING.rst: https://github.com/beetbox/beets/blob/master/CONTRIBUTING.rst
Read More
---------
@@ -98,14 +100,21 @@ Read More
Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Twitter for
news and updates.
-.. _its Web site: http://beets.io/
-.. _@b33ts: http://twitter.com/b33ts/
+.. _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: http://www.cs.cornell.edu/~asampson/
+.. _Adrian Sampson: https://www.cs.cornell.edu/~asampson/
diff --git a/README_kr.rst b/README_kr.rst
index 18389061c..25dd052d8 100644
--- a/README_kr.rst
+++ b/README_kr.rst
@@ -1,7 +1,7 @@
-.. image:: http://img.shields.io/pypi/v/beets.svg
+.. image:: https://img.shields.io/pypi/v/beets.svg
:target: https://pypi.python.org/pypi/beets
-.. image:: http://img.shields.io/codecov/c/github/beetbox/beets.svg
+.. 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
@@ -34,7 +34,7 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들
- 필요하는 메타 데이터를 계산하거나 패치 할 때: `album art`_,
`lyrics`_, `genres`_, `tempos`_, `ReplayGain`_ levels, or `acoustic
fingerprints`_.
-- `MusicBrainz`_, `Discogs`_,`Beatport`_로부터 메타데이터를 가져오거나,
+- `MusicBrainz`_, `Discogs`_,`Beatport`_로부터 메타데이터를 가져오거나,
노래 제목이나 음향 특징으로 메타데이터를 추측한다
- `Transcode audio`_ 당신이 좋아하는 어떤 포맷으로든 변경한다.
- 당신의 라이브러리에서 `duplicate tracks and albums`_ 이나 `albums that are missing tracks`_ 를 검사한다.
@@ -45,31 +45,31 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들
- 명령어로부터 음악 파일의 메타데이터를 분석할 수 있다.
- `MPD`_ 프로토콜을 사용하여 음악 플레이어로 음악을 들으면, 엄청나게 다양한 인터페이스로 작동한다.
-만약 Beets에 당신이 원하는게 아직 없다면,
+만약 Beets에 당신이 원하는게 아직 없다면,
당신이 python을 안다면 `writing your own plugin`_ _은 놀라울정도로 간단하다.
-.. _plugins: http://beets.readthedocs.org/page/plugins/
-.. _MPD: http://www.musicpd.org/
-.. _MusicBrainz music collection: http://musicbrainz.org/doc/Collections/
+.. _plugins: https://beets.readthedocs.org/page/plugins/
+.. _MPD: https://www.musicpd.org/
+.. _MusicBrainz music collection: https://musicbrainz.org/doc/Collections/
.. _writing your own plugin:
- http://beets.readthedocs.org/page/dev/plugins.html
+ https://beets.readthedocs.org/page/dev/plugins.html
.. _HTML5 Audio:
http://www.w3.org/TR/html-markup/audio.html
.. _albums that are missing tracks:
- http://beets.readthedocs.org/page/plugins/missing.html
+ https://beets.readthedocs.org/page/plugins/missing.html
.. _duplicate tracks and albums:
- http://beets.readthedocs.org/page/plugins/duplicates.html
+ https://beets.readthedocs.org/page/plugins/duplicates.html
.. _Transcode audio:
- http://beets.readthedocs.org/page/plugins/convert.html
-.. _Discogs: http://www.discogs.com/
+ https://beets.readthedocs.org/page/plugins/convert.html
+.. _Discogs: https://www.discogs.com/
.. _acoustic fingerprints:
- http://beets.readthedocs.org/page/plugins/chroma.html
-.. _ReplayGain: http://beets.readthedocs.org/page/plugins/replaygain.html
-.. _tempos: http://beets.readthedocs.org/page/plugins/acousticbrainz.html
-.. _genres: http://beets.readthedocs.org/page/plugins/lastgenre.html
-.. _album art: http://beets.readthedocs.org/page/plugins/fetchart.html
-.. _lyrics: http://beets.readthedocs.org/page/plugins/lyrics.html
-.. _MusicBrainz: http://musicbrainz.org/
+ https://beets.readthedocs.org/page/plugins/chroma.html
+.. _ReplayGain: https://beets.readthedocs.org/page/plugins/replaygain.html
+.. _tempos: https://beets.readthedocs.org/page/plugins/acousticbrainz.html
+.. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html
+.. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html
+.. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html
+.. _MusicBrainz: https://musicbrainz.org/
.. _Beatport: https://www.beatport.com
설치
@@ -78,7 +78,7 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들
당신은 ``pip install beets`` 을 사용해서 Beets를 설치할 수 있다.
그리고 `Getting Started`_ 가이드를 확인할 수 있다.
-.. _Getting Started: http://beets.readthedocs.org/page/guides/main.html
+.. _Getting Started: https://beets.readthedocs.org/page/guides/main.html
컨트리뷰션
----------
@@ -87,16 +87,16 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들
당신은 docs 안에 `For Developers`_ 에도 관심이 있을수 있다.
.. _Hacking: https://github.com/beetbox/beets/wiki/Hacking
-.. _For Developers: http://docs.beets.io/page/dev/
+.. _For Developers: https://beets.readthedocs.io/en/stable/dev/
Read More
---------
-`its Web site`_ 에서 Beets에 대해 조금 더 알아볼 수 있다.
+`its Web site`_ 에서 Beets에 대해 조금 더 알아볼 수 있다.
트위터에서 `@b33ts`_ 를 팔로우하면 새 소식을 볼 수 있다.
-.. _its Web site: http://beets.io/
-.. _@b33ts: http://twitter.com/b33ts/
+.. _its Web site: https://beets.io/
+.. _@b33ts: https://twitter.com/b33ts/
저자들
-------
@@ -105,4 +105,4 @@ Read More
돕고 싶다면 `forum`_.를 방문하면 된다.
.. _forum: https://discourse.beets.io
-.. _Adrian Sampson: http://www.cs.cornell.edu/~asampson/
+.. _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 ede4fbe12..7ab0d57fd 100644
--- a/beets/autotag/__init__.py
+++ b/beets/autotag/__init__.py
@@ -22,13 +22,54 @@ from beets import logging
from beets import config
# Parts of external interface.
-from .hooks import AlbumInfo, TrackInfo, AlbumMatch, TrackMatch # noqa
+from .hooks import ( # noqa
+ AlbumInfo,
+ TrackInfo,
+ AlbumMatch,
+ TrackMatch,
+ Distance,
+)
from .match import tag_item, tag_album, Proposal # noqa
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.
@@ -43,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?
@@ -151,44 +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',
- 'albumstatus',
- 'albumdisambig',
- 'releasegroupdisambig',
- 'data_source',
- ),
- 'track': (
- 'disctitle',
- 'lyricist',
- 'media',
- 'composer',
- 'composer_sort',
- 'arranger',
- 'work',
- 'mb_workid',
- 'work_disambig',
- )
- }
-
# 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 57cd1c309..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,40 +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, 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):
+ 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, **kwargs):
self.album = album
self.album_id = album_id
self.artist = artist
@@ -102,6 +100,8 @@ class AlbumInfo(object):
self.script = script
self.language = language
self.country = country
+ self.style = style
+ self.genre = genre
self.albumstatus = albumstatus
self.media = media
self.albumdisambig = albumdisambig
@@ -112,6 +112,10 @@ class AlbumInfo(object):
self.original_day = original_day
self.data_source = data_source
self.data_url = data_url
+ 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.
@@ -121,59 +125,45 @@ class AlbumInfo(object):
constituent `TrackInfo` objects, are decoded to Unicode.
"""
for fld in ['album', 'artist', 'albumtype', 'label', 'artist_sort',
- 'catalognum', 'script', 'language', 'country',
- 'albumstatus', 'albumdisambig', 'releasegroupdisambig',
- 'artist_credit', 'media']:
+ 'catalognum', 'script', 'language', 'country', 'style',
+ 'genre', 'albumstatus', 'albumdisambig',
+ 'releasegroupdisambig', 'artist_credit',
+ 'media', 'discogs_albumid', 'discogs_labelid',
+ 'discogs_artistid']:
value = getattr(self, fld)
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):
+ 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
@@ -198,6 +188,10 @@ class TrackInfo(object):
self.work = work
self.mb_workid = mb_workid
self.work_disambig = work_disambig
+ 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'):
@@ -210,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.
@@ -333,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']
@@ -600,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)
@@ -618,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 b8f91f440..ea8ef24da 100644
--- a/beets/autotag/mb.py
+++ b/beets/autotag/mb.py
@@ -38,8 +38,16 @@ 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__,
- 'http://beets.io/')
+ 'https://beets.io/')
class MusicBrainzAPIError(util.HumanReadableException):
@@ -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 3786bbbd2..524ee0b00 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 c37def875..5aa2b9812 100644
--- a/beets/dbcore/types.py
+++ b/beets/dbcore/types.py
@@ -97,7 +97,7 @@ class Type(object):
For fixed fields the type of `value` is determined by the column
type affinity given in the `sql` property and the SQL to Python
mapping of the database adapter. For more information see:
- http://www.sqlite.org/datatype3.html
+ https://www.sqlite.org/datatype3.html
https://docs.python.org/2/library/sqlite3.html#sqlite-and-python-types
Flexible fields have the type affinity `TEXT`. This means the
@@ -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 4c4316a8e..77cce4bd3 100644
--- a/beets/importer.py
+++ b/beets/importer.py
@@ -754,6 +754,8 @@ class ImportTask(BaseImportTask):
self.record_replaced(lib)
self.remove_replaced(lib)
self.album = lib.add_album(self.imported_items())
+ if 'data_source' in self.imported_items()[0]:
+ self.album.data_source = self.imported_items()[0].data_source
self.reimport_metadata(lib)
def record_replaced(self, lib):
@@ -1032,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 87bbcd134..5e6a0ec8a 100644
--- a/beets/library.py
+++ b/beets/library.py
@@ -413,7 +413,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)
@@ -451,6 +452,10 @@ class Item(LibModel):
'albumartist_sort': types.STRING,
'albumartist_credit': types.STRING,
'genre': types.STRING,
+ 'style': types.STRING,
+ 'discogs_albumid': types.INTEGER,
+ 'discogs_artistid': types.INTEGER,
+ 'discogs_labelid': types.INTEGER,
'lyricist': types.STRING,
'composer': types.STRING,
'composer_sort': types.STRING,
@@ -989,6 +994,10 @@ class Album(LibModel):
'albumartist_credit': types.STRING,
'album': types.STRING,
'genre': types.STRING,
+ 'style': types.STRING,
+ 'discogs_albumid': types.INTEGER,
+ 'discogs_artistid': types.INTEGER,
+ 'discogs_labelid': types.INTEGER,
'year': types.PaddedInt(4),
'month': types.PaddedInt(2),
'day': types.PaddedInt(2),
@@ -1034,6 +1043,10 @@ class Album(LibModel):
'albumartist_credit',
'album',
'genre',
+ 'style',
+ 'discogs_albumid',
+ 'discogs_artistid',
+ 'discogs_labelid',
'year',
'month',
'day',
@@ -1172,7 +1185,7 @@ class Album(LibModel):
"""
item = self.items().get()
if not item:
- raise ValueError(u'empty album')
+ raise ValueError(u'empty album for album id %d' % self.id)
return os.path.dirname(item.path)
def _albumtotal(self):
diff --git a/beets/plugins.py b/beets/plugins.py
index 7c98225ca..695725cb8 100644
--- a/beets/plugins.py
+++ b/beets/plugins.py
@@ -20,6 +20,7 @@ from __future__ import division, absolute_import, print_function
import traceback
import re
import inspect
+import abc
from collections import defaultdict
from functools import wraps
@@ -29,6 +30,7 @@ from beets import logging
import mediafile
import six
+
PLUGIN_NAMESPACE = 'beetsplug'
# Plugins using the Last.fm API can share the same API key.
@@ -170,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.
"""
@@ -204,7 +206,7 @@ class BeetsPlugin(object):
``descriptor`` must be an instance of ``mediafile.MediaField``.
"""
- # Defer impor to prevent circular dependency
+ # Defer import to prevent circular dependency
from beets import library
mediafile.MediaFile.add_field(name, descriptor)
library.Item._media_fields.add(name)
@@ -377,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
@@ -576,3 +579,189 @@ def notify_info_yielded(event):
yield v
return decorated
return decorator
+
+
+def get_distance(config, data_source, info):
+ """Returns the ``data_source`` weight and the maximum source weight
+ for albums or individual tracks.
+ """
+ dist = beets.autotag.Distance()
+ if info.data_source == data_source:
+ dist.add('source', config['source_weight'].as_number())
+ return dist
+
+
+def apply_item_changes(lib, item, move, pretend, write):
+ """Store, move, and write the item according to the arguments.
+
+ :param lib: beets library.
+ :type lib: beets.library.Library
+ :param item: Item whose changes to apply.
+ :type item: beets.library.Item
+ :param move: Move the item if it's in the library.
+ :type move: bool
+ :param pretend: Return without moving, writing, or storing the item's
+ metadata.
+ :type pretend: bool
+ :param write: Write the item's metadata to its media file.
+ :type write: bool
+ """
+ if pretend:
+ return
+
+ from beets import util
+
+ # Move the item if it's in the library.
+ if move and lib.directory in util.ancestry(item.path):
+ item.move(with_album=False)
+
+ if write:
+ item.try_write()
+
+ item.store()
+
+
+@six.add_metaclass(abc.ABCMeta)
+class MetadataSourcePlugin(object):
+ def __init__(self):
+ super(MetadataSourcePlugin, self).__init__()
+ self.config.add({'source_weight': 0.5})
+
+ @abc.abstractproperty
+ def id_regex(self):
+ raise NotImplementedError
+
+ @abc.abstractproperty
+ def data_source(self):
+ raise NotImplementedError
+
+ @abc.abstractproperty
+ def search_url(self):
+ raise NotImplementedError
+
+ @abc.abstractproperty
+ def album_url(self):
+ raise NotImplementedError
+
+ @abc.abstractproperty
+ def track_url(self):
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def _search_api(self, query_type, filters, keywords=''):
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def album_for_id(self, album_id):
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def track_for_id(self, track_id=None, track_data=None):
+ raise NotImplementedError
+
+ @staticmethod
+ def get_artist(artists, id_key='id', name_key='name'):
+ """Returns an artist string (all artists) and an artist_id (the main
+ artist) for a list of artist object dicts.
+
+ For each artist, this function moves articles (such as 'a', 'an',
+ and 'the') to the front and strips trailing disambiguation numbers. It
+ returns a tuple containing the comma-separated string of all
+ normalized artists and the ``id`` of the main/first artist.
+
+ :param artists: Iterable of artist dicts or lists returned by API.
+ :type artists: list[dict] or list[list]
+ :param id_key: Key or index corresponding to the value of ``id`` for
+ the main/first artist. Defaults to 'id'.
+ :type id_key: str or int
+ :param name_key: Key or index corresponding to values of names
+ to concatenate for the artist string (containing all artists).
+ Defaults to 'name'.
+ :type name_key: str or int
+ :return: Normalized artist string.
+ :rtype: str
+ """
+ artist_id = None
+ artist_names = []
+ for artist in artists:
+ if not artist_id:
+ artist_id = artist[id_key]
+ name = artist[name_key]
+ # Strip disambiguation number.
+ name = re.sub(r' \(\d+\)$', '', name)
+ # Move articles to the front.
+ name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I)
+ artist_names.append(name)
+ artist = ', '.join(artist_names).replace(' ,', ',') or None
+ return artist, artist_id
+
+ def _get_id(self, url_type, id_):
+ """Parse an ID from its URL if necessary.
+
+ :param url_type: Type of URL. Either 'album' or 'track'.
+ :type url_type: str
+ :param id_: Album/track ID or URL.
+ :type id_: str
+ :return: Album/track ID.
+ :rtype: str
+ """
+ self._log.debug(
+ u"Searching {} for {} '{}'", self.data_source, url_type, id_
+ )
+ match = re.search(self.id_regex['pattern'].format(url_type), str(id_))
+ if match:
+ id_ = match.group(self.id_regex['match_group'])
+ if id_:
+ return id_
+ return None
+
+ 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).
+
+ :param items: List of items comprised by an album to be matched.
+ :type items: list[beets.library.Item]
+ :param artist: The artist of the album to be matched.
+ :type artist: str
+ :param album: The name of the album to be matched.
+ :type album: str
+ :param va_likely: True if the album to be matched likely has
+ Various Artists.
+ :type va_likely: bool
+ :return: Candidate AlbumInfo objects.
+ :rtype: list[beets.autotag.hooks.AlbumInfo]
+ """
+ query_filters = {'album': album}
+ if not va_likely:
+ query_filters['artist'] = artist
+ results = self._search_api(query_type='album', filters=query_filters)
+ albums = [self.album_for_id(album_id=r['id']) for r in results]
+ return [a for a in albums if a is not None]
+
+ def item_candidates(self, item, artist, title):
+ """Returns a list of TrackInfo objects for Search API results
+ matching ``title`` and ``artist``.
+
+ :param item: Singleton item to be matched.
+ :type item: beets.library.Item
+ :param artist: The artist of the track to be matched.
+ :type artist: str
+ :param title: The title of the track to be matched.
+ :type title: str
+ :return: Candidate TrackInfo objects.
+ :rtype: list[beets.autotag.hooks.TrackInfo]
+ """
+ tracks = self._search_api(
+ query_type='track', keywords=title, filters={'artist': artist}
+ )
+ return [self.track_for_id(track_data=track) for track in tracks]
+
+ def album_distance(self, items, album_info, mapping):
+ return get_distance(
+ data_source=self.data_source, info=album_info, config=self.config
+ )
+
+ def track_distance(self, item, track_info):
+ return get_distance(
+ data_source=self.data_source, info=track_info, config=self.config
+ )
diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py
index d798f5134..40f0243cc 100644
--- a/beets/ui/__init__.py
+++ b/beets/ui/__init__.py
@@ -204,7 +204,7 @@ def input_(prompt=None):
"""
# raw_input incorrectly sends prompts to stderr, not stdout, so we
# use print_() explicitly to display prompts.
- # http://bugs.python.org/issue1927
+ # https://bugs.python.org/issue1927
if prompt:
print_(prompt, end=u' ')
@@ -475,7 +475,7 @@ def human_seconds_short(interval):
# Colorization.
# ANSI terminal colorization code heavily inspired by pygments:
-# http://dev.pocoo.org/hg/pygments-main/file/b2deea5b5030/pygments/console.py
+# https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py
# (pygments is by Tim Hatch, Armin Ronacher, et al.)
COLOR_ESCAPE = "\x1b["
DARK_COLORS = {
@@ -929,7 +929,7 @@ class CommonOptionsParser(optparse.OptionParser, object):
#
# This is a fairly generic subcommand parser for optparse. It is
# maintained externally here:
-# http://gist.github.com/462717
+# https://gist.github.com/462717
# There you will also find a better description of the code and a more
# succinct example program.
diff --git a/beets/ui/commands.py b/beets/ui/commands.py
index ef4fd144a..56f9ad1f5 100755
--- a/beets/ui/commands.py
+++ b/beets/ui/commands.py
@@ -241,7 +241,8 @@ def show_change(cur_artist, cur_album, match):
if mediums and mediums > 1:
return u'{0}-{1}'.format(medium, medium_index)
else:
- return six.text_type(medium_index or index)
+ return six.text_type(medium_index if medium_index is not None
+ else index)
else:
return six.text_type(index)
@@ -476,10 +477,11 @@ def summarize_items(items, singleton):
def _summary_judgment(rec):
"""Determines whether a decision should be made without even asking
the user. This occurs in quiet mode and when an action is chosen for
- NONE recommendations. Return an action or None if the user should be
- queried. May also print to the console if a summary judgment is
- made.
+ NONE recommendations. Return None if the user should be queried.
+ Otherwise, returns an action. May also print to the console if a
+ summary judgment is made.
"""
+
if config['import']['quiet']:
if rec == Recommendation.strong:
return importer.action.APPLY
@@ -488,14 +490,14 @@ def _summary_judgment(rec):
'skip': importer.action.SKIP,
'asis': importer.action.ASIS,
})
-
+ elif config['import']['timid']:
+ return None
elif rec == Recommendation.none:
action = config['import']['none_rec_action'].as_choice({
'skip': importer.action.SKIP,
'asis': importer.action.ASIS,
'ask': None,
})
-
else:
return None
@@ -542,7 +544,7 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
print_(u"No matching release found for {0} tracks."
.format(itemcount))
print_(u'For help, see: '
- u'http://beets.readthedocs.org/en/latest/faq.html#nomatch')
+ u'https://beets.readthedocs.org/en/latest/faq.html#nomatch')
sel = ui.input_options(choice_opts)
if sel in choice_actions:
return choice_actions[sel]
@@ -1183,6 +1185,12 @@ def update_items(lib, query, album, move, pretend, fields):
def update_func(lib, opts, args):
+ # Verify that the library folder exists to prevent accidental wipes.
+ if not os.path.isdir(lib.directory):
+ ui.print_("Library path is unavailable or does not exist.")
+ ui.print_(lib.directory)
+ if not ui.input_yn("Are you sure you want to continue (y/n)?", True):
+ return
update_items(lib, decargs(args), opts.album, ui.should_move(opts.move),
opts.pretend, opts.fields)
diff --git a/beets/util/__init__.py b/beets/util/__init__.py
index e2348cf6e..bb84aedc7 100644
--- a/beets/util/__init__.py
+++ b/beets/util/__init__.py
@@ -24,7 +24,7 @@ import re
import shutil
import fnmatch
import functools
-from collections import Counter
+from collections import Counter, namedtuple
from multiprocessing.pool import ThreadPool
import traceback
import subprocess
@@ -223,6 +223,13 @@ def sorted_walk(path, ignore=(), ignore_hidden=False, logger=None):
yield res
+def path_as_posix(path):
+ """Return the string representation of the path with forward (/)
+ slashes.
+ """
+ return path.replace(b'\\', b'/')
+
+
def mkdirall(path):
"""Make all the enclosing directories of path (like mkdir -p on the
parent).
@@ -412,7 +419,7 @@ def syspath(path, prefix=True):
path = path.decode(encoding, 'replace')
# Add the magic prefix if it isn't already there.
- # http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx
+ # https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx
if prefix and not path.startswith(WINDOWS_MAGIC_PREFIX):
if path.startswith(u'\\\\'):
# UNC path. Final path should look like \\?\UNC\...
@@ -563,7 +570,7 @@ def unique_path(path):
# Note: The Windows "reserved characters" are, of course, allowed on
# Unix. They are forbidden here because they cause problems on Samba
# shares, which are sufficiently common as to cause frequent problems.
-# http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx
+# https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx
CHAR_REPLACE = [
(re.compile(r'[\\/]'), u'_'), # / and \ -- forbidden everywhere.
(re.compile(r'^\.'), u'_'), # Leading dot (hidden files on Unix).
@@ -763,7 +770,11 @@ def cpu_count():
num = 0
elif sys.platform == 'darwin':
try:
- num = int(command_output(['/usr/sbin/sysctl', '-n', 'hw.ncpu']))
+ num = int(command_output([
+ '/usr/sbin/sysctl',
+ '-n',
+ 'hw.ncpu',
+ ]).stdout)
except (ValueError, OSError, subprocess.CalledProcessError):
num = 0
else:
@@ -794,9 +805,16 @@ def convert_command_args(args):
return [convert(a) for a in args]
+# stdout and stderr as bytes
+CommandOutput = namedtuple("CommandOutput", ("stdout", "stderr"))
+
+
def command_output(cmd, shell=False):
"""Runs the command and returns its output after it has exited.
+ Returns a CommandOutput. The attributes ``stdout`` and ``stderr`` contain
+ byte strings of the respective output streams.
+
``cmd`` is a list of arguments starting with the command names. The
arguments are bytes on Unix and strings on Windows.
If ``shell`` is true, ``cmd`` is assumed to be a string and passed to a
@@ -831,7 +849,7 @@ def command_output(cmd, shell=False):
cmd=' '.join(cmd),
output=stdout + stderr,
)
- return stdout
+ return CommandOutput(stdout, stderr)
def max_filename_length(path, limit=MAX_FILENAME_LENGTH):
diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py
index 1ee3e560d..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)
@@ -129,7 +139,7 @@ def im_getsize(path_in):
['-format', '%w %h', util.syspath(path_in, prefix=False)]
try:
- out = util.command_output(cmd)
+ out = util.command_output(cmd).stdout
except subprocess.CalledProcessError as exc:
log.warning(u'ImageMagick size query failed')
log.debug(
@@ -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):
@@ -265,7 +276,7 @@ def get_im_version():
cmd = cmd_name + ['--version']
try:
- out = util.command_output(cmd)
+ out = util.command_output(cmd).stdout
except (subprocess.CalledProcessError, OSError) as exc:
log.debug(u'ImageMagick version check failed: {}', exc)
else:
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/absubmit.py b/beetsplug/absubmit.py
index 9d26ac5db..7419736a3 100644
--- a/beetsplug/absubmit.py
+++ b/beetsplug/absubmit.py
@@ -32,6 +32,9 @@ from beets import plugins
from beets import util
from beets import ui
+# We use this field to check whether AcousticBrainz info is present.
+PROBE_FIELD = 'mood_acoustic'
+
class ABSubmitError(Exception):
"""Raised when failing to analyse file with extractor."""
@@ -43,7 +46,7 @@ def call(args):
Raise a AnalysisABSubmitError on failure.
"""
try:
- return util.command_output(args)
+ return util.command_output(args).stdout
except subprocess.CalledProcessError as e:
raise ABSubmitError(
u'{0} exited with status {1}'.format(args[0], e.returncode)
@@ -55,7 +58,11 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
def __init__(self):
super(AcousticBrainzSubmitPlugin, self).__init__()
- self.config.add({'extractor': u''})
+ self.config.add({
+ 'extractor': u'',
+ 'force': False,
+ 'pretend': False
+ })
self.extractor = self.config['extractor'].as_str()
if self.extractor:
@@ -73,8 +80,8 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
call([self.extractor])
except OSError:
raise ui.UserError(
- u'No extractor command found: please install the '
- u'extractor binary from http://acousticbrainz.org/download'
+ u'No extractor command found: please install the extractor'
+ u' binary from https://acousticbrainz.org/download'
)
except ABSubmitError:
# Extractor found, will exit with an error if not called with
@@ -98,12 +105,24 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
'absubmit',
help=u'calculate and submit AcousticBrainz analysis'
)
+ cmd.parser.add_option(
+ u'-f', u'--force', dest='force_refetch',
+ action='store_true', default=False,
+ help=u're-download data when already present'
+ )
+ cmd.parser.add_option(
+ u'-p', u'--pretend', dest='pretend_fetch',
+ action='store_true', default=False,
+ help=u'pretend to perform action, but show \
+only files which would be processed'
+ )
cmd.func = self.command
return [cmd]
def command(self, lib, opts, args):
# Get items from arguments
items = lib.items(ui.decargs(args))
+ self.opts = opts
util.par_map(self.analyze_submit, items)
def analyze_submit(self, item):
@@ -113,12 +132,22 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
def _get_analysis(self, item):
mbid = item['mb_trackid']
- # If file has no mbid skip it.
+
+ # Avoid re-analyzing files that already have AB data.
+ if not self.opts.force_refetch and not self.config['force']:
+ if item.get(PROBE_FIELD):
+ return None
+
+ # If file has no MBID, skip it.
if not mbid:
self._log.info(u'Not analysing {}, missing '
u'musicbrainz track id.', item)
return None
+ if self.opts.pretend_fetch or self.config['pretend']:
+ self._log.info(u'pretend action - extract item: {}', item)
+ return None
+
# Temporary file to save extractor output to, extractor only works
# if an output file is given. Here we use a temporary file to copy
# the data into a python object and then remove the file from the
@@ -135,7 +164,7 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
item=item, error=e
)
return None
- with open(filename, 'rb') as tmp_file:
+ with open(filename, 'r') as tmp_file:
analysis = json.load(tmp_file)
# Add the hash to the output.
analysis['metadata']['version']['essentia_build_sha'] = \
diff --git a/beetsplug/acousticbrainz.py b/beetsplug/acousticbrainz.py
index 01f3ac6ac..725e0d634 100644
--- a/beetsplug/acousticbrainz.py
+++ b/beetsplug/acousticbrainz.py
@@ -74,6 +74,9 @@ ABSCHEME = {
'sad': 'mood_sad'
}
},
+ 'moods_mirex': {
+ 'value': 'moods_mirex'
+ },
'ismir04_rhythm': {
'value': 'rhythm'
},
@@ -82,6 +85,9 @@ ABSCHEME = {
'tonal': 'tonal'
}
},
+ 'timbre': {
+ 'value': 'timbre'
+ },
'voice_instrumental': {
'value': 'voice_instrumental'
},
@@ -124,7 +130,9 @@ class AcousticPlugin(plugins.BeetsPlugin):
'mood_party': types.Float(6),
'mood_relaxed': types.Float(6),
'mood_sad': types.Float(6),
+ 'moods_mirex': types.STRING,
'rhythm': types.Float(6),
+ 'timbre': types.STRING,
'tonal': types.Float(6),
'voice_instrumental': types.STRING,
}
diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py
index da59bef87..df0abb2fc 100644
--- a/beetsplug/beatport.py
+++ b/beetsplug/beatport.py
@@ -28,13 +28,13 @@ from requests_oauthlib.oauth1_session import (TokenRequestDenied, TokenMissing,
import beets
import beets.ui
-from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
-from beets.plugins import BeetsPlugin
+from beets.autotag.hooks import AlbumInfo, TrackInfo
+from beets.plugins import BeetsPlugin, MetadataSourcePlugin, get_distance
import confuse
AUTH_ERRORS = (TokenRequestDenied, TokenMissing, VerifierMissing)
-USER_AGENT = u'beets/{0} +http://beets.io/'.format(beets.__version__)
+USER_AGENT = u'beets/{0} +https://beets.io/'.format(beets.__version__)
class BeatportAPIError(Exception):
@@ -109,7 +109,7 @@ class BeatportClient(object):
:rtype: (unicode, unicode) tuple
"""
self.api.parse_authorization_response(
- "http://beets.io/auth?" + auth_data)
+ "https://beets.io/auth?" + auth_data)
access_data = self.api.fetch_access_token(
self._make_url('/identity/1/oauth/access-token'))
return access_data['oauth_token'], access_data['oauth_token_secret']
@@ -150,9 +150,11 @@ class BeatportClient(object):
:rtype: :py:class:`BeatportRelease`
"""
response = self._get('/catalog/3/releases', id=beatport_id)
- release = BeatportRelease(response[0])
- release.tracks = self.get_release_tracks(beatport_id)
- return release
+ if response:
+ release = BeatportRelease(response[0])
+ release.tracks = self.get_release_tracks(beatport_id)
+ return release
+ return None
def get_release_tracks(self, beatport_id):
""" Get all tracks for a given release.
@@ -224,8 +226,9 @@ class BeatportRelease(BeatportObject):
if 'category' in data:
self.category = data['category']
if 'slug' in data:
- self.url = "http://beatport.com/release/{0}/{1}".format(
+ self.url = "https://beatport.com/release/{0}/{1}".format(
data['slug'], data['id'])
+ self.genre = data.get('genre')
@six.python_2_unicode_compatible
@@ -252,12 +255,24 @@ class BeatportTrack(BeatportObject):
except ValueError:
pass
if 'slug' in data:
- self.url = "http://beatport.com/track/{0}/{1}".format(data['slug'],
- data['id'])
+ self.url = "https://beatport.com/track/{0}/{1}" \
+ .format(data['slug'], data['id'])
self.track_number = data.get('trackNumber')
+ self.bpm = data.get('bpm')
+ self.initial_key = six.text_type(
+ (data.get('key') or {}).get('shortName')
+ )
+
+ # Use 'subgenre' and if not present, 'genre' as a fallback.
+ if data.get('subGenres'):
+ self.genre = six.text_type(data['subGenres'][0].get('name'))
+ elif data.get('genres'):
+ self.genre = six.text_type(data['genres'][0].get('name'))
class BeatportPlugin(BeetsPlugin):
+ data_source = 'Beatport'
+
def __init__(self):
super(BeatportPlugin, self).__init__()
self.config.add({
@@ -321,24 +336,26 @@ class BeatportPlugin(BeetsPlugin):
return self.config['tokenfile'].get(confuse.Filename(in_app_dir=True))
def album_distance(self, items, album_info, mapping):
- """Returns the beatport source weight and the maximum source weight
+ """Returns the Beatport source weight and the maximum source weight
for albums.
"""
- dist = Distance()
- if album_info.data_source == 'Beatport':
- dist.add('source', self.config['source_weight'].as_number())
- return dist
+ return get_distance(
+ data_source=self.data_source,
+ info=album_info,
+ config=self.config
+ )
def track_distance(self, item, track_info):
- """Returns the beatport source weight and the maximum source weight
+ """Returns the Beatport source weight and the maximum source weight
for individual tracks.
"""
- dist = Distance()
- if track_info.data_source == 'Beatport':
- dist.add('source', self.config['source_weight'].as_number())
- return dist
+ return get_distance(
+ data_source=self.data_source,
+ info=track_info,
+ 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).
"""
@@ -365,27 +382,31 @@ class BeatportPlugin(BeetsPlugin):
def album_for_id(self, release_id):
"""Fetches a release by its Beatport ID and returns an AlbumInfo object
- or None if the release is not found.
+ or None if the query is not a valid ID or release is not found.
"""
self._log.debug(u'Searching for release {0}', release_id)
match = re.search(r'(^|beatport\.com/release/.+/)(\d+)$', release_id)
if not match:
+ self._log.debug(u'Not a valid Beatport release ID.')
return None
release = self.client.get_release(match.group(2))
- album = self._get_album_info(release)
- return album
+ if release:
+ return self._get_album_info(release)
+ return None
def track_for_id(self, track_id):
"""Fetches a track by its Beatport ID and returns a TrackInfo object
- or None if the track is not found.
+ or None if the track is not a valid Beatport ID or track is not found.
"""
self._log.debug(u'Searching for track {0}', track_id)
match = re.search(r'(^|beatport\.com/track/.+/)(\d+)$', track_id)
if not match:
+ self._log.debug(u'Not a valid Beatport track ID.')
return None
bp_track = self.client.get_track(match.group(2))
- track = self._get_track_info(bp_track)
- return track
+ if bp_track is not None:
+ return self._get_track_info(bp_track)
+ return None
def _get_releases(self, query):
"""Returns a list of AlbumInfo objects for a beatport search query.
@@ -419,7 +440,8 @@ class BeatportPlugin(BeetsPlugin):
day=release.release_date.day,
label=release.label_name,
catalognum=release.catalog_number, media=u'Digital',
- data_source=u'Beatport', data_url=release.url)
+ data_source=self.data_source, data_url=release.url,
+ genre=release.genre)
def _get_track_info(self, track):
"""Returns a TrackInfo object for a Beatport Track object.
@@ -433,25 +455,17 @@ class BeatportPlugin(BeetsPlugin):
artist=artist, artist_id=artist_id,
length=length, index=track.track_number,
medium_index=track.track_number,
- data_source=u'Beatport', data_url=track.url)
+ data_source=self.data_source, data_url=track.url,
+ bpm=track.bpm, initial_key=track.initial_key,
+ genre=track.genre)
def _get_artist(self, artists):
"""Returns an artist string (all artists) and an artist_id (the main
artist) for a list of Beatport release or track artists.
"""
- artist_id = None
- bits = []
- for artist in artists:
- if not artist_id:
- artist_id = artist[0]
- name = artist[1]
- # Strip disambiguation number.
- name = re.sub(r' \(\d+\)$', '', name)
- # Move articles to the front.
- name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I)
- bits.append(name)
- artist = ', '.join(bits).replace(' ,', ',') or None
- return artist, artist_id
+ return MetadataSourcePlugin.get_artist(
+ artists=artists, id_key=0, name_key=1
+ )
def _get_tracks(self, query):
"""Returns a list of TrackInfo objects for a Beatport query.
diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py
index 045bce035..dad864b8b 100644
--- a/beetsplug/bpd/__init__.py
+++ b/beetsplug/bpd/__init__.py
@@ -639,6 +639,8 @@ class BaseServer(object):
self.playlist.pop(old_index)
if self.current_index > old_index:
self.current_index -= 1
+ self.playlist_version += 1
+ self._send_event("playlist")
if self.current_index >= len(self.playlist):
# Fallen off the end. Move to stopped state or loop.
if self.repeat:
diff --git a/beetsplug/bpd/gstplayer.py b/beetsplug/bpd/gstplayer.py
index 8d4e7c9ff..3ba293bf2 100644
--- a/beetsplug/bpd/gstplayer.py
+++ b/beetsplug/bpd/gstplayer.py
@@ -64,7 +64,8 @@ class GstPlayer(object):
"""
# Set up the Gstreamer player. From the pygst tutorial:
- # http://pygstdocs.berlios.de/pygst-tutorial/playbin.html
+ # https://pygstdocs.berlios.de/pygst-tutorial/playbin.html (gone)
+ # https://brettviren.github.io/pygst-tutorial-org/pygst-tutorial.html
####
# Updated to GStreamer 1.0 with:
# https://wiki.ubuntu.com/Novacut/GStreamer1.0
diff --git a/beetsplug/bpsync.py b/beetsplug/bpsync.py
new file mode 100644
index 000000000..aefb1517b
--- /dev/null
+++ b/beetsplug/bpsync.py
@@ -0,0 +1,188 @@
+# -*- coding: utf-8 -*-
+# This file is part of beets.
+# Copyright 2019, Rahul Ahuja.
+#
+# 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.
+
+"""Update library's tags using Beatport.
+"""
+from __future__ import division, absolute_import, print_function
+
+from beets.plugins import BeetsPlugin, apply_item_changes
+from beets import autotag, library, ui, util
+
+from .beatport import BeatportPlugin
+
+
+class BPSyncPlugin(BeetsPlugin):
+ def __init__(self):
+ super(BPSyncPlugin, self).__init__()
+ self.beatport_plugin = BeatportPlugin()
+ self.beatport_plugin.setup()
+
+ def commands(self):
+ cmd = ui.Subcommand('bpsync', help=u'update metadata from Beatport')
+ cmd.parser.add_option(
+ u'-p',
+ u'--pretend',
+ action='store_true',
+ help=u'show all changes but do nothing',
+ )
+ cmd.parser.add_option(
+ u'-m',
+ u'--move',
+ action='store_true',
+ dest='move',
+ help=u"move files in the library directory",
+ )
+ cmd.parser.add_option(
+ u'-M',
+ u'--nomove',
+ action='store_false',
+ dest='move',
+ help=u"don't move files in library",
+ )
+ cmd.parser.add_option(
+ u'-W',
+ u'--nowrite',
+ action='store_false',
+ default=None,
+ dest='write',
+ help=u"don't write updated metadata to files",
+ )
+ cmd.parser.add_format_option()
+ cmd.func = self.func
+ return [cmd]
+
+ def func(self, lib, opts, args):
+ """Command handler for the bpsync function.
+ """
+ move = ui.should_move(opts.move)
+ pretend = opts.pretend
+ write = ui.should_write(opts.write)
+ query = ui.decargs(args)
+
+ self.singletons(lib, query, move, pretend, write)
+ self.albums(lib, query, move, pretend, write)
+
+ def singletons(self, lib, query, move, pretend, write):
+ """Retrieve and apply info from the autotagger for items matched by
+ query.
+ """
+ for item in lib.items(query + [u'singleton:true']):
+ if not item.mb_trackid:
+ self._log.info(
+ u'Skipping singleton with no mb_trackid: {}', item
+ )
+ continue
+
+ if not self.is_beatport_track(item):
+ self._log.info(
+ u'Skipping non-{} singleton: {}',
+ self.beatport_plugin.data_source,
+ item,
+ )
+ continue
+
+ # Apply.
+ trackinfo = self.beatport_plugin.track_for_id(item.mb_trackid)
+ with lib.transaction():
+ autotag.apply_item_metadata(item, trackinfo)
+ apply_item_changes(lib, item, move, pretend, write)
+
+ @staticmethod
+ def is_beatport_track(item):
+ return (
+ item.get('data_source') == BeatportPlugin.data_source
+ and item.mb_trackid.isnumeric()
+ )
+
+ def get_album_tracks(self, album):
+ if not album.mb_albumid:
+ self._log.info(u'Skipping album with no mb_albumid: {}', album)
+ return False
+ if not album.mb_albumid.isnumeric():
+ self._log.info(
+ u'Skipping album with invalid {} ID: {}',
+ self.beatport_plugin.data_source,
+ album,
+ )
+ return False
+ items = list(album.items())
+ if album.get('data_source') == self.beatport_plugin.data_source:
+ return items
+ if not all(self.is_beatport_track(item) for item in items):
+ self._log.info(
+ u'Skipping non-{} release: {}',
+ self.beatport_plugin.data_source,
+ album,
+ )
+ return False
+ return items
+
+ def albums(self, lib, query, move, pretend, write):
+ """Retrieve and apply info from the autotagger for albums matched by
+ query and their items.
+ """
+ # Process matching albums.
+ for album in lib.albums(query):
+ # Do we have a valid Beatport album?
+ items = self.get_album_tracks(album)
+ if not items:
+ continue
+
+ # Get the Beatport album information.
+ albuminfo = self.beatport_plugin.album_for_id(album.mb_albumid)
+ if not albuminfo:
+ self._log.info(
+ u'Release ID {} not found for album {}',
+ album.mb_albumid,
+ album,
+ )
+ continue
+
+ beatport_trackid_to_trackinfo = {
+ track.track_id: track for track in albuminfo.tracks
+ }
+ library_trackid_to_item = {
+ int(item.mb_trackid): item for item in items
+ }
+ item_to_trackinfo = {
+ item: beatport_trackid_to_trackinfo[track_id]
+ for track_id, item in library_trackid_to_item.items()
+ }
+
+ self._log.info(u'applying changes to {}', album)
+ with lib.transaction():
+ autotag.apply_metadata(albuminfo, item_to_trackinfo)
+ changed = False
+ # Find any changed item to apply Beatport changes to album.
+ any_changed_item = items[0]
+ for item in items:
+ item_changed = ui.show_model_changes(item)
+ changed |= item_changed
+ if item_changed:
+ any_changed_item = item
+ apply_item_changes(lib, item, move, pretend, write)
+
+ if pretend or not changed:
+ continue
+
+ # Update album structure to reflect an item in it.
+ for key in library.Album.item_keys:
+ album[key] = any_changed_item[key]
+ album.store()
+
+ # Move album art (and any inconsistent items).
+ if move and lib.directory in util.ancestry(items[0].path):
+ self._log.debug(u'moving album {}', album)
+ album.move()
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 c873ec7c1..4b16ec816 100644
--- a/beetsplug/convert.py
+++ b/beetsplug/convert.py
@@ -114,6 +114,8 @@ class ConvertPlugin(BeetsPlugin):
self.config.add({
u'dest': None,
u'pretend': False,
+ u'link': False,
+ u'hardlink': False,
u'threads': util.cpu_count(),
u'format': u'mp3',
u'id3v23': u'inherit',
@@ -167,6 +169,13 @@ class ConvertPlugin(BeetsPlugin):
help=u'set the target format of the tracks')
cmd.parser.add_option('-y', '--yes', action='store_true', dest='yes',
help=u'do not ask for confirmation')
+ cmd.parser.add_option('-l', '--link', action='store_true', dest='link',
+ help=u'symlink files that do not \
+ need transcoding.')
+ cmd.parser.add_option('-H', '--hardlink', action='store_true',
+ dest='hardlink',
+ help=u'hardlink files that do not \
+ need transcoding. Overrides --link.')
cmd.parser.add_album_option()
cmd.func = self.convert_func
return [cmd]
@@ -251,7 +260,7 @@ class ConvertPlugin(BeetsPlugin):
util.displayable_path(source))
def convert_item(self, dest_dir, keep_new, path_formats, fmt,
- pretend=False):
+ pretend=False, link=False, hardlink=False):
"""A pipeline thread that converts `Item` objects from a
library.
"""
@@ -299,20 +308,35 @@ class ConvertPlugin(BeetsPlugin):
util.move(item.path, original)
if should_transcode(item, fmt):
+ linked = False
try:
self.encode(command, original, converted, pretend)
except subprocess.CalledProcessError:
continue
else:
+ linked = link or hardlink
if pretend:
- self._log.info(u'cp {0} {1}',
+ msg = 'ln' if hardlink else ('ln -s' if link else 'cp')
+
+ self._log.info(u'{2} {0} {1}',
util.displayable_path(original),
- util.displayable_path(converted))
+ util.displayable_path(converted),
+ msg)
else:
# No transcoding necessary.
- self._log.info(u'Copying {0}',
- util.displayable_path(item.path))
- util.copy(original, converted)
+ msg = 'Hardlinking' if hardlink \
+ else ('Linking' if link else 'Copying')
+
+ self._log.info(u'{1} {0}',
+ util.displayable_path(item.path),
+ msg)
+
+ if hardlink:
+ util.hardlink(original, converted)
+ elif link:
+ util.link(original, converted)
+ else:
+ util.copy(original, converted)
if pretend:
continue
@@ -331,7 +355,7 @@ class ConvertPlugin(BeetsPlugin):
item.read()
item.store() # Store new path and audio data.
- if self.config['embed']:
+ if self.config['embed'] and not linked:
album = item._cached_album
if album and album.artpath:
self._log.debug(u'embedding album art from {}',
@@ -346,7 +370,8 @@ class ConvertPlugin(BeetsPlugin):
plugins.send('after_convert', item=item,
dest=converted, keepnew=False)
- def copy_album_art(self, album, dest_dir, path_formats, pretend=False):
+ def copy_album_art(self, album, dest_dir, path_formats, pretend=False,
+ link=False, hardlink=False):
"""Copies or converts the associated cover art of the album. Album must
have at least one track.
"""
@@ -400,14 +425,26 @@ class ConvertPlugin(BeetsPlugin):
ArtResizer.shared.resize(maxwidth, album.artpath, dest)
else:
if pretend:
- self._log.info(u'cp {0} {1}',
+ msg = 'ln' if hardlink else ('ln -s' if link else 'cp')
+
+ self._log.info(u'{2} {0} {1}',
util.displayable_path(album.artpath),
- util.displayable_path(dest))
+ util.displayable_path(dest),
+ msg)
else:
- self._log.info(u'Copying cover art from {0} to {1}',
+ msg = 'Hardlinking' if hardlink \
+ else ('Linking' if link else 'Copying')
+
+ self._log.info(u'{2} cover art from {0} to {1}',
util.displayable_path(album.artpath),
- util.displayable_path(dest))
- util.copy(album.artpath, dest)
+ util.displayable_path(dest),
+ msg)
+ if hardlink:
+ util.hardlink(album.artpath, dest)
+ elif link:
+ util.link(album.artpath, dest)
+ else:
+ util.copy(album.artpath, dest)
def convert_func(self, lib, opts, args):
dest = opts.dest or self.config['dest'].get()
@@ -426,6 +463,16 @@ class ConvertPlugin(BeetsPlugin):
else:
pretend = self.config['pretend'].get(bool)
+ if opts.hardlink is not None:
+ hardlink = opts.hardlink
+ link = False
+ elif opts.link is not None:
+ hardlink = False
+ link = opts.link
+ else:
+ hardlink = self.config['hardlink'].get(bool)
+ link = self.config['link'].get(bool)
+
if opts.album:
albums = lib.albums(ui.decargs(args))
items = [i for a in albums for i in a.items()]
@@ -446,13 +493,16 @@ class ConvertPlugin(BeetsPlugin):
if opts.album and self.config['copy_album_art']:
for album in albums:
- self.copy_album_art(album, dest, path_formats, pretend)
+ self.copy_album_art(album, dest, path_formats, pretend,
+ link, hardlink)
convert = [self.convert_item(dest,
opts.keep_new,
path_formats,
fmt,
- pretend)
+ pretend,
+ link,
+ hardlink)
for _ in range(threads)]
pipe = util.pipeline.Pipeline([iter(items), convert])
pipe.run_parallel()
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/deezer.py b/beetsplug/deezer.py
new file mode 100644
index 000000000..4e3fca33a
--- /dev/null
+++ b/beetsplug/deezer.py
@@ -0,0 +1,233 @@
+# -*- coding: utf-8 -*-
+# This file is part of beets.
+# Copyright 2019, Rahul Ahuja.
+#
+# 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.
+
+"""Adds Deezer release and track search support to the autotagger
+"""
+from __future__ import absolute_import, print_function, division
+
+import collections
+
+import six
+import unidecode
+import requests
+
+from beets import ui
+from beets.autotag import AlbumInfo, TrackInfo
+from beets.plugins import MetadataSourcePlugin, BeetsPlugin
+
+
+class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
+ data_source = 'Deezer'
+
+ # Base URLs for the Deezer API
+ # Documentation: https://developers.deezer.com/api/
+ search_url = 'https://api.deezer.com/search/'
+ album_url = 'https://api.deezer.com/album/'
+ track_url = 'https://api.deezer.com/track/'
+
+ id_regex = {
+ 'pattern': r'(^|deezer\.com/)([a-z]*/)?({}/)?(\d+)',
+ 'match_group': 4,
+ }
+
+ def __init__(self):
+ super(DeezerPlugin, self).__init__()
+
+ def album_for_id(self, album_id):
+ """Fetch an album by its Deezer ID or URL and return an
+ AlbumInfo object or None if the album is not found.
+
+ :param album_id: Deezer ID or URL for the album.
+ :type album_id: str
+ :return: AlbumInfo object for album.
+ :rtype: beets.autotag.hooks.AlbumInfo or None
+ """
+ deezer_id = self._get_id('album', album_id)
+ if deezer_id is None:
+ return None
+
+ album_data = requests.get(self.album_url + deezer_id).json()
+ artist, artist_id = self.get_artist(album_data['contributors'])
+
+ release_date = album_data['release_date']
+ date_parts = [int(part) for part in release_date.split('-')]
+ num_date_parts = len(date_parts)
+
+ if num_date_parts == 3:
+ year, month, day = date_parts
+ elif num_date_parts == 2:
+ year, month = date_parts
+ day = None
+ elif num_date_parts == 1:
+ year = date_parts[0]
+ month = None
+ day = None
+ else:
+ raise ui.UserError(
+ u"Invalid `release_date` returned "
+ u"by {} API: '{}'".format(self.data_source, release_date)
+ )
+
+ tracks_data = requests.get(
+ self.album_url + deezer_id + '/tracks'
+ ).json()['data']
+ if not tracks_data:
+ return None
+ tracks = []
+ medium_totals = collections.defaultdict(int)
+ for i, track_data in enumerate(tracks_data, start=1):
+ track = self._get_track(track_data)
+ track.index = i
+ medium_totals[track.medium] += 1
+ tracks.append(track)
+ for track in tracks:
+ track.medium_total = medium_totals[track.medium]
+
+ return AlbumInfo(
+ album=album_data['title'],
+ album_id=deezer_id,
+ artist=artist,
+ artist_credit=self.get_artist([album_data['artist']])[0],
+ artist_id=artist_id,
+ tracks=tracks,
+ albumtype=album_data['record_type'],
+ va=len(album_data['contributors']) == 1
+ and artist.lower() == 'various artists',
+ year=year,
+ month=month,
+ day=day,
+ label=album_data['label'],
+ mediums=max(medium_totals.keys()),
+ data_source=self.data_source,
+ data_url=album_data['link'],
+ )
+
+ def _get_track(self, track_data):
+ """Convert a Deezer track object dict to a TrackInfo object.
+
+ :param track_data: Deezer Track object dict
+ :type track_data: dict
+ :return: TrackInfo object for track
+ :rtype: beets.autotag.hooks.TrackInfo
+ """
+ artist, artist_id = self.get_artist(
+ track_data.get('contributors', [track_data['artist']])
+ )
+ return TrackInfo(
+ title=track_data['title'],
+ track_id=track_data['id'],
+ artist=artist,
+ artist_id=artist_id,
+ length=track_data['duration'],
+ index=track_data['track_position'],
+ medium=track_data['disk_number'],
+ medium_index=track_data['track_position'],
+ data_source=self.data_source,
+ data_url=track_data['link'],
+ )
+
+ def track_for_id(self, track_id=None, track_data=None):
+ """Fetch a track by its Deezer ID or URL and return a
+ TrackInfo object or None if the track is not found.
+
+ :param track_id: (Optional) Deezer ID or URL for the track. Either
+ ``track_id`` or ``track_data`` must be provided.
+ :type track_id: str
+ :param track_data: (Optional) Simplified track object dict. May be
+ provided instead of ``track_id`` to avoid unnecessary API calls.
+ :type track_data: dict
+ :return: TrackInfo object for track
+ :rtype: beets.autotag.hooks.TrackInfo or None
+ """
+ if track_data is None:
+ deezer_id = self._get_id('track', track_id)
+ if deezer_id is None:
+ return None
+ track_data = requests.get(self.track_url + deezer_id).json()
+ track = self._get_track(track_data)
+
+ # Get album's tracks to set `track.index` (position on the entire
+ # release) and `track.medium_total` (total number of tracks on
+ # the track's disc).
+ album_tracks_data = requests.get(
+ self.album_url + str(track_data['album']['id']) + '/tracks'
+ ).json()['data']
+ medium_total = 0
+ for i, track_data in enumerate(album_tracks_data, start=1):
+ if track_data['disk_number'] == track.medium:
+ medium_total += 1
+ if track_data['id'] == track.track_id:
+ track.index = i
+ track.medium_total = medium_total
+ return track
+
+ @staticmethod
+ def _construct_search_query(filters=None, keywords=''):
+ """Construct a query string with the specified filters and keywords to
+ be provided to the Deezer Search API
+ (https://developers.deezer.com/api/search).
+
+ :param filters: (Optional) Field filters to apply.
+ :type filters: dict
+ :param keywords: (Optional) Query keywords to use.
+ :type keywords: str
+ :return: Query string to be provided to the Search API.
+ :rtype: str
+ """
+ query_components = [
+ keywords,
+ ' '.join('{}:"{}"'.format(k, v) for k, v in filters.items()),
+ ]
+ query = ' '.join([q for q in query_components if q])
+ if not isinstance(query, six.text_type):
+ query = query.decode('utf8')
+ return unidecode.unidecode(query)
+
+ def _search_api(self, query_type, filters=None, keywords=''):
+ """Query the Deezer Search API for the specified ``keywords``, applying
+ the provided ``filters``.
+
+ :param query_type: The Deezer Search API method to use. Valid types
+ are: 'album', 'artist', 'history', 'playlist', 'podcast',
+ 'radio', 'track', 'user', and 'track'.
+ :type query_type: str
+ :param filters: (Optional) Field filters to apply.
+ :type filters: dict
+ :param keywords: (Optional) Query keywords to use.
+ :type keywords: str
+ :return: JSON data for the class:`Response ` object or None
+ if no search results are returned.
+ :rtype: dict or None
+ """
+ query = self._construct_search_query(
+ keywords=keywords, filters=filters
+ )
+ if not query:
+ return None
+ self._log.debug(
+ u"Searching {} for '{}'".format(self.data_source, query)
+ )
+ response = requests.get(
+ self.search_url + query_type, params={'q': query}
+ )
+ response.raise_for_status()
+ response_data = response.json().get('data', [])
+ self._log.debug(
+ u"Found {} result(s) from {} for '{}'",
+ len(response_data),
+ self.data_source,
+ query,
+ )
+ return response_data
diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py
index 68b4b5a95..3b2595585 100644
--- a/beetsplug/discogs.py
+++ b/beetsplug/discogs.py
@@ -20,8 +20,8 @@ from __future__ import division, absolute_import, print_function
import beets.ui
from beets import config
-from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
-from beets.plugins import BeetsPlugin
+from beets.autotag.hooks import AlbumInfo, TrackInfo
+from beets.plugins import MetadataSourcePlugin, BeetsPlugin, get_distance
import confuse
from discogs_client import Release, Master, Client
from discogs_client.exceptions import DiscogsAPIError
@@ -37,7 +37,9 @@ import traceback
from string import ascii_lowercase
-USER_AGENT = u'beets/{0} +http://beets.io/'.format(beets.__version__)
+USER_AGENT = u'beets/{0} +https://beets.io/'.format(beets.__version__)
+API_KEY = 'rAzVUQYRaoFjeBjyWuWZ'
+API_SECRET = 'plxtUTqoCzwxZpqdPysCwGuBSmZNdZVy'
# Exceptions that discogs_client should really handle but does not.
CONNECTION_ERRORS = (ConnectionError, socket.error, http_client.HTTPException,
@@ -50,11 +52,13 @@ class DiscogsPlugin(BeetsPlugin):
def __init__(self):
super(DiscogsPlugin, self).__init__()
self.config.add({
- 'apikey': 'rAzVUQYRaoFjeBjyWuWZ',
- 'apisecret': 'plxtUTqoCzwxZpqdPysCwGuBSmZNdZVy',
+ 'apikey': API_KEY,
+ 'apisecret': API_SECRET,
'tokenfile': 'discogs_token.json',
'source_weight': 0.5,
'user_token': '',
+ 'separator': u', ',
+ 'index_tracks': False,
})
self.config['apikey'].redact = True
self.config['apisecret'].redact = True
@@ -156,12 +160,22 @@ class DiscogsPlugin(BeetsPlugin):
def album_distance(self, items, album_info, mapping):
"""Returns the album distance.
"""
- dist = Distance()
- if album_info.data_source == 'Discogs':
- dist.add('source', self.config['source_weight'].as_number())
- return dist
+ return get_distance(
+ data_source='Discogs',
+ info=album_info,
+ config=self.config
+ )
- def candidates(self, items, artist, album, va_likely):
+ def track_distance(self, item, track_info):
+ """Returns the track distance.
+ """
+ return get_distance(
+ data_source='Discogs',
+ info=track_info,
+ config=self.config
+ )
+
+ 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).
"""
@@ -207,7 +221,8 @@ class DiscogsPlugin(BeetsPlugin):
getattr(result, 'title')
except DiscogsAPIError as e:
if e.status_code != 404:
- self._log.debug(u'API Error: {0} (query: {1})', e, result._uri)
+ self._log.debug(u'API Error: {0} (query: {1})', e,
+ result.data['resource_url'])
if e.status_code == 401:
self.reset_auth()
return self.album_for_id(album_id)
@@ -259,7 +274,8 @@ class DiscogsPlugin(BeetsPlugin):
return year
except DiscogsAPIError as e:
if e.status_code != 404:
- self._log.debug(u'API Error: {0} (query: {1})', e, result._uri)
+ self._log.debug(u'API Error: {0} (query: {1})', e,
+ result.data['resource_url'])
if e.status_code == 401:
self.reset_auth()
return self.get_master_year(master_id)
@@ -287,7 +303,9 @@ class DiscogsPlugin(BeetsPlugin):
self._log.warning(u"Release does not contain the required fields")
return None
- artist, artist_id = self.get_artist([a.data for a in result.artists])
+ artist, artist_id = MetadataSourcePlugin.get_artist(
+ [a.data for a in result.artists]
+ )
album = re.sub(r' +', ' ', result.title)
album_id = result.data['id']
# Use `.data` to access the tracklist directly instead of the
@@ -302,10 +320,13 @@ class DiscogsPlugin(BeetsPlugin):
mediums = [t.medium for t in tracks]
country = result.data.get('country')
data_url = result.data.get('uri')
+ style = self.format(result.data.get('styles'))
+ genre = self.format(result.data.get('genres'))
+ discogs_albumid = self.extract_release_id(result.data.get('uri'))
# Extract information for the optional AlbumInfo fields that are
# contained on nested discogs fields.
- albumtype = media = label = catalogno = None
+ albumtype = media = label = catalogno = labelid = None
if result.data.get('formats'):
albumtype = ', '.join(
result.data['formats'][0].get('descriptions', [])) or None
@@ -313,6 +334,7 @@ class DiscogsPlugin(BeetsPlugin):
if result.data.get('labels'):
label = result.data['labels'][0].get('name')
catalogno = result.data['labels'][0].get('catno')
+ labelid = result.data['labels'][0].get('id')
# Additional cleanups (various artists name, catalog number, media).
if va:
@@ -334,36 +356,29 @@ 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,
- country=country, 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)
+ 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,
+ media=media, original_year=original_year,
+ data_source='Discogs', data_url=data_url,
+ discogs_albumid=discogs_albumid,
+ discogs_labelid=labelid, discogs_artistid=artist_id)
- def get_artist(self, artists):
- """Returns an artist string (all artists) and an artist_id (the main
- artist) for a list of discogs album or track artists.
- """
- artist_id = None
- bits = []
- for i, artist in enumerate(artists):
- if not artist_id:
- artist_id = artist['id']
- name = artist['name']
- # Strip disambiguation number.
- name = re.sub(r' \(\d+\)$', '', name)
- # Move articles to the front.
- name = re.sub(r'(?i)^(.*?), (a|an|the)$', r'\2 \1', name)
- bits.append(name)
- if artist['join'] and i < len(artists) - 1:
- bits.append(artist['join'])
- artist = ' '.join(bits).replace(' ,', ',') or None
- return artist, artist_id
+ def format(self, classification):
+ if classification:
+ return self.config['separator'].as_str() \
+ .join(sorted(classification))
+ else:
+ return None
+
+ def extract_release_id(self, uri):
+ if uri:
+ return uri.split("/")[-1]
+ else:
+ return None
def get_tracks(self, tracklist):
"""Returns a list of TrackInfo objects for a discogs tracklist.
@@ -380,14 +395,28 @@ class DiscogsPlugin(BeetsPlugin):
tracks = []
index_tracks = {}
index = 0
+ # Distinct works and intra-work divisions, as defined by index tracks.
+ divisions, next_divisions = [], []
for track in clean_tracklist:
# Only real tracks have `position`. Otherwise, it's an index track.
if track['position']:
index += 1
- track_info = self.get_track_info(track, index)
+ if next_divisions:
+ # End of a block of index tracks: update the current
+ # divisions.
+ divisions += next_divisions
+ del next_divisions[:]
+ track_info = self.get_track_info(track, index, divisions)
track_info.track_alt = track['position']
tracks.append(track_info)
else:
+ next_divisions.append(track['title'])
+ # We expect new levels of division at the beginning of the
+ # tracklist (and possibly elsewhere).
+ try:
+ divisions.pop()
+ except IndexError:
+ pass
index_tracks[index + 1] = track['title']
# Fix up medium and medium_index for each track. Discogs position is
@@ -522,18 +551,22 @@ class DiscogsPlugin(BeetsPlugin):
return tracklist
- def get_track_info(self, track, index):
+ def get_track_info(self, track, index, divisions):
"""Returns a TrackInfo object for a discogs track.
"""
title = track['title']
+ if self.config['index_tracks']:
+ prefix = ', '.join(divisions)
+ title = ': '.join([prefix, title])
track_id = None
medium, medium_index, _ = self.get_track_index(track['position'])
- artist, artist_id = self.get_artist(track.get('artists', []))
+ artist, artist_id = MetadataSourcePlugin.get_artist(
+ track.get('artists', [])
+ )
length = self.get_track_length(track['duration'])
- return TrackInfo(title, track_id, artist=artist, artist_id=artist_id,
- length=length, index=index,
- medium=medium, medium_index=medium_index,
- artist_sort=None, disctitle=None, artist_credit=None)
+ return TrackInfo(title=title, track_id=track_id, artist=artist,
+ artist_id=artist_id, length=length, index=index,
+ medium=medium, medium_index=medium_index)
def get_track_index(self, position):
"""Returns the medium, medium index and subtrack index for a discogs
diff --git a/beetsplug/duplicates.py b/beetsplug/duplicates.py
index b316cfda6..4e6e540ea 100644
--- a/beetsplug/duplicates.py
+++ b/beetsplug/duplicates.py
@@ -205,7 +205,7 @@ class DuplicatesPlugin(BeetsPlugin):
u'computing checksum',
key, displayable_path(item.path))
try:
- checksum = command_output(args)
+ checksum = command_output(args).stdout
setattr(item, key, checksum)
item.store()
self._log.debug(u'computed checksum for {0} using {1}',
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 d783f5b93..8d98d0ba2 100644
--- a/beetsplug/export.py
+++ b/beetsplug/export.py
@@ -18,8 +18,10 @@
from __future__ import division, absolute_import, print_function
import sys
-import json
import codecs
+import json
+import csv
+from xml.etree import ElementTree
from datetime import datetime, date
from beets.plugins import BeetsPlugin
@@ -44,7 +46,7 @@ class ExportPlugin(BeetsPlugin):
self.config.add({
'default_format': 'json',
'json': {
- # json module formatting options
+ # JSON module formatting options.
'formatting': {
'ensure_ascii': False,
'indent': 4,
@@ -52,6 +54,19 @@ class ExportPlugin(BeetsPlugin):
'sort_keys': True
}
},
+ 'csv': {
+ # CSV module formatting options.
+ 'formatting': {
+ # The delimiter used to seperate columns.
+ 'delimiter': ',',
+ # The dialect to use when formating the file output.
+ 'dialect': 'excel'
+ }
+ },
+ 'xml': {
+ # XML module formatting options.
+ 'formatting': {}
+ }
# TODO: Use something like the edit plugin
# 'item_fields': []
})
@@ -78,17 +93,21 @@ class ExportPlugin(BeetsPlugin):
u'-o', u'--output',
help=u'path for the output file. If not given, will print the data'
)
+ cmd.parser.add_option(
+ u'-f', u'--format', default='json',
+ help=u"the output format: json (default), csv, or xml"
+ )
return [cmd]
def run(self, lib, opts, args):
-
file_path = opts.output
- file_format = self.config['default_format'].get(str)
file_mode = 'a' if opts.append else 'w'
+ file_format = opts.format or self.config['default_format'].get(str)
format_options = self.config[file_format]['formatting'].get(dict)
export_format = ExportFormat.factory(
- file_format, **{
+ file_type=file_format,
+ **{
'file_path': file_path,
'file_mode': file_mode
}
@@ -100,6 +119,7 @@ class ExportPlugin(BeetsPlugin):
included_keys = []
for keys in opts.included_keys:
included_keys.extend(keys.split(','))
+
key_filter = make_key_filter(included_keys)
for data_emitter in data_collector(lib, ui.decargs(args)):
@@ -117,35 +137,69 @@ class ExportPlugin(BeetsPlugin):
class ExportFormat(object):
"""The output format type"""
-
- @classmethod
- def factory(cls, type, **kwargs):
- if type == "json":
- if kwargs['file_path']:
- return JsonFileFormat(**kwargs)
- else:
- return JsonPrintFormat()
- raise NotImplementedError()
-
- def export(self, data, **kwargs):
- raise NotImplementedError()
-
-
-class JsonPrintFormat(ExportFormat):
- """Outputs to the console"""
-
- def export(self, data, **kwargs):
- json.dump(data, sys.stdout, cls=ExportEncoder, **kwargs)
-
-
-class JsonFileFormat(ExportFormat):
- """Saves in a json file"""
-
def __init__(self, file_path, file_mode=u'w', encoding=u'utf-8'):
self.path = file_path
self.mode = file_mode
self.encoding = encoding
+ # creates a file object to write/append or sets to stdout
+ self.out_stream = codecs.open(self.path, self.mode, self.encoding) \
+ if self.path else sys.stdout
+
+ @classmethod
+ def factory(cls, file_type, **kwargs):
+ if file_type == "json":
+ return JsonFormat(**kwargs)
+ elif file_type == "csv":
+ return CSVFormat(**kwargs)
+ elif file_type == "xml":
+ return XMLFormat(**kwargs)
+ else:
+ raise NotImplementedError()
def export(self, data, **kwargs):
- with codecs.open(self.path, self.mode, self.encoding) as f:
- json.dump(data, f, cls=ExportEncoder, **kwargs)
+ raise NotImplementedError()
+
+
+class JsonFormat(ExportFormat):
+ """Saves in a json file"""
+ def __init__(self, file_path, file_mode=u'w', encoding=u'utf-8'):
+ super(JsonFormat, self).__init__(file_path, file_mode, encoding)
+
+ def export(self, data, **kwargs):
+ json.dump(data, self.out_stream, cls=ExportEncoder, **kwargs)
+
+
+class CSVFormat(ExportFormat):
+ """Saves in a csv file"""
+ def __init__(self, file_path, file_mode=u'w', encoding=u'utf-8'):
+ super(CSVFormat, self).__init__(file_path, file_mode, encoding)
+
+ def export(self, data, **kwargs):
+ header = list(data[0].keys()) if data else []
+ writer = csv.DictWriter(self.out_stream, fieldnames=header, **kwargs)
+ writer.writeheader()
+ writer.writerows(data)
+
+
+class XMLFormat(ExportFormat):
+ """Saves in a xml file"""
+ def __init__(self, file_path, file_mode=u'w', encoding=u'utf-8'):
+ super(XMLFormat, self).__init__(file_path, file_mode, encoding)
+
+ def export(self, data, **kwargs):
+ # Creates the XML file structure.
+ 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 = ElementTree.SubElement(tracks, u'track')
+ for key, value in item.items():
+ track_details = ElementTree.SubElement(track, key)
+ track_details.text = value
+ # Depending on the version of python the encoding needs to change
+ try:
+ data = ElementTree.tostring(library, encoding='unicode', **kwargs)
+ except LookupError:
+ data = ElementTree.tostring(library, encoding='utf-8', **kwargs)
+
+ self.out_stream.write(data)
diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py
index af1aaa567..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,18 +318,39 @@ 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):
NAME = u"Amazon"
- URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg'
+ if util.SNI_SUPPORTED:
+ URL = 'https://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg'
+ else:
+ URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg'
INDICES = (1, 2)
def get(self, album, plugin, paths):
@@ -325,7 +364,10 @@ class Amazon(RemoteArtSource):
class AlbumArtOrg(RemoteArtSource):
NAME = u"AlbumArt.org scraper"
- URL = 'http://www.albumart.org/index_detail.php'
+ if util.SNI_SUPPORTED:
+ URL = 'https://www.albumart.org/index_detail.php'
+ else:
+ URL = 'http://www.albumart.org/index_detail.php'
PAT = r'href\s*=\s*"([^>"]*)"[^>]*title\s*=\s*"View larger image"'
def get(self, album, plugin, paths):
@@ -499,12 +541,18 @@ class ITunesStore(RemoteArtSource):
payload['term'])
return
+ if self._config['high_resolution']:
+ image_suffix = '100000x100000-999'
+ else:
+ image_suffix = '1200x1200bb'
+
for c in candidates:
try:
if (c['artistName'] == album.albumartist
and c['collectionName'] == album.album):
art_url = c['artworkUrl100']
- art_url = art_url.replace('100x100', '1200x1200')
+ art_url = art_url.replace('100x100bb',
+ image_suffix)
yield self._candidate(url=art_url,
match=Candidate.MATCH_EXACT)
except KeyError as e:
@@ -514,7 +562,8 @@ class ITunesStore(RemoteArtSource):
try:
fallback_art_url = candidates[0]['artworkUrl100']
- fallback_art_url = fallback_art_url.replace('100x100', '1200x1200')
+ fallback_art_url = fallback_art_url.replace('100x100bb',
+ image_suffix)
yield self._candidate(url=fallback_art_url,
match=Candidate.MATCH_FALLBACK)
except KeyError as e:
@@ -723,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,
@@ -738,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()}
@@ -759,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'],
@@ -767,13 +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(
@@ -809,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]
@@ -889,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):
@@ -903,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
@@ -926,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/hook.py b/beetsplug/hook.py
index ac0c4acad..ff3968a6a 100644
--- a/beetsplug/hook.py
+++ b/beetsplug/hook.py
@@ -105,7 +105,10 @@ class HookPlugin(BeetsPlugin):
u' '.join(command_pieces), event)
try:
- subprocess.Popen(command_pieces).wait()
+ subprocess.check_call(command_pieces)
+ except subprocess.CalledProcessError as exc:
+ self._log.error(u'hook for {0} exited with status {1}',
+ event, exc.returncode)
except OSError as exc:
self._log.error(u'hook for {0} failed: {1}', event, exc)
diff --git a/beetsplug/importadded.py b/beetsplug/importadded.py
index 36407b14c..29aeeab0f 100644
--- a/beetsplug/importadded.py
+++ b/beetsplug/importadded.py
@@ -124,7 +124,7 @@ class ImportAddedPlugin(BeetsPlugin):
util.displayable_path(item.path), item.added)
item.store()
- def update_after_write_time(self, item):
+ def update_after_write_time(self, item, path):
"""Update the mtime of the item's file with the item.added value
after each write of the item if `preserve_write_mtimes` is enabled.
"""
diff --git a/beetsplug/ipfs.py b/beetsplug/ipfs.py
index 90ba5fdd0..40a17d756 100644
--- a/beetsplug/ipfs.py
+++ b/beetsplug/ipfs.py
@@ -123,7 +123,7 @@ class IPFSPlugin(BeetsPlugin):
cmd = "ipfs add -q -r".split()
cmd.append(album_dir)
try:
- output = util.command_output(cmd).split()
+ output = util.command_output(cmd).stdout.split()
except (OSError, subprocess.CalledProcessError) as exc:
self._log.error(u'Failed to add {0}, error: {1}', album_dir, exc)
return False
@@ -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:
@@ -183,7 +185,7 @@ class IPFSPlugin(BeetsPlugin):
else:
cmd = "ipfs add -q ".split()
cmd.append(tmp.name)
- output = util.command_output(cmd)
+ output = util.command_output(cmd).stdout
except (OSError, subprocess.CalledProcessError) as err:
msg = "Failed to publish library. Error: {0}".format(err)
self._log.error(msg)
@@ -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/keyfinder.py b/beetsplug/keyfinder.py
index a3fbc8211..a75b8d972 100644
--- a/beetsplug/keyfinder.py
+++ b/beetsplug/keyfinder.py
@@ -18,6 +18,7 @@
from __future__ import division, absolute_import, print_function
+import os.path
import subprocess
from beets import ui
@@ -52,21 +53,25 @@ class KeyFinderPlugin(BeetsPlugin):
def find_key(self, items, write=False):
overwrite = self.config['overwrite'].get(bool)
- bin = self.config['bin'].as_str()
+ command = [self.config['bin'].as_str()]
+ # The KeyFinder GUI program needs the -f flag before the path.
+ # keyfinder-cli is similar, but just wants the path with no flag.
+ if 'keyfinder-cli' not in os.path.basename(command[0]).lower():
+ command.append('-f')
for item in items:
if item['initial_key'] and not overwrite:
continue
try:
- output = util.command_output([bin, '-f',
- util.syspath(item.path)])
+ output = util.command_output(command + [util.syspath(
+ item.path)]).stdout
except (subprocess.CalledProcessError, OSError) as exc:
self._log.error(u'execution failed: {0}', exc)
continue
except UnicodeEncodeError:
# Workaround for Python 2 Windows bug.
- # http://bugs.python.org/issue1759845
+ # https://bugs.python.org/issue1759845
self._log.error(u'execution failed for Unicode path: {0!r}',
item.path)
continue
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/lastimport.py b/beetsplug/lastimport.py
index d7b84b0aa..ca97004cf 100644
--- a/beetsplug/lastimport.py
+++ b/beetsplug/lastimport.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
-# Copyright 2016, Rafael Bodill http://github.com/rafi
+# Copyright 2016, Rafael Bodill https://github.com/rafi
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py
index 9e44eeef6..16696d425 100644
--- a/beetsplug/lyrics.py
+++ b/beetsplug/lyrics.py
@@ -55,6 +55,7 @@ except ImportError:
from beets import plugins
from beets import ui
+from beets import util
import beets
DIV_RE = re.compile(r'<(/?)div>?', re.I)
@@ -186,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):
@@ -199,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
@@ -351,62 +359,95 @@ 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):
"""Fetch lyrics from LyricsWiki."""
- URL_PATTERN = 'http://lyrics.wikia.com/%s:%s'
+ if util.SNI_SUPPORTED:
+ URL_PATTERN = 'https://lyrics.wikia.com/%s:%s'
+ else:
+ URL_PATTERN = 'http://lyrics.wikia.com/%s:%s'
def fetch(self, artist, title):
url = self.build_url(artist, title)
@@ -522,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,
@@ -740,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,
@@ -750,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)
@@ -765,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 == '':
@@ -796,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
@@ -877,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()
@@ -895,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/mbsubmit.py b/beetsplug/mbsubmit.py
index 02bd5f697..44a476d15 100644
--- a/beetsplug/mbsubmit.py
+++ b/beetsplug/mbsubmit.py
@@ -19,7 +19,7 @@ This plugin allows the user to print track information in a format that is
parseable by the MusicBrainz track parser [1]. Programmatic submitting is not
implemented by MusicBrainz yet.
-[1] http://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings
+[1] https://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings
"""
from __future__ import division, absolute_import, print_function
diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py
index b8121d9c9..a2b3bc4aa 100644
--- a/beetsplug/mbsync.py
+++ b/beetsplug/mbsync.py
@@ -17,7 +17,7 @@
"""
from __future__ import division, absolute_import, print_function
-from beets.plugins import BeetsPlugin
+from beets.plugins import BeetsPlugin, apply_item_changes
from beets import autotag, library, ui, util
from beets.autotag import hooks
from collections import defaultdict
@@ -27,19 +27,6 @@ import re
MBID_REGEX = r"(\d|\w){8}-(\d|\w){4}-(\d|\w){4}-(\d|\w){4}-(\d|\w){12}"
-def apply_item_changes(lib, item, move, pretend, write):
- """Store, move and write the item according to the arguments.
- """
- if not pretend:
- # Move the item if it's in the library.
- if move and lib.directory in util.ancestry(item.path):
- item.move(with_album=False)
-
- if write:
- item.try_write()
- item.store()
-
-
class MBSyncPlugin(BeetsPlugin):
def __init__(self):
super(MBSyncPlugin, self).__init__()
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/parentwork.py b/beetsplug/parentwork.py
new file mode 100644
index 000000000..b3e464e60
--- /dev/null
+++ b/beetsplug/parentwork.py
@@ -0,0 +1,212 @@
+# -*- coding: utf-8 -*-
+# This file is part of beets.
+# Copyright 2017, Dorian Soergel.
+#
+# 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.
+
+"""Gets parent work, its disambiguation and id, composer, composer sort name
+and work composition date
+"""
+
+from __future__ import division, absolute_import, print_function
+
+from beets import ui
+from beets.plugins import BeetsPlugin
+
+import musicbrainzngs
+
+
+def direct_parent_id(mb_workid, work_date=None):
+ """Given a Musicbrainz work id, find the id one of the works the work is
+ part of and the first composition date it encounters.
+ """
+ work_info = musicbrainzngs.get_work_by_id(mb_workid,
+ includes=["work-rels",
+ "artist-rels"])
+ if 'artist-relation-list' in work_info['work'] and work_date is None:
+ for artist in work_info['work']['artist-relation-list']:
+ if artist['type'] == 'composer':
+ if 'end' in artist.keys():
+ work_date = artist['end']
+
+ if 'work-relation-list' in work_info['work']:
+ for direct_parent in work_info['work']['work-relation-list']:
+ if direct_parent['type'] == 'parts' \
+ and direct_parent.get('direction') == 'backward':
+ direct_id = direct_parent['work']['id']
+ return direct_id, work_date
+ return None, work_date
+
+
+def work_parent_id(mb_workid):
+ """Find the parent work id and composition date of a work given its id.
+ """
+ work_date = None
+ while True:
+ new_mb_workid, work_date = direct_parent_id(mb_workid, work_date)
+ if not new_mb_workid:
+ return mb_workid, work_date
+ mb_workid = new_mb_workid
+ return mb_workid, work_date
+
+
+def find_parentwork_info(mb_workid):
+ """Get the MusicBrainz information dict about a parent work, including
+ the artist relations, and the composition date for a work's parent work.
+ """
+ parent_id, work_date = work_parent_id(mb_workid)
+ work_info = musicbrainzngs.get_work_by_id(parent_id,
+ includes=["artist-rels"])
+ return work_info, work_date
+
+
+class ParentWorkPlugin(BeetsPlugin):
+ def __init__(self):
+ super(ParentWorkPlugin, self).__init__()
+
+ self.config.add({
+ 'auto': False,
+ 'force': False,
+ })
+
+ if self.config['auto']:
+ self.import_stages = [self.imported]
+
+ def commands(self):
+
+ def func(lib, opts, args):
+ self.config.set_args(opts)
+ force_parent = self.config['force'].get(bool)
+ write = ui.should_write()
+
+ for item in lib.items(ui.decargs(args)):
+ 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')
+
+ command.parser.add_option(
+ u'-f', u'--force', dest='force',
+ action='store_true', default=None,
+ help=u're-fetch when parent work is already present')
+
+ command.func = func
+ return [command]
+
+ def imported(self, session, task):
+ """Import hook for fetching parent works automatically.
+ """
+ force_parent = self.config['force'].get(bool)
+
+ for item in task.imported_items():
+ self.find_work(item, force_parent)
+ item.store()
+
+ def get_info(self, item, work_info):
+ """Given the parent work info dict, fetch parent_composer,
+ parent_composer_sort, parentwork, parentwork_disambig, mb_workid and
+ composer_ids.
+ """
+
+ parent_composer = []
+ parent_composer_sort = []
+ parentwork_info = {}
+
+ composer_exists = False
+ if 'artist-relation-list' in work_info['work']:
+ for artist in work_info['work']['artist-relation-list']:
+ 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(
+ parent_composer_sort)
+
+ if not composer_exists:
+ self._log.debug(
+ 'no composer for {}; add one at '
+ 'https://musicbrainz.org/work/{}',
+ item, work_info['work']['id'],
+ )
+
+ parentwork_info['parentwork'] = work_info['work']['title']
+ parentwork_info['mb_parentworkid'] = work_info['work']['id']
+
+ if 'disambiguation' in work_info['work']:
+ parentwork_info['parentwork_disambig'] = work_info[
+ 'work']['disambiguation']
+
+ else:
+ parentwork_info['parentwork_disambig'] = None
+
+ return parentwork_info
+
+ def find_work(self, item, force):
+ """Finds the parent work of a recording and populates the tags
+ accordingly.
+
+ The parent work is found recursively, by finding the direct parent
+ repeatedly until there are no more links in the chain. We return the
+ final, topmost work in the chain.
+
+ Namely, the tags parentwork, parentwork_disambig, mb_parentworkid,
+ parent_composer, parent_composer_sort and work_date are populated.
+ """
+
+ if not item.mb_workid:
+ self._log.info('No work for {}, \
+add one at https://musicbrainz.org/recording/{}', item, item.mb_trackid)
+ return
+
+ hasparent = hasattr(item, 'parentwork')
+ 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'],
+ parent_info['parent_composer'])
+ else:
+ self._log.debug("Work fetched: {} - no parent composer",
+ parent_info['parentwork'])
+
+ elif hasparent:
+ self._log.debug("{}: Work present, skipping", item)
+ return
+
+ # apply all non-null values to the item
+ for key, value in parent_info.items():
+ if value:
+ item[key] = value
+
+ if work_date:
+ item['work_date'] = work_date
+ return ui.show_model_changes(
+ item, fields=['parentwork', 'parentwork_disambig',
+ 'mb_parentworkid', 'parent_composer',
+ 'parent_composer_sort', 'work_date',
+ 'parentwork_workid_current', 'parentwork_date'])
diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py
index 4ab02c6b7..3f8ba2b0e 100644
--- a/beetsplug/playlist.py
+++ b/beetsplug/playlist.py
@@ -18,6 +18,7 @@ import os
import fnmatch
import tempfile
import beets
+from beets.util import path_as_posix
class PlaylistQuery(beets.dbcore.Query):
@@ -70,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):
@@ -86,6 +87,7 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin):
'auto': False,
'playlist_dir': '.',
'relative_to': 'library',
+ 'forward_slash': False,
})
self.playlist_dir = self.config['playlist_dir'].as_filename()
@@ -160,6 +162,8 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin):
try:
new_path = self.changes[beets.util.normpath(lookup)]
except KeyError:
+ if self.config['forward_slash']:
+ line = path_as_posix(line)
tempfp.write(line)
else:
if new_path is None:
@@ -170,8 +174,10 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin):
changes += 1
if is_relative:
new_path = os.path.relpath(new_path, base_dir)
-
- tempfp.write(line.replace(original_path, new_path))
+ line = line.replace(original_path, new_path)
+ if self.config['forward_slash']:
+ line = path_as_posix(line)
+ tempfp.write(line)
if changes or deletions:
self._log.info(
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 4a0ea064f..0ba04783d 100644
--- a/beetsplug/replaygain.py
+++ b/beetsplug/replaygain.py
@@ -18,8 +18,11 @@ from __future__ import division, absolute_import, print_function
import subprocess
import os
import collections
+import math
import sys
import warnings
+import enum
+import re
import xml.parsers.expat
from six.moves import zip
@@ -47,12 +50,12 @@ class FatalGstreamerPluginReplayGainError(FatalReplayGainError):
loading the required plugins."""
-def call(args):
+def call(args, **kwargs):
"""Execute the command and return its output or raise a
ReplayGainError on failure.
"""
try:
- return command_output(args)
+ return command_output(args, **kwargs)
except subprocess.CalledProcessError as e:
raise ReplayGainError(
u"{0} exited with status {1}".format(args[0], e.returncode)
@@ -64,12 +67,45 @@ def call(args):
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.
+
+ According to https://wiki.hydrogenaud.io/index.php?title=
+ ReplayGain_2.0_specification#Reference_level
+ """
+ return db - 107
+
+
+def lufs_to_db(db):
+ """Convert LUFS to db.
+
+ According to https://wiki.hydrogenaud.io/index.php?title=
+ ReplayGain_2.0_specification#Reference_level
+ """
+ return db + 107
+
+
# Backend base and plumbing classes.
+# gain: in LU to reference level
+# peak: part of full scale (FS is 1.0)
Gain = collections.namedtuple("Gain", "gain peak")
+# album_gain: Gain object
+# track_gains: list of Gain objects
AlbumGain = collections.namedtuple("AlbumGain", "album_gain track_gains")
+class Peak(enum.Enum):
+ none = 0
+ true = 1
+ sample = 2
+
+
class Backend(object):
"""An abstract class representing engine for calculating RG values.
"""
@@ -80,12 +116,16 @@ class Backend(object):
"""
self._log = log
- def compute_track_gain(self, items):
+ def compute_track_gain(self, items, target_level, peak):
+ """Computes the track gain of the given tracks, returns a list
+ of Gain objects.
+ """
raise NotImplementedError()
- def compute_album_gain(self, album):
- # TODO: implement album gain in terms of track gain of the
- # individual tracks which can be used for any backend.
+ def compute_album_gain(self, items, target_level, peak):
+ """Computes the album gain of the given album, returns an
+ AlbumGain object.
+ """
raise NotImplementedError()
@@ -95,45 +135,55 @@ class Bs1770gainBackend(Backend):
its flavors EBU R128, ATSC A/85 and Replaygain 2.0.
"""
+ methods = {
+ -24: "atsc",
+ -23: "ebu",
+ -18: "replaygain",
+ }
+
def __init__(self, config, log):
super(Bs1770gainBackend, self).__init__(config, log)
config.add({
'chunk_at': 5000,
- 'method': 'replaygain',
+ 'method': '',
})
self.chunk_at = config['chunk_at'].as_number()
- self.method = '--' + config['method'].as_str()
+ # backward compatibility to `method` config option
+ self.__method = config['method'].as_str()
cmd = 'bs1770gain'
try:
- call([cmd, self.method])
+ 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? Is your method in config correct?'
+ u'Is bs1770gain installed?'
)
if not self.command:
raise FatalReplayGainError(
u'no replaygain command found: install bs1770gain'
)
- def compute_track_gain(self, items):
+ def compute_track_gain(self, items, target_level, peak):
"""Computes the track gain of the given tracks, returns a list
of TrackGain objects.
"""
- output = self.compute_gain(items, False)
+ output = self.compute_gain(items, target_level, False)
return output
- def compute_album_gain(self, album):
+ def compute_album_gain(self, items, target_level, peak):
"""Computes the album gain of the given album, returns an
AlbumGain object.
"""
# TODO: What should be done when not all tracks in the album are
# supported?
- supported_items = album.items()
- output = self.compute_gain(supported_items, True)
+ output = self.compute_gain(items, target_level, True)
if not output:
raise ReplayGainError(u'no output from bs1770gain')
@@ -158,7 +208,7 @@ class Bs1770gainBackend(Backend):
else:
break
- def compute_gain(self, items, is_album):
+ def compute_gain(self, items, target_level, is_album):
"""Computes the track or album gain of a list of items, returns
a list of TrackGain objects.
When computing album gain, the last TrackGain object returned is
@@ -179,23 +229,45 @@ class Bs1770gainBackend(Backend):
i = 0
for chunk in self.isplitter(items, self.chunk_at):
i += 1
- returnchunk = self.compute_chunk_gain(chunk, is_album)
+ returnchunk = self.compute_chunk_gain(
+ chunk,
+ is_album,
+ target_level
+ )
albumgaintot += returnchunk[-1].gain
albumpeaktot = max(albumpeaktot, returnchunk[-1].peak)
returnchunks = returnchunks + returnchunk[0:-1]
returnchunks.append(Gain(albumgaintot / i, albumpeaktot))
return returnchunks
else:
- return self.compute_chunk_gain(items, is_album)
+ return self.compute_chunk_gain(items, is_album, target_level)
- def compute_chunk_gain(self, items, is_album):
+ def compute_chunk_gain(self, items, is_album, target_level):
"""Compute ReplayGain values and return a list of results
dictionaries as given by `parse_tool_output`.
"""
+ # choose method
+ target_level = db_to_lufs(target_level)
+ if self.__method != "":
+ # backward compatibility to `method` option
+ method = self.__method
+ gain_adjustment = target_level \
+ - [k for k, v in self.methods.items() if v == method][0]
+ elif target_level in self.methods:
+ method = self.methods[target_level]
+ gain_adjustment = 0
+ else:
+ lufs_target = -23
+ method = self.methods[lufs_target]
+ gain_adjustment = target_level - lufs_target
+
# Construct shell command.
cmd = [self.command]
- cmd += [self.method]
+ 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
@@ -207,10 +279,17 @@ class Bs1770gainBackend(Backend):
self._log.debug(
u'executing {0}', u' '.join(map(displayable_path, args))
)
- output = call(args)
+ output = call(args).stdout
self._log.debug(u'analysis finished: {0}', output)
results = self.parse_tool_output(output, path_list, is_album)
+
+ if gain_adjustment:
+ results = [
+ Gain(res.gain + gain_adjustment, res.peak)
+ for res in results
+ ]
+
self._log.debug(u'{0} items, {1} results', len(items), len(results))
return results
@@ -223,6 +302,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':
@@ -231,9 +311,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':
@@ -249,6 +333,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
@@ -279,6 +374,250 @@ class Bs1770gainBackend(Backend):
return out
+# ffmpeg backend
+class FfmpegBackend(Backend):
+ """A replaygain backend using ffmpeg's ebur128 filter.
+ """
+ def __init__(self, config, log):
+ super(FfmpegBackend, self).__init__(config, log)
+ self._ffmpeg_path = "ffmpeg"
+
+ # check that ffmpeg is installed
+ try:
+ ffmpeg_version_out = call([self._ffmpeg_path, "-version"])
+ except OSError:
+ raise FatalReplayGainError(
+ u"could not find ffmpeg at {0}".format(self._ffmpeg_path)
+ )
+ incompatible_ffmpeg = True
+ for line in ffmpeg_version_out.stdout.splitlines():
+ if line.startswith(b"configuration:"):
+ if b"--enable-libebur128" in line:
+ incompatible_ffmpeg = False
+ if line.startswith(b"libavfilter"):
+ version = line.split(b" ", 1)[1].split(b"/", 1)[0].split(b".")
+ version = tuple(map(int, version))
+ if version >= (6, 67, 100):
+ incompatible_ffmpeg = False
+ if incompatible_ffmpeg:
+ raise FatalReplayGainError(
+ u"Installed FFmpeg version does not support ReplayGain."
+ u"calculation. Either libavfilter version 6.67.100 or above or"
+ u"the --enable-libebur128 configuration option is required."
+ )
+
+ def compute_track_gain(self, items, target_level, peak):
+ """Computes the track gain of the given tracks, returns a list
+ of Gain objects (the track gains).
+ """
+ gains = []
+ for item in items:
+ gains.append(
+ self._analyse_item(
+ item,
+ target_level,
+ peak,
+ count_blocks=False,
+ )[0] # take only the gain, discarding number of gating blocks
+ )
+ return gains
+
+ def compute_album_gain(self, items, target_level, peak):
+ """Computes the album gain of the given album, returns an
+ AlbumGain object.
+ """
+ target_level_lufs = db_to_lufs(target_level)
+
+ # analyse tracks
+ # list of track Gain objects
+ track_gains = []
+ # maximum peak
+ album_peak = 0
+ # sum of BS.1770 gating block powers
+ sum_powers = 0
+ # total number of BS.1770 gating blocks
+ n_blocks = 0
+
+ for item in items:
+ track_gain, track_n_blocks = self._analyse_item(
+ item, target_level, peak
+ )
+ track_gains.append(track_gain)
+
+ # album peak is maximum track peak
+ album_peak = max(album_peak, track_gain.peak)
+
+ # prepare album_gain calculation
+ # total number of blocks is sum of track blocks
+ n_blocks += track_n_blocks
+
+ # convert `LU to target_level` -> LUFS
+ track_loudness = target_level_lufs - track_gain.gain
+ # This reverses ITU-R BS.1770-4 p. 6 equation (5) to convert
+ # from loudness to power. The result is the average gating
+ # block power.
+ track_power = 10**((track_loudness + 0.691) / 10)
+
+ # Weight that average power by the number of gating blocks to
+ # get the sum of all their powers. Add that to the sum of all
+ # block powers in this album.
+ sum_powers += track_power * track_n_blocks
+
+ # calculate album gain
+ if n_blocks > 0:
+ # compare ITU-R BS.1770-4 p. 6 equation (5)
+ # Album gain is the replaygain of the concatenation of all tracks.
+ album_gain = -0.691 + 10 * math.log10(sum_powers / n_blocks)
+ else:
+ album_gain = -70
+ # convert LUFS -> `LU to target_level`
+ album_gain = target_level_lufs - album_gain
+
+ self._log.debug(
+ u"{0}: gain {1} LU, peak {2}"
+ .format(items, album_gain, album_peak)
+ )
+
+ return AlbumGain(Gain(album_gain, album_peak), track_gains)
+
+ def _construct_cmd(self, item, peak_method):
+ """Construct the shell command to analyse items."""
+ return [
+ self._ffmpeg_path,
+ "-nostats",
+ "-hide_banner",
+ "-i",
+ item.path,
+ "-map",
+ "a:0",
+ "-filter",
+ "ebur128=peak={0}".format(peak_method),
+ "-f",
+ "null",
+ "-",
+ ]
+
+ def _analyse_item(self, item, target_level, peak, count_blocks=True):
+ """Analyse item. Return a pair of a Gain object and the number
+ of gating blocks above the threshold.
+
+ If `count_blocks` is False, the number of gating blocks returned
+ will be 0.
+ """
+ target_level_lufs = db_to_lufs(target_level)
+ peak_method = peak.name
+
+ # call ffmpeg
+ self._log.debug(u"analyzing {0}".format(item))
+ cmd = self._construct_cmd(item, peak_method)
+ self._log.debug(
+ u'executing {0}', u' '.join(map(displayable_path, cmd))
+ )
+ output = call(cmd).stderr.splitlines()
+
+ # parse output
+
+ if peak == Peak.none:
+ peak = 0
+ else:
+ line_peak = self._find_line(
+ output,
+ " {0} peak:".format(peak_method.capitalize()).encode(),
+ start_line=len(output) - 1, step_size=-1,
+ )
+ peak = self._parse_float(
+ output[self._find_line(
+ output, b" Peak:",
+ line_peak,
+ )]
+ )
+ # convert TPFS -> part of FS
+ peak = 10**(peak / 20)
+
+ line_integrated_loudness = self._find_line(
+ output, b" Integrated loudness:",
+ start_line=len(output) - 1, step_size=-1,
+ )
+ gain = self._parse_float(
+ output[self._find_line(
+ output, b" I:",
+ line_integrated_loudness,
+ )]
+ )
+ # convert LUFS -> LU from target level
+ gain = target_level_lufs - gain
+
+ # count BS.1770 gating blocks
+ n_blocks = 0
+ if count_blocks:
+ gating_threshold = self._parse_float(
+ output[self._find_line(
+ output, b" Threshold:",
+ start_line=line_integrated_loudness,
+ )]
+ )
+ for line in output:
+ if not line.startswith(b"[Parsed_ebur128"):
+ continue
+ if line.endswith(b"Summary:"):
+ continue
+ line = line.split(b"M:", 1)
+ if len(line) < 2:
+ continue
+ if self._parse_float(b"M: " + line[1]) >= gating_threshold:
+ n_blocks += 1
+ self._log.debug(
+ u"{0}: {1} blocks over {2} LUFS"
+ .format(item, n_blocks, gating_threshold)
+ )
+
+ self._log.debug(
+ u"{0}: gain {1} LU, peak {2}"
+ .format(item, gain, peak)
+ )
+
+ return Gain(gain, peak), n_blocks
+
+ def _find_line(self, output, search, start_line=0, step_size=1):
+ """Return index of line beginning with `search`.
+
+ Begins searching at index `start_line` in `output`.
+ """
+ end_index = len(output) if step_size > 0 else -1
+ for i in range(start_line, end_index, step_size):
+ if output[i].startswith(search):
+ return i
+ raise ReplayGainError(
+ u"ffmpeg output: missing {0} after line {1}"
+ .format(repr(search), start_line)
+ )
+
+ def _parse_float(self, line):
+ """Extract a float from a key value pair in `line`.
+
+ This format is expected: /[^:]:[[:space:]]*value.*/, where `value` is
+ the float.
+ """
+ # extract value
+ value = line.split(b":", 1)
+ if len(value) < 2:
+ raise ReplayGainError(
+ u"ffmpeg output: expected key value pair, found {0}"
+ .format(line)
+ )
+ value = value[1].lstrip()
+ # strip unit
+ value = value.split(b" ", 1)[0]
+ # cast value to float
+ try:
+ return float(value)
+ except ValueError:
+ raise ReplayGainError(
+ u"ffmpeg output: expected float value, found {0}"
+ .format(value)
+ )
+
+
# mpgain/aacgain CLI tool backend.
class CommandBackend(Backend):
@@ -312,30 +651,28 @@ class CommandBackend(Backend):
)
self.noclip = config['noclip'].get(bool)
- target_level = config['targetlevel'].as_number()
- self.gain_offset = int(target_level - 89)
- def compute_track_gain(self, items):
+ def compute_track_gain(self, items, target_level, peak):
"""Computes the track gain of the given tracks, returns a list
of TrackGain objects.
"""
supported_items = list(filter(self.format_supported, items))
- output = self.compute_gain(supported_items, False)
+ output = self.compute_gain(supported_items, target_level, False)
return output
- def compute_album_gain(self, album):
+ def compute_album_gain(self, items, target_level, peak):
"""Computes the album gain of the given album, returns an
AlbumGain object.
"""
# TODO: What should be done when not all tracks in the album are
# supported?
- supported_items = list(filter(self.format_supported, album.items()))
- if len(supported_items) != len(album.items()):
+ supported_items = list(filter(self.format_supported, items))
+ if len(supported_items) != len(items):
self._log.debug(u'tracks are of unsupported format')
return AlbumGain(None, [])
- output = self.compute_gain(supported_items, True)
+ output = self.compute_gain(supported_items, target_level, True)
return AlbumGain(output[-1], output[:-1])
def format_supported(self, item):
@@ -347,7 +684,7 @@ class CommandBackend(Backend):
return False
return True
- def compute_gain(self, items, is_album):
+ def compute_gain(self, items, target_level, is_album):
"""Computes the track or album gain of a list of items, returns
a list of TrackGain objects.
@@ -374,12 +711,12 @@ class CommandBackend(Backend):
else:
# Disable clipping warning.
cmd = cmd + ['-c']
- cmd = cmd + ['-d', str(self.gain_offset)]
+ cmd = cmd + ['-d', str(int(target_level - 89))]
cmd = cmd + [syspath(i.path) for i in items]
self._log.debug(u'analyzing {0} files', len(items))
self._log.debug(u"executing {0}", " ".join(map(displayable_path, cmd)))
- output = call(cmd)
+ output = call(cmd).stdout
self._log.debug(u'analysis finished')
return self.parse_tool_output(output,
len(items) + (1 if is_album else 0))
@@ -437,8 +774,6 @@ class GStreamerBackend(Backend):
# to rganalsys should have their gain computed, even if it
# already exists.
self._rg.set_property("forced", True)
- self._rg.set_property("reference-level",
- config["targetlevel"].as_number())
self._sink = self.Gst.ElementFactory.make("fakesink", "sink")
self._pipe = self.Gst.Pipeline()
@@ -499,7 +834,7 @@ class GStreamerBackend(Backend):
self.GLib = GLib
self.Gst = Gst
- def compute(self, files, album):
+ def compute(self, files, target_level, album):
self._error = None
self._files = list(files)
@@ -508,6 +843,8 @@ class GStreamerBackend(Backend):
self._file_tags = collections.defaultdict(dict)
+ self._rg.set_property("reference-level", target_level)
+
if album:
self._rg.set_property("num-tracks", len(self._files))
@@ -516,8 +853,8 @@ class GStreamerBackend(Backend):
if self._error is not None:
raise self._error
- def compute_track_gain(self, items):
- self.compute(items, False)
+ def compute_track_gain(self, items, target_level, peak):
+ self.compute(items, target_level, False)
if len(self._file_tags) != len(items):
raise ReplayGainError(u"Some tracks did not receive tags")
@@ -528,9 +865,9 @@ class GStreamerBackend(Backend):
return ret
- def compute_album_gain(self, album):
- items = list(album.items())
- self.compute(items, True)
+ def compute_album_gain(self, items, target_level, peak):
+ items = list(items)
+ self.compute(items, target_level, True)
if len(self._file_tags) != len(items):
raise ReplayGainError(u"Some items in album did not receive tags")
@@ -714,7 +1051,7 @@ class AudioToolsBackend(Backend):
file format is not supported
"""
try:
- audiofile = self._mod_audiotools.open(item.path)
+ audiofile = self._mod_audiotools.open(py3_path(syspath(item.path)))
except IOError:
raise ReplayGainError(
u"File {} was not found".format(item.path)
@@ -744,14 +1081,21 @@ class AudioToolsBackend(Backend):
return
return rg
- def compute_track_gain(self, items):
+ def compute_track_gain(self, items, target_level, peak):
"""Compute ReplayGain values for the requested items.
:return list: list of :class:`Gain` objects
"""
- return [self._compute_track_gain(item) for item in items]
+ return [self._compute_track_gain(item, target_level) for item in items]
- def _title_gain(self, rg, audiofile):
+ def _with_target_level(self, gain, target_level):
+ """Return `gain` relative to `target_level`.
+
+ Assumes `gain` is relative to 89 db.
+ """
+ return gain + (target_level - 89)
+
+ def _title_gain(self, rg, audiofile, target_level):
"""Get the gain result pair from PyAudioTools using the `ReplayGain`
instance `rg` for the given `audiofile`.
@@ -761,14 +1105,15 @@ class AudioToolsBackend(Backend):
try:
# The method needs an audiotools.PCMReader instance that can
# be obtained from an audiofile instance.
- return rg.title_gain(audiofile.to_pcm())
+ gain, peak = rg.title_gain(audiofile.to_pcm())
except ValueError as exc:
# `audiotools.replaygain` can raise a `ValueError` if the sample
# rate is incorrect.
self._log.debug(u'error in rg.title_gain() call: {}', exc)
raise ReplayGainError(u'audiotools audio data error')
+ return self._with_target_level(gain, target_level), peak
- def _compute_track_gain(self, item):
+ def _compute_track_gain(self, item, target_level):
"""Compute ReplayGain value for the requested item.
:rtype: :class:`Gain`
@@ -778,30 +1123,32 @@ class AudioToolsBackend(Backend):
# Each call to title_gain on a ReplayGain object returns peak and gain
# of the track.
- rg_track_gain, rg_track_peak = self._title_gain(rg, audiofile)
+ rg_track_gain, rg_track_peak = self._title_gain(
+ rg, audiofile, target_level
+ )
self._log.debug(u'ReplayGain for track {0} - {1}: {2:.2f}, {3:.2f}',
item.artist, item.title, rg_track_gain, rg_track_peak)
return Gain(gain=rg_track_gain, peak=rg_track_peak)
- def compute_album_gain(self, album):
+ def compute_album_gain(self, items, target_level, peak):
"""Compute ReplayGain values for the requested album and its items.
:rtype: :class:`AlbumGain`
"""
- self._log.debug(u'Analysing album {0}', album)
-
# The first item is taken and opened to get the sample rate to
# initialize the replaygain object. The object is used for all the
# tracks in the album to get the album values.
- item = list(album.items())[0]
+ item = list(items)[0]
audiofile = self.open_audio_file(item)
rg = self.init_replaygain(audiofile, item)
track_gains = []
- for item in album.items():
+ for item in items:
audiofile = self.open_audio_file(item)
- rg_track_gain, rg_track_peak = self._title_gain(rg, audiofile)
+ rg_track_gain, rg_track_peak = self._title_gain(
+ rg, audiofile, target_level
+ )
track_gains.append(
Gain(gain=rg_track_gain, peak=rg_track_peak)
)
@@ -811,8 +1158,9 @@ class AudioToolsBackend(Backend):
# After getting the values for all tracks, it's possible to get the
# album values.
rg_album_gain, rg_album_peak = rg.album_gain()
+ rg_album_gain = self._with_target_level(rg_album_gain, target_level)
self._log.debug(u'ReplayGain for album {0}: {1:.2f}, {2:.2f}',
- album, rg_album_gain, rg_album_peak)
+ items[0].album, rg_album_gain, rg_album_peak)
return AlbumGain(
Gain(gain=rg_album_gain, peak=rg_album_peak),
@@ -831,6 +1179,12 @@ class ReplayGainPlugin(BeetsPlugin):
"gstreamer": GStreamerBackend,
"audiotools": AudioToolsBackend,
"bs1770gain": Bs1770gainBackend,
+ "ffmpeg": FfmpegBackend,
+ }
+
+ peak_methods = {
+ "true": Peak.true,
+ "sample": Peak.sample,
}
def __init__(self):
@@ -841,11 +1195,15 @@ class ReplayGainPlugin(BeetsPlugin):
'overwrite': False,
'auto': True,
'backend': u'command',
+ 'per_disc': False,
+ 'peak': 'true',
'targetlevel': 89,
'r128': ['Opus'],
+ 'r128_targetlevel': lufs_to_db(-23),
})
self.overwrite = self.config['overwrite'].get(bool)
+ self.per_disc = self.config['per_disc'].get(bool)
backend_name = self.config['backend'].as_str()
if backend_name not in self.backends:
raise ui.UserError(
@@ -855,6 +1213,16 @@ class ReplayGainPlugin(BeetsPlugin):
u', '.join(self.backends.keys())
)
)
+ peak_method = self.config["peak"].as_str()
+ if peak_method not in self.peak_methods:
+ raise ui.UserError(
+ u"Selected ReplayGain peak method {0} is not supported. "
+ u"Please select one of: {1}".format(
+ peak_method,
+ u', '.join(self.peak_methods.keys())
+ )
+ )
+ self._peak_method = self.peak_methods[peak_method]
# On-import analysis.
if self.config['auto']:
@@ -871,8 +1239,6 @@ class ReplayGainPlugin(BeetsPlugin):
raise ui.UserError(
u'replaygain initialization failed: {0}'.format(e))
- self.r128_backend_instance = ''
-
def should_use_r128(self, item):
"""Checks the plugin setting to decide whether the calculation
should be done using the EBU R128 standard and use R128_ tags instead.
@@ -902,29 +1268,47 @@ class ReplayGainPlugin(BeetsPlugin):
item.rg_track_gain = track_gain.gain
item.rg_track_peak = track_gain.peak
item.store()
-
- self._log.debug(u'applied track gain {0}, peak {1}',
+ self._log.debug(u'applied track gain {0} LU, peak {1} of FS',
item.rg_track_gain, item.rg_track_peak)
+ def store_album_gain(self, item, album_gain):
+ item.rg_album_gain = album_gain.gain
+ item.rg_album_peak = album_gain.peak
+ item.store()
+ self._log.debug(u'applied album gain {0} LU, peak {1} of FS',
+ item.rg_album_gain, item.rg_album_peak)
+
def store_track_r128_gain(self, item, track_gain):
- item.r128_track_gain = int(round(track_gain.gain * pow(2, 8)))
+ item.r128_track_gain = track_gain.gain
item.store()
- self._log.debug(u'applied r128 track gain {0}', item.r128_track_gain)
+ self._log.debug(u'applied r128 track gain {0} LU',
+ item.r128_track_gain)
- def store_album_gain(self, album, album_gain):
- album.rg_album_gain = album_gain.gain
- album.rg_album_peak = album_gain.peak
- album.store()
+ def store_album_r128_gain(self, item, album_gain):
+ item.r128_album_gain = album_gain.gain
+ item.store()
+ self._log.debug(u'applied r128 album gain {0} LU',
+ item.r128_album_gain)
- self._log.debug(u'applied album gain {0}, peak {1}',
- album.rg_album_gain, album.rg_album_peak)
+ def tag_specific_values(self, items):
+ """Return some tag specific values.
- def store_album_r128_gain(self, album, album_gain):
- album.r128_album_gain = int(round(album_gain.gain * pow(2, 8)))
- album.store()
+ Returns a tuple (store_track_gain, store_album_gain, target_level,
+ peak_method).
+ """
+ if any([self.should_use_r128(item) for item in items]):
+ store_track_gain = self.store_track_r128_gain
+ store_album_gain = self.store_album_r128_gain
+ target_level = self.config['r128_targetlevel'].as_number()
+ peak = Peak.none # R128_* tags do not store the track/album peak
+ else:
+ store_track_gain = self.store_track_gain
+ store_album_gain = self.store_album_gain
+ target_level = self.config['targetlevel'].as_number()
+ peak = self._peak_method
- self._log.debug(u'applied r128 album gain {0}', album.r128_album_gain)
+ return store_track_gain, store_album_gain, target_level, peak
def handle_album(self, album, write, force=False):
"""Compute album and track replay gain store it in all of the
@@ -942,40 +1326,44 @@ 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
- if any([self.should_use_r128(item) for item in album.items()]):
- if self.r128_backend_instance == '':
- self.init_r128_backend()
- backend_instance = self.r128_backend_instance
- store_track_gain = self.store_track_r128_gain
- store_album_gain = self.store_album_r128_gain
+ tag_vals = self.tag_specific_values(album.items())
+ store_track_gain, store_album_gain, target_level, peak = tag_vals
+
+ discs = dict()
+ if self.per_disc:
+ for item in album.items():
+ if discs.get(item.disc) is None:
+ discs[item.disc] = []
+ discs[item.disc].append(item)
else:
- backend_instance = self.backend_instance
- store_track_gain = self.store_track_gain
- store_album_gain = self.store_album_gain
+ discs[1] = album.items()
- try:
- album_gain = backend_instance.compute_album_gain(album)
- if len(album_gain.track_gains) != len(album.items()):
- raise ReplayGainError(
- u"ReplayGain backend failed "
- u"for some tracks in album {0}".format(album)
+ for discnumber, items in discs.items():
+ try:
+ album_gain = self.backend_instance.compute_album_gain(
+ items, target_level, peak
)
+ if len(album_gain.track_gains) != len(items):
+ raise ReplayGainError(
+ u"ReplayGain backend failed "
+ u"for some tracks in album {0}".format(album)
+ )
- store_album_gain(album, album_gain.album_gain)
- for item, track_gain in zip(album.items(), album_gain.track_gains):
- store_track_gain(item, track_gain)
- if write:
- item.try_write()
- except ReplayGainError as e:
- self._log.info(u"ReplayGain error: {0}", e)
- except FatalReplayGainError as e:
- raise ui.UserError(
- u"Fatal replay gain error: {0}".format(e))
+ for item, track_gain in zip(items, album_gain.track_gains):
+ store_track_gain(item, track_gain)
+ store_album_gain(item, album_gain.album_gain)
+ if write:
+ item.try_write()
+ except ReplayGainError as e:
+ self._log.info(u"ReplayGain error: {0}", e)
+ except FatalReplayGainError as e:
+ raise ui.UserError(
+ u"Fatal replay gain error: {0}".format(e))
def handle_track(self, item, write, force=False):
"""Compute track replay gain and store it in the item.
@@ -990,17 +1378,13 @@ class ReplayGainPlugin(BeetsPlugin):
self._log.info(u'analyzing {0}', item)
- if self.should_use_r128(item):
- if self.r128_backend_instance == '':
- self.init_r128_backend()
- backend_instance = self.r128_backend_instance
- store_track_gain = self.store_track_r128_gain
- else:
- backend_instance = self.backend_instance
- store_track_gain = self.store_track_gain
+ tag_vals = self.tag_specific_values([item])
+ store_track_gain, store_album_gain, target_level, peak = tag_vals
try:
- track_gains = backend_instance.compute_track_gain([item])
+ track_gains = self.backend_instance.compute_track_gain(
+ [item], target_level, peak
+ )
if len(track_gains) != 1:
raise ReplayGainError(
u"ReplayGain backend failed for track {0}".format(item)
@@ -1015,19 +1399,6 @@ class ReplayGainPlugin(BeetsPlugin):
raise ui.UserError(
u"Fatal replay gain error: {0}".format(e))
- def init_r128_backend(self):
- backend_name = 'bs1770gain'
-
- try:
- self.r128_backend_instance = self.backends[backend_name](
- self.config, self._log
- )
- except (ReplayGainError, FatalReplayGainError) as e:
- raise ui.UserError(
- u'replaygain initialization failed: {0}'.format(e))
-
- self.r128_backend_instance.method = '--ebu'
-
def imported(self, session, task):
"""Add replay gain info to items or albums of ``task``.
"""
diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py
index a83fc4d19..700b0c76a 100644
--- a/beetsplug/smartplaylist.py
+++ b/beetsplug/smartplaylist.py
@@ -21,7 +21,7 @@ from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
from beets import ui
from beets.util import (mkdirall, normpath, sanitize_path, syspath,
- bytestring_path)
+ bytestring_path, path_as_posix)
from beets.library import Item, Album, parse_query_string
from beets.dbcore import OrQuery
from beets.dbcore.query import MultipleSort, ParsingError
@@ -37,7 +37,8 @@ class SmartPlaylistPlugin(BeetsPlugin):
'relative_to': None,
'playlist_dir': u'.',
'auto': True,
- 'playlists': []
+ 'playlists': [],
+ 'forward_slash': False,
})
self._matched_playlists = None
@@ -104,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 = []
@@ -206,6 +208,8 @@ class SmartPlaylistPlugin(BeetsPlugin):
mkdirall(m3u_path)
with open(syspath(m3u_path), 'wb') as f:
for path in m3us[m3u]:
+ if self.config['forward_slash'].get():
+ path = path_as_posix(path)
f.write(path + b'\n')
self._log.info(u"{0} playlists updated", len(self._matched_playlists))
diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py
index f6df91bb3..8fe0d394c 100644
--- a/beetsplug/spotify.py
+++ b/beetsplug/spotify.py
@@ -1,5 +1,21 @@
# -*- coding: utf-8 -*-
+# This file is part of beets.
+# Copyright 2019, Rahul Ahuja.
+#
+# 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.
+"""Adds Spotify release and track search support to the autotagger, along with
+Spotify playlist construction.
+"""
from __future__ import division, absolute_import, print_function
import re
@@ -11,22 +27,30 @@ import collections
import six
import unidecode
import requests
+import confuse
from beets import ui
-from beets.plugins import BeetsPlugin
-import confuse
-from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
+from beets.autotag.hooks import AlbumInfo, TrackInfo
+from beets.plugins import MetadataSourcePlugin, BeetsPlugin
-class SpotifyPlugin(BeetsPlugin):
+class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
+ data_source = 'Spotify'
+
# Base URLs for the Spotify API
# Documentation: https://developer.spotify.com/web-api
oauth_token_url = 'https://accounts.spotify.com/api/token'
- open_track_url = 'http://open.spotify.com/track/'
+ open_track_url = 'https://open.spotify.com/track/'
search_url = 'https://api.spotify.com/v1/search'
album_url = 'https://api.spotify.com/v1/albums/'
track_url = 'https://api.spotify.com/v1/tracks/'
- playlist_partial = 'spotify:trackset:Playlist:'
+
+ # Spotify IDs consist of 22 alphanumeric characters
+ # (zero-left-padded base62 representation of randomly generated UUID4)
+ id_regex = {
+ 'pattern': r'(^|open\.spotify\.com/{}/)([0-9A-Za-z]{{22}})',
+ 'match_group': 2,
+ }
def __init__(self):
super(SpotifyPlugin, self).__init__()
@@ -43,7 +67,6 @@ class SpotifyPlugin(BeetsPlugin):
'client_id': '4e414367a1d14c75a5c5129a627fcab8',
'client_secret': 'f82bdc09b2254f1a8286815d02fd46dc',
'tokenfile': 'spotify_token.json',
- 'source_weight': 0.5,
}
)
self.config['client_secret'].redact = True
@@ -93,7 +116,9 @@ class SpotifyPlugin(BeetsPlugin):
self.access_token = response.json()['access_token']
# Save the token for later use.
- self._log.debug(u'Spotify access token: {}', self.access_token)
+ self._log.debug(
+ u'{} access token: {}', self.data_source, self.access_token
+ )
with open(self.tokenfile, 'w') as f:
json.dump({'access_token': self.access_token}, f)
@@ -119,31 +144,19 @@ class SpotifyPlugin(BeetsPlugin):
if response.status_code != 200:
if u'token expired' in response.text:
self._log.debug(
- 'Spotify access token has expired. Reauthenticating.'
+ '{} access token has expired. Reauthenticating.',
+ self.data_source,
)
self._authenticate()
return self._handle_response(request_type, url, params=params)
else:
- raise ui.UserError(u'Spotify API error:\n{}', response.text)
+ raise ui.UserError(
+ u'{} API error:\n{}\nURL:\n{}\nparams:\n{}'.format(
+ self.data_source, response.text, url, params
+ )
+ )
return response.json()
- def _get_spotify_id(self, url_type, id_):
- """Parse a Spotify ID from its URL if necessary.
-
- :param url_type: Type of Spotify URL, either 'album' or 'track'.
- :type url_type: str
- :param id_: Spotify ID or URL.
- :type id_: str
- :return: Spotify ID.
- :rtype: str
- """
- # Spotify IDs consist of 22 alphanumeric characters
- # (zero-left-padded base62 representation of randomly generated UUID4)
- id_regex = r'(^|open\.spotify\.com/{}/)([0-9A-Za-z]{{22}})'
- self._log.debug(u'Searching for {} {}', url_type, id_)
- match = re.search(id_regex.format(url_type), id_)
- return match.group(2) if match else None
-
def album_for_id(self, album_id):
"""Fetch an album by its Spotify ID or URL and return an
AlbumInfo object or None if the album is not found.
@@ -153,61 +166,63 @@ class SpotifyPlugin(BeetsPlugin):
:return: AlbumInfo object for album
:rtype: beets.autotag.hooks.AlbumInfo or None
"""
- spotify_id = self._get_spotify_id('album', album_id)
+ spotify_id = self._get_id('album', album_id)
if spotify_id is None:
return None
- response_data = self._handle_response(
+ album_data = self._handle_response(
requests.get, self.album_url + spotify_id
)
- artist, artist_id = self._get_artist(response_data['artists'])
+ artist, artist_id = self.get_artist(album_data['artists'])
date_parts = [
- int(part) for part in response_data['release_date'].split('-')
+ int(part) for part in album_data['release_date'].split('-')
]
- release_date_precision = response_data['release_date_precision']
+ release_date_precision = album_data['release_date_precision']
if release_date_precision == 'day':
year, month, day = date_parts
elif release_date_precision == 'month':
year, month = date_parts
day = None
elif release_date_precision == 'year':
- year = date_parts
+ year = date_parts[0]
month = None
day = None
else:
raise ui.UserError(
u"Invalid `release_date_precision` returned "
- u"by Spotify API: '{}'".format(release_date_precision)
+ u"by {} API: '{}'".format(
+ self.data_source, release_date_precision
+ )
)
tracks = []
medium_totals = collections.defaultdict(int)
- for i, track_data in enumerate(response_data['tracks']['items']):
+ for i, track_data in enumerate(album_data['tracks']['items'], start=1):
track = self._get_track(track_data)
- track.index = i + 1
+ track.index = i
medium_totals[track.medium] += 1
tracks.append(track)
for track in tracks:
track.medium_total = medium_totals[track.medium]
return AlbumInfo(
- album=response_data['name'],
+ album=album_data['name'],
album_id=spotify_id,
artist=artist,
artist_id=artist_id,
tracks=tracks,
- albumtype=response_data['album_type'],
- va=len(response_data['artists']) == 1
+ albumtype=album_data['album_type'],
+ va=len(album_data['artists']) == 1
and artist.lower() == 'various artists',
year=year,
month=month,
day=day,
- label=response_data['label'],
+ label=album_data['label'],
mediums=max(medium_totals.keys()),
- data_source='Spotify',
- data_url=response_data['external_urls']['spotify'],
+ data_source=self.data_source,
+ data_url=album_data['external_urls']['spotify'],
)
def _get_track(self, track_data):
@@ -219,7 +234,7 @@ class SpotifyPlugin(BeetsPlugin):
:return: TrackInfo object for track
:rtype: beets.autotag.hooks.TrackInfo
"""
- artist, artist_id = self._get_artist(track_data['artists'])
+ artist, artist_id = self.get_artist(track_data['artists'])
return TrackInfo(
title=track_data['name'],
track_id=track_data['id'],
@@ -229,7 +244,7 @@ class SpotifyPlugin(BeetsPlugin):
index=track_data['track_number'],
medium=track_data['disc_number'],
medium_index=track_data['track_number'],
- data_source='Spotify',
+ data_source=self.data_source,
data_url=track_data['external_urls']['spotify'],
)
@@ -247,7 +262,7 @@ class SpotifyPlugin(BeetsPlugin):
:rtype: beets.autotag.hooks.TrackInfo or None
"""
if track_data is None:
- spotify_id = self._get_spotify_id('track', track_id)
+ spotify_id = self._get_id('track', track_id)
if spotify_id is None:
return None
track_data = self._handle_response(
@@ -262,107 +277,14 @@ class SpotifyPlugin(BeetsPlugin):
requests.get, self.album_url + track_data['album']['id']
)
medium_total = 0
- for i, track_data in enumerate(album_data['tracks']['items']):
+ for i, track_data in enumerate(album_data['tracks']['items'], start=1):
if track_data['disc_number'] == track.medium:
medium_total += 1
if track_data['id'] == track.track_id:
- track.index = i + 1
+ track.index = i
track.medium_total = medium_total
return track
- @staticmethod
- def _get_artist(artists):
- """Returns an artist string (all artists) and an artist_id (the main
- artist) for a list of Spotify artist object dicts.
-
- :param artists: Iterable of simplified Spotify artist objects
- (https://developer.spotify.com/documentation/web-api/reference/object-model/#artist-object-simplified)
- :type artists: list[dict]
- :return: Normalized artist string
- :rtype: str
- """
- artist_id = None
- artist_names = []
- for artist in artists:
- if not artist_id:
- artist_id = artist['id']
- name = artist['name']
- # Move articles to the front.
- name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I)
- artist_names.append(name)
- artist = ', '.join(artist_names).replace(' ,', ',') or None
- return artist, artist_id
-
- def album_distance(self, items, album_info, mapping):
- """Returns the Spotify source weight and the maximum source weight
- for albums.
- """
- dist = Distance()
- if album_info.data_source == 'Spotify':
- dist.add('source', self.config['source_weight'].as_number())
- return dist
-
- def track_distance(self, item, track_info):
- """Returns the Spotify source weight and the maximum source weight
- for individual tracks.
- """
- dist = Distance()
- if track_info.data_source == 'Spotify':
- dist.add('source', self.config['source_weight'].as_number())
- return dist
-
- def candidates(self, items, artist, album, va_likely):
- """Returns a list of AlbumInfo objects for Spotify Search API results
- matching an ``album`` and ``artist`` (if not various).
-
- :param items: List of items comprised by an album to be matched.
- :type items: list[beets.library.Item]
- :param artist: The artist of the album to be matched.
- :type artist: str
- :param album: The name of the album to be matched.
- :type album: str
- :param va_likely: True if the album to be matched likely has
- Various Artists.
- :type va_likely: bool
- :return: Candidate AlbumInfo objects.
- :rtype: list[beets.autotag.hooks.AlbumInfo]
- """
- query_filters = {'album': album}
- if not va_likely:
- query_filters['artist'] = artist
- response_data = self._search_spotify(
- query_type='album', filters=query_filters
- )
- if response_data is None:
- return []
- return [
- self.album_for_id(album_id=album_data['id'])
- for album_data in response_data['albums']['items']
- ]
-
- def item_candidates(self, item, artist, title):
- """Returns a list of TrackInfo objects for Spotify Search API results
- matching ``title`` and ``artist``.
-
- :param item: Singleton item to be matched.
- :type item: beets.library.Item
- :param artist: The artist of the track to be matched.
- :type artist: str
- :param title: The title of the track to be matched.
- :type title: str
- :return: Candidate TrackInfo objects.
- :rtype: list[beets.autotag.hooks.TrackInfo]
- """
- response_data = self._search_spotify(
- query_type='track', keywords=title, filters={'artist': artist}
- )
- if response_data is None:
- return []
- return [
- self.track_for_id(track_data=track_data)
- for track_data in response_data['tracks']['items']
- ]
-
@staticmethod
def _construct_search_query(filters=None, keywords=''):
"""Construct a query string with the specified filters and keywords to
@@ -385,14 +307,12 @@ class SpotifyPlugin(BeetsPlugin):
query = query.decode('utf8')
return unidecode.unidecode(query)
- def _search_spotify(self, query_type, filters=None, keywords=''):
+ def _search_api(self, query_type, filters=None, keywords=''):
"""Query the Spotify Search API for the specified ``keywords``, applying
the provided ``filters``.
- :param query_type: A comma-separated list of item types to search
- across. Valid types are: 'album', 'artist', 'playlist', and
- 'track'. Search results include hits from all the specified item
- types.
+ :param query_type: Item type to search across. Valid types are:
+ 'album', 'artist', 'playlist', and 'track'.
:type query_type: str
:param filters: (Optional) Field filters to apply.
:type filters: dict
@@ -407,19 +327,25 @@ class SpotifyPlugin(BeetsPlugin):
)
if not query:
return None
- self._log.debug(u"Searching Spotify for '{}'".format(query))
- response_data = self._handle_response(
- requests.get,
- self.search_url,
- params={'q': query, 'type': query_type},
- )
- num_results = 0
- for result_type_data in response_data.values():
- num_results += len(result_type_data['items'])
self._log.debug(
- u"Found {} results from Spotify for '{}'", num_results, query
+ u"Searching {} for '{}'".format(self.data_source, query)
)
- return response_data if num_results > 0 else None
+ response_data = (
+ self._handle_response(
+ requests.get,
+ self.search_url,
+ params={'q': query, 'type': query_type},
+ )
+ .get(query_type + 's', {})
+ .get('items', [])
+ )
+ self._log.debug(
+ u"Found {} result(s) from {} for '{}'",
+ len(response_data),
+ self.data_source,
+ query,
+ )
+ return response_data
def commands(self):
def queries(lib, opts, args):
@@ -429,21 +355,23 @@ class SpotifyPlugin(BeetsPlugin):
self._output_match_results(results)
spotify_cmd = ui.Subcommand(
- 'spotify', help=u'build a Spotify playlist'
+ 'spotify', help=u'build a {} playlist'.format(self.data_source)
)
spotify_cmd.parser.add_option(
u'-m',
u'--mode',
action='store',
- help=u'"open" to open Spotify with playlist, '
- u'"list" to print (default)',
+ help=u'"open" to open {} with playlist, '
+ u'"list" to print (default)'.format(self.data_source),
)
spotify_cmd.parser.add_option(
u'-f',
u'--show-failures',
action='store_true',
dest='show_failures',
- help=u'list tracks that did not match a Spotify ID',
+ help=u'list tracks that did not match a {} ID'.format(
+ self.data_source
+ ),
)
spotify_cmd.func = queries
return [spotify_cmd]
@@ -483,7 +411,8 @@ class SpotifyPlugin(BeetsPlugin):
if not items:
self._log.debug(
- u'Your beets query returned no items, skipping Spotify.'
+ u'Your beets query returned no items, skipping {}.',
+ self.data_source,
)
return
@@ -511,16 +440,15 @@ class SpotifyPlugin(BeetsPlugin):
# Query the Web API for each track, look for the items' JSON data
query_filters = {'artist': artist, 'album': album}
- response_data = self._search_spotify(
+ response_data_tracks = self._search_api(
query_type='track', keywords=keywords, filters=query_filters
)
- if response_data is None:
+ if not response_data_tracks:
query = self._construct_search_query(
keywords=keywords, filters=query_filters
)
failures.append(query)
continue
- response_data_tracks = response_data['tracks']['items']
# Apply market filter if requested
region_filter = self.config['region_filter'].get()
@@ -536,7 +464,8 @@ class SpotifyPlugin(BeetsPlugin):
or self.config['tiebreak'].get() == 'first'
):
self._log.debug(
- u'Spotify track(s) found, count: {}',
+ u'{} track(s) found, count: {}',
+ self.data_source,
len(response_data_tracks),
)
chosen_result = response_data_tracks[0]
@@ -555,16 +484,19 @@ class SpotifyPlugin(BeetsPlugin):
if failure_count > 0:
if self.config['show_failures'].get():
self._log.info(
- u'{} track(s) did not match a Spotify ID:', failure_count
+ u'{} track(s) did not match a {} ID:',
+ failure_count,
+ self.data_source,
)
for track in failures:
self._log.info(u'track: {}', track)
self._log.info(u'')
else:
self._log.warning(
- u'{} track(s) did not match a Spotify ID;\n'
+ u'{} track(s) did not match a {} ID:\n'
u'use --show-failures to display',
failure_count,
+ self.data_source,
)
return results
@@ -580,11 +512,19 @@ class SpotifyPlugin(BeetsPlugin):
if results:
spotify_ids = [track_data['id'] for track_data in results]
if self.config['mode'].get() == 'open':
- self._log.info(u'Attempting to open Spotify with playlist')
- spotify_url = self.playlist_partial + ",".join(spotify_ids)
+ self._log.info(
+ u'Attempting to open {} with playlist'.format(
+ self.data_source
+ )
+ )
+ spotify_url = 'spotify:trackset:Playlist:' + ','.join(
+ spotify_ids
+ )
webbrowser.open(spotify_url)
else:
for spotify_id in spotify_ids:
print(self.open_track_url + spotify_id)
else:
- self._log.warning(u'No Spotify tracks found from beets query')
+ self._log.warning(
+ u'No {} tracks found from beets query'.format(self.data_source)
+ )
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 bb9e8a952..45fc3a8cb 100644
--- a/beetsplug/subsonicupdate.py
+++ b/beetsplug/subsonicupdate.py
@@ -17,20 +17,20 @@
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
-from beets.plugins import BeetsPlugin
-from beets import config
-import requests
-import string
import hashlib
import random
+import string
+
+import requests
+
+from beets import config
+from beets.plugins import BeetsPlugin
__author__ = 'https://github.com/maffo999'
@@ -41,47 +41,75 @@ class SubsonicUpdate(BeetsPlugin):
# 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.loaded)
+ self.register_listener('import', self.start_scan)
- def loaded(self):
- host = config['subsonic']['host'].as_str()
- port = config['subsonic']['port'].get(int)
- user = config['subsonic']['user'].as_str()
- passw = config['subsonic']['pass'].as_str()
- contextpath = config['subsonic']['contextpath'].as_str()
+ @staticmethod
+ def __create_token():
+ """Create salt and token from given password.
- # To avoid sending plaintext passwords, authentication will be
- # performed via username, a token, and a 6 random
- # letters/numbers sequence.
- # The token is the concatenation of your password and the 6 random
- # letters/numbers (the salt) which is hashed with MD5.
+ :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 n in range(6)])
- t = passw + salt
- token = hashlib.md5()
- token.update(t.encode('utf-8'))
+ 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 = self.__format_url()
+ salt, token = self.__create_token()
+
payload = {
'u': user,
- 't': token.hexdigest(),
+ 't': token,
's': salt,
'v': '1.15.0', # Subsonic 6.1 and newer.
'c': 'beets'
}
- if contextpath == '/':
- contextpath = ''
- url = "http://{}:{}{}/rest/startScan".format(host, port, contextpath)
+
response = requests.post(url, params=payload)
- if response.status_code != 200:
- self._log.error(u'Generic error, please try again later.')
+ if response.status_code == 403:
+ self._log.error(u'Server authentication failed')
+ elif response.status_code == 200:
+ self._log.debug(u'Updating Subsonic')
+ else:
+ self._log.error(
+ u'Generic error, please try again later [Status Code: {}]'
+ .format(response.status_code))
diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py
index 04845e880..1b262eca5 100644
--- a/beetsplug/thumbnails.py
+++ b/beetsplug/thumbnails.py
@@ -160,7 +160,7 @@ class ThumbnailsPlugin(BeetsPlugin):
def thumbnail_file_name(self, path):
"""Compute the thumbnail file name
- See http://standards.freedesktop.org/thumbnail-spec/latest/x227.html
+ See https://standards.freedesktop.org/thumbnail-spec/latest/x227.html
"""
uri = self.get_uri(path)
hash = md5(uri.encode('utf-8')).hexdigest()
@@ -168,7 +168,7 @@ class ThumbnailsPlugin(BeetsPlugin):
def add_tags(self, album, image_path):
"""Write required metadata to the thumbnail
- See http://standards.freedesktop.org/thumbnail-spec/latest/x142.html
+ See https://standards.freedesktop.org/thumbnail-spec/latest/x142.html
"""
mtime = os.stat(album.artpath).st_mtime
metadata = {"Thumb::URI": self.get_uri(album.artpath),
@@ -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/unimported.py b/beetsplug/unimported.py
new file mode 100644
index 000000000..544e9de46
--- /dev/null
+++ b/beetsplug/unimported.py
@@ -0,0 +1,60 @@
+# -*- 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.
+
+"""
+List all files in the library folder which are not listed in the
+ beets library database, including art files
+"""
+
+from __future__ import absolute_import, division, print_function
+import os
+
+from beets import util
+from beets.plugins import BeetsPlugin
+from beets.ui import Subcommand, print_
+
+__author__ = 'https://github.com/MrNuggelz'
+
+
+class Unimported(BeetsPlugin):
+
+ def __init__(self):
+ super(Unimported, self).__init__()
+ self.config.add(
+ {
+ 'ignore_extensions': []
+ }
+ )
+
+ def commands(self):
+ def print_unimported(lib, opts, args):
+ ignore_exts = [('.' + x).encode() for x
+ in self.config['ignore_extensions'].as_str_seq()]
+ in_folder = set(
+ (os.path.join(r, file) for r, d, f in os.walk(lib.directory)
+ for file in f if not any(
+ [file.endswith(extension) for extension in
+ ignore_exts])))
+ in_library = set(x.path for x in lib.items())
+ art_files = set(x.artpath for x in lib.albums())
+ for f in in_folder - in_library - art_files:
+ print_(util.displayable_path(f))
+
+ unimported = Subcommand(
+ 'unimported',
+ help='list all files in the library folder which are not listed'
+ ' in the beets library database')
+ unimported.func = print_unimported
+ return [unimported]
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/beetsplug/web/static/beets.js b/beetsplug/web/static/beets.js
index 51985c183..97af70110 100644
--- a/beetsplug/web/static/beets.js
+++ b/beetsplug/web/static/beets.js
@@ -129,7 +129,7 @@ $.fn.player = function(debug) {
// Simple selection disable for jQuery.
// Cut-and-paste from:
-// http://stackoverflow.com/questions/2700000
+// https://stackoverflow.com/questions/2700000
$.fn.disableSelection = function() {
$(this).attr('unselectable', 'on')
.css('-moz-user-select', 'none')
diff --git a/docs/changelog.rst b/docs/changelog.rst
index dd1e265c3..6547e9b51 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -6,17 +6,142 @@ Changelog
New features:
+* :doc:`/plugins/lastgenre`: Added more heavy metal genres: https://en.wikipedia.org/wiki/Heavy_metal_genres to genres.txt and genres-tree.yaml
+* :doc:`/plugins/subsonicplaylist`: import playlist from a subsonic server.
+* A new :ref:`extra_tags` configuration option allows more tagged metadata
+ to be included in MusicBrainz queries.
+* A new :doc:`/plugins/fish` adds `Fish shell`_ tab autocompletion to beets
+* :doc:`plugins/fetchart` and :doc:`plugins/embedart`: Added a new ``quality``
+ option that controls the quality of the image output when the image is
+ resized.
+* :doc:`plugins/keyfinder`: Added support for `keyfinder-cli`_
+ Thanks to :user:`BrainDamage`.
+* :doc:`plugins/fetchart`: Added a new ``high_resolution`` config option to
+ allow downloading of higher resolution iTunes artwork (at the expense of
+ file size).
+ :bug: `3391`
+* :doc:`plugins/discogs` now adds two extra fields: `discogs_labelid` and
+ `discogs_artistid`
+ :bug: `3413`
+* :doc:`/plugins/export`: Added new ``-f`` (``--format``) flag;
+ which allows for the ability to export in json, csv and xml.
+ Thanks to :user:`austinmm`.
+ :bug:`3402`
+* :doc:`/plugins/unimported`: lets you find untracked files in your library directory.
* We now fetch information about `works`_ from MusicBrainz.
MusicBrainz matches provide the fields ``work`` (the title), ``mb_workid``
(the MBID), and ``work_disambig`` (the disambiguation string).
Thanks to :user:`dosoe`.
:bug:`2580` :bug:`3272`
+* :doc:`/plugins/convert`: Added new ``-l`` (``--link``) flag and ``link``
+ option as well as the ``-H`` (``--hardlink``) flag and ``hardlink``
+ option which symlinks or hardlinks files that do not need to
+ be converted instead of copying them.
+ :bug:`2324`
* :doc:`/plugins/bpd`: BPD now supports most of the features of version 0.16
of the MPD protocol. This is enough to get it talking to more complicated
clients like ncmpcpp, but there are still some incompatibilities, largely due
to MPD commands we don't support yet. Let us know if you find an MPD client
that doesn't get along with BPD!
:bug:`3214` :bug:`800`
+* :doc:`/plugins/replaygain`: The plugin now supports a ``per_disc`` option
+ which enables calculation of album ReplayGain on disc level instead of album
+ level.
+ Thanks to :user:`samuelnilsson`
+ :bug:`293`
+* :doc:`/plugins/replaygain`: The new ``ffmpeg`` ReplayGain backend supports
+ ``R128_`` tags, just like the ``bs1770gain`` backend.
+ :bug:`3056`
+* :doc:`plugins/replaygain`: ``r128_targetlevel`` is a new configuration option
+ for the ReplayGain plugin: It defines the reference volume for files using
+ ``R128_`` tags. ``targetlevel`` only configures the reference volume for
+ ``REPLAYGAIN_`` files.
+ This also deprecates the ``bs1770gain`` ReplayGain backend's ``method``
+ option. Use ``targetlevel`` and ``r128_targetlevel`` instead.
+ :bug:`3065`
+* A new :doc:`/plugins/parentwork` gets information about the original work,
+ which is useful for classical music.
+ Thanks to :user:`dosoe`.
+ :bug:`2580` :bug:`3279`
+* :doc:`/plugins/discogs`: The field now collects the "style" field.
+ Thanks to :user:`thedevilisinthedetails`.
+ :bug:`2579` :bug:`3251`
+* :doc:`/plugins/absubmit`: By default, the plugin now avoids re-analyzing
+ files that already have AB data.
+ There are new ``force`` and ``pretend`` options to help control this new
+ behavior.
+ Thanks to :user:`SusannaMaria`.
+ :bug:`3318`
+* :doc:`/plugins/discogs`: The plugin now also gets genre information and a
+ new ``discogs_albumid`` field from the Discogs API.
+ Thanks to :user:`thedevilisinthedetails`.
+ :bug:`465` :bug:`3322`
+* :doc:`/plugins/acousticbrainz`: The plugin now fetches two more additional
+ fields: ``moods_mirex`` and ``timbre``.
+ Thanks to :user:`malcops`.
+ :bug:`2860`
+* :doc:`/plugins/playlist` and :doc:`/plugins/smartplaylist`: A new
+ ``forward_slash`` config option facilitates compatibility with MPD on
+ Windows.
+ Thanks to :user:`MartyLake`.
+ :bug:`3331` :bug:`3334`
+* The 'data_source' field is now also applied as an album-level flexible
+ attribute during imports, allowing for more refined album level searches.
+ :bug:`3350` :bug:`1693`
+* :doc:`/plugins/deezer`: Added Deezer plugin as an import metadata provider:
+ you can now match tracks and albums using the `Deezer`_ database.
+ Thanks to :user:`rhlahuja`.
+ :bug:`3355`
+* :doc:`/plugins/beatport`: The plugin now gets the musical key, BPM and the
+ genre for each track.
+ :bug:`2080`
+* :doc:`/plugins/beatport`: Fix default assignment of the musical key.
+ :bug:`3377`
+* :doc:`/plugins/bpsync`: Add `bpsync` plugin to sync metadata changes
+ from the Beatport database.
+* :doc:`/plugins/beatport`: Fix assignment of `genre` and rename `musical_key`
+ to `initial_key`.
+ :bug:`3387`
+* :doc:`/plugins/hook` now treats non-zero exit codes as errors.
+ :bug:`3409`
+* :doc:`/plugins/subsonicupdate`: A new ``url`` configuration replaces the
+ older (and now deprecated) separate ``host``, ``port``, and ``contextpath``
+ config options. As a consequence, the plugin can now talk to Subsonic over
+ HTTPS.
+ Thanks to :user:`jef`.
+ :bug:`3449`
+* :doc:`/plugins/discogs`: The new ``index_tracks`` option enables
+ incorporation of work names and intra-work divisions into imported track
+ titles.
+ Thanks to :user:`cole-miller`.
+ :bug:`3459`
+* :doc:`/plugins/fetchart`: Album art can now be fetched from `last.fm`_.
+ :bug:`3530`
+* The classes ``AlbumInfo`` and ``TrackInfo`` now have flexible attributes,
+ allowing to solve :bug:`1547`.
+ Thanks to :user:`dosoe`.
+* :doc:`/plugins/web`: The query API now interprets backslashes as path
+ separators to support path queries.
+ Thanks to :user:`nmeum`.
+ :bug:`3567`
+* ``beet import`` now handles tar archives with bzip2 or gzip compression.
+ :bug:`3606`
+* :doc:`/plugins/plexupdate`: Add option to use secure connection to Plex
+ server, and to ignore certificate validation errors if necessary.
+ :bug:`2871`
+* :doc:`/plugins/lyrics`: Improved searching Genius backend when artist
+ contained special characters.
+ :bug:`3634`
+* :doc:`/plugins/parentwork`: Also get the composition date of the parent work,
+ instead of just the child work.
+ Thanks to :user:`aereaux`.
+ :bug:`3650`
+* :doc:`/plugins/lyrics`: Fix a bug in the heuristic for detecting valid
+ lyrics in the Google source of the lyrics plugin
+ :bug:`2969`
+* :doc:`/plugins/thumbnails`: Fix a bug where pathlib expected a string instead
+ of bytes for a path.
+ :bug:`3360`
* Fields in queries now fall back to an item's album and check its fields too.
Notably, this allows querying items by an album flex attribute, also in path
configuration.
@@ -25,6 +150,11 @@ New features:
Fixes:
+* :doc:`/plugins/fetchart`: Fixed a bug that caused fetchart to not take
+ environment variables such as proxy servers into account when making requests
+ :bug:`3450`
+* :doc:`/plugins/fetchart`: Temporary files for fetched album art that fail
+ validation are now removed
* :doc:`/plugins/inline`: In function-style field definitions that refer to
flexible attributes, values could stick around from one function invocation
to the next. This meant that, when displaying a list of objects, later
@@ -35,6 +165,84 @@ Fixes:
fixing crashes in MPD clients like mpDris2 on seek.
The ``playlistid`` command now works properly in its zero-argument form.
:bug:`3214`
+* :doc:`/plugins/replaygain`: Fix a Python 3 incompatibility in the Python
+ Audio Tools backend.
+ :bug:`3305`
+* :doc:`/plugins/importadded`: Fixed a crash that occurred when the
+ ``after_write`` signal was emitted.
+ :bug:`3301`
+* :doc:`plugins/replaygain`: Fix the storage format in R128 gain tags.
+ :bug:`3311` :bug:`3314`
+* :doc:`/plugins/discogs`: Fixed a crash that occurred when the Master URI
+ isn't set.
+ :bug:`2965` :bug:`3239`
+* :doc:`/plugins/spotify`: Fix handling of year-only release dates
+ returned by Spotify Albums API.
+ Thanks to :user:`rhlahuja`.
+ :bug:`3343`
+* Fixed a bug that caused the UI to display incorrect track numbers for tracks
+ with index 0 when the ``per_disc_numbering`` option was set.
+ :bug:`3346`
+* ``none_rec_action`` does not import automatically when ``timid`` is enabled.
+ Thanks to :user:`RollingStar`.
+ :bug:`3242`
+* Fix a bug that caused a crash when tagging items with the beatport plugin.
+ :bug:`3374`
+* ``beet update`` will now confirm that the user still wants to update if
+ their library folder cannot be found, preventing the user from accidentally
+ wiping out their beets database.
+ Thanks to :user:`logan-arens`.
+ :bug:`1934`
+* :doc:`/plugins/bpd`: Fix the transition to next track when in consume mode.
+ Thanks to :user:`aereaux`.
+ :bug:`3437`
+* :doc:`/plugins/lyrics`: Fix a corner-case with Genius lowercase artist names
+ :bug:`3446`
+* :doc:`/plugins/replaygain`: Support ``bs1770gain`` v0.6.0 and up
+ :bug:`3480`
+* :doc:`/plugins/parentwork`: Don't save tracks when nothing has changed.
+ :bug:`3492`
+* Added a warning when configuration files defined in the `include` directive
+ of the configuration file fail to be imported.
+ :bug:`3498`
+* Added the normalize method to the dbcore.types.INTEGER class which now
+ properly returns integer values, which should avoid problems where fields
+ like ``bpm`` would sometimes store non-integer values.
+ :bug:`762` :bug:`3507` :bug:`3508`
+* Removed ``@classmethod`` decorator from dbcore.query.NoneQuery.match method
+ failing with AttributeError when called. It is now an instance method.
+ :bug:`3516` :bug:`3517`
+* :doc:`/plugins/lyrics`: Tolerate missing lyrics div in Genius scraper.
+ Thanks to :user:`thejli21`.
+ :bug:`3535` :bug:`3554`
+* :doc:`/plugins/lyrics`: Use the artist sort name to search for lyrics, which
+ can help find matches when the artist name has special characters.
+ Thanks to :user:`hashhar`.
+ :bug:`3340` :bug:`3558`
+* :doc:`/plugins/replaygain`: Trying to calculate volume gain for an album
+ consisting of some formats using ``ReplayGain`` and some using ``R128``
+ will no longer crash; instead it is skipped and and a message is logged.
+ The log message has also been rewritten for to improve clarity.
+ Thanks to :user:`autrimpo`.
+ :bug:`3533`
+* :doc:`/plugins/lyrics`: Adapt the Genius backend to changes in markup to
+ reduce the scraping failure rate.
+ :bug:`3535` :bug:`3594`
+* :doc:`/plugins/lyrics`: Fix crash when writing ReST files for a query without
+ results or fetched lyrics
+ :bug:`2805`
+* Adapt to breaking changes in Python's ``ast`` module in 3.8
+* :doc:`/plugins/fetchart`: Attempt to fetch pre-resized thumbnails from Cover
+ Art Archive if the ``maxwidth`` option matches one of the sizes supported by
+ the Cover Art Archive API.
+ Thanks to :user:`trolley`.
+ :bug:`3637`
+* :doc:`/plugins/ipfs`: Fix Python 3 compatibility.
+ Thanks to :user:`musoke`.
+ :bug:`2554`
+* Fix a bug that caused metadata starting with something resembling a drive
+ letter to be incorrectly split into an extra directory after the colon.
+ :bug:`3685`
For plugin developers:
@@ -49,6 +257,26 @@ For plugin developers:
is almost identical apart from the name change. Again, we'll re-export at the
old location (with a deprecation warning) for backwards compatibility, but
might stop doing this in a future release.
+* ``beets.util.command_output`` now returns a named tuple containing both the
+ standard output and the standard error data instead of just stdout alone.
+ Client code will need to access the ``stdout`` attribute on the return
+ value.
+ Thanks to :user:`zsinskri`.
+ :bug:`3329`
+* There were sporadic failures in ``test.test_player``. Hopefully these are
+ fixed. If they resurface, please reopen the relevant issue.
+ :bug:`3309` :bug:`3330`
+* The internal structure of the replaygain plugin had some changes: There are no
+ longer separate R128 backend instances. Instead the targetlevel is passed to
+ ``compute_album_gain`` and ``compute_track_gain``.
+ :bug:`3065`
+* The ``beets.plugins.MetadataSourcePlugin`` base class has been added to
+ simplify development of plugins which query album, track, and search
+ APIs to provide metadata matches for the importer. Refer to the Spotify and
+ Deezer plugins for examples of using this template class.
+ :bug:`3355`
+* The autotag hooks have been modified such that they now take 'bpm',
+ 'musical_key' and a per-track based 'genre' as attributes.
* Item (and attribute) access on an item now falls back to the album's
attributes as well. If you specifically want to access an item's attributes,
use ``Item.get(key, with_album=False)``. :bug:`2988`
@@ -70,10 +298,15 @@ For packagers:
* We attempted to fix an unreliable test, so a patch to `skip `_
or `repair `_
the test may no longer be necessary.
+* This version drops support for Python 3.4.
+.. _Fish shell: https://fishshell.com/
.. _MediaFile: https://github.com/beetbox/mediafile
.. _Confuse: https://github.com/beetbox/confuse
.. _works: https://musicbrainz.org/doc/Work
+.. _Deezer: https://www.deezer.com
+.. _keyfinder-cli: https://github.com/EvanPurkhiser/keyfinder-cli
+.. _last.fm: https://last.fm
1.4.9 (May 30, 2019)
@@ -1227,7 +1460,7 @@ There are even more new features:
don't actually need to be moved. :bug:`1583`
.. _Google Code-In: https://codein.withgoogle.com/
-.. _AcousticBrainz: http://acousticbrainz.org/
+.. _AcousticBrainz: https://acousticbrainz.org/
Fixes:
@@ -1252,7 +1485,7 @@ Fixes:
* :doc:`/plugins/replaygain`: Fix a crash using the Python Audio Tools
backend. :bug:`1873`
-.. _beets.io: http://beets.io/
+.. _beets.io: https://beets.io/
.. _Beetbox: https://github.com/beetbox
@@ -1369,7 +1602,7 @@ Fixes:
communication errors. The backend has also been disabled by default, since
the API it depends on is currently down. :bug:`1770`
-.. _Emby: http://emby.media
+.. _Emby: https://emby.media
1.3.15 (October 17, 2015)
@@ -1531,8 +1764,8 @@ Fixes:
* :doc:`/plugins/convert`: Fix a problem with filename encoding on Windows
under Python 3. :bug:`2515` :bug:`2516`
-.. _Python bug: http://bugs.python.org/issue16512
-.. _ipfs: http://ipfs.io
+.. _Python bug: https://bugs.python.org/issue16512
+.. _ipfs: https://ipfs.io
1.3.13 (April 24, 2015)
@@ -1883,7 +2116,7 @@ As usual, there are loads of little fixes and improvements:
* The :ref:`config-cmd` command can now use ``$EDITOR`` variables with
arguments.
-.. _API changes: http://developer.echonest.com/forums/thread/3650
+.. _API changes: https://developer.echonest.com/forums/thread/3650
.. _Plex: https://plex.tv/
.. _musixmatch: https://www.musixmatch.com/
@@ -2363,7 +2596,7 @@ Fixes:
* :doc:`/plugins/convert`: Display a useful error message when the FFmpeg
executable can't be found.
-.. _requests: http://www.python-requests.org/
+.. _requests: https://www.python-requests.org/
1.3.3 (February 26, 2014)
@@ -2545,7 +2778,7 @@ As usual, there are also innumerable little fixes and improvements:
.. _Acoustic Attributes: http://developer.echonest.com/acoustic-attributes.html
-.. _MPD: http://www.musicpd.org/
+.. _MPD: https://www.musicpd.org/
1.3.1 (October 12, 2013)
@@ -2612,7 +2845,7 @@ And some fixes:
* :doc:`/plugins/scrub`: Avoid preserving certain non-standard ID3 tags such
as NCON.
-.. _Opus: http://www.opus-codec.org/
+.. _Opus: https://www.opus-codec.org/
.. _@Verrus: https://github.com/Verrus
@@ -2650,7 +2883,7 @@ previous versions would spit out a warning and then list your entire library.
There's more detail than you could ever need `on the beets blog`_.
-.. _on the beets blog: http://beets.io/blog/flexattr.html
+.. _on the beets blog: https://beets.io/blog/flexattr.html
1.2.2 (August 27, 2013)
@@ -2844,8 +3077,8 @@ And a batch of fixes:
* :doc:`/plugins/lyrics`: Lyrics searches should now turn up more results due
to some fixes in dealing with special characters.
-.. _Discogs: http://discogs.com/
-.. _Beatport: http://www.beatport.com/
+.. _Discogs: https://discogs.com/
+.. _Beatport: https://www.beatport.com/
1.1.0 (April 29, 2013)
@@ -2894,7 +3127,7 @@ will automatically migrate your configuration to the new system.
header. Thanks to Uwe L. Korn.
* :doc:`/plugins/lastgenre`: Fix an error when using genre canonicalization.
-.. _Tomahawk: http://www.tomahawk-player.org/
+.. _Tomahawk: https://tomahawk-player.org/
1.1b3 (March 16, 2013)
----------------------
@@ -3069,7 +3302,7 @@ Other new stuff:
(YAML doesn't like tabs.)
* Fix the ``-l`` (log path) command-line option for the ``import`` command.
-.. _iTunes Sound Check: http://support.apple.com/kb/HT2425
+.. _iTunes Sound Check: https://support.apple.com/kb/HT2425
1.1b1 (January 29, 2013)
------------------------
@@ -3078,7 +3311,7 @@ This release entirely revamps beets' configuration system. The configuration
file is now a `YAML`_ document and is located, along with other support files,
in a common directory (e.g., ``~/.config/beets`` on Unix-like systems).
-.. _YAML: http://en.wikipedia.org/wiki/YAML
+.. _YAML: https://en.wikipedia.org/wiki/YAML
* Renamed plugins: The ``rdm`` plugin has been renamed to ``random`` and
``fuzzy_search`` has been renamed to ``fuzzy``.
@@ -3238,9 +3471,9 @@ begins today on features for version 1.1.
unintentionally loading the plugins they contain.
.. _The Echo Nest: http://the.echonest.com/
-.. _Tomahawk resolver: http://beets.io/blog/tomahawk-resolver.html
+.. _Tomahawk resolver: https://beets.io/blog/tomahawk-resolver.html
.. _mp3gain: http://mp3gain.sourceforge.net/download.php
-.. _aacgain: http://aacgain.altosdesign.com
+.. _aacgain: https://aacgain.altosdesign.com
1.0b15 (July 26, 2012)
----------------------
@@ -3349,7 +3582,7 @@ fetching cover art for your music, enable this plugin after upgrading to beets
database with ``beet import -AWC /path/to/music``.
* Fix ``import`` with relative path arguments on Windows.
-.. _artist credits: http://wiki.musicbrainz.org/Artist_Credit
+.. _artist credits: https://wiki.musicbrainz.org/Artist_Credit
1.0b14 (May 12, 2012)
---------------------
@@ -3507,7 +3740,7 @@ to come in the next couple of releases.
data.
* Fix the ``list`` command in BPD (thanks to Simon Chopin).
-.. _Colorama: http://pypi.python.org/pypi/colorama
+.. _Colorama: https://pypi.python.org/pypi/colorama
1.0b12 (January 16, 2012)
-------------------------
@@ -3620,12 +3853,12 @@ release: one for assigning genres and another for ReplayGain analysis.
corrupted.
.. _KraYmer: https://github.com/KraYmer
-.. _Next Generation Schema: http://musicbrainz.org/doc/XML_Web_Service/Version_2
+.. _Next Generation Schema: https://musicbrainz.org/doc/XML_Web_Service/Version_2
.. _python-musicbrainzngs: https://github.com/alastair/python-musicbrainzngs
-.. _acoustid: http://acoustid.org/
+.. _acoustid: https://acoustid.org/
.. _Peter Brunner: https://github.com/Lugoues
.. _Simon Chopin: https://github.com/laarmen
-.. _albumart.org: http://www.albumart.org/
+.. _albumart.org: https://www.albumart.org/
1.0b10 (September 22, 2011)
---------------------------
@@ -3794,8 +4027,8 @@ below, for a plethora of new features.
* Fix a crash on album queries with item-only field names.
-.. _xargs: http://en.wikipedia.org/wiki/xargs
-.. _unidecode: http://pypi.python.org/pypi/Unidecode/0.04.1
+.. _xargs: https://en.wikipedia.org/wiki/xargs
+.. _unidecode: https://pypi.python.org/pypi/Unidecode/0.04.1
1.0b8 (April 28, 2011)
----------------------
@@ -3938,7 +4171,7 @@ new configuration options and the ability to clean up empty directory subtrees.
* The old "albumify" plugin for upgrading databases was removed.
-.. _as specified by MusicBrainz: http://wiki.musicbrainz.org/ReleaseType
+.. _as specified by MusicBrainz: https://wiki.musicbrainz.org/ReleaseType
1.0b6 (January 20, 2011)
------------------------
@@ -4054,7 +4287,7 @@ are also rolled into this release.
* Fixed escaping of ``/`` characters in paths on Windows.
-.. _!!!: http://musicbrainz.org/artist/f26c72d3-e52c-467b-b651-679c73d8e1a7.html
+.. _!!!: https://musicbrainz.org/artist/f26c72d3-e52c-467b-b651-679c73d8e1a7.html
1.0b4 (August 9, 2010)
----------------------
@@ -4243,7 +4476,7 @@ Vorbis) and an option to log untaggable albums during import.
removed dependency on the aging ``cmdln`` module in favor of `a hand-rolled
solution`_.
-.. _a hand-rolled solution: http://gist.github.com/462717
+.. _a hand-rolled solution: https://gist.github.com/462717
1.0b1 (June 17, 2010)
---------------------
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/dev/api.rst b/docs/dev/api.rst
deleted file mode 100644
index d9e68481d..000000000
--- a/docs/dev/api.rst
+++ /dev/null
@@ -1,84 +0,0 @@
-API Documentation
-=================
-
-.. currentmodule:: beets.library
-
-This page describes the internal API of beets' core. It's a work in
-progress---since beets is an application first and a library second, its API
-has been mainly undocumented until recently. Please file bugs if you run
-across incomplete or incorrect docs here.
-
-The :class:`Library` object is the central repository for data in beets. It
-represents a database containing songs, which are :class:`Item` instances, and
-groups of items, which are :class:`Album` instances.
-
-The Library Class
------------------
-
-.. autoclass:: Library(path, directory[, path_formats[, replacements]])
-
- .. automethod:: items
-
- .. automethod:: albums
-
- .. automethod:: get_item
-
- .. automethod:: get_album
-
- .. automethod:: add
-
- .. automethod:: add_album
-
- .. automethod:: transaction
-
-Transactions
-''''''''''''
-
-The :class:`Library` class provides the basic methods necessary to access and
-manipulate its contents. To perform more complicated operations atomically, or
-to interact directly with the underlying SQLite database, you must use a
-*transaction*. For example::
-
- lib = Library()
- with lib.transaction() as tx:
- items = lib.items(query)
- lib.add_album(list(items))
-
-.. currentmodule:: beets.dbcore.db
-
-.. autoclass:: Transaction
- :members:
-
-Model Classes
--------------
-
-The two model entities in beets libraries, :class:`Item` and :class:`Album`,
-share a base class, :class:`Model`, that provides common functionality and
-ORM-like abstraction.
-
-The fields model classes can be accessed using attributes (dots, as in
-``item.artist``) or items (brackets, as in ``item['artist']``). The
-:class:`Model` base class provides some methods that resemble `dict`
-objects.
-
-Model base
-''''''''''
-
-.. currentmodule:: beets.dbcore
-
-.. autoclass:: Model
- :members:
-
-Item
-''''
-
-.. currentmodule:: beets.library
-
-.. autoclass:: Item
- :members:
-
-Album
-'''''
-
-.. autoclass:: Album
- :members:
diff --git a/docs/dev/cli.rst b/docs/dev/cli.rst
new file mode 100644
index 000000000..77d3af5a5
--- /dev/null
+++ b/docs/dev/cli.rst
@@ -0,0 +1,9 @@
+Providing a CLI
+===============
+
+The ``beets.ui`` module houses interactions with the user via a terminal, the
+:doc:`/reference/cli`.
+The main function is called when the user types beet on the command line.
+The CLI functionality is organized into commands, some of which are built-in
+and some of which are provided by plugins. The built-in commands are all
+implemented in the ``beets.ui.commands`` submodule.
diff --git a/docs/dev/importer.rst b/docs/dev/importer.rst
new file mode 100644
index 000000000..5182c7134
--- /dev/null
+++ b/docs/dev/importer.rst
@@ -0,0 +1,19 @@
+Music Importer
+==============
+
+The importer component is responsible for the user-centric workflow that adds
+music to a library. This is one of the first aspects that a user experiences
+when using beets: it finds music in the filesystem, groups it into albums,
+finds corresponding metadata in MusicBrainz, asks the user for intervention,
+applies changes, and moves/copies files. A description of its user interface is
+given in :doc:`/guides/tagger`.
+
+The workflow is implemented in the ``beets.importer`` module and is
+distinct from the core logic for matching MusicBrainz metadata (in the
+``beets.autotag`` module). The workflow is also decoupled from the command-line
+interface with the hope that, eventually, other (graphical) interfaces can be
+bolted onto the same importer implementation.
+
+The importer is multithreaded and follows the pipeline pattern. Each pipeline
+stage is a Python coroutine. The ``beets.util.pipeline`` module houses
+a generic, reusable implementation of a multithreaded pipeline.
diff --git a/docs/dev/index.rst b/docs/dev/index.rst
index 45640254c..f1465494d 100644
--- a/docs/dev/index.rst
+++ b/docs/dev/index.rst
@@ -7,9 +7,11 @@ in hacking beets itself or creating plugins for it.
See also the documentation for `MediaFile`_, the library used by beets to read
and write metadata tags in media files.
-.. _MediaFile: http://mediafile.readthedocs.io/
+.. _MediaFile: https://mediafile.readthedocs.io/
.. toctree::
plugins
- api
+ library
+ importer
+ cli
diff --git a/docs/dev/library.rst b/docs/dev/library.rst
new file mode 100644
index 000000000..77e218b93
--- /dev/null
+++ b/docs/dev/library.rst
@@ -0,0 +1,279 @@
+Library Database API
+====================
+
+.. currentmodule:: beets.library
+
+This page describes the internal API of beets' core database features. It
+doesn't exhaustively document the API, but is aimed at giving an overview of
+the architecture to orient anyone who wants to dive into the code.
+
+The :class:`Library` object is the central repository for data in beets. It
+represents a database containing songs, which are :class:`Item` instances, and
+groups of items, which are :class:`Album` instances.
+
+The Library Class
+-----------------
+
+The :class:`Library` is typically instantiated as a singleton. A single
+invocation of beets usually has only one :class:`Library`. It's powered by
+:class:`dbcore.Database` under the hood, which handles the `SQLite`_
+abstraction, something like a very minimal `ORM`_. The library is also
+responsible for handling queries to retrieve stored objects.
+
+.. autoclass:: Library(path, directory[, path_formats[, replacements]])
+
+ .. automethod:: __init__
+
+ You can add new items or albums to the library:
+
+ .. automethod:: add
+
+ .. automethod:: add_album
+
+ And there are methods for querying the database:
+
+ .. automethod:: items
+
+ .. automethod:: albums
+
+ .. automethod:: get_item
+
+ .. automethod:: get_album
+
+ Any modifications must go through a :class:`Transaction` which you get can
+ using this method:
+
+ .. automethod:: transaction
+
+.. _SQLite: https://sqlite.org/
+.. _ORM: https://en.wikipedia.org/wiki/Object-relational_mapping
+
+
+Model Classes
+-------------
+
+The two model entities in beets libraries, :class:`Item` and :class:`Album`,
+share a base class, :class:`LibModel`, that provides common functionality. That
+class itself specialises :class:`dbcore.Model` which provides an ORM-like
+abstraction.
+
+To get or change the metadata of a model (an item or album), either access its
+attributes (e.g., ``print(album.year)`` or ``album.year = 2012``) or use the
+``dict``-like interface (e.g. ``item['artist']``).
+
+
+Model base
+''''''''''
+
+Models use dirty-flags to track when the object's metadata goes out of
+sync with the database. The dirty dictionary maps field names to booleans
+indicating whether the field has been written since the object was last
+synchronized (via load or store) with the database.
+
+.. autoclass:: LibModel
+
+ .. automethod:: all_keys
+
+ .. automethod:: __init__
+
+ .. autoattribute:: _types
+
+ .. autoattribute:: _fields
+
+ There are CRUD-like methods for interacting with the database:
+
+ .. automethod:: store
+
+ .. automethod:: load
+
+ .. automethod:: remove
+
+ .. automethod:: add
+
+ The base class :class:`dbcore.Model` has a ``dict``-like interface, so
+ normal the normal mapping API is supported:
+
+ .. automethod:: keys
+
+ .. automethod:: update
+
+ .. automethod:: items
+
+ .. automethod:: get
+
+Item
+''''
+
+Each :class:`Item` object represents a song or track. (We use the more generic
+term item because, one day, beets might support non-music media.) An item can
+either be purely abstract, in which case it's just a bag of metadata fields,
+or it can have an associated file (indicated by ``item.path``).
+
+In terms of the underlying SQLite database, items are backed by a single table
+called items with one column per metadata fields. The metadata fields currently
+in use are listed in ``library.py`` in ``Item._fields``.
+
+To read and write a file's tags, we use the `MediaFile`_ library.
+To make changes to either the database or the tags on a file, you
+update an item's fields (e.g., ``item.title = "Let It Be"``) and then call
+``item.write()``.
+
+.. _MediaFile: https://mediafile.readthedocs.io/
+
+Items also track their modification times (mtimes) to help detect when they
+become out of sync with on-disk metadata, mainly to speed up the
+:ref:`update-cmd` (which needs to check whether the database is in sync with
+the filesystem). This feature turns out to be sort of complicated.
+
+For any :class:`Item`, there are two mtimes: the on-disk mtime (maintained by
+the OS) and the database mtime (maintained by beets). Correspondingly, there is
+on-disk metadata (ID3 tags, for example) and DB metadata. The goal with the
+mtime is to ensure that the on-disk and DB mtimes match when the on-disk and DB
+metadata are in sync; this lets beets do a quick mtime check and avoid
+rereading files in some circumstances.
+
+Specifically, beets attempts to maintain the following invariant:
+
+ If the on-disk metadata differs from the DB metadata, then the on-disk
+ mtime must be greater than the DB mtime.
+
+As a result, it is always valid for the DB mtime to be zero (assuming that real
+disk mtimes are always positive). However, whenever possible, beets tries to
+set ``db_mtime = disk_mtime`` at points where it knows the metadata is
+synchronized. When it is possible that the metadata is out of sync, beets can
+then just set ``db_mtime = 0`` to return to a consistent state.
+
+This leads to the following implementation policy:
+
+ * On every write of disk metadata (``Item.write()``), the DB mtime is updated
+ to match the post-write disk mtime.
+ * Same for metadata reads (``Item.read()``).
+ * On every modification to DB metadata (``item.field = ...``), the DB mtime
+ is reset to zero.
+
+
+.. autoclass:: Item
+
+ .. automethod:: __init__
+
+ .. automethod:: from_path
+
+ .. automethod:: get_album
+
+ .. automethod:: destination
+
+ .. automethod:: current_mtime
+
+ The methods ``read()`` and ``write()`` are complementary: one reads a
+ file's tags and updates the item's metadata fields accordingly while the
+ other takes the item's fields and writes them to the file's tags.
+
+ .. automethod:: read
+
+ .. automethod:: write
+
+ .. automethod:: try_write
+
+ .. automethod:: try_sync
+
+ The :class:`Item` class supplements the normal model interface so that they
+ interacting with the filesystem as well:
+
+ .. automethod:: move
+
+ .. automethod:: remove
+
+Album
+'''''
+
+An :class:`Album` is a collection of Items in the database. Every item in the
+database has either zero or one associated albums (accessible via
+``item.album_id``). An item that has no associated album is called a
+singleton.
+Changing fields on an album (e.g. ``album.year = 2012``) updates the album
+itself and also changes the same field in all associated items.
+
+An :class:`Album` object keeps track of album-level metadata, which is (mostly)
+a subset of the track-level metadata. The album-level metadata fields are
+listed in ``Album._fields``.
+For those fields that are both item-level and album-level (e.g., ``year`` or
+``albumartist``), every item in an album should share the same value. Albums
+use an SQLite table called ``albums``, in which each column is an album
+metadata field.
+
+.. autoclass:: Album
+
+ .. automethod:: __init__
+
+ .. automethod:: item_dir
+
+ Albums extend the normal model interface to also forward changes to their
+ items:
+
+ .. autoattribute:: item_keys
+
+ .. automethod:: store
+
+ .. automethod:: try_sync
+
+ .. automethod:: move
+
+ .. automethod:: remove
+
+ Albums also manage album art, image files that are associated with each
+ album:
+
+ .. automethod:: set_art
+
+ .. automethod:: move_art
+
+ .. automethod:: art_destination
+
+Transactions
+''''''''''''
+
+The :class:`Library` class provides the basic methods necessary to access and
+manipulate its contents. To perform more complicated operations atomically, or
+to interact directly with the underlying SQLite database, you must use a
+*transaction* (see this `blog post`_ for motivation). For example::
+
+ lib = Library()
+ with lib.transaction() as tx:
+ items = lib.items(query)
+ lib.add_album(list(items))
+
+.. _blog post: https://beets.io/blog/sqlite-nightmare.html
+
+.. currentmodule:: beets.dbcore.db
+
+.. autoclass:: Transaction
+ :members:
+
+
+Queries
+-------
+
+To access albums and items in a library, we use :doc:`/reference/query`.
+In beets, the :class:`Query` abstract base class represents a criterion that
+matches items or albums in the database.
+Every subclass of :class:`Query` must implement two methods, which implement
+two different ways of identifying matching items/albums.
+
+The ``clause()`` method should return an SQLite ``WHERE`` clause that matches
+appropriate albums/items. This allows for efficient batch queries.
+Correspondingly, the ``match(item)`` method should take an :class:`Item` object
+and return a boolean, indicating whether or not a specific item matches the
+criterion. This alternate implementation allows clients to determine whether
+items that have already been fetched from the database match the query.
+
+There are many different types of queries. Just as an example,
+:class:`FieldQuery` determines whether a certain field matches a certain value
+(an equality query).
+:class:`AndQuery` (like its abstract superclass, :class:`CollectionQuery`)
+takes a set of other query objects and bundles them together, matching only
+albums/items that match all constituent queries.
+
+Beets has a human-writable plain-text query syntax that can be parsed into
+:class:`Query` objects. Calling ``AndQuery.from_strings`` parses a list of
+query parts into a query object that can then be used with :class:`Library`
+objects.
diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst
index 7ff397bc6..3328654e0 100644
--- a/docs/dev/plugins.rst
+++ b/docs/dev/plugins.rst
@@ -15,7 +15,7 @@ structure should look like this::
myawesomeplugin.py
.. _Stack Overflow question about namespace packages:
- http://stackoverflow.com/questions/1675734/how-do-i-create-a-namespace-package-in-python/1676069#1676069
+ https://stackoverflow.com/questions/1675734/how-do-i-create-a-namespace-package-in-python/1676069#1676069
Then, you'll need to put this stuff in ``__init__.py`` to make ``beetsplug`` a
namespace package::
@@ -42,7 +42,7 @@ Then, as described above, edit your ``config.yaml`` to include
``plugins: myawesomeplugin`` (substituting the name of the Python module
containing your plugin).
-.. _virtualenv: http://pypi.python.org/pypi/virtualenv
+.. _virtualenv: https://pypi.org/project/virtualenv
.. _add_subcommands:
@@ -73,7 +73,7 @@ but it defaults to an empty parser (you can extend it later). ``help`` is a
description of your command, and ``aliases`` is a list of shorthand versions of
your command name.
-.. _OptionParser instance: http://docs.python.org/library/optparse.html
+.. _OptionParser instance: https://docs.python.org/library/optparse.html
You'll need to add a function to your command by saying ``mycommand.func =
myfunction``. This function should take the following parameters: ``lib`` (a
@@ -81,7 +81,7 @@ beets ``Library`` object) and ``opts`` and ``args`` (command-line options and
arguments as returned by `OptionParser.parse_args`_).
.. _OptionParser.parse_args:
- http://docs.python.org/library/optparse.html#parsing-arguments
+ https://docs.python.org/library/optparse.html#parsing-arguments
The function should use any of the utility functions defined in ``beets.ui``.
Try running ``pydoc beets.ui`` to see what's available.
@@ -301,7 +301,7 @@ To access this value, say ``self.config['foo'].get()`` at any point in your
plugin's code. The `self.config` object is a *view* as defined by the `Confuse`_
library.
-.. _Confuse: http://confuse.readthedocs.org/
+.. _Confuse: https://confuse.readthedocs.org/
If you want to access configuration values *outside* of your plugin's section,
import the `config` object from the `beets` module. That is, just put ``from
@@ -379,7 +379,7 @@ access to file tags. If you have created a descriptor you can add it through
your plugins ``add_media_field()`` method.
.. automethod:: beets.plugins.BeetsPlugin.add_media_field
-.. _MediaFile: http://mediafile.readthedocs.io/
+.. _MediaFile: https://mediafile.readthedocs.io/
Here's an example plugin that provides a meaningless new field "foo"::
diff --git a/docs/faq.rst b/docs/faq.rst
index b7ec10df5..9732a4725 100644
--- a/docs/faq.rst
+++ b/docs/faq.rst
@@ -6,8 +6,8 @@ Got a question that isn't answered here? Try `IRC`_, the `discussion board`_, or
:ref:`filing an issue ` in the bug tracker.
.. _IRC: irc://irc.freenode.net/beets
-.. _mailing list: http://groups.google.com/group/beets-users
-.. _discussion board: http://discourse.beets.io
+.. _mailing list: https://groups.google.com/group/beets-users
+.. _discussion board: https://discourse.beets.io
.. contents::
:local:
@@ -94,14 +94,14 @@ the tracks into a single directory to force them to be tagged together.
An MBID looks like one of these:
-- ``http://musicbrainz.org/release/ded77dcf-7279-457e-955d-625bd3801b87``
+- ``https://musicbrainz.org/release/ded77dcf-7279-457e-955d-625bd3801b87``
- ``d569deba-8c6b-4d08-8c43-d0e5a1b8c7f3``
Beets can recognize either the hex-with-dashes UUID-style string or the
full URL that contains it (as of 1.0b11).
You can get these IDs by `searching on the MusicBrainz web
-site `__ and going to a *release* page (when
+site `__ and going to a *release* page (when
tagging full albums) or a *recording* page (when tagging singletons).
Then, copy the URL of the page and paste it into beets.
@@ -119,7 +119,7 @@ Run a command like this::
pip install -U beets
-The ``-U`` flag tells `pip `__ to upgrade
+The ``-U`` flag tells `pip `__ to upgrade
beets to the latest version. If you want a specific version, you can
specify with using ``==`` like so::
@@ -163,10 +163,10 @@ on GitHub. `Enter a new issue `__
there to report a bug. Please follow these guidelines when reporting an issue:
- Most importantly: if beets is crashing, please `include the
- traceback `__. Tracebacks can be more
+ traceback `__. Tracebacks can be more
readable if you put them in a pastebin (e.g.,
`Gist `__ or
- `Hastebin `__), especially when communicating
+ `Hastebin `__), especially when communicating
over IRC or email.
- Turn on beets' debug output (using the -v option: for example,
``beet -v import ...``) and include that with your bug report. Look
@@ -188,7 +188,7 @@ there to report a bug. Please follow these guidelines when reporting an issue:
If you've never reported a bug before, Mozilla has some well-written
`general guidelines for good bug
-reports `__.
+reports `__.
.. _find-config:
@@ -237,7 +237,7 @@ Why does beets…
There are a number of possibilities:
- First, make sure the album is in `the MusicBrainz
- database `__. You
+ database `__. You
can search on their site to make sure it's cataloged there. (If not,
anyone can edit MusicBrainz---so consider adding the data yourself.)
- If the album in question is a multi-disc release, see the relevant
@@ -320,7 +320,7 @@ it encounters files that *look* like music files (according to their
extension) but seem to be broken. Most of the time, this is because the
file is corrupted. To check whether the file is intact, try opening it
in another media player (e.g.,
-`VLC `__) to see whether it can
+`VLC `__) to see whether it can
read the file. You can also use specialized programs for checking file
integrity---for example, type ``metaflac --list music.flac`` to check
FLAC files.
@@ -378,4 +378,4 @@ installed using pip, the command ``pip show -f beets`` can show you where
``beet`` was placed on your system. If you need help extending your ``$PATH``,
try `this Super User answer`_.
-.. _this Super User answer: http://superuser.com/a/284361/4569
+.. _this Super User answer: https://superuser.com/a/284361/4569
diff --git a/docs/guides/advanced.rst b/docs/guides/advanced.rst
index 38cc31d0c..f4f8d3cd9 100644
--- a/docs/guides/advanced.rst
+++ b/docs/guides/advanced.rst
@@ -93,7 +93,7 @@ everything by the Long Winters for listening on the go.
The plugin has many more dials you can fiddle with to get your conversions how
you like them. Check out :doc:`its documentation `.
-.. _ffmpeg: http://www.ffmpeg.org
+.. _ffmpeg: https://www.ffmpeg.org
Store any data you like
@@ -127,7 +127,7 @@ And, unlike :ref:`built-in fields `, such fields can be removed::
Read more than you ever wanted to know about the *flexible attributes*
feature `on the beets blog`_.
-.. _on the beets blog: http://beets.io/blog/flexattr.html
+.. _on the beets blog: https://beets.io/blog/flexattr.html
Choose a path style manually for some music
diff --git a/docs/guides/main.rst b/docs/guides/main.rst
index 563b7ef82..2f05634d9 100644
--- a/docs/guides/main.rst
+++ b/docs/guides/main.rst
@@ -4,7 +4,7 @@ Getting Started
Welcome to `beets`_! This guide will help you begin using it to make your music
collection better.
-.. _beets: http://beets.io/
+.. _beets: https://beets.io/
Installing
----------
@@ -12,7 +12,7 @@ Installing
You will need Python.
Beets works on `Python 2.7`_ and Python 3.4 or later.
-.. _Python 2.7: http://www.python.org/download/
+.. _Python 2.7: https://www.python.org/download/
* **macOS** v10.7 (Lion) and later include Python 2.7 out of the box.
You can opt for Python 3 by installing it via `Homebrew`_:
@@ -43,13 +43,13 @@ Beets works on `Python 2.7`_ and Python 3.4 or later.
* On **Fedora** 22 or later, there is a `DNF package`_::
$ sudo dnf install beets beets-plugins beets-doc
-
+
* On **Solus**, run ``eopkg install beets``.
* On **NixOS**, there's a `package `_ you can install with ``nix-env -i beets``.
.. _DNF package: https://apps.fedoraproject.org/packages/beets
-.. _SlackBuild: http://slackbuilds.org/repository/14.2/multimedia/beets/
+.. _SlackBuild: https://slackbuilds.org/repository/14.2/multimedia/beets/
.. _FreeBSD: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets
.. _AUR: https://aur.archlinux.org/packages/beets-git/
.. _Debian details: https://tracker.debian.org/pkg/beets
@@ -64,14 +64,14 @@ beets`` if you run into permissions problems).
To install without pip, download beets from `its PyPI page`_ and run ``python
setup.py install`` in the directory therein.
-.. _its PyPI page: http://pypi.python.org/pypi/beets#downloads
-.. _pip: http://www.pip-installer.org/
+.. _its PyPI page: https://pypi.org/project/beets#downloads
+.. _pip: https://pip.pypa.io
The best way to upgrade beets to a new version is by running ``pip install -U
beets``. You may want to follow `@b33ts`_ on Twitter to hear about progress on
new versions.
-.. _@b33ts: http://twitter.com/b33ts
+.. _@b33ts: https://twitter.com/b33ts
Installing on macOS 10.11 and Higher
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -87,7 +87,7 @@ If this happens, you can install beets for the current user only by typing
``~/Library/Python/3.6/bin`` to your ``$PATH``.
.. _System Integrity Protection: https://support.apple.com/en-us/HT204899
-.. _Homebrew: http://brew.sh
+.. _Homebrew: https://brew.sh
Installing on Windows
^^^^^^^^^^^^^^^^^^^^^
@@ -122,10 +122,10 @@ Because I don't use Windows myself, I may have missed something. If you have
trouble or you have more detail to contribute here, please direct it to
`the mailing list`_.
-.. _install Python: http://python.org/download/
+.. _install Python: https://python.org/download/
.. _beets.reg: https://github.com/beetbox/beets/blob/master/extra/beets.reg
-.. _install pip: http://www.pip-installer.org/en/latest/installing.html#install-pip
-.. _get-pip.py: https://raw.github.com/pypa/pip/master/contrib/get-pip.py
+.. _install pip: https://pip.pypa.io/en/stable/installing/
+.. _get-pip.py: https://bootstrap.pypa.io/get-pip.py
Configuring
@@ -179,7 +179,7 @@ There are approximately six million other configuration options you can set
here, including the directory and file naming scheme. See
:doc:`/reference/config` for a full reference.
-.. _YAML: http://yaml.org/
+.. _YAML: https://yaml.org/
Importing Your Library
----------------------
@@ -300,6 +300,6 @@ import`` gives more specific help about the ``import`` command.
Please let me know what you think of beets via `the discussion board`_ or
`Twitter`_.
-.. _the mailing list: http://groups.google.com/group/beets-users
-.. _the discussion board: http://discourse.beets.io
-.. _twitter: http://twitter.com/b33ts
+.. _the mailing list: https://groups.google.com/group/beets-users
+.. _the discussion board: https://discourse.beets.io
+.. _twitter: https://twitter.com/b33ts
diff --git a/docs/guides/tagger.rst b/docs/guides/tagger.rst
index b70857ca5..467d605a4 100644
--- a/docs/guides/tagger.rst
+++ b/docs/guides/tagger.rst
@@ -272,7 +272,7 @@ Before you jump into acoustic fingerprinting with both feet, though, give beets
a try without it. You may be surprised at how well metadata-based matching
works.
-.. _Chromaprint: http://acoustid.org/chromaprint
+.. _Chromaprint: https://acoustid.org/chromaprint
Album Art, Lyrics, Genres and Such
----------------------------------
@@ -292,7 +292,7 @@ sure the album is present in `the MusicBrainz database`_. You can search on
their site to make sure it's cataloged there. If not, anyone can edit
MusicBrainz---so consider adding the data yourself.
-.. _the MusicBrainz database: http://musicbrainz.org/
+.. _the MusicBrainz database: https://musicbrainz.org/
If you think beets is ignoring an album that's listed in MusicBrainz, please
`file a bug report`_.
@@ -305,5 +305,5 @@ I Hope That Makes Sense
If we haven't made the process clear, please post on `the discussion
board`_ and we'll try to improve this guide.
-.. _the mailing list: http://groups.google.com/group/beets-users
-.. _the discussion board: http://discourse.beets.io
+.. _the mailing list: https://groups.google.com/group/beets-users
+.. _the discussion board: https://discourse.beets.io
diff --git a/docs/index.rst b/docs/index.rst
index 43ba0526a..1d03a0f27 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -2,7 +2,7 @@ beets: the music geek's media organizer
=======================================
Welcome to the documentation for `beets`_, the media library management system
-for obsessive-compulsive music geeks.
+for obsessive music geeks.
If you're new to beets, begin with the :doc:`guides/main` guide. That guide
walks you through installing beets, setting it up how you like it, and starting
@@ -17,10 +17,10 @@ Freenode, drop by `the discussion board`_, send email to `the mailing list`_,
or `file a bug`_ in the issue tracker. Please let us know where you think this
documentation can be improved.
-.. _beets: http://beets.io/
-.. _the mailing list: http://groups.google.com/group/beets-users
+.. _beets: https://beets.io/
+.. _the mailing list: https://groups.google.com/group/beets-users
.. _file a bug: https://github.com/beetbox/beets/issues
-.. _the discussion board: http://discourse.beets.io
+.. _the discussion board: https://discourse.beets.io
Contents
--------
@@ -32,6 +32,7 @@ Contents
reference/index
plugins/index
faq
+ contributing
dev/index
.. toctree::
diff --git a/docs/plugins/absubmit.rst b/docs/plugins/absubmit.rst
index 30a77d4b0..64c77e077 100644
--- a/docs/plugins/absubmit.rst
+++ b/docs/plugins/absubmit.rst
@@ -7,22 +7,32 @@ The ``absubmit`` plugin lets you submit acoustic analysis results to the
Installation
------------
-The ``absubmit`` plugin requires the `streaming_extractor_music`_ program to run. Its source can be found on `GitHub`_, and while it is possible to compile the extractor from source, AcousticBrainz would prefer if you used their binary (see the AcousticBrainz `FAQ`_).
+The ``absubmit`` plugin requires the `streaming_extractor_music`_ program
+to run. Its source can be found on `GitHub`_, and while it is possible to
+compile the extractor from source, AcousticBrainz would prefer if you used
+their binary (see the AcousticBrainz `FAQ`_).
-The ``absubmit`` plugin also requires `requests`_, which you can install using `pip`_ by typing::
+The ``absubmit`` plugin also requires `requests`_, which you can install
+using `pip`_ by typing::
pip install requests
-After installing both the extractor binary and requests you can enable the plugin ``absubmit`` in your configuration (see :ref:`using-plugins`).
+After installing both the extractor binary and requests you can enable
+the plugin ``absubmit`` in your configuration (see :ref:`using-plugins`).
Submitting Data
---------------
Type::
- beet absubmit [QUERY]
+ beet absubmit [-f] [-d] [QUERY]
-to run the analysis program and upload its results.
+To run the analysis program and upload its results. By default, the
+command will only look for AcousticBrainz data when the tracks
+doesn't already have it; the ``-f`` or ``--force`` switch makes it refetch
+data even when it already exists. You can use the ``-d`` or ``--dry`` swtich
+to check which files will be analyzed, before you start a longer period
+of processing.
The plugin works on music with a MusicBrainz track ID attached. The plugin
will also skip music that the analysis tool doesn't support.
@@ -34,16 +44,24 @@ will also skip music that the analysis tool doesn't support.
Configuration
-------------
-To configure the plugin, make a ``absubmit:`` section in your configuration file. The available options are:
+To configure the plugin, make a ``absubmit:`` section in your configuration
+file. The available options are:
-- **auto**: Analyze every file on import. Otherwise, you need to use the ``beet absubmit`` command explicitly.
+- **auto**: Analyze every file on import. Otherwise, you need to use the
+ ``beet absubmit`` command explicitly.
Default: ``no``
- **extractor**: The absolute path to the `streaming_extractor_music`_ binary.
Default: search for the program in your ``$PATH``
+- **force**: Analyze items and submit of AcousticBrainz data even for tracks
+ that already have it.
+ Default: ``no``.
+- **pretend**: Do not analyze and submit of AcousticBrainz data but print out
+ the items which would be processed.
+ Default: ``no``.
-.. _streaming_extractor_music: http://acousticbrainz.org/download
-.. _FAQ: http://acousticbrainz.org/faq
-.. _pip: http://www.pip-installer.org/
-.. _requests: http://docs.python-requests.org/en/master/
+.. _streaming_extractor_music: https://acousticbrainz.org/download
+.. _FAQ: https://acousticbrainz.org/faq
+.. _pip: https://pip.pypa.io
+.. _requests: https://docs.python-requests.org/en/master/
.. _github: https://github.com/MTG/essentia
.. _AcousticBrainz: https://acousticbrainz.org
diff --git a/docs/plugins/acousticbrainz.rst b/docs/plugins/acousticbrainz.rst
index 5bd514c64..7d7aed237 100644
--- a/docs/plugins/acousticbrainz.rst
+++ b/docs/plugins/acousticbrainz.rst
@@ -4,7 +4,7 @@ AcousticBrainz Plugin
The ``acousticbrainz`` plugin gets acoustic-analysis information from the
`AcousticBrainz`_ project.
-.. _AcousticBrainz: http://acousticbrainz.org/
+.. _AcousticBrainz: https://acousticbrainz.org/
Enable the ``acousticbrainz`` plugin in your configuration (see :ref:`using-plugins`) and run it by typing::
@@ -38,7 +38,9 @@ these fields:
* ``mood_party``
* ``mood_relaxed``
* ``mood_sad``
+* ``moods_mirex``
* ``rhythm``
+* ``timbre``
* ``tonal``
* ``voice_instrumental``
diff --git a/docs/plugins/beatport.rst b/docs/plugins/beatport.rst
index da77cd4cd..cbf5b4312 100644
--- a/docs/plugins/beatport.rst
+++ b/docs/plugins/beatport.rst
@@ -4,7 +4,9 @@ Beatport Plugin
The ``beatport`` plugin adds support for querying the `Beatport`_ catalogue
during the autotagging process. This can potentially be helpful for users
whose collection includes a lot of diverse electronic music releases, for which
-both MusicBrainz and (to a lesser degree) Discogs show no matches.
+both MusicBrainz and (to a lesser degree) `Discogs`_ show no matches.
+
+.. _Discogs: https://discogs.com
Installation
------------
@@ -21,16 +23,24 @@ run the :ref:`import-cmd` command after enabling the plugin, it will ask you
to authorize with Beatport by visiting the site in a browser. On the site
you will be asked to enter your username and password to authorize beets
to query the Beatport API. You will then be displayed with a single line of
-text that you should paste into your terminal. This will store the
-authentication data for subsequent runs and you will not be required to
-repeat the above steps.
+text that you should paste as a whole into your terminal. This will store the
+authentication data for subsequent runs and you will not be required to repeat
+the above steps.
Matches from Beatport should now show up alongside matches
from MusicBrainz and other sources.
If you have a Beatport ID or a URL for a release or track you want to tag, you
-can just enter one of the two at the "enter Id" prompt in the importer.
+can just enter one of the two at the "enter Id" prompt in the importer. You can
+also search for an id like so:
-.. _requests: http://docs.python-requests.org/en/latest/
+ beet import path/to/music/library --search-id id
+
+Configuration
+-------------
+
+This plugin can be configured like other metadata source plugins as described in :ref:`metadata-source-plugin-configuration`.
+
+.. _requests: https://docs.python-requests.org/en/latest/
.. _requests_oauthlib: https://github.com/requests/requests-oauthlib
-.. _Beatport: http://beatport.com
+.. _Beatport: https://beetport.com
diff --git a/docs/plugins/bpd.rst b/docs/plugins/bpd.rst
index c1a94e972..49563a73a 100644
--- a/docs/plugins/bpd.rst
+++ b/docs/plugins/bpd.rst
@@ -6,7 +6,7 @@ implements the MPD protocol, so it's compatible with all the great MPD clients
out there. I'm using `Theremin`_, `gmpc`_, `Sonata`_, and `Ario`_ successfully.
.. _Theremin: https://theremin.sigterm.eu/
-.. _gmpc: http://gmpc.wikia.com/wiki/Gnome_Music_Player_Client
+.. _gmpc: https://gmpc.wikia.com/wiki/Gnome_Music_Player_Client
.. _Sonata: http://sonata.berlios.de/
.. _Ario: http://ario-player.sourceforge.net/
@@ -29,8 +29,8 @@ You will also need the various GStreamer plugin packages to make everything
work. See the :doc:`/plugins/chroma` documentation for more information on
installing GStreamer plugins.
-.. _GStreamer WinBuilds: http://www.gstreamer-winbuild.ylatuya.es/
-.. _Homebrew: http://mxcl.github.com/homebrew/
+.. _GStreamer WinBuilds: https://www.gstreamer-winbuild.ylatuya.es/
+.. _Homebrew: https://brew.sh
Usage
-----
@@ -44,7 +44,7 @@ Then, you can run BPD by invoking::
Fire up your favorite MPD client to start playing music. The MPD site has `a
long list of available clients`_. Here are my favorites:
-.. _a long list of available clients: http://mpd.wikia.com/wiki/Clients
+.. _a long list of available clients: https://mpd.wikia.com/wiki/Clients
* Linux: `gmpc`_, `Sonata`_
@@ -52,9 +52,9 @@ long list of available clients`_. Here are my favorites:
* Windows: I don't know. Get in touch if you have a recommendation.
-* iPhone/iPod touch: `MPoD`_
+* iPhone/iPod touch: `Rigelian`_
-.. _MPoD: http://www.katoemba.net/makesnosenseatall/mpod/
+.. _Rigelian: https://www.rigelian.net/
One nice thing about MPD's (and thus BPD's) client-server architecture is that
the client can just as easily on a different computer from the server as it can
@@ -109,7 +109,7 @@ behaviour to their MPD equivalents. BPD aims to look enough like MPD that it
can interact with the ecosystem of clients, but doesn't try to be
a fully-fledged MPD replacement in terms of its playback capabilities.
-.. _the MPD protocol: http://www.musicpd.org/doc/protocol/
+.. _the MPD protocol: https://www.musicpd.org/doc/protocol/
These are some of the known differences between BPD and MPD:
diff --git a/docs/plugins/bpsync.rst b/docs/plugins/bpsync.rst
new file mode 100644
index 000000000..29cbd08e3
--- /dev/null
+++ b/docs/plugins/bpsync.rst
@@ -0,0 +1,34 @@
+BPSync Plugin
+=============
+
+This plugin provides the ``bpsync`` command, which lets you fetch metadata
+from Beatport for albums and tracks that already have Beatport IDs.
+This plugin works similarly to :doc:`/plugins/mbsync`.
+
+If you have downloaded music from Beatport, this can speed
+up the initial import if you just import "as-is" and then use ``bpsync`` to
+get up-to-date tags that are written to the files according to your beets
+configuration.
+
+
+Usage
+-----
+
+Enable the ``bpsync`` plugin in your configuration (see :ref:`using-plugins`)
+and then run ``beet bpsync QUERY`` to fetch updated metadata for a part of your
+collection (or omit the query to run over your whole library).
+
+This plugin treats albums and singletons (non-album tracks) separately. It
+first processes all matching singletons and then proceeds on to full albums.
+The same query is used to search for both kinds of entities.
+
+The command has a few command-line options:
+
+* To preview the changes that would be made without applying them, use the
+ ``-p`` (``--pretend``) flag.
+* By default, files will be moved (renamed) according to their metadata if
+ they are inside your beets library directory. To disable this, use the
+ ``-M`` (``--nomove``) command-line option.
+* If you have the ``import.write`` configuration option enabled, then this
+ plugin will write new metadata to files' tags. To disable this, use the
+ ``-W`` (``--nowrite``) option.
diff --git a/docs/plugins/chroma.rst b/docs/plugins/chroma.rst
index 617d8cf69..a6b60e6d8 100644
--- a/docs/plugins/chroma.rst
+++ b/docs/plugins/chroma.rst
@@ -8,8 +8,8 @@ information at all (or have completely incorrect data). This plugin uses an
open-source fingerprinting technology called `Chromaprint`_ and its associated
Web service, called `Acoustid`_.
-.. _Chromaprint: http://acoustid.org/chromaprint
-.. _acoustid: http://acoustid.org/
+.. _Chromaprint: https://acoustid.org/chromaprint
+.. _acoustid: https://acoustid.org/
Turning on fingerprinting can increase the accuracy of the
autotagger---especially on files with very poor metadata---but it comes at a
@@ -31,7 +31,7 @@ First, install pyacoustid itself. You can do this using `pip`_, like so::
$ pip install pyacoustid
-.. _pip: http://www.pip-installer.org/
+.. _pip: https://pip.pypa.io
Then, you will need to install `Chromaprint`_, either as a dynamic library or
in the form of a command-line tool (``fpcalc``).
@@ -45,7 +45,7 @@ The simplest way to get up and running, especially on Windows, is to
means something like ``C:\\Program Files``. On OS X or Linux, put the
executable somewhere like ``/usr/local/bin``.
-.. _download: http://acoustid.org/chromaprint
+.. _download: https://acoustid.org/chromaprint
Installing the Library
''''''''''''''''''''''
@@ -56,7 +56,7 @@ site has links to packages for major Linux distributions. If you use
`Homebrew`_ on Mac OS X, you can install the library with ``brew install
chromaprint``.
-.. _Homebrew: http://mxcl.github.com/homebrew/
+.. _Homebrew: https://brew.sh/
You will also need a mechanism for decoding audio files supported by the
`audioread`_ library:
@@ -78,12 +78,12 @@ You will also need a mechanism for decoding audio files supported by the
* On Windows, builds are provided by `GStreamer`_
.. _audioread: https://github.com/beetbox/audioread
-.. _pyacoustid: http://github.com/beetbox/pyacoustid
-.. _FFmpeg: http://ffmpeg.org/
-.. _MAD: http://spacepants.org/src/pymad/
-.. _pymad: http://www.underbit.com/products/mad/
-.. _Core Audio: http://developer.apple.com/technologies/mac/audio-and-video.html
-.. _Gstreamer: http://gstreamer.freedesktop.org/
+.. _pyacoustid: https://github.com/beetbox/pyacoustid
+.. _FFmpeg: https://ffmpeg.org/
+.. _MAD: https://spacepants.org/src/pymad/
+.. _pymad: https://www.underbit.com/products/mad/
+.. _Core Audio: https://developer.apple.com/technologies/mac/audio-and-video.html
+.. _Gstreamer: https://gstreamer.freedesktop.org/
.. _PyGObject: https://wiki.gnome.org/Projects/PyGObject
To decode audio formats (MP3, FLAC, etc.) with GStreamer, you'll need the
@@ -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
@@ -132,4 +134,4 @@ Then, run ``beet submit``. (You can also provide a query to submit a subset of
your library.) The command will use stored fingerprints if they're available;
otherwise it will fingerprint each file before submitting it.
-.. _get an API key: http://acoustid.org/api-key
+.. _get an API key: https://acoustid.org/api-key
diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst
index 92545af30..6e9d00a11 100644
--- a/docs/plugins/convert.rst
+++ b/docs/plugins/convert.rst
@@ -14,7 +14,7 @@ To use the ``convert`` plugin, first enable it in your configuration (see
:ref:`using-plugins`). By default, the plugin depends on `FFmpeg`_ to
transcode the audio, so you might want to install it.
-.. _FFmpeg: http://ffmpeg.org
+.. _FFmpeg: https://ffmpeg.org
Usage
@@ -24,7 +24,9 @@ To convert a part of your collection, run ``beet convert QUERY``. The
command will transcode all the files matching the query to the
destination directory given by the ``-d`` (``--dest``) option or the
``dest`` configuration. The path layout mirrors that of your library,
-but it may be customized through the ``paths`` configuration.
+but it may be customized through the ``paths`` configuration. Files
+that have been previously converted---and thus already exist in the
+destination directory---will be skipped.
The plugin uses a command-line program to transcode the audio. With the
``-f`` (``--format``) option you can choose the transcoding command
@@ -46,6 +48,12 @@ To test your configuration without taking any actions, use the ``--pretend``
flag. The plugin will print out the commands it will run instead of executing
them.
+By default, files that do not need to be transcoded will be copied to their
+destination. Passing the ``-l`` (``--link``) flag creates symbolic links
+instead, passing ``-H`` (``--hardlink``) creates hard links.
+Note that album art embedding is disabled for files that are linked.
+Refer to the ``link`` and ``hardlink`` options below.
+
Configuration
-------------
@@ -91,6 +99,18 @@ file. The available options are:
- **threads**: The number of threads to use for parallel encoding.
By default, the plugin will detect the number of processors available and use
them all.
+- **link**: By default, files that do not need to be transcoded will be copied
+ to their destination. This option creates symbolic links instead. Note that
+ options such as ``embed`` that modify the output files after the transcoding
+ step will cause the original files to be modified as well if ``link`` is
+ enabled. For this reason, album-art embedding is disabled
+ for files that are linked.
+ Default: ``false``.
+- **hardlink**: This options works similar to ``link``, but it creates
+ hard links instead of symlinks.
+ This option overrides ``link``. Only works when converting to a directory
+ on the same filesystem as the library.
+ Default: ``false``.
You can also configure the format to use for transcoding (see the next
section):
@@ -170,6 +190,6 @@ can use the :doc:`/plugins/replaygain` to do this analysis. See the LAME
options and a thorough discussion of MP3 encoding.
.. _documentation: http://lame.sourceforge.net/using.php
-.. _HydrogenAudio wiki: http://wiki.hydrogenaud.io/index.php?title=LAME
-.. _gapless: http://wiki.hydrogenaud.io/index.php?title=Gapless_playback
-.. _LAME: http://lame.sourceforge.net/
+.. _HydrogenAudio wiki: https://wiki.hydrogenaud.io/index.php?title=LAME
+.. _gapless: https://wiki.hydrogenaud.io/index.php?title=Gapless_playback
+.. _LAME: https://lame.sourceforge.net/
diff --git a/docs/plugins/deezer.rst b/docs/plugins/deezer.rst
new file mode 100644
index 000000000..29f561e6a
--- /dev/null
+++ b/docs/plugins/deezer.rst
@@ -0,0 +1,25 @@
+Deezer Plugin
+==============
+
+The ``deezer`` plugin provides metadata matches for the importer using the
+`Deezer`_ `Album`_ and `Track`_ APIs.
+
+.. _Deezer: https://www.deezer.com
+.. _Album: https://developers.deezer.com/api/album
+.. _Track: https://developers.deezer.com/api/track
+
+Basic Usage
+-----------
+
+First, enable the ``deezer`` plugin (see :ref:`using-plugins`).
+
+You can enter the URL for an album or song on Deezer at the ``enter Id``
+prompt during import::
+
+ Enter search, enter Id, aBort, eDit, edit Candidates, plaY? i
+ Enter release ID: https://www.deezer.com/en/album/572261
+
+Configuration
+-------------
+
+This plugin can be configured like other metadata source plugins as described in :ref:`metadata-source-plugin-configuration`.
diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst
index a02b34590..c199ccf49 100644
--- a/docs/plugins/discogs.rst
+++ b/docs/plugins/discogs.rst
@@ -4,7 +4,7 @@ Discogs Plugin
The ``discogs`` plugin extends the autotagger's search capabilities to
include matches from the `Discogs`_ database.
-.. _Discogs: http://discogs.com
+.. _Discogs: https://discogs.com
Installation
------------
@@ -43,6 +43,38 @@ documentation), login to `Discogs`_, and visit the
token`` button, and place the generated token in your configuration, as the
``user_token`` config option in the ``discogs`` section.
+Configuration
+-------------
+
+This plugin can be configured like other metadata source plugins as described in :ref:`metadata-source-plugin-configuration`.
+
+There is one additional option in the ``discogs:`` section, ``index_tracks``.
+Index tracks (see the `Discogs guidelines
+`_),
+along with headers, mark divisions between distinct works on the same release
+or within works. When ``index_tracks`` is enabled::
+
+ discogs:
+ index_tracks: yes
+
+beets will incorporate the names of the divisions containing each track into
+the imported track's title. For example, importing
+`this album
+`_
+would result in track names like::
+
+ Messiah, Part I: No.1: Sinfony
+ Messiah, Part II: No.22: Chorus- Behold The Lamb Of God
+ Athalia, Act I, Scene I: Sinfonia
+
+whereas with ``index_tracks`` disabled you'd get::
+
+ No.1: Sinfony
+ No.22: Chorus- Behold The Lamb Of God
+ Sinfonia
+
+This option is useful when importing classical music.
+
Troubleshooting
---------------
diff --git a/docs/plugins/embedart.rst b/docs/plugins/embedart.rst
index 2a34a59e8..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
@@ -69,7 +76,7 @@ Note: ``compare_threshold`` option requires `ImageMagick`_, and ``maxwidth``
requires either `ImageMagick`_ or `Pillow`_.
.. _Pillow: https://github.com/python-pillow/Pillow
-.. _ImageMagick: http://www.imagemagick.org/
+.. _ImageMagick: https://www.imagemagick.org/
.. _PHASH: http://www.fmwconcepts.com/misc_tests/perceptual_hash_test_results_510/
Manually Embedding and Extracting Art
diff --git a/docs/plugins/embyupdate.rst b/docs/plugins/embyupdate.rst
index d820f5c6b..626fafa9d 100644
--- a/docs/plugins/embyupdate.rst
+++ b/docs/plugins/embyupdate.rst
@@ -17,8 +17,8 @@ To use the ``embyupdate`` plugin you need to install the `requests`_ library wit
With that all in place, you'll see beets send the "update" command to your Emby server every time you change your beets library.
-.. _Emby: http://emby.media/
-.. _requests: http://docs.python-requests.org/en/latest/
+.. _Emby: https://emby.media/
+.. _requests: https://docs.python-requests.org/en/latest/
Configuration
-------------
diff --git a/docs/plugins/export.rst b/docs/plugins/export.rst
index 4326ccb16..f3756718c 100644
--- a/docs/plugins/export.rst
+++ b/docs/plugins/export.rst
@@ -2,9 +2,11 @@ Export Plugin
=============
The ``export`` plugin lets you get data from the items and export the content
-as `JSON`_.
+as `JSON`_, `CSV`_, or `XML`_.
-.. _JSON: http://www.json.org
+.. _JSON: https://www.json.org
+.. _CSV: https://fileinfo.com/extension/csv
+.. _XML: https://fileinfo.com/extension/xml
Enable the ``export`` plugin (see :ref:`using-plugins` for help). Then, type ``beet export`` followed by a :doc:`query ` to get the data from
your library. For example, run this::
@@ -13,6 +15,7 @@ your library. For example, run this::
to print a JSON file containing information about your Beatles tracks.
+
Command-Line Options
--------------------
@@ -36,30 +39,42 @@ The ``export`` command has these command-line options:
* ``--append``: Appends the data to the file instead of writing.
+* ``--format`` or ``-f``: Specifies the format the data will be exported as. If not informed, JSON will be used by default. The format options include csv, json and xml.
+
Configuration
-------------
To configure the plugin, make a ``export:`` section in your configuration
-file. Under the ``json`` key, these options are available:
+file.
+For JSON export, these options are available under the ``json`` key:
- **ensure_ascii**: Escape non-ASCII characters with ``\uXXXX`` entities.
-
- **indent**: The number of spaces for indentation.
-
- **separators**: A ``[item_separator, dict_separator]`` tuple.
-
- **sort_keys**: Sorts the keys in JSON dictionaries.
-These options match the options from the `Python json module`_.
+Those options match the options from the `Python json module`_.
+Similarly, these options are available for the CSV format under the ``csv``
+key:
+
+- **delimiter**: Used as the separating character between fields. The default value is a comma (,).
+- **dialect**: The kind of CSV file to produce. The default is `excel`.
+
+These options match the options from the `Python csv module`_.
.. _Python json module: https://docs.python.org/2/library/json.html#basic-usage
+.. _Python csv module: https://docs.python.org/3/library/csv.html#csv-fmt-params
The default options look like this::
export:
json:
formatting:
- ensure_ascii: False
+ ensure_ascii: false
indent: 4
separators: [',' , ': ']
sort_keys: true
+ csv:
+ formatting:
+ delimiter: ','
+ dialect: excel
diff --git a/docs/plugins/fetchart.rst b/docs/plugins/fetchart.rst
index 8af7f686a..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: http://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,9 +71,14 @@ 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``.
+- **high_resolution**: If enabled, fetchart retrieves artwork in the highest
+ resolution it can find (warning: image files can sometimes reach >20MB).
+ Default: ``no``.
Note: ``maxwidth`` and ``enforce_ratio`` options require either `ImageMagick`_
or `Pillow`_.
@@ -81,7 +93,7 @@ or `Pillow`_.
.. _beets custom search engine: https://cse.google.com.au:443/cse/publicurl?cx=001442825323518660753:hrh5ch1gjzm
.. _Pillow: https://github.com/python-pillow/Pillow
-.. _ImageMagick: http://www.imagemagick.org/
+.. _ImageMagick: https://www.imagemagick.org/
Here's an example that makes plugin select only images that contain ``front`` or
``back`` keywords in their filenames and prioritizes the iTunes source over
@@ -114,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:
@@ -135,7 +148,7 @@ On some versions of Windows, the program can be shadowed by a system-provided
environment variable so that ImageMagick comes first or use Pillow instead.
.. _Pillow: https://github.com/python-pillow/Pillow
-.. _ImageMagick: http://www.imagemagick.org/
+.. _ImageMagick: https://www.imagemagick.org/
.. _album-art-sources:
@@ -191,7 +204,7 @@ Optionally, you can `define a custom search engine`_. Get your search engine's
token and use it for your ``google_engine`` configuration option. The
default engine searches the entire web for cover art.
-.. _define a custom search engine: http://www.google.com/cse/all
+.. _define a custom search engine: https://www.google.com/cse/all
Note that the Google custom search API is limited to 100 queries per day.
After that, the fetchart plugin will fall back on other declared data sources.
@@ -211,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/ftintitle.rst b/docs/plugins/ftintitle.rst
index 8a080b3e2..66c9ecd69 100644
--- a/docs/plugins/ftintitle.rst
+++ b/docs/plugins/ftintitle.rst
@@ -41,4 +41,4 @@ your entire collection.
Use the ``-d`` flag to remove featured artists (equivalent of the ``drop``
config option).
-.. _MusicBrainz style: http://musicbrainz.org/doc/Style
+.. _MusicBrainz style: https://musicbrainz.org/doc/Style
diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst
index f95a6285d..aab922fcd 100644
--- a/docs/plugins/index.rst
+++ b/docs/plugins/index.rst
@@ -35,6 +35,27 @@ like this::
pip install beets[fetchart,lyrics,lastgenre]
+.. _metadata-source-plugin-configuration:
+
+Using Metadata Source Plugins
+-----------------------------
+
+Some plugins provide sources for metadata in addition to MusicBrainz. These
+plugins share the following configuration option:
+
+- **source_weight**: Penalty applied to matches during import. Set to 0.0 to
+ disable.
+ Default: ``0.5``.
+
+For example, to equally consider matches from Discogs and MusicBrainz add the
+following to your configuration::
+
+ plugins: discogs
+
+ discogs:
+ source_weight: 0.0
+
+
.. toctree::
:hidden:
@@ -44,9 +65,11 @@ like this::
beatport
bpd
bpm
+ bpsync
bucket
chroma
convert
+ deezer
discogs
duplicates
edit
@@ -55,6 +78,7 @@ like this::
export
fetchart
filefilter
+ fish
freedesktop
fromfilename
ftintitle
@@ -80,6 +104,7 @@ like this::
missing
mpdstats
mpdupdate
+ parentwork
permissions
play
playlist
@@ -91,10 +116,12 @@ like this::
smartplaylist
sonosupdate
spotify
+ subsonicplaylist
subsonicupdate
the
thumbnails
types
+ unimported
web
zero
@@ -104,10 +131,14 @@ Autotagger Extensions
* :doc:`chroma`: Use acoustic fingerprinting to identify audio files with
missing or incorrect metadata.
* :doc:`discogs`: Search for releases in the `Discogs`_ database.
+* :doc:`spotify`: Search for releases in the `Spotify`_ database.
+* :doc:`deezer`: Search for releases in the `Deezer`_ database.
* :doc:`fromfilename`: Guess metadata for untagged tracks from their
filenames.
-.. _Discogs: http://www.discogs.com/
+.. _Discogs: https://www.discogs.com/
+.. _Spotify: https://www.spotify.com
+.. _Deezer: https://www.deezer.com/
Metadata
--------
@@ -115,6 +146,7 @@ Metadata
* :doc:`absubmit`: Analyse audio with the `streaming_extractor_music`_ program and submit the metadata to the AcousticBrainz server
* :doc:`acousticbrainz`: Fetch various AcousticBrainz metadata
* :doc:`bpm`: Measure tempo using keystrokes.
+* :doc:`bpsync`: Fetch updated metadata from Beatport.
* :doc:`edit`: Edit metadata from a text editor.
* :doc:`embedart`: Embed album art images into files' metadata.
* :doc:`fetchart`: Fetch album cover art from various sources.
@@ -127,16 +159,17 @@ Metadata
* :doc:`lastgenre`: Fetch genres based on Last.fm tags.
* :doc:`lastimport`: Collect play counts from Last.fm.
* :doc:`lyrics`: Automatically fetch song lyrics.
-* :doc:`mbsync`: Fetch updated metadata from MusicBrainz
+* :doc:`mbsync`: Fetch updated metadata from MusicBrainz.
* :doc:`metasync`: Fetch metadata from local or remote sources
* :doc:`mpdstats`: Connect to `MPD`_ and update the beets library with play
statistics (last_played, play_count, skip_count, rating).
+* :doc:`parentwork`: Fetch work titles and works they are part of.
* :doc:`replaygain`: Calculate volume normalization for players that support it.
* :doc:`scrub`: Clean extraneous metadata from music files.
* :doc:`zero`: Nullify fields by pattern or unconditionally.
.. _KeyFinder: http://www.ibrahimshaath.co.uk/keyfinder/
-.. _streaming_extractor_music: http://acousticbrainz.org/download
+.. _streaming_extractor_music: https://acousticbrainz.org/download
Path Formats
------------
@@ -153,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
@@ -167,12 +201,16 @@ Interoperability
* :doc:`sonosupdate`: Automatically notifies `Sonos`_ whenever the beets library
changes.
* :doc:`thumbnails`: Get thumbnails with the cover art on your album folders.
+* :doc:`subsonicupdate`: Automatically notifies `Subsonic`_ whenever the beets
+ library changes.
-.. _Emby: http://emby.media
-.. _Plex: http://plex.tv
-.. _Kodi: http://kodi.tv
-.. _Sonos: http://sonos.com
+.. _Emby: https://emby.media
+.. _Fish shell: https://fishshell.com/
+.. _Plex: https://plex.tv
+.. _Kodi: https://kodi.tv
+.. _Sonos: https://sonos.com
+.. _Subsonic: http://www.subsonic.org/
Miscellaneous
-------------
@@ -194,14 +232,14 @@ Miscellaneous
* :doc:`mbcollection`: Maintain your MusicBrainz collection list.
* :doc:`mbsubmit`: Print an album's tracks in a MusicBrainz-friendly format.
* :doc:`missing`: List missing tracks.
-* `mstream`_: A music streaming server + webapp that can be used alongside beets.
+* `mstream`_: A music streaming server + webapp that can be used alongside beets.
* :doc:`random`: Randomly choose albums and tracks from your library.
* :doc:`spotify`: Create Spotify playlists from the Beets library.
* :doc:`types`: Declare types for flexible attributes.
* :doc:`web`: An experimental Web-based GUI for beets.
-.. _MPD: http://www.musicpd.org/
-.. _MPD clients: http://mpd.wikia.com/wiki/Clients
+.. _MPD: https://www.musicpd.org/
+.. _MPD clients: https://mpd.wikia.com/wiki/Clients
.. _mstream: https://github.com/IrosTheBeggar/mStream
.. _other-plugins:
@@ -263,6 +301,27 @@ 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 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
.. _copyartifacts: https://github.com/sbarakat/beets-copyartifacts
@@ -284,3 +343,12 @@ Here are a few of the plugins written by the beets community:
.. _beets-popularity: https://github.com/abba23/beets-popularity
.. _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/info.rst b/docs/plugins/info.rst
index 238a957ff..d628fb4ad 100644
--- a/docs/plugins/info.rst
+++ b/docs/plugins/info.rst
@@ -42,4 +42,4 @@ Additional command-line options include:
* ``--keys-only`` or ``-k``: Show the name of the tags without the values.
.. _id3v2: http://id3v2.sourceforge.net
-.. _mp3info: http://www.ibiblio.org/mp3info/
+.. _mp3info: https://www.ibiblio.org/mp3info/
diff --git a/docs/plugins/ipfs.rst b/docs/plugins/ipfs.rst
index 141143ae7..5bf8ca906 100644
--- a/docs/plugins/ipfs.rst
+++ b/docs/plugins/ipfs.rst
@@ -4,7 +4,7 @@ IPFS Plugin
The ``ipfs`` plugin makes it easy to share your library and music with friends.
The plugin uses `ipfs`_ for storing the library and file content.
-.. _ipfs: http://ipfs.io/
+.. _ipfs: https://ipfs.io/
Installation
------------
diff --git a/docs/plugins/keyfinder.rst b/docs/plugins/keyfinder.rst
index 856939ecc..2ed2c1cec 100644
--- a/docs/plugins/keyfinder.rst
+++ b/docs/plugins/keyfinder.rst
@@ -1,9 +1,9 @@
Key Finder Plugin
=================
-The `keyfinder` plugin uses the `KeyFinder`_ program to detect the
-musical key of track from its audio data and store it in the
-`initial_key` field of your database. It does so
+The `keyfinder` plugin uses either the `KeyFinder`_ or `keyfinder-cli`_
+program to detect the musical key of a track from its audio data and store
+it in the `initial_key` field of your database. It does so
automatically when importing music or through the ``beet keyfinder
[QUERY]`` command.
@@ -20,13 +20,16 @@ configuration file. The available options are:
import. Otherwise, you need to use the ``beet keyfinder`` command
explicitly.
Default: ``yes``
-- **bin**: The name of the `KeyFinder`_ program on your system or
- a path to the binary. If you installed the KeyFinder GUI on a Mac, for
- example, you want something like
+- **bin**: The name of the program use for key analysis. You can use either
+ `KeyFinder`_ or `keyfinder-cli`_.
+ If you installed the KeyFinder GUI on a Mac, for example, you want
+ something like
``/Applications/KeyFinder.app/Contents/MacOS/KeyFinder``.
+ If using `keyfinder-cli`_, the binary must be named ``keyfinder-cli``.
Default: ``KeyFinder`` (i.e., search for the program in your ``$PATH``)..
- **overwrite**: Calculate a key even for files that already have an
`initial_key` value.
Default: ``no``.
-.. _KeyFinder: http://www.ibrahimshaath.co.uk/keyfinder/
+.. _KeyFinder: https://www.ibrahimshaath.co.uk/keyfinder/
+.. _keyfinder-cli: https://github.com/EvanPurkhiser/keyfinder-cli/
diff --git a/docs/plugins/kodiupdate.rst b/docs/plugins/kodiupdate.rst
index a1ec04775..e60f503f2 100644
--- a/docs/plugins/kodiupdate.rst
+++ b/docs/plugins/kodiupdate.rst
@@ -26,8 +26,8 @@ In Kodi's interface, navigate to System/Settings/Network/Services and choose "Al
With that all in place, you'll see beets send the "update" command to your Kodi
host every time you change your beets library.
-.. _Kodi: http://kodi.tv/
-.. _requests: http://docs.python-requests.org/en/latest/
+.. _Kodi: https://kodi.tv/
+.. _requests: https://docs.python-requests.org/en/latest/
Configuration
-------------
diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst
index c3d5f97ec..5fcdd2254 100644
--- a/docs/plugins/lastgenre.rst
+++ b/docs/plugins/lastgenre.rst
@@ -7,8 +7,8 @@ importing and autotagging music, beets does not assign a genre. The
to your albums and items.
.. _does not contain genre information:
- http://musicbrainz.org/doc/General_FAQ#Why_does_MusicBrainz_not_support_genre_information.3F
-.. _Last.fm: http://last.fm/
+ https://musicbrainz.org/doc/General_FAQ#Why_does_MusicBrainz_not_support_genre_information.3F
+.. _Last.fm: https://last.fm/
Installation
------------
@@ -34,7 +34,7 @@ The genre list file should contain one genre per line. Blank lines are ignored.
For the curious, the default genre list is generated by a `script that scrapes
Wikipedia`_.
-.. _pip: http://www.pip-installer.org/
+.. _pip: https://pip.pypa.io
.. _pylast: https://github.com/pylast/pylast
.. _script that scrapes Wikipedia: https://gist.github.com/1241307
.. _internal whitelist: https://raw.githubusercontent.com/beetbox/beets/master/beetsplug/lastgenre/genres.txt
@@ -72,7 +72,7 @@ nothing would ever be matched to a more generic node since all the specific
subgenres are in the whitelist to begin with.
-.. _YAML: http://www.yaml.org/
+.. _YAML: https://www.yaml.org/
.. _tree of nested genre names: https://raw.githubusercontent.com/beetbox/beets/master/beetsplug/lastgenre/genres-tree.yaml
diff --git a/docs/plugins/lastimport.rst b/docs/plugins/lastimport.rst
index 8006d6bbb..1c12b8616 100644
--- a/docs/plugins/lastimport.rst
+++ b/docs/plugins/lastimport.rst
@@ -6,7 +6,7 @@ library into beets' database. You can later create :doc:`smart playlists
` by querying ``play_count`` and do other fun stuff
with this field.
-.. _Last.fm: http://last.fm
+.. _Last.fm: https://last.fm
Installation
------------
@@ -23,7 +23,7 @@ Next, add your Last.fm username to your beets configuration file::
lastfm:
user: beetsfanatic
-.. _pip: http://www.pip-installer.org/
+.. _pip: https://pip.pypa.io
.. _pylast: https://github.com/pylast/pylast
Importing Play Counts
diff --git a/docs/plugins/lyrics.rst b/docs/plugins/lyrics.rst
index 799bd0325..fac07ad87 100644
--- a/docs/plugins/lyrics.rst
+++ b/docs/plugins/lyrics.rst
@@ -5,9 +5,9 @@ The ``lyrics`` plugin fetches and stores song lyrics from databases on the Web.
Namely, the current version of the plugin uses `Lyric Wiki`_,
`Musixmatch`_, `Genius.com`_, and, optionally, the Google custom search API.
-.. _Lyric Wiki: http://lyrics.wikia.com/
+.. _Lyric Wiki: https://lyrics.wikia.com/
.. _Musixmatch: https://www.musixmatch.com/
-.. _Genius.com: http://genius.com/
+.. _Genius.com: https://genius.com/
Fetch Lyrics During Import
@@ -26,7 +26,7 @@ already have them. The lyrics will be stored in the beets database. If the
``import.write`` config option is on, then the lyrics will also be written to
the files' tags.
-.. _requests: http://docs.python-requests.org/en/latest/
+.. _requests: https://docs.python-requests.org/en/latest/
Configuration
@@ -105,11 +105,11 @@ A minimal ``conf.py`` and ``index.rst`` files are created the first time the
command is run. They are not overwritten on subsequent runs, so you can safely
modify these files to customize the output.
-.. _Sphinx: http://www.sphinx-doc.org/
+.. _Sphinx: https://www.sphinx-doc.org/
.. _reStructuredText: http://docutils.sourceforge.net/rst.html
Sphinx supports various `builders
-`_, but here are a
+`_, but here are a
few suggestions.
* Build an HTML version::
@@ -148,13 +148,13 @@ Optionally, you can `define a custom search engine`_. Get your search engine's
token and use it for your ``google_engine_ID`` configuration option. By
default, beets use a list of sources known to be scrapeable.
-.. _define a custom search engine: http://www.google.com/cse/all
+.. _define a custom search engine: https://www.google.com/cse/all
Note that the Google custom search API is limited to 100 queries per day.
After that, the lyrics plugin will fall back on other declared data sources.
-.. _pip: http://www.pip-installer.org/
-.. _BeautifulSoup: http://www.crummy.com/software/BeautifulSoup/bs4/doc/
+.. _pip: https://pip.pypa.io
+.. _BeautifulSoup: https://www.crummy.com/software/BeautifulSoup/bs4/doc/
Activate Genius Lyrics
----------------------
diff --git a/docs/plugins/mbcollection.rst b/docs/plugins/mbcollection.rst
index 803d34904..00acd4604 100644
--- a/docs/plugins/mbcollection.rst
+++ b/docs/plugins/mbcollection.rst
@@ -4,7 +4,7 @@ MusicBrainz Collection Plugin
The ``mbcollection`` plugin lets you submit your catalog to MusicBrainz to
maintain your `music collection`_ list there.
-.. _music collection: http://musicbrainz.org/doc/Collections
+.. _music collection: https://musicbrainz.org/doc/Collections
To begin, just enable the ``mbcollection`` plugin in your
configuration (see :ref:`using-plugins`).
@@ -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/mbsubmit.rst b/docs/plugins/mbsubmit.rst
index 5c13375ba..70e14662d 100644
--- a/docs/plugins/mbsubmit.rst
+++ b/docs/plugins/mbsubmit.rst
@@ -5,7 +5,7 @@ The ``mbsubmit`` plugin provides an extra prompt choice during an import
session that prints the tracks of the current album in a format that is
parseable by MusicBrainz's `track parser`_.
-.. _track parser: http://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings
+.. _track parser: https://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings
Usage
-----
@@ -15,7 +15,7 @@ and select the ``Print tracks`` choice which is by default displayed when no
strong recommendations are found for the album::
No matching release found for 3 tracks.
- For help, see: http://beets.readthedocs.org/en/latest/faq.html#nomatch
+ For help, see: https://beets.readthedocs.org/en/latest/faq.html#nomatch
[U]se as-is, as Tracks, Group albums, Skip, Enter search, enter Id, aBort,
Print tracks? p
01. An Obscure Track - An Obscure Artist (3:37)
@@ -23,7 +23,7 @@ strong recommendations are found for the album::
03. The Third Track - Another Obscure Artist (3:02)
No matching release found for 3 tracks.
- For help, see: http://beets.readthedocs.org/en/latest/faq.html#nomatch
+ For help, see: https://beets.readthedocs.org/en/latest/faq.html#nomatch
[U]se as-is, as Tracks, Group albums, Skip, Enter search, enter Id, aBort,
Print tracks?
diff --git a/docs/plugins/metasync.rst b/docs/plugins/metasync.rst
index 6703d3c19..691550595 100644
--- a/docs/plugins/metasync.rst
+++ b/docs/plugins/metasync.rst
@@ -22,7 +22,7 @@ Enable the ``metasync`` plugin in your configuration (see
To synchronize with Amarok, you'll need the `dbus-python`_ library. There are
packages for most major Linux distributions.
-.. _dbus-python: http://dbus.freedesktop.org/releases/dbus-python/
+.. _dbus-python: https://dbus.freedesktop.org/releases/dbus-python/
Configuration
diff --git a/docs/plugins/mpdstats.rst b/docs/plugins/mpdstats.rst
index 2e5e78c36..de9b2ca59 100644
--- a/docs/plugins/mpdstats.rst
+++ b/docs/plugins/mpdstats.rst
@@ -4,12 +4,16 @@ MPDStats Plugin
``mpdstats`` is a plugin for beets that collects statistics about your listening
habits from `MPD`_. It collects the following information about tracks:
-* play_count: The number of times you *fully* listened to this track.
-* skip_count: The number of times you *skipped* this track.
-* last_played: UNIX timestamp when you last played this track.
-* rating: A rating based on *play_count* and *skip_count*.
+* ``play_count``: The number of times you *fully* listened to this track.
+* ``skip_count``: The number of times you *skipped* this track.
+* ``last_played``: UNIX timestamp when you last played this track.
+* ``rating``: A rating based on ``play_count`` and ``skip_count``.
-.. _MPD: http://www.musicpd.org/
+To gather these statistics it runs as an MPD client and watches the current state
+of MPD. This means that ``mpdstats`` needs to be running continuously for it to
+work.
+
+.. _MPD: https://www.musicpd.org/
Installing Dependencies
-----------------------
@@ -23,7 +27,7 @@ Install the library from `pip`_, like so::
Add the ``mpdstats`` plugin to your configuration (see :ref:`using-plugins`).
-.. _pip: http://www.pip-installer.org/
+.. _pip: https://pip.pypa.io
Usage
-----
diff --git a/docs/plugins/mpdupdate.rst b/docs/plugins/mpdupdate.rst
index 7ac647536..01a6a9fe7 100644
--- a/docs/plugins/mpdupdate.rst
+++ b/docs/plugins/mpdupdate.rst
@@ -4,7 +4,7 @@ MPDUpdate Plugin
``mpdupdate`` is a very simple plugin for beets that lets you automatically
update `MPD`_'s index whenever you change your beets library.
-.. _MPD: http://www.musicpd.org/
+.. _MPD: https://www.musicpd.org/
To use ``mpdupdate`` plugin, enable it in your configuration
(see :ref:`using-plugins`).
diff --git a/docs/plugins/parentwork.rst b/docs/plugins/parentwork.rst
new file mode 100644
index 000000000..fb15af9f1
--- /dev/null
+++ b/docs/plugins/parentwork.rst
@@ -0,0 +1,60 @@
+ParentWork Plugin
+=================
+
+The ``parentwork`` plugin fetches the work title, parent work title and
+parent work composer from MusicBrainz.
+
+In the MusicBrainz database, a recording can be associated with a work. A
+work can itself be associated with another work, for example one being part
+of the other (what we call the *direct parent*). This plugin looks the work id
+from the library and then looks up the direct parent, then the direct parent
+of the direct parent and so on until it reaches the top. The work at the top
+is what we call the *parent work*.
+
+This plugin is especially designed for
+classical music. For classical music, just fetching the work title as in
+MusicBrainz is not satisfying, because MusicBrainz has separate works for, for
+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.
+
+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.
+- **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`).
+
+Configuration
+-------------
+
+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 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.
+ Default: ``no``
diff --git a/docs/plugins/playlist.rst b/docs/plugins/playlist.rst
index 3622581db..81fc60beb 100644
--- a/docs/plugins/playlist.rst
+++ b/docs/plugins/playlist.rst
@@ -11,14 +11,15 @@ Then configure your playlists like this::
auto: no
relative_to: ~/Music
playlist_dir: ~/.mpd/playlists
+ forward_slash: no
-It is possible to query the library based on a playlist by speicifying its
+It is possible to query the library based on a playlist by specifying its
absolute path::
$ beet ls playlist:/path/to/someplaylist.m3u
The plugin also supports referencing playlists by name. The playlist is then
-seached in the playlist_dir and the ".m3u" extension is appended to the
+searched in the playlist_dir and the ".m3u" extension is appended to the
name::
$ beet ls playlist:anotherplaylist
@@ -30,7 +31,7 @@ configuration option.
Configuration
-------------
-To configure the plugin, make a ``smartplaylist:`` section in your
+To configure the plugin, make a ``playlist:`` section in your
configuration file. In addition to the ``playlists`` described above, the
other configuration options are:
@@ -45,3 +46,7 @@ other configuration options are:
set it to ``playlist`` to use the playlist's parent directory or to
``library`` to use the library directory.
Default: ``library``
+- **forward_slash**: Forces forward slashes in the generated playlist files.
+ If you intend to use this plugin to generate playlists for MPD on
+ Windows, set this to yes.
+ Default: Use system separator.
diff --git a/docs/plugins/plexupdate.rst b/docs/plugins/plexupdate.rst
index 4ac047660..92fc949d2 100644
--- a/docs/plugins/plexupdate.rst
+++ b/docs/plugins/plexupdate.rst
@@ -21,11 +21,11 @@ To use the ``plexupdate`` plugin you need to install the `requests`_ library wit
pip install requests
-With that all in place, you'll see beets send the "update" command to your Plex
+With that all in place, you'll see beets send the "update" command to your Plex
server every time you change your beets library.
-.. _Plex: http://plex.tv/
-.. _requests: http://docs.python-requests.org/en/latest/
+.. _Plex: https://plex.tv/
+.. _requests: https://docs.python-requests.org/en/latest/
.. _documentation about tokens: https://support.plex.tv/hc/en-us/articles/204059436-Finding-your-account-token-X-Plex-Token
Configuration
@@ -41,3 +41,7 @@ The available options under the ``plex:`` section are:
Default: Empty.
- **library_name**: The name of the Plex library to update.
Default: ``Music``
+- **secure**: Use secure connections to the Plex server.
+ Default: ``False``
+- **ignore_cert_errors**: Ignore TLS certificate errors when using secure connections.
+ Default: ``False``
diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst
index 825f279e2..9602618da 100644
--- a/docs/plugins/replaygain.rst
+++ b/docs/plugins/replaygain.rst
@@ -4,16 +4,16 @@ ReplayGain Plugin
This plugin adds support for `ReplayGain`_, a technique for normalizing audio
playback levels.
-.. _ReplayGain: http://wiki.hydrogenaudio.org/index.php?title=ReplayGain
+.. _ReplayGain: https://wiki.hydrogenaudio.org/index.php?title=ReplayGain
Installation
------------
-This plugin can use one of three backends to compute the ReplayGain values:
-GStreamer, mp3gain (and its cousin, aacgain), Python Audio Tools. mp3gain
-can be easier to install but GStreamer and Audio Tools support more audio
-formats.
+This plugin can use one of many backends to compute the ReplayGain values:
+GStreamer, mp3gain (and its cousin, aacgain), Python Audio Tools or ffmpeg.
+ffmpeg and mp3gain can be easier to install. mp3gain supports less audio formats
+then the other backend.
Once installed, this plugin analyzes all files during the import process. This
can be a slow process; to instead analyze after the fact, disable automatic
@@ -27,7 +27,7 @@ install GStreamer and plugins for compatibility with your audio files.
You will need at least GStreamer 1.0 and `PyGObject 3.x`_ (a.k.a. ``python-gi``).
.. _PyGObject 3.x: https://pygobject.readthedocs.io/en/latest/
-.. _GStreamer: http://gstreamer.freedesktop.org/
+.. _GStreamer: https://gstreamer.freedesktop.org/
Then, enable the ``replaygain`` plugin (see :ref:`using-plugins`) and specify
the GStreamer backend by adding this to your configuration file::
@@ -47,8 +47,8 @@ command-line tool or the `aacgain`_ fork thereof. Here are some hints:
* On Windows, download and install the original `mp3gain`_.
.. _mp3gain: http://mp3gain.sourceforge.net/download.php
-.. _aacgain: http://aacgain.altosdesign.com
-.. _Homebrew: http://mxcl.github.com/homebrew/
+.. _aacgain: https://aacgain.altosdesign.com
+.. _Homebrew: https://brew.sh
Then, enable the plugin (see :ref:`using-plugins`) and specify the "command"
backend in your configuration file::
@@ -75,6 +75,15 @@ On OS X, most of the dependencies can be installed with `Homebrew`_::
.. _Python Audio Tools: http://audiotools.sourceforge.net
+ffmpeg
+``````
+
+This backend uses ffmpeg to calculate EBU R128 gain values.
+To use it, install the `ffmpeg`_ command-line tool and select the
+``ffmpeg`` backend in your config file.
+
+.. _ffmpeg: https://ffmpeg.org
+
Configuration
-------------
@@ -83,16 +92,24 @@ configuration file. The available options are:
- **auto**: Enable ReplayGain analysis during import.
Default: ``yes``.
-- **backend**: The analysis backend; either ``gstreamer``, ``command``, or ``audiotools``.
+- **backend**: The analysis backend; either ``gstreamer``, ``command``, ``audiotools``
+ or ``ffmpeg``.
Default: ``command``.
- **overwrite**: Re-analyze files that already have ReplayGain tags.
Default: ``no``.
-- **targetlevel**: A number of decibels for the target loudness level.
- Default: 89.
+- **targetlevel**: A number of decibels for the target loudness level for files
+ using ``REPLAYGAIN_`` tags.
+ Default: ``89``.
+- **r128_targetlevel**: The target loudness level in decibels (i.e.
+ `` + 107``) for files using ``R128_`` tags.
+ Default: 84 (Use ``83`` for ATSC A/85, ``84`` for EBU R128 or ``89`` for
+ ReplayGain 2.0.)
- **r128**: A space separated list of formats that will use ``R128_`` tags with
integer values instead of the common ``REPLAYGAIN_`` tags with floating point
values. Requires the "ffmpeg" backend.
Default: ``Opus``.
+- **per_disc**: Calculate album ReplayGain on disc level instead of album level.
+ Default: ``no``
These options only work with the "command" backend:
@@ -104,6 +121,11 @@ These options only work with the "command" backend:
would keep clipping from occurring.
Default: ``yes``.
+This option only works with the "ffmpeg" backend:
+
+- **peak**: Either ``true`` (the default) or ``sample``. ``true`` is
+ more accurate but slower.
+
Manual Analysis
---------------
diff --git a/docs/plugins/smartplaylist.rst b/docs/plugins/smartplaylist.rst
index 8ccbd0091..dd3ee45ba 100644
--- a/docs/plugins/smartplaylist.rst
+++ b/docs/plugins/smartplaylist.rst
@@ -5,7 +5,7 @@ Smart Playlist Plugin
beets queries every time your library changes. This plugin is specifically
created to work well with `MPD's`_ playlist functionality.
-.. _MPD's: http://www.musicpd.org/
+.. _MPD's: https://www.musicpd.org/
To use it, enable the ``smartplaylist`` plugin in your configuration
(see :ref:`using-plugins`).
@@ -14,6 +14,7 @@ Then configure your smart playlists like the following example::
smartplaylist:
relative_to: ~/Music
playlist_dir: ~/.mpd/playlists
+ forward_slash: no
playlists:
- name: all.m3u
query: ''
@@ -96,3 +97,7 @@ other configuration options are:
directory. If you intend to use this plugin to generate playlists for MPD,
point this to your MPD music directory.
Default: Use absolute paths.
+- **forward_slash**: Forces forward slashes in the generated playlist files.
+ If you intend to use this plugin to generate playlists for MPD on
+ Windows, set this to yes.
+ Default: Use system separator.
diff --git a/docs/plugins/sonosupdate.rst b/docs/plugins/sonosupdate.rst
index 97a13bd07..cae69d554 100644
--- a/docs/plugins/sonosupdate.rst
+++ b/docs/plugins/sonosupdate.rst
@@ -14,5 +14,5 @@ To use the ``sonosupdate`` plugin you need to install the `soco`_ library with::
With that all in place, you'll see beets send the "update" command to your Sonos
controller every time you change your beets library.
-.. _Sonos: http://sonos.com/
+.. _Sonos: https://sonos.com/
.. _soco: http://python-soco.com
diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst
index 3f4c6c43d..96b198f64 100644
--- a/docs/plugins/spotify.rst
+++ b/docs/plugins/spotify.rst
@@ -31,8 +31,8 @@ Here's an example::
$ beet spotify "In The Lonely Hour"
Processing 14 tracks...
- http://open.spotify.com/track/19w0OHr8SiZzRhjpnjctJ4
- http://open.spotify.com/track/3PRLM4FzhplXfySa4B7bxS
+ https://open.spotify.com/track/19w0OHr8SiZzRhjpnjctJ4
+ https://open.spotify.com/track/3PRLM4FzhplXfySa4B7bxS
[...]
Command-line options include:
@@ -52,6 +52,9 @@ prompt during import::
Configuration
-------------
+This plugin can be configured like other metadata source plugins as described in :ref:`metadata-source-plugin-configuration`. In addition, the following
+configuration options are provided.
+
The default options should work as-is, but there are some options you can put
in config.yaml under the ``spotify:`` section:
@@ -79,9 +82,6 @@ in config.yaml under the ``spotify:`` section:
track/album/artist fields before sending them to Spotify. Can be useful for
changing certain abbreviations, like ft. -> feat. See the examples below.
Default: None.
-- **source_weight**: Penalty applied to Spotify matches during import. Set to
- 0.0 to disable.
- Default: ``0.5``.
Here's an example::
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/subsonicupdate.rst b/docs/plugins/subsonicupdate.rst
index daf4a0cfb..3549be091 100644
--- a/docs/plugins/subsonicupdate.rst
+++ b/docs/plugins/subsonicupdate.rst
@@ -4,7 +4,7 @@ SubsonicUpdate Plugin
``subsonicupdate`` is a very simple plugin for beets that lets you automatically
update `Subsonic`_'s index whenever you change your beets library.
-.. _Subsonic: http://www.subsonic.org
+.. _Subsonic: https://www.subsonic.org
To use ``subsonicupdate`` plugin, enable it in your configuration
(see :ref:`using-plugins`).
@@ -13,11 +13,9 @@ You can do that using a ``subsonic:`` section in your ``config.yaml``,
which looks like this::
subsonic:
- host: X.X.X.X
- port: 4040
+ url: https://example.com:443/subsonic
user: username
pass: password
- contextpath: /subsonic
With that all in place, beets will send a Rest API to your Subsonic
server every time you import new music.
@@ -30,8 +28,7 @@ Configuration
The available options under the ``subsonic:`` section are:
-- **host**: The Subsonic server name/IP. Default: ``localhost``
-- **port**: The Subsonic server port. Default: ``4040``
+- **url**: The Subsonic server resource. Default: ``http://localhost:4040``
- **user**: The Subsonic user. Default: ``admin``
-- **pass**: The Subsonic user password. Default: ``admin``
-- **contextpath**: The Subsonic context path. Default: ``/``
+- **pass**: The Subsonic user password. (This may either be a clear-text
+ password or hex-encoded with the prefix ``enc:``.) Default: ``admin``
diff --git a/docs/plugins/thumbnails.rst b/docs/plugins/thumbnails.rst
index c2a28d091..0f46e04e8 100644
--- a/docs/plugins/thumbnails.rst
+++ b/docs/plugins/thumbnails.rst
@@ -13,7 +13,7 @@ as the :doc:`/plugins/fetchart`. You'll need 2 additional python packages:
`ImageMagick`_ or `Pillow`_.
.. _Pillow: https://github.com/python-pillow/Pillow
-.. _ImageMagick: http://www.imagemagick.org/
+.. _ImageMagick: https://www.imagemagick.org/
Configuration
-------------
diff --git a/docs/plugins/unimported.rst b/docs/plugins/unimported.rst
new file mode 100644
index 000000000..447c4ec8c
--- /dev/null
+++ b/docs/plugins/unimported.rst
@@ -0,0 +1,17 @@
+Unimported Plugin
+=================
+
+The ``unimported`` plugin allows to list all files in the library folder which are not listed in the beets library database, including art files.
+
+Command Line Usage
+------------------
+
+To use the ``unimported`` plugin, enable it in your configuration (see
+:ref:`using-plugins`). Then use it by invoking the ``beet unimported`` command.
+The command will list all files in the library folder which are not imported. You can
+exclude file extensions using the configuration file::
+
+ unimported:
+ ignore_extensions: jpg png
+
+The default configuration list all unimported files, ignoring no extensions.
\ No newline at end of file
diff --git a/docs/plugins/web.rst b/docs/plugins/web.rst
index 35287acc8..65d4743fb 100644
--- a/docs/plugins/web.rst
+++ b/docs/plugins/web.rst
@@ -19,13 +19,13 @@ The Web interface depends on `Flask`_. To get it, just run ``pip install
flask``. Then enable the ``web`` plugin in your configuration (see
:ref:`using-plugins`).
-.. _Flask: http://flask.pocoo.org/
+.. _Flask: https://flask.pocoo.org/
If you need CORS (it's disabled by default---see :ref:`web-cors`, below), then
you also need `flask-cors`_. Just type ``pip install flask-cors``.
.. _flask-cors: https://github.com/CoryDolphin/flask-cors
-.. _CORS: http://en.wikipedia.org/wiki/Cross-origin_resource_sharing
+.. _CORS: https://en.wikipedia.org/wiki/Cross-origin_resource_sharing
Run the Server
@@ -78,8 +78,8 @@ The Web backend is built using a simple REST+JSON API with the excellent
`Flask`_ library. The frontend is a single-page application written with
`Backbone.js`_. This allows future non-Web clients to use the same backend API.
-.. _Flask: http://flask.pocoo.org/
-.. _Backbone.js: http://backbonejs.org
+.. _Flask: https://flask.pocoo.org/
+.. _Backbone.js: https://backbonejs.org
Eventually, to make the Web player really viable, we should use a Flash fallback
for unsupported formats/browsers. There are a number of options for this:
@@ -88,9 +88,9 @@ for unsupported formats/browsers. There are a number of options for this:
* `html5media`_
* `MediaElement.js`_
-.. _audio.js: http://kolber.github.com/audiojs/
-.. _html5media: http://html5media.info/
-.. _MediaElement.js: http://mediaelementjs.com/
+.. _audio.js: https://kolber.github.io/audiojs/
+.. _html5media: https://html5media.info/
+.. _MediaElement.js: https://mediaelementjs.com/
.. _web-cors:
@@ -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/plugins/zero.rst b/docs/plugins/zero.rst
index 3ef241c87..1ed915891 100644
--- a/docs/plugins/zero.rst
+++ b/docs/plugins/zero.rst
@@ -5,9 +5,10 @@ The ``zero`` plugin allows you to null fields in files' metadata tags. Fields
can be nulled unconditionally or conditioned on a pattern match. For example,
the plugin can strip useless comments like "ripped by MyGreatRipper."
-The plugin can work in one of two modes. The first mode, the default, is a
-blacklist, where you choose the tags you want to remove. The second mode is a
-whitelist, where you instead specify the tags you want to keep.
+The plugin can work in one of two modes:
+
+* ``fields``: A blacklist, where you choose the tags you want to remove (used by default).
+* ``keep_fields``: A whitelist, where you instead specify the tags you want to keep.
To use the ``zero`` plugin, enable the plugin in your configuration
(see :ref:`using-plugins`).
@@ -20,7 +21,7 @@ fields to nullify and the conditions for nullifying them:
* Set ``auto`` to ``yes`` to null fields automatically on import.
Default: ``yes``.
-* Set ``fields`` to a whitespace-separated list of fields to change. You can
+* Set ``fields`` to a whitespace-separated list of fields to remove. You can
get the list of all available fields by running ``beet fields``. In
addition, the ``images`` field allows you to remove any images
embedded in the media file.
@@ -29,8 +30,8 @@ fields to nullify and the conditions for nullifying them:
``fields`` or ``keep_fields``---not both!
* To conditionally filter a field, use ``field: [regexp, regexp]`` to specify
regular expressions.
-* By default this plugin only affects files' tags ; the beets database is left
- unchanged. To update the tags in the database, set the ``update_database`` option.
+* By default this plugin only affects files' tags; the beets database is left
+ unchanged. To update the tags in the database, set the ``update_database`` option to true.
For example::
diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst
index 7b9e9eb72..724afc80a 100644
--- a/docs/reference/cli.rst
+++ b/docs/reference/cli.rst
@@ -71,7 +71,7 @@ box. To extract `rar` files, install the `rarfile`_ package and the
Optional command flags:
-* By default, the command copies files your the library directory and
+* By default, the command copies files to your library directory and
updates the ID3 tags on your music. In order to move the files, instead of
copying, use the ``-m`` (move) option. If you'd like to leave your music
files untouched, try the ``-C`` (don't copy) and ``-W`` (don't write tags)
@@ -212,12 +212,12 @@ 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.
-.. _xargs: http://en.wikipedia.org/wiki/Xargs
+.. _xargs: https://en.wikipedia.org/wiki/Xargs
.. _remove-cmd:
@@ -453,7 +453,7 @@ available via your package manager. On OS X, you can install it via Homebrew
with ``brew install bash-completion``; Homebrew will give you instructions for
sourcing the script.
-.. _bash-completion: http://bash-completion.alioth.debian.org/
+.. _bash-completion: https://github.com/scop/bash-completion
.. _bash: https://www.gnu.org/software/bash/
The completion script suggests names of subcommands and (after typing
@@ -498,6 +498,6 @@ defines some bash-specific functions to make this work without errors::
See Also
--------
- ``http://beets.readthedocs.org/``
+ ``https://beets.readthedocs.org/``
:manpage:`beetsconfig(5)`
diff --git a/docs/reference/config.rst b/docs/reference/config.rst
index a96e3dfb3..46f14f2c5 100644
--- a/docs/reference/config.rst
+++ b/docs/reference/config.rst
@@ -31,7 +31,7 @@ In YAML, you will need to use spaces (not tabs!) to indent some lines. If you
have questions about more sophisticated syntax, take a look at the `YAML`_
documentation.
-.. _YAML: http://yaml.org/
+.. _YAML: https://yaml.org/
The rest of this page enumerates the dizzying litany of configuration options
available in beets. You might also want to see an
@@ -167,7 +167,7 @@ equivalent to wrapping all your path templates in the ``%asciify{}``
Default: ``no``.
-.. _unidecode module: http://pypi.python.org/pypi/Unidecode
+.. _unidecode module: https://pypi.org/project/Unidecode
.. _art-filename:
@@ -314,7 +314,7 @@ standard output. It's also used to read messages from the standard input.
By default, this is determined automatically from the locale
environment variables.
-.. _known to python: http://docs.python.org/2/library/codecs.html#standard-encodings
+.. _known to python: https://docs.python.org/2/library/codecs.html#standard-encodings
.. _clutter:
@@ -508,10 +508,10 @@ incremental_skip_later
~~~~~~~~~~~~~~~~~~~~~~
Either ``yes`` or ``no``, controlling whether skipped directories are
-recorded in the incremental list. When set to ``yes``, skipped directories
-will be recorded, and skipped later. When set to ``no``, skipped
+recorded in the incremental list. When set to ``yes``, skipped
directories won't be recorded, and beets will try to import them again
-later. Defaults to ``no``.
+later. When set to ``no``, skipped directories will be recorded, and
+skipped later. Defaults to ``no``.
.. _from_scratch:
@@ -688,7 +688,7 @@ to one request per second.
.. _your own MusicBrainz database: https://musicbrainz.org/doc/MusicBrainz_Server/Setup
.. _main server: https://musicbrainz.org/
-.. _limited: http://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting
+.. _limited: https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting
.. _Building search indexes: https://musicbrainz.org/doc/MusicBrainz_Server/Setup#Building_search_indexes
.. _searchlimit:
@@ -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
@@ -981,6 +1001,6 @@ Here's an example file::
See Also
--------
- ``http://beets.readthedocs.org/``
+ ``https://beets.readthedocs.org/``
:manpage:`beet(1)`
diff --git a/docs/reference/pathformat.rst b/docs/reference/pathformat.rst
index 79998a9e1..9213cae4b 100644
--- a/docs/reference/pathformat.rst
+++ b/docs/reference/pathformat.rst
@@ -23,7 +23,7 @@ a dollars sign. As with `Python template strings`_, ``${title}`` is equivalent
to ``$title``; you can use this if you need to separate a field name from the
text that follows it.
-.. _Python template strings: http://docs.python.org/library/string.html#template-strings
+.. _Python template strings: https://docs.python.org/library/string.html#template-strings
A Note About Artists
@@ -38,7 +38,7 @@ tracks in a "Talking Heads" directory and one in a "Tom Tom Club" directory. You
probably don't want that! So use ``$albumartist``.
.. _Stop Making Sense:
- http://musicbrainz.org/release/798dcaab-0f1a-4f02-a9cb-61d5b0ddfd36.html
+ https://musicbrainz.org/release/798dcaab-0f1a-4f02-a9cb-61d5b0ddfd36.html
As a convenience, however, beets allows ``$albumartist`` to fall back to the value for ``$artist`` and vice-versa if one tag is present but the other is not.
@@ -89,8 +89,8 @@ These functions are built in to beets:
without ``$``. Note that this doesn't work with built-in :ref:`itemfields`, as
they are always defined.
-.. _unidecode module: http://pypi.python.org/pypi/Unidecode
-.. _strftime: http://docs.python.org/2/library/time.html#time.strftime
+.. _unidecode module: https://pypi.org/project/Unidecode
+.. _strftime: https://docs.python.org/3/library/time.html#time.strftime
Plugins can extend beets with more template functions (see
:ref:`templ_plugins`).
@@ -228,8 +228,8 @@ Ordinary metadata:
* disctitle
* encoder
-.. _artist credit: http://wiki.musicbrainz.org/Artist_Credit
-.. _list of type names: http://musicbrainz.org/doc/Release_Group/Type
+.. _artist credit: https://wiki.musicbrainz.org/Artist_Credit
+.. _list of type names: https://musicbrainz.org/doc/Release_Group/Type
Audio information:
diff --git a/docs/reference/query.rst b/docs/reference/query.rst
index d103d9aec..5c16f610b 100644
--- a/docs/reference/query.rst
+++ b/docs/reference/query.rst
@@ -122,7 +122,7 @@ expressions, such as ``()[]|``. To type those characters, you'll need to
escape them (e.g., with backslashes or quotation marks, depending on your
shell).
-.. _Python's built-in implementation: http://docs.python.org/library/re.html
+.. _Python's built-in implementation: https://docs.python.org/library/re.html
.. _numericquery:
diff --git a/extra/_beet b/extra/_beet
index 56c86d036..129c0485e 100644
--- a/extra/_beet
+++ b/extra/_beet
@@ -1,6 +1,6 @@
#compdef beet
-# zsh completion for beets music library manager and MusicBrainz tagger: http://beets.radbox.org/
+# zsh completion for beets music library manager and MusicBrainz tagger: https://beets.io/
# Default values for BEETS_LIBRARY & BEETS_CONFIG needed for the cache checking function.
# They will be updated under the assumption that the config file is in the same directory as the library.
@@ -34,7 +34,7 @@ _beet_check_cache () {
# useful: argument to _regex_arguments for matching any word
local matchany=/$'[^\0]##\0'/
# arguments to _regex_arguments for completing files and directories
-local -a files dirs
+local -a files dirs
files=("$matchany" ':file:file:_files')
dirs=("$matchany" ':dir:directory:_dirs')
@@ -73,7 +73,7 @@ if ! _retrieve_cache beetscmds || _cache_invalid beetscmds; then
# create completion function for queries
_regex_arguments _beet_query "$matchany" \# \( "$matchquery" ":query:query string:$queryelem" \) \#
local "beets_query"="$(which _beet_query)"
- # arguments for _regex_arguments for completing lists of queries and modifications
+ # arguments for _regex_arguments for completing lists of queries and modifications
beets_query_args=( \( "$matchquery" ":query:query string:{_beet_query}" \) \# )
beets_modify_args=( \( "$matchmodify" ":modify:modify string:$modifyelem" \) \# )
# now build arguments for _beet and _beet_help completion functions
@@ -82,7 +82,7 @@ if ! _retrieve_cache beetscmds || _cache_invalid beetscmds; then
subcmd="${i[(w)1]}"
# remove first word and parenthesised alias, replace : with -, [ with (, ] with ), and remove single quotes
cmddesc="${${${${${i[(w)2,-1]##\(*\) #}//:/-}//\[/(}//\]/)}//\'/}"
- # update arguments needed for creating _beet
+ # update arguments needed for creating _beet
beets_regex_words_subcmds+=(/"${subcmd}"$'\0'/ ":subcmds:subcommands:((${subcmd}:${cmddesc// /\ }))")
beets_regex_words_subcmds+=(\( "${matchany}" ":option:option:{_beet_subcmd ${subcmd}}" \) \# \|)
# update arguments needed for creating _beet_help
@@ -137,7 +137,7 @@ _beet_subcmd_options() {
fi
;;
(LOG)
- local -a files
+ local -a files
files=("$matchany" ':file:file:_files')
regex_words+=("$opt:$optdesc:\$files")
;;
@@ -180,7 +180,7 @@ _beet_subcmd() {
if [[ ! $(type _beet_${subcmd} | grep function) =~ function ]]; then
if ! _retrieve_cache "beets${subcmd}" || _cache_invalid "beets${subcmd}"; then
local matchany=/$'[^\0]##\0'/
- local -a files
+ local -a files
files=("$matchany" ':file:file:_files')
# get arguments for completing subcommand options
_beet_subcmd_options "$subcmd"
@@ -197,7 +197,7 @@ _beet_subcmd() {
(fields|migrate|version|config)
_regex_arguments _beet_${subcmd} "${matchany}" /"${subcmd}"$'\0'/ "${options[@]}"
;;
- (help)
+ (help)
_regex_words subcmds "subcommands" "${beets_regex_words_help[@]}"
_regex_arguments _beet_help "${matchany}" /$'help\0'/ "${options[@]}" "${reply[@]}"
;;
@@ -232,6 +232,6 @@ zstyle ":completion:${curcontext}:" tag-order '! options'
# Execute the completion function
_beet "$@"
-# Local Variables:
+# Local Variables:
# mode:shell-script
# End:
diff --git a/setup.cfg b/setup.cfg
index 0660b2721..3f5fb0c57 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,23 +1,27 @@
-[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
+# 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
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 7c209f019..0e2cb332a 100755
--- a/setup.py
+++ b/setup.py
@@ -60,7 +60,7 @@ setup(
description='music tagger and library organizer',
author='Adrian Sampson',
author_email='adrian@radbox.org',
- url='http://beets.io/',
+ url='https://beets.io/',
license='MIT',
platforms='ALL',
long_description=_read('README.rst'),
@@ -91,7 +91,7 @@ setup(
'unidecode',
'musicbrainzngs>=0.4',
'pyyaml',
- 'mediafile>=0.1.0',
+ 'mediafile>=0.2.0',
'confuse>=1.0.0',
] + [
# Avoid a version of munkres incompatible with Python 3.
@@ -109,23 +109,33 @@ 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',
+ 'rarfile',
+ '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 []
+ ),
+ 'lint': [
+ 'flake8',
+ 'flake8-blind-except',
+ 'flake8-coding',
+ 'flake8-future-import',
+ 'pep8-naming',
+ ],
+
+ # Plugin (optional) dependencies:
'absubmit': ['requests'],
'fetchart': ['requests', 'Pillow'],
'embedart': ['Pillow'],
@@ -156,7 +166,7 @@ setup(
# badfiles: mp3val and flac
# bpd: python-gi and GStreamer 1.0+
# embedart: ImageMagick
- # absubmit: extractor binary from http://acousticbrainz.org/download
+ # absubmit: extractor binary from https://acousticbrainz.org/download
# keyfinder: KeyFinder
# replaygain: python-gi and GStreamer 1.0+ or mp3gain/aacgain
# or Python Audio Tools
@@ -172,7 +182,6 @@ setup(
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
- 'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
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/examplecom/beetssong.txt b/test/rsrc/lyrics/examplecom/beetssong.txt
index 3bba9f702..c546dd602 100644
--- a/test/rsrc/lyrics/examplecom/beetssong.txt
+++ b/test/rsrc/lyrics/examplecom/beetssong.txt
@@ -220,7 +220,7 @@ e9.size = "120x600, 160x600";
Beets is the media library management system for obsessive music geeks.
The purpose of beets is to get your music collection right once and for all. It catalogs your collection, automatically improving its metadata as it goes. It then provides a bouquet of tools for manipulating and accessing your music.
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.