mirror of
https://github.com/beetbox/beets.git
synced 2026-01-17 21:52:34 +01:00
Merge branch 'master' into parallel-replaygain
This commit is contained in:
commit
72710cd8c7
91 changed files with 5130 additions and 695 deletions
|
|
@ -2,7 +2,6 @@
|
|||
omit =
|
||||
*/pyshared/*
|
||||
*/python?.?/*
|
||||
*/site-packages/nose/*
|
||||
*/test/*
|
||||
exclude_lines =
|
||||
assert False
|
||||
|
|
|
|||
16
.github/flake8-problem-matcher.json
vendored
Normal file
16
.github/flake8-problem-matcher.json
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"problemMatcher": [
|
||||
{
|
||||
"owner": "flake8",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^(.*?):(\\d+):(\\d+): (.*)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"column": 3,
|
||||
"message": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
11
.github/pull_request_template.md
vendored
Normal file
11
.github/pull_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
## Description
|
||||
|
||||
Fixes #X. <!-- Insert issue number here if applicable. -->
|
||||
|
||||
(...)
|
||||
|
||||
## To Do
|
||||
|
||||
- [ ] Documentation. (If you've add a new command-line flag, for example, find the appropriate page under `docs/` to describe it.)
|
||||
- [ ] Changelog. (Add an entry to `docs/changelog.rst` near the top of the document.)
|
||||
- [ ] Tests. (Encouraged but not strictly required.)
|
||||
18
.github/sphinx-problem-matcher.json
vendored
Normal file
18
.github/sphinx-problem-matcher.json
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"problemMatcher": [
|
||||
{
|
||||
"owner": "sphinx",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^Warning, treated as error:$"
|
||||
},
|
||||
{
|
||||
"regexp": "^(.*?):(\\d+):(.*)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"message": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
28
.github/stale.yml
vendored
Normal file
28
.github/stale.yml
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Configuration for probot-stale - https://github.com/probot/stale
|
||||
|
||||
daysUntilClose: 7
|
||||
staleLabel: stale
|
||||
|
||||
issues:
|
||||
daysUntilStale: 60
|
||||
onlyLabels:
|
||||
- needinfo
|
||||
markComment: >
|
||||
Is this still relevant? If so, what is blocking it?
|
||||
Is there anything you can do to help move it forward?
|
||||
|
||||
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
|
||||
pulls:
|
||||
daysUntilStale: 120
|
||||
markComment: >
|
||||
Is this still relevant? If so, what is blocking it?
|
||||
Is there anything you can do to help move it forward?
|
||||
|
||||
|
||||
This pull request has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
88
.github/workflows/ci.yaml
vendored
Normal file
88
.github/workflows/ci.yaml
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
name: ci
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ${{ matrix.platform }}
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ubuntu-latest]
|
||||
python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9-dev]
|
||||
|
||||
env:
|
||||
PY_COLORS: 1
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install base dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install tox sphinx
|
||||
|
||||
- name: Test with tox
|
||||
if: matrix.python-version != '3.8'
|
||||
run: |
|
||||
tox -e py-test
|
||||
|
||||
- name: Test with tox and get coverage
|
||||
if: matrix.python-version == '3.8'
|
||||
run: |
|
||||
tox -vv -e py-cov
|
||||
|
||||
- name: Upload code coverage
|
||||
if: matrix.python-version == '3.8'
|
||||
run: |
|
||||
pip install codecov || true
|
||||
codecov || true
|
||||
|
||||
test-docs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
PY_COLORS: 1
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Python 2.7
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 2.7
|
||||
|
||||
- name: Install base dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install tox sphinx
|
||||
|
||||
- name: Add problem matcher
|
||||
run: echo "::add-matcher::.github/sphinx-problem-matcher.json"
|
||||
|
||||
- name: Build and check docs using tox
|
||||
run: tox -e docs
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.8
|
||||
|
||||
- name: Install base dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install tox sphinx
|
||||
|
||||
- name: Add problem matcher
|
||||
run: echo "::add-matcher::.github/flake8-problem-matcher.json"
|
||||
|
||||
- name: Lint with flake8
|
||||
run: tox -e py-lint
|
||||
45
.github/workflows/integration_test.yaml
vendored
Normal file
45
.github/workflows/integration_test.yaml
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
name: integration tests
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 0 * * SUN' # run every Sunday at midnight
|
||||
jobs:
|
||||
test_integration:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
PY_COLORS: 1
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set up latest Python version
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.9-dev
|
||||
|
||||
- name: Install base dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install tox sphinx
|
||||
|
||||
- name: Test with tox
|
||||
run: |
|
||||
tox -e int
|
||||
|
||||
- name: Notify on failure
|
||||
if: ${{ failure() }}
|
||||
env:
|
||||
ZULIP_BOT_CREDENTIALS: ${{ secrets.ZULIP_BOT_CREDENTIALS }}
|
||||
run: |
|
||||
if [ -z "${ZULIP_BOT_CREDENTIALS}" ]; then
|
||||
echo "Skipping notify, ZULIP_BOT_CREDENTIALS is unset"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
curl -X POST https://beets.zulipchat.com/api/v1/messages \
|
||||
-u "${ZULIP_BOT_CREDENTIALS}" \
|
||||
-d "type=stream" \
|
||||
-d "to=github" \
|
||||
-d "subject=${GITHUB_WORKFLOW} - $(date -u +%Y-%m-%d)" \
|
||||
-d "content=[${GITHUB_WORKFLOW}#${GITHUB_RUN_NUMBER}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) failed."
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
87
.travis.yml
87
.travis.yml
|
|
@ -1,87 +0,0 @@
|
|||
dist: trusty
|
||||
sudo: required
|
||||
language: python
|
||||
|
||||
env:
|
||||
global:
|
||||
# Undocumented feature of nose-show-skipped.
|
||||
NOSE_SHOW_SKIPPED: 1
|
||||
|
||||
matrix:
|
||||
include:
|
||||
- python: 2.7.13
|
||||
env: {TOX_ENV: py27-cov, COVERAGE: 1}
|
||||
- python: 2.7.13
|
||||
env: {TOX_ENV: py27-test}
|
||||
# - python: 3.4
|
||||
# env: {TOX_ENV: py34-test}
|
||||
# - python: 3.4_with_system_site_packages
|
||||
# env: {TOX_ENV: py34-test}
|
||||
- python: 3.5
|
||||
env: {TOX_ENV: py35-test}
|
||||
- python: 3.6
|
||||
env: {TOX_ENV: py36-test}
|
||||
- python: 3.7
|
||||
env: {TOX_ENV: py37-test}
|
||||
dist: xenial
|
||||
- python: 3.8
|
||||
env: {TOX_ENV: py38-test}
|
||||
dist: xenial
|
||||
# - python: pypy
|
||||
# - env: {TOX_ENV: pypy-test}
|
||||
- python: 3.6
|
||||
env: {TOX_ENV: py36-flake8}
|
||||
- python: 2.7.13
|
||||
env: {TOX_ENV: docs}
|
||||
# Non-Python dependencies.
|
||||
addons:
|
||||
apt:
|
||||
sources:
|
||||
- sourceline: "deb https://archive.ubuntu.com/ubuntu/ trusty multiverse"
|
||||
- sourceline: "deb https://archive.ubuntu.com/ubuntu/ trusty-updates multiverse"
|
||||
packages:
|
||||
- bash-completion
|
||||
- gir1.2-gst-plugins-base-1.0
|
||||
- gir1.2-gstreamer-1.0
|
||||
- gstreamer1.0-plugins-good
|
||||
- gstreamer1.0-plugins-bad
|
||||
- imagemagick
|
||||
- python-gi
|
||||
- python-gst-1.0
|
||||
- python3-gi
|
||||
- python3-gst-1.0
|
||||
- unrar
|
||||
|
||||
# To install dependencies, tell tox to do everything but actually running the
|
||||
# test.
|
||||
install:
|
||||
- travis_retry pip install tox sphinx
|
||||
# upgrade requests to satisfy sphinx linkcheck (for building man pages)
|
||||
- if [[ $TRAVIS_PYTHON_VERSION == *_site_packages ]]; then pip install -U requests; fi
|
||||
- travis_retry tox -e $TOX_ENV --notest
|
||||
|
||||
script:
|
||||
# prevents "libdc1394 error: Failed to initialize libdc1394" errors
|
||||
- sudo ln -s /dev/null /dev/raw1394
|
||||
- if [[ $TRAVIS_PYTHON_VERSION == *_site_packages ]]; then SITE_PACKAGES=--sitepackages; fi
|
||||
# pip in trusty breaks on packages prefixed with "_". See https://github.com/pypa/pip/issues/3681
|
||||
- if [[ $TRAVIS_PYTHON_VERSION == 3.4_with_system_site_packages ]]; then sudo rm -rf /usr/lib/python3/dist-packages/_lxc-0.1.egg-info; fi
|
||||
- tox -e $TOX_ENV $SITE_PACKAGES
|
||||
|
||||
# Report coverage to codecov.io.
|
||||
before_install:
|
||||
- "[ ! -z $COVERAGE ] && travis_retry pip install codecov || true"
|
||||
after_success:
|
||||
- "[ ! -z $COVERAGE ] && codecov || true"
|
||||
|
||||
cache:
|
||||
pip: true
|
||||
|
||||
notifications:
|
||||
irc:
|
||||
channels:
|
||||
- "irc.freenode.org#beets"
|
||||
use_notice: true
|
||||
skip_join: true
|
||||
on_success: change
|
||||
on_failure: always
|
||||
366
CONTRIBUTING.rst
Normal file
366
CONTRIBUTING.rst
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
############
|
||||
Contributing
|
||||
############
|
||||
|
||||
.. contents::
|
||||
:depth: 3
|
||||
|
||||
Thank you!
|
||||
==========
|
||||
|
||||
First off, thank you for considering contributing to beets! It’s people
|
||||
like you that make beets continue to succeed.
|
||||
|
||||
These guidelines describe how you can help most effectively. By
|
||||
following these guidelines, you can make life easier for the development
|
||||
team as it indicates you respect the maintainers’ time; in return, the
|
||||
maintainers will reciprocate by helping to address your issue, review
|
||||
changes, and finalize pull requests.
|
||||
|
||||
Types of Contributions
|
||||
======================
|
||||
|
||||
We love to get contributions from our community—you! There are many ways
|
||||
to contribute, whether you’re a programmer or not.
|
||||
|
||||
Non-Programming
|
||||
---------------
|
||||
|
||||
- Promote beets! Help get the word out by telling your friends, writing
|
||||
a blog post, or discussing it on a forum you frequent.
|
||||
- Improve the `documentation <http://beets.readthedocs.org/>`__. 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 <https://discourse.beets.io/>`__ under the “Show and Tell”
|
||||
category for a chance to get featured in `the
|
||||
docs <https://beets.readthedocs.io/en/stable/guides/advanced.html>`__.
|
||||
- Consider helping out in `our forums <https://discourse.beets.io/>`__
|
||||
by responding to support requests or driving some new discussions.
|
||||
|
||||
Programming
|
||||
-----------
|
||||
|
||||
- As a programmer (even if 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 <https://beets.readthedocs.io/en/stable/dev/>`__.
|
||||
|
||||
Getting the Source
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The easiest way to get started with the latest beets source is to use
|
||||
`pip <https://pip.pypa.io/>`__ to install an “editable” package. This
|
||||
can be done with one command:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ pip install -e git+https://github.com/beetbox/beets.git#egg=beets
|
||||
|
||||
Or, equivalently:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ git clone https://github.com/beetbox/beets.git
|
||||
$ cd beets
|
||||
$ pip install -e .
|
||||
|
||||
If you already have a released version of beets installed, you may need
|
||||
to remove it first by typing ``pip uninstall beets``. The pip command
|
||||
above will put the beets source in a ``src/beets`` directory and install
|
||||
the ``beet`` CLI script to a standard location on your system. You may
|
||||
want to use the ``--src`` option to specify the parent directory where
|
||||
the source will be checked out and the ``--user`` option such that the
|
||||
package will be installed to your home directory (compare with the
|
||||
output of ``pip install --help``).
|
||||
|
||||
Code Contribution Ideas
|
||||
^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
- We maintain a set of `issues marked as
|
||||
“bite-sized” <https://github.com/beetbox/beets/labels/bitesize>`__.
|
||||
These are issues that would serve as a good introduction to the
|
||||
codebase. Claim one and start exploring!
|
||||
- Like testing? Our `test
|
||||
coverage <https://codecov.io/github/beetbox/beets>`__ is somewhat
|
||||
low. You can help out by finding low-coverage modules or checking out
|
||||
other `testing-related
|
||||
issues <https://github.com/beetbox/beets/labels/testing>`__.
|
||||
- There are several ways to improve the tests in general (see :ref:`testing` and some
|
||||
places to think about performance optimization (see
|
||||
`Optimization <https://github.com/beetbox/beets/wiki/Optimization>`__).
|
||||
- Not all of our code is up to our coding conventions. In particular,
|
||||
the `API
|
||||
documentation <https://beets.readthedocs.io/en/stable/dev/api.html>`__
|
||||
are currently quite sparse. You can help by adding to the docstrings
|
||||
in the code and to the documentation pages themselves. beets follows
|
||||
`PEP-257 <https://www.python.org/dev/peps/pep-0257/>`__ for
|
||||
docstrings and in some places, we also sometimes use `ReST autodoc
|
||||
syntax for
|
||||
Sphinx <https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html>`__
|
||||
to, for example, refer to a class name.
|
||||
|
||||
Your First Contribution
|
||||
=======================
|
||||
|
||||
If this is your first time contributing to an open source project,
|
||||
welcome! If you are confused at all about how to contribute or what to
|
||||
contribute, take a look at `this great
|
||||
tutorial <http://makeapullrequest.com/>`__, or stop by our
|
||||
`forums <https://discourse.beets.io/>`__ if you have any questions.
|
||||
|
||||
We maintain a list of issues we reserved for those new to open source
|
||||
labeled `“first timers
|
||||
only” <https://github.com/beetbox/beets/issues?q=is%3Aopen+is%3Aissue+label%3A%22first+timers+only%22>`__.
|
||||
Since the goal of these issues is to get users comfortable with
|
||||
contributing to an open source project, please do not hesitate to ask
|
||||
any questions.
|
||||
|
||||
How to Submit Your Work
|
||||
=======================
|
||||
|
||||
Do you have a great bug fix, new feature, or documentation expansion
|
||||
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 <https://tox.readthedocs.org/en/latest/>`__. For more
|
||||
information on running tests, see :ref:`testing`.
|
||||
7. Push to your fork and open a pull request! 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 <https://beets.readthedocs.io/en/stable/dev/api.html>`__ that
|
||||
serves as an introduction to beets’ design.
|
||||
|
||||
Coding Conventions
|
||||
==================
|
||||
|
||||
General
|
||||
-------
|
||||
There are a few coding conventions we use in beets:
|
||||
|
||||
- Whenever you access the library database, do so through the provided
|
||||
Library methods or via a Transaction object. Never call
|
||||
``lib.conn.*`` directly. For example, do this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
with g.lib.transaction() as tx:
|
||||
rows = tx.query('SELECT DISTINCT "{0}" FROM "{1}" ORDER BY "{2}"'
|
||||
.format(field, model._table, sort_field))
|
||||
|
||||
To fetch Item objects from the database, use lib.items(…) and supply
|
||||
a query as an argument. Resist the urge to write raw SQL for your
|
||||
query. If you must use lower-level queries into the database, do
|
||||
this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
with lib.transaction() as tx:
|
||||
rows = tx.query('SELECT …')
|
||||
|
||||
Transaction objects help control concurrent access to the database
|
||||
and assist in debugging conflicting accesses.
|
||||
- Always use the `future
|
||||
imports <http://docs.python.org/library/__future__.html>`__
|
||||
``print_function``, ``division``, and ``absolute_import``, but *not*
|
||||
``unicode_literals``. These help keep your code modern and will help
|
||||
in the eventual move to Python 3.
|
||||
- ``str.format()`` should be used instead of the ``%`` operator
|
||||
- Never ``print`` informational messages; use the
|
||||
`logging <http://docs.python.org/library/logging.html>`__ module
|
||||
instead. In particular, we have our own logging shim, so 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 <http://docs.python.org/library/stdtypes.html#str.format>`__-style
|
||||
logging instead of ``%``-style, so you can type
|
||||
``log.debug(u"{0}", obj)`` to do your formatting.
|
||||
|
||||
- Exception handlers must use ``except A as B:`` instead of
|
||||
``except A, B:``.
|
||||
|
||||
Style
|
||||
-----
|
||||
|
||||
We follow `PEP 8`_ and `google's docstring format`_.
|
||||
|
||||
You can use ``tox -e lint`` to check your code for any style errors.
|
||||
|
||||
.. _PEP 8: https://www.python.org/dev/peps/pep-0008/
|
||||
.. _google's docstring format: https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings
|
||||
|
||||
Handling Paths
|
||||
--------------
|
||||
|
||||
A great deal of convention deals with the handling of **paths**. Paths
|
||||
are stored internally—in the database, for instance—as byte strings
|
||||
(i.e., ``bytes`` instead of ``str`` in Python 3). This is because POSIX
|
||||
operating systems’ path names are only reliably usable as byte
|
||||
strings—operating systems typically recommend but do not require that
|
||||
filenames use a given encoding, so violations of any reported encoding
|
||||
are inevitable. On Windows, the strings are always encoded with UTF-8;
|
||||
on Unix, the encoding is controlled by the filesystem. Here are some
|
||||
guidelines to follow:
|
||||
|
||||
- If you have a Unicode path or 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 <http://www.vim.org/>`__. Here are
|
||||
some ``.vimrc`` lines that might help with PEP 8-compliant Python
|
||||
coding::
|
||||
|
||||
filetype indent on
|
||||
autocmd FileType python setlocal shiftwidth=4 tabstop=4 softtabstop=4 expandtab shiftround autoindent
|
||||
|
||||
Consider installing `this alternative Python indentation
|
||||
plugin <https://github.com/mitsuhiko/vim-python-combined>`__. I also
|
||||
like `neomake <https://github.com/neomake/neomake>`__ with its flake8
|
||||
checker.
|
||||
|
||||
.. _testing:
|
||||
|
||||
Testing
|
||||
=======
|
||||
|
||||
Running the Tests
|
||||
-----------------
|
||||
|
||||
To run the tests for multiple Python versions, compile the docs, and
|
||||
check style, use `tox`_. Just type ``tox`` or use something like
|
||||
``tox -e py27`` to test a specific configuration. `detox`_ makes this go
|
||||
faster.
|
||||
|
||||
You can disable a hand-selected set of "slow" tests by setting the
|
||||
environment variable SKIP_SLOW_TESTS before running them.
|
||||
|
||||
Other ways to run the tests:
|
||||
|
||||
- ``python testall.py`` (ditto)
|
||||
- ``python -m unittest discover -p 'test_*'`` (ditto)
|
||||
- `pytest`_
|
||||
|
||||
You can also see the latest test results on `Linux`_ and on `Windows`_.
|
||||
|
||||
Note, if you are on Windows and are seeing errors running tox, it may be related to `this issue`_,
|
||||
in which case you may have to install tox v3.8.3 e.g. ``python -m pip install tox=3.8.3``
|
||||
|
||||
.. _this issue: https://github.com/tox-dev/tox/issues/1550
|
||||
|
||||
Coverage
|
||||
^^^^^^^^
|
||||
|
||||
``tox -e cov`` will add coverage info for tests: Coverage is pretty low
|
||||
still -- see the current status on `Codecov`_.
|
||||
|
||||
Red Flags
|
||||
^^^^^^^^^
|
||||
|
||||
The `pytest-random`_ plugin makes it easy to randomize the order of
|
||||
tests. ``py.test test --random`` will occasionally turn up failing tests
|
||||
that reveal ordering dependencies—which are bad news!
|
||||
|
||||
Test Dependencies
|
||||
^^^^^^^^^^^^^^^^^
|
||||
|
||||
The tests have a few more dependencies than beets itself. (The
|
||||
additional dependencies consist of testing utilities and dependencies of
|
||||
non-default plugins exercised by the test suite.) The dependencies are
|
||||
listed under 'test' in ``extras_require`` in `setup.py`_.
|
||||
To install the test dependencies, run ``python -m pip install .[test]``.
|
||||
Or, just run a test suite with ``tox`` which will install them
|
||||
automatically.
|
||||
|
||||
.. _setup.py: https://github.com/beetbox/beets/blob/master/setup.py#L99`
|
||||
|
||||
Writing Tests
|
||||
-------------
|
||||
|
||||
Writing tests is done by adding or modifying files in folder `test`_.
|
||||
Take a look at
|
||||
`https://github.com/beetbox/beets/blob/master/test/test_template.py#L224`_
|
||||
to get a basic view on how tests are written. We currently allow writing
|
||||
tests with either `unittest`_ or `pytest`_.
|
||||
|
||||
Any tests that involve sending out network traffic e.g. an external API
|
||||
call, should be skipped normally and run under our weekly `integration
|
||||
test`_ suite. These tests can be useful in detecting external changes
|
||||
that would affect ``beets``. In order to do this, simply add the
|
||||
following snippet before the applicable test case:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.environ.get('INTEGRATION_TEST', '0') == '1',
|
||||
'integration testing not enabled')
|
||||
|
||||
If you do this, it is also advised to create a similar test that 'mocks'
|
||||
the network call and can be run under normal circumstances by our CI and
|
||||
others. See `unittest.mock`_ for more info.
|
||||
|
||||
- **AVOID** using the ``start()`` and ``stop()`` methods of
|
||||
``mock.patch``, as they require manual cleanup. Use the annotation or
|
||||
context manager forms instead.
|
||||
|
||||
.. _Python unittest: https://docs.python.org/2/library/unittest.html
|
||||
.. _Codecov: https://codecov.io/github/beetbox/beets
|
||||
.. _pytest-random: https://github.com/klrmn/pytest-random
|
||||
.. _tox: http://tox.readthedocs.org
|
||||
.. _detox: https://pypi.python.org/pypi/detox/
|
||||
.. _pytest: http://pytest.org
|
||||
.. _Linux: https://github.com/beetbox/beets/actions
|
||||
.. _Windows: https://ci.appveyor.com/project/beetbox/beets/
|
||||
.. _`https://github.com/beetbox/beets/blob/master/setup.py#L99`: https://github.com/beetbox/beets/blob/master/setup.py#L99
|
||||
.. _test: https://github.com/beetbox/beets/tree/master/test
|
||||
.. _`https://github.com/beetbox/beets/blob/master/test/test_template.py#L224`: https://github.com/beetbox/beets/blob/master/test/test_template.py#L224
|
||||
.. _unittest: https://docs.python.org/3.8/library/unittest.html
|
||||
.. _integration test: https://github.com/beetbox/beets/actions?query=workflow%3A%22integration+tests%22
|
||||
.. _unittest.mock: https://docs.python.org/3/library/unittest.mock.html
|
||||
.. _Python unittest: https://docs.python.org/2/library/unittest.html
|
||||
23
README.rst
23
README.rst
|
|
@ -4,8 +4,8 @@
|
|||
.. image:: https://img.shields.io/codecov/c/github/beetbox/beets.svg
|
||||
:target: https://codecov.io/github/beetbox/beets
|
||||
|
||||
.. image:: https://travis-ci.org/beetbox/beets.svg?branch=master
|
||||
:target: https://travis-ci.org/beetbox/beets
|
||||
.. image:: https://github.com/beetbox/beets/workflows/ci/badge.svg?branch=master
|
||||
:target: https://github.com/beetbox/beets/actions
|
||||
|
||||
.. image:: https://repology.org/badge/tiny-repos/beets.svg
|
||||
:target: https://repology.org/project/beets/versions
|
||||
|
|
@ -90,11 +90,9 @@ Check out the `Getting Started`_ guide for more information.
|
|||
Contribute
|
||||
----------
|
||||
|
||||
Check out the `Hacking`_ page on the wiki for tips on how to help out.
|
||||
You might also be interested in the `For Developers`_ section in the docs.
|
||||
Thank you for considering contributing to ``beets``! Whether you're a programmer or not, you should be able to find all the info you need at `CONTRIBUTING.rst`_.
|
||||
|
||||
.. _Hacking: https://github.com/beetbox/beets/wiki/Hacking
|
||||
.. _For Developers: https://beets.readthedocs.io/en/stable/dev/
|
||||
.. _CONTRIBUTING.rst: https://github.com/beetbox/beets/blob/master/CONTRIBUTING.rst
|
||||
|
||||
Read More
|
||||
---------
|
||||
|
|
@ -105,11 +103,18 @@ news and updates.
|
|||
.. _its Web site: https://beets.io/
|
||||
.. _@b33ts: https://twitter.com/b33ts/
|
||||
|
||||
Contact
|
||||
-------
|
||||
* Encountered a bug you'd like to report or have an idea for a new feature? Check out our `issue tracker`_! If your issue or feature hasn't already been reported, please `open a new ticket`_ and we'll be in touch with you shortly. If you'd like to vote on a feature/bug, simply give a :+1: on issues you'd like to see prioritized over others.
|
||||
* Need help/support, would like to start a discussion, or would just like to introduce yourself to the team? Check out our `forums`_!
|
||||
|
||||
.. _issue tracker: https://github.com/beetbox/beets/issues
|
||||
.. _open a new ticket: https://github.com/beetbox/beets/issues/new/choose
|
||||
.. _forums: https://discourse.beets.io/
|
||||
|
||||
Authors
|
||||
-------
|
||||
|
||||
Beets is by `Adrian Sampson`_ with a supporting cast of thousands. For help,
|
||||
please visit our `forum`_.
|
||||
Beets is by `Adrian Sampson`_ with a supporting cast of thousands.
|
||||
|
||||
.. _forum: https://discourse.beets.io
|
||||
.. _Adrian Sampson: https://www.cs.cornell.edu/~asampson/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -15,9 +15,8 @@
|
|||
|
||||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
import os
|
||||
|
||||
import confuse
|
||||
from sys import stderr
|
||||
|
||||
__version__ = u'1.5.0'
|
||||
__author__ = u'Adrian Sampson <adrian@radbox.org>'
|
||||
|
|
@ -32,11 +31,12 @@ class IncludeLazyConfig(confuse.LazyConfig):
|
|||
|
||||
try:
|
||||
for view in self['include']:
|
||||
filename = view.as_filename()
|
||||
if os.path.isfile(filename):
|
||||
self.set_file(filename)
|
||||
self.set_file(view.as_filename())
|
||||
except confuse.NotFoundError:
|
||||
pass
|
||||
except confuse.ConfigReadError as err:
|
||||
stderr.write("configuration `import` failed: {}"
|
||||
.format(err.reason))
|
||||
|
||||
|
||||
config = IncludeLazyConfig('beets', __name__)
|
||||
|
|
|
|||
27
beets/art.py
27
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,41 @@ from .match import Recommendation # noqa
|
|||
# Global logger.
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
# Metadata fields that are already hardcoded, or where the tag name changes.
|
||||
SPECIAL_FIELDS = {
|
||||
'album': (
|
||||
'va',
|
||||
'releasegroup_id',
|
||||
'artist_id',
|
||||
'album_id',
|
||||
'mediums',
|
||||
'tracks',
|
||||
'year',
|
||||
'month',
|
||||
'day',
|
||||
'artist',
|
||||
'artist_credit',
|
||||
'artist_sort',
|
||||
'data_url'
|
||||
),
|
||||
'track': (
|
||||
'track_alt',
|
||||
'artist_id',
|
||||
'release_track_id',
|
||||
'medium',
|
||||
'index',
|
||||
'medium_index',
|
||||
'title',
|
||||
'artist_credit',
|
||||
'artist_sort',
|
||||
'artist',
|
||||
'track_id',
|
||||
'medium_total',
|
||||
'data_url',
|
||||
'length'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
# Additional utilities for the main interface.
|
||||
|
||||
|
|
@ -49,23 +84,14 @@ def apply_item_metadata(item, track_info):
|
|||
item.mb_releasetrackid = track_info.release_track_id
|
||||
if track_info.artist_id:
|
||||
item.mb_artistid = track_info.artist_id
|
||||
if track_info.data_source:
|
||||
item.data_source = track_info.data_source
|
||||
|
||||
if track_info.lyricist is not None:
|
||||
item.lyricist = track_info.lyricist
|
||||
if track_info.composer is not None:
|
||||
item.composer = track_info.composer
|
||||
if track_info.composer_sort is not None:
|
||||
item.composer_sort = track_info.composer_sort
|
||||
if track_info.arranger is not None:
|
||||
item.arranger = track_info.arranger
|
||||
if track_info.work is not None:
|
||||
item.work = track_info.work
|
||||
if track_info.mb_workid is not None:
|
||||
item.mb_workid = track_info.mb_workid
|
||||
if track_info.work_disambig is not None:
|
||||
item.work_disambig = track_info.work_disambig
|
||||
for field, value in track_info.items():
|
||||
# We only overwrite fields that are not already hardcoded.
|
||||
if field in SPECIAL_FIELDS['track']:
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
item[field] = value
|
||||
|
||||
# At the moment, the other metadata is left intact (including album
|
||||
# and track number). Perhaps these should be emptied?
|
||||
|
|
@ -157,52 +183,19 @@ def apply_metadata(album_info, mapping):
|
|||
# Track alt.
|
||||
item.track_alt = track_info.track_alt
|
||||
|
||||
# Miscellaneous/nullable metadata.
|
||||
misc_fields = {
|
||||
'album': (
|
||||
'albumtype',
|
||||
'label',
|
||||
'asin',
|
||||
'catalognum',
|
||||
'script',
|
||||
'language',
|
||||
'country',
|
||||
'style',
|
||||
'genre',
|
||||
'discogs_albumid',
|
||||
'discogs_artistid',
|
||||
'discogs_labelid',
|
||||
'albumstatus',
|
||||
'albumdisambig',
|
||||
'releasegroupdisambig',
|
||||
'data_source',
|
||||
),
|
||||
'track': (
|
||||
'disctitle',
|
||||
'lyricist',
|
||||
'media',
|
||||
'composer',
|
||||
'composer_sort',
|
||||
'arranger',
|
||||
'work',
|
||||
'mb_workid',
|
||||
'work_disambig',
|
||||
'bpm',
|
||||
'initial_key',
|
||||
'genre'
|
||||
)
|
||||
}
|
||||
|
||||
# Don't overwrite fields with empty values unless the
|
||||
# field is explicitly allowed to be overwritten
|
||||
for field in misc_fields['album']:
|
||||
for field, value in album_info.items():
|
||||
if field in SPECIAL_FIELDS['album']:
|
||||
continue
|
||||
clobber = field in config['overwrite_null']['album'].as_str_seq()
|
||||
value = getattr(album_info, field)
|
||||
if value is None and not clobber:
|
||||
continue
|
||||
item[field] = value
|
||||
|
||||
for field in misc_fields['track']:
|
||||
for field, value in track_info.items():
|
||||
if field in SPECIAL_FIELDS['track']:
|
||||
continue
|
||||
clobber = field in config['overwrite_null']['track'].as_str_seq()
|
||||
value = getattr(track_info, field)
|
||||
if value is None and not clobber:
|
||||
|
|
|
|||
|
|
@ -39,8 +39,25 @@ except AttributeError:
|
|||
|
||||
|
||||
# Classes used to represent candidate options.
|
||||
class AttrDict(dict):
|
||||
"""A dictionary that supports attribute ("dot") access, so `d.field`
|
||||
is equivalent to `d['field']`.
|
||||
"""
|
||||
|
||||
class AlbumInfo(object):
|
||||
def __getattr__(self, attr):
|
||||
if attr in self:
|
||||
return self.get(attr)
|
||||
else:
|
||||
raise AttributeError
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
self.__setitem__(key, value)
|
||||
|
||||
def __hash__(self):
|
||||
return id(self)
|
||||
|
||||
|
||||
class AlbumInfo(AttrDict):
|
||||
"""Describes a canonical release that may be used to match a release
|
||||
in the library. Consists of these data members:
|
||||
|
||||
|
|
@ -49,43 +66,21 @@ class AlbumInfo(object):
|
|||
- ``artist``: name of the release's primary artist
|
||||
- ``artist_id``
|
||||
- ``tracks``: list of TrackInfo objects making up the release
|
||||
- ``asin``: Amazon ASIN
|
||||
- ``albumtype``: string describing the kind of release
|
||||
- ``va``: boolean: whether the release has "various artists"
|
||||
- ``year``: release year
|
||||
- ``month``: release month
|
||||
- ``day``: release day
|
||||
- ``label``: music label responsible for the release
|
||||
- ``mediums``: the number of discs in this release
|
||||
- ``artist_sort``: name of the release's artist for sorting
|
||||
- ``releasegroup_id``: MBID for the album's release group
|
||||
- ``catalognum``: the label's catalog number for the release
|
||||
- ``script``: character set used for metadata
|
||||
- ``language``: human language of the metadata
|
||||
- ``country``: the release country
|
||||
- ``albumstatus``: MusicBrainz release status (Official, etc.)
|
||||
- ``media``: delivery mechanism (Vinyl, etc.)
|
||||
- ``albumdisambig``: MusicBrainz release disambiguation comment
|
||||
- ``releasegroupdisambig``: MusicBrainz release group
|
||||
disambiguation comment.
|
||||
- ``artist_credit``: Release-specific artist name
|
||||
- ``data_source``: The original data source (MusicBrainz, Discogs, etc.)
|
||||
- ``data_url``: The data source release URL.
|
||||
|
||||
``mediums`` along with the fields up through ``tracks`` are required.
|
||||
The others are optional and may be None.
|
||||
"""
|
||||
def __init__(self, album, album_id, artist, artist_id, tracks, asin=None,
|
||||
albumtype=None, va=False, year=None, month=None, day=None,
|
||||
label=None, mediums=None, artist_sort=None,
|
||||
releasegroup_id=None, catalognum=None, script=None,
|
||||
language=None, country=None, style=None, genre=None,
|
||||
albumstatus=None, media=None, albumdisambig=None,
|
||||
def __init__(self, tracks, album=None, album_id=None, artist=None,
|
||||
artist_id=None, asin=None, albumtype=None, va=False,
|
||||
year=None, month=None, day=None, label=None, mediums=None,
|
||||
artist_sort=None, releasegroup_id=None, catalognum=None,
|
||||
script=None, language=None, country=None, style=None,
|
||||
genre=None, albumstatus=None, media=None, albumdisambig=None,
|
||||
releasegroupdisambig=None, artist_credit=None,
|
||||
original_year=None, original_month=None,
|
||||
original_day=None, data_source=None, data_url=None,
|
||||
discogs_albumid=None, discogs_labelid=None,
|
||||
discogs_artistid=None):
|
||||
discogs_artistid=None, **kwargs):
|
||||
self.album = album
|
||||
self.album_id = album_id
|
||||
self.artist = artist
|
||||
|
|
@ -120,6 +115,7 @@ class AlbumInfo(object):
|
|||
self.discogs_albumid = discogs_albumid
|
||||
self.discogs_labelid = discogs_labelid
|
||||
self.discogs_artistid = discogs_artistid
|
||||
self.update(kwargs)
|
||||
|
||||
# Work around a bug in python-musicbrainz-ngs that causes some
|
||||
# strings to be bytes rather than Unicode.
|
||||
|
|
@ -138,53 +134,36 @@ class AlbumInfo(object):
|
|||
if isinstance(value, bytes):
|
||||
setattr(self, fld, value.decode(codec, 'ignore'))
|
||||
|
||||
if self.tracks:
|
||||
for track in self.tracks:
|
||||
track.decode(codec)
|
||||
for track in self.tracks:
|
||||
track.decode(codec)
|
||||
|
||||
def copy(self):
|
||||
dupe = AlbumInfo([])
|
||||
dupe.update(self)
|
||||
dupe.tracks = [track.copy() for track in self.tracks]
|
||||
return dupe
|
||||
|
||||
|
||||
class TrackInfo(object):
|
||||
class TrackInfo(AttrDict):
|
||||
"""Describes a canonical track present on a release. Appears as part
|
||||
of an AlbumInfo's ``tracks`` list. Consists of these data members:
|
||||
|
||||
- ``title``: name of the track
|
||||
- ``track_id``: MusicBrainz ID; UUID fragment only
|
||||
- ``release_track_id``: MusicBrainz ID respective to a track on a
|
||||
particular release; UUID fragment only
|
||||
- ``artist``: individual track artist name
|
||||
- ``artist_id``
|
||||
- ``length``: float: duration of the track in seconds
|
||||
- ``index``: position on the entire release
|
||||
- ``media``: delivery mechanism (Vinyl, etc.)
|
||||
- ``medium``: the disc number this track appears on in the album
|
||||
- ``medium_index``: the track's position on the disc
|
||||
- ``medium_total``: the number of tracks on the item's disc
|
||||
- ``artist_sort``: name of the track artist for sorting
|
||||
- ``disctitle``: name of the individual medium (subtitle)
|
||||
- ``artist_credit``: Recording-specific artist name
|
||||
- ``data_source``: The original data source (MusicBrainz, Discogs, etc.)
|
||||
- ``data_url``: The data source release URL.
|
||||
- ``lyricist``: individual track lyricist name
|
||||
- ``composer``: individual track composer name
|
||||
- ``composer_sort``: individual track composer sort name
|
||||
- ``arranger`: individual track arranger name
|
||||
- ``track_alt``: alternative track number (tape, vinyl, etc.)
|
||||
- ``work`: individual track work title
|
||||
- ``mb_workid`: individual track work id
|
||||
- ``work_disambig`: individual track work diambiguation
|
||||
|
||||
Only ``title`` and ``track_id`` are required. The rest of the fields
|
||||
may be None. The indices ``index``, ``medium``, and ``medium_index``
|
||||
are all 1-based.
|
||||
"""
|
||||
def __init__(self, title, track_id, release_track_id=None, artist=None,
|
||||
artist_id=None, length=None, index=None, medium=None,
|
||||
medium_index=None, medium_total=None, artist_sort=None,
|
||||
disctitle=None, artist_credit=None, data_source=None,
|
||||
data_url=None, media=None, lyricist=None, composer=None,
|
||||
composer_sort=None, arranger=None, track_alt=None,
|
||||
work=None, mb_workid=None, work_disambig=None, bpm=None,
|
||||
initial_key=None, genre=None):
|
||||
def __init__(self, title=None, track_id=None, release_track_id=None,
|
||||
artist=None, artist_id=None, length=None, index=None,
|
||||
medium=None, medium_index=None, medium_total=None,
|
||||
artist_sort=None, disctitle=None, artist_credit=None,
|
||||
data_source=None, data_url=None, media=None, lyricist=None,
|
||||
composer=None, composer_sort=None, arranger=None,
|
||||
track_alt=None, work=None, mb_workid=None,
|
||||
work_disambig=None, bpm=None, initial_key=None, genre=None,
|
||||
**kwargs):
|
||||
self.title = title
|
||||
self.track_id = track_id
|
||||
self.release_track_id = release_track_id
|
||||
|
|
@ -212,6 +191,7 @@ class TrackInfo(object):
|
|||
self.bpm = bpm
|
||||
self.initial_key = initial_key
|
||||
self.genre = genre
|
||||
self.update(kwargs)
|
||||
|
||||
# As above, work around a bug in python-musicbrainz-ngs.
|
||||
def decode(self, codec='utf-8'):
|
||||
|
|
@ -224,6 +204,11 @@ class TrackInfo(object):
|
|||
if isinstance(value, bytes):
|
||||
setattr(self, fld, value.decode(codec, 'ignore'))
|
||||
|
||||
def copy(self):
|
||||
dupe = TrackInfo()
|
||||
dupe.update(self)
|
||||
return dupe
|
||||
|
||||
|
||||
# Candidate distance scoring.
|
||||
|
||||
|
|
@ -347,7 +332,7 @@ class Distance(object):
|
|||
self._penalties = {}
|
||||
|
||||
@LazyClassProperty
|
||||
def _weights(cls): # noqa
|
||||
def _weights(cls): # noqa: N805
|
||||
"""A dictionary from keys to floating-point weights.
|
||||
"""
|
||||
weights_view = config['match']['distance_weights']
|
||||
|
|
@ -614,17 +599,21 @@ def tracks_for_id(track_id):
|
|||
|
||||
|
||||
@plugins.notify_info_yielded(u'albuminfo_received')
|
||||
def album_candidates(items, artist, album, va_likely):
|
||||
def album_candidates(items, artist, album, va_likely, extra_tags):
|
||||
"""Search for album matches. ``items`` is a list of Item objects
|
||||
that make up the album. ``artist`` and ``album`` are the respective
|
||||
names (strings), which may be derived from the item list or may be
|
||||
entered by the user. ``va_likely`` is a boolean indicating whether
|
||||
the album is likely to be a "various artists" release.
|
||||
the album is likely to be a "various artists" release. ``extra_tags``
|
||||
is an optional dictionary of additional tags used to further
|
||||
constrain the search.
|
||||
"""
|
||||
|
||||
# Base candidates if we have album and artist to match.
|
||||
if artist and album:
|
||||
try:
|
||||
for candidate in mb.match_album(artist, album, len(items)):
|
||||
for candidate in mb.match_album(artist, album, len(items),
|
||||
extra_tags):
|
||||
yield candidate
|
||||
except mb.MusicBrainzAPIError as exc:
|
||||
exc.log(log)
|
||||
|
|
@ -632,13 +621,15 @@ def album_candidates(items, artist, album, va_likely):
|
|||
# Also add VA matches from MusicBrainz where appropriate.
|
||||
if va_likely and album:
|
||||
try:
|
||||
for candidate in mb.match_album(None, album, len(items)):
|
||||
for candidate in mb.match_album(None, album, len(items),
|
||||
extra_tags):
|
||||
yield candidate
|
||||
except mb.MusicBrainzAPIError as exc:
|
||||
exc.log(log)
|
||||
|
||||
# Candidates from plugins.
|
||||
for candidate in plugins.candidates(items, artist, album, va_likely):
|
||||
for candidate in plugins.candidates(items, artist, album, va_likely,
|
||||
extra_tags):
|
||||
yield candidate
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -38,6 +38,14 @@ else:
|
|||
|
||||
SKIPPED_TRACKS = ['[data track]']
|
||||
|
||||
FIELDS_TO_MB_KEYS = {
|
||||
'catalognum': 'catno',
|
||||
'country': 'country',
|
||||
'label': 'label',
|
||||
'media': 'format',
|
||||
'year': 'date',
|
||||
}
|
||||
|
||||
musicbrainzngs.set_useragent('beets', beets.__version__,
|
||||
'https://beets.io/')
|
||||
|
||||
|
|
@ -185,8 +193,8 @@ def track_info(recording, index=None, medium=None, medium_index=None,
|
|||
the number of tracks on the medium. Each number is a 1-based index.
|
||||
"""
|
||||
info = beets.autotag.hooks.TrackInfo(
|
||||
recording['title'],
|
||||
recording['id'],
|
||||
title=recording['title'],
|
||||
track_id=recording['id'],
|
||||
index=index,
|
||||
medium=medium,
|
||||
medium_index=medium_index,
|
||||
|
|
@ -333,11 +341,11 @@ def album_info(release):
|
|||
track_infos.append(ti)
|
||||
|
||||
info = beets.autotag.hooks.AlbumInfo(
|
||||
release['title'],
|
||||
release['id'],
|
||||
artist_name,
|
||||
release['artist-credit'][0]['artist']['id'],
|
||||
track_infos,
|
||||
album=release['title'],
|
||||
album_id=release['id'],
|
||||
artist=artist_name,
|
||||
artist_id=release['artist-credit'][0]['artist']['id'],
|
||||
tracks=track_infos,
|
||||
mediums=len(release['medium-list']),
|
||||
artist_sort=artist_sort_name,
|
||||
artist_credit=artist_credit_name,
|
||||
|
|
@ -411,13 +419,13 @@ def album_info(release):
|
|||
return info
|
||||
|
||||
|
||||
def match_album(artist, album, tracks=None):
|
||||
def match_album(artist, album, tracks=None, extra_tags=None):
|
||||
"""Searches for a single album ("release" in MusicBrainz parlance)
|
||||
and returns an iterator over AlbumInfo objects. May raise a
|
||||
MusicBrainzAPIError.
|
||||
|
||||
The query consists of an artist name, an album name, and,
|
||||
optionally, a number of tracks on the album.
|
||||
optionally, a number of tracks on the album and any other extra tags.
|
||||
"""
|
||||
# Build search criteria.
|
||||
criteria = {'release': album.lower().strip()}
|
||||
|
|
@ -429,6 +437,16 @@ def match_album(artist, album, tracks=None):
|
|||
if tracks is not None:
|
||||
criteria['tracks'] = six.text_type(tracks)
|
||||
|
||||
# Additional search cues from existing metadata.
|
||||
if extra_tags:
|
||||
for tag in extra_tags:
|
||||
key = FIELDS_TO_MB_KEYS[tag]
|
||||
value = six.text_type(extra_tags.get(tag, '')).lower().strip()
|
||||
if key == 'catno':
|
||||
value = value.replace(u' ', '')
|
||||
if value:
|
||||
criteria[key] = value
|
||||
|
||||
# Abort if we have no search terms.
|
||||
if not any(criteria.values()):
|
||||
return
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1034,8 +1034,8 @@ class ArchiveImportTask(SentinelImportTask):
|
|||
cls._handlers = []
|
||||
from zipfile import is_zipfile, ZipFile
|
||||
cls._handlers.append((is_zipfile, ZipFile))
|
||||
from tarfile import is_tarfile, TarFile
|
||||
cls._handlers.append((is_tarfile, TarFile))
|
||||
import tarfile
|
||||
cls._handlers.append((tarfile.is_tarfile, tarfile.open))
|
||||
try:
|
||||
from rarfile import is_rarfile, RarFile
|
||||
except ImportError:
|
||||
|
|
|
|||
|
|
@ -410,7 +410,8 @@ class FormattedItemMapping(dbcore.db.FormattedMapping):
|
|||
raise KeyError(key)
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""Get the value for a key. Certain unset values are remapped.
|
||||
"""Get the value for a key. `artist` and `albumartist`
|
||||
are fallback values for each other when not set.
|
||||
"""
|
||||
value = self._get(key)
|
||||
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ class BeetsPlugin(object):
|
|||
"""
|
||||
return beets.autotag.hooks.Distance()
|
||||
|
||||
def candidates(self, items, artist, album, va_likely):
|
||||
def candidates(self, items, artist, album, va_likely, extra_tags=None):
|
||||
"""Should return a sequence of AlbumInfo objects that match the
|
||||
album whose items are provided.
|
||||
"""
|
||||
|
|
@ -379,11 +379,12 @@ def album_distance(items, album_info, mapping):
|
|||
return dist
|
||||
|
||||
|
||||
def candidates(items, artist, album, va_likely):
|
||||
def candidates(items, artist, album, va_likely, extra_tags=None):
|
||||
"""Gets MusicBrainz candidates for an album from each plugin.
|
||||
"""
|
||||
for plugin in find_plugins():
|
||||
for candidate in plugin.candidates(items, artist, album, va_likely):
|
||||
for candidate in plugin.candidates(items, artist, album, va_likely,
|
||||
extra_tags):
|
||||
yield candidate
|
||||
|
||||
|
||||
|
|
@ -714,7 +715,7 @@ class MetadataSourcePlugin(object):
|
|||
return id_
|
||||
return None
|
||||
|
||||
def candidates(self, items, artist, album, va_likely):
|
||||
def candidates(self, items, artist, album, va_likely, extra_tags=None):
|
||||
"""Returns a list of AlbumInfo objects for Search API results
|
||||
matching an ``album`` and ``artist`` (if not various).
|
||||
|
||||
|
|
|
|||
|
|
@ -40,14 +40,19 @@ else:
|
|||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
def resize_url(url, maxwidth):
|
||||
def resize_url(url, maxwidth, quality=0):
|
||||
"""Return a proxied image URL that resizes the original image to
|
||||
maxwidth (preserving aspect ratio).
|
||||
"""
|
||||
return '{0}?{1}'.format(PROXY_URL, urlencode({
|
||||
params = {
|
||||
'url': url.replace('http://', ''),
|
||||
'w': maxwidth,
|
||||
}))
|
||||
}
|
||||
|
||||
if quality > 0:
|
||||
params['q'] = quality
|
||||
|
||||
return '{0}?{1}'.format(PROXY_URL, urlencode(params))
|
||||
|
||||
|
||||
def temp_file_for(path):
|
||||
|
|
@ -59,7 +64,7 @@ def temp_file_for(path):
|
|||
return util.bytestring_path(f.name)
|
||||
|
||||
|
||||
def pil_resize(maxwidth, path_in, path_out=None):
|
||||
def pil_resize(maxwidth, path_in, path_out=None, quality=0):
|
||||
"""Resize using Python Imaging Library (PIL). Return the output path
|
||||
of resized image.
|
||||
"""
|
||||
|
|
@ -72,7 +77,7 @@ def pil_resize(maxwidth, path_in, path_out=None):
|
|||
im = Image.open(util.syspath(path_in))
|
||||
size = maxwidth, maxwidth
|
||||
im.thumbnail(size, Image.ANTIALIAS)
|
||||
im.save(util.py3_path(path_out))
|
||||
im.save(util.py3_path(path_out), quality=quality)
|
||||
return path_out
|
||||
except IOError:
|
||||
log.error(u"PIL cannot create thumbnail for '{0}'",
|
||||
|
|
@ -80,7 +85,7 @@ def pil_resize(maxwidth, path_in, path_out=None):
|
|||
return path_in
|
||||
|
||||
|
||||
def im_resize(maxwidth, path_in, path_out=None):
|
||||
def im_resize(maxwidth, path_in, path_out=None, quality=0):
|
||||
"""Resize using ImageMagick.
|
||||
|
||||
Use the ``magick`` program or ``convert`` on older versions. Return
|
||||
|
|
@ -93,10 +98,15 @@ def im_resize(maxwidth, path_in, path_out=None):
|
|||
# "-resize WIDTHx>" shrinks images with the width larger
|
||||
# than the given width while maintaining the aspect ratio
|
||||
# with regards to the height.
|
||||
cmd = ArtResizer.shared.im_convert_cmd + \
|
||||
[util.syspath(path_in, prefix=False),
|
||||
'-resize', '{0}x>'.format(maxwidth),
|
||||
util.syspath(path_out, prefix=False)]
|
||||
cmd = ArtResizer.shared.im_convert_cmd + [
|
||||
util.syspath(path_in, prefix=False),
|
||||
'-resize', '{0}x>'.format(maxwidth),
|
||||
]
|
||||
|
||||
if quality > 0:
|
||||
cmd += ['-quality', '{0}'.format(quality)]
|
||||
|
||||
cmd.append(util.syspath(path_out, prefix=False))
|
||||
|
||||
try:
|
||||
util.command_output(cmd)
|
||||
|
|
@ -190,18 +200,19 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
|
|||
self.im_convert_cmd = ['magick']
|
||||
self.im_identify_cmd = ['magick', 'identify']
|
||||
|
||||
def resize(self, maxwidth, path_in, path_out=None):
|
||||
def resize(self, maxwidth, path_in, path_out=None, quality=0):
|
||||
"""Manipulate an image file according to the method, returning a
|
||||
new path. For PIL or IMAGEMAGIC methods, resizes the image to a
|
||||
temporary file. For WEBPROXY, returns `path_in` unmodified.
|
||||
temporary file and encodes with the specified quality level.
|
||||
For WEBPROXY, returns `path_in` unmodified.
|
||||
"""
|
||||
if self.local:
|
||||
func = BACKEND_FUNCS[self.method[0]]
|
||||
return func(maxwidth, path_in, path_out)
|
||||
return func(maxwidth, path_in, path_out, quality=quality)
|
||||
else:
|
||||
return path_in
|
||||
|
||||
def proxy_url(self, maxwidth, url):
|
||||
def proxy_url(self, maxwidth, url, quality=0):
|
||||
"""Modifies an image URL according the method, returning a new
|
||||
URL. For WEBPROXY, a URL on the proxy server is returned.
|
||||
Otherwise, the URL is returned unmodified.
|
||||
|
|
@ -209,7 +220,7 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
|
|||
if self.local:
|
||||
return url
|
||||
else:
|
||||
return resize_url(url, maxwidth)
|
||||
return resize_url(url, maxwidth, quality)
|
||||
|
||||
@property
|
||||
def local(self):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ class BeatportPlugin(BeetsPlugin):
|
|||
config=self.config
|
||||
)
|
||||
|
||||
def candidates(self, items, artist, release, va_likely):
|
||||
def candidates(self, items, artist, release, va_likely, extra_tags=None):
|
||||
"""Returns a list of AlbumInfo objects for beatport search results
|
||||
matching release and artist (if not various).
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
u'never_convert_lossy_files': False,
|
||||
u'copy_album_art': False,
|
||||
u'album_art_maxwidth': 0,
|
||||
u'delete_originals': False,
|
||||
})
|
||||
self.early_import_stages = [self.auto_convert]
|
||||
|
||||
|
|
@ -532,11 +533,16 @@ class ConvertPlugin(BeetsPlugin):
|
|||
|
||||
# Change the newly-imported database entry to point to the
|
||||
# converted file.
|
||||
source_path = item.path
|
||||
item.path = dest
|
||||
item.write()
|
||||
item.read() # Load new audio information data.
|
||||
item.store()
|
||||
|
||||
if self.config['delete_originals']:
|
||||
self._log.info(u'Removing original file {0}', source_path)
|
||||
util.remove(source_path, False)
|
||||
|
||||
def _cleanup(self, task, session):
|
||||
for path in task.old_paths:
|
||||
if path in _temp_files:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ from string import ascii_lowercase
|
|||
|
||||
|
||||
USER_AGENT = u'beets/{0} +https://beets.io/'.format(beets.__version__)
|
||||
API_KEY = 'rAzVUQYRaoFjeBjyWuWZ'
|
||||
API_KEY = 'rAzVUQYRaoFjeBjyWuWZ'
|
||||
API_SECRET = 'plxtUTqoCzwxZpqdPysCwGuBSmZNdZVy'
|
||||
|
||||
# Exceptions that discogs_client should really handle but does not.
|
||||
|
|
@ -175,7 +175,7 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
config=self.config
|
||||
)
|
||||
|
||||
def candidates(self, items, artist, album, va_likely):
|
||||
def candidates(self, items, artist, album, va_likely, extra_tags=None):
|
||||
"""Returns a list of AlbumInfo objects for discogs search results
|
||||
matching an album and artist (if not various).
|
||||
"""
|
||||
|
|
@ -356,17 +356,14 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
# a master release, otherwise fetch the master release.
|
||||
original_year = self.get_master_year(master_id) if master_id else year
|
||||
|
||||
return AlbumInfo(album, album_id, artist, artist_id, tracks, asin=None,
|
||||
albumtype=albumtype, va=va, year=year, month=None,
|
||||
day=None, label=label, mediums=len(set(mediums)),
|
||||
artist_sort=None, releasegroup_id=master_id,
|
||||
catalognum=catalogno, script=None, language=None,
|
||||
return AlbumInfo(album=album, album_id=album_id, artist=artist,
|
||||
artist_id=artist_id, tracks=tracks,
|
||||
albumtype=albumtype, va=va, year=year,
|
||||
label=label, mediums=len(set(mediums)),
|
||||
releasegroup_id=master_id, catalognum=catalogno,
|
||||
country=country, style=style, genre=genre,
|
||||
albumstatus=None, media=media,
|
||||
albumdisambig=None, artist_credit=None,
|
||||
original_year=original_year, original_month=None,
|
||||
original_day=None, data_source='Discogs',
|
||||
data_url=data_url,
|
||||
media=media, original_year=original_year,
|
||||
data_source='Discogs', data_url=data_url,
|
||||
discogs_albumid=discogs_albumid,
|
||||
discogs_labelid=labelid, discogs_artistid=artist_id)
|
||||
|
||||
|
|
@ -567,10 +564,9 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
track.get('artists', [])
|
||||
)
|
||||
length = self.get_track_length(track['duration'])
|
||||
return TrackInfo(title, track_id, artist=artist, artist_id=artist_id,
|
||||
length=length, index=index,
|
||||
medium=medium, medium_index=medium_index,
|
||||
artist_sort=None, disctitle=None, artist_credit=None)
|
||||
return TrackInfo(title=title, track_id=track_id, artist=artist,
|
||||
artist_id=artist_id, length=length, index=index,
|
||||
medium=medium, medium_index=medium_index)
|
||||
|
||||
def get_track_index(self, position):
|
||||
"""Returns the medium, medium index and subtrack index for a discogs
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import sys
|
|||
import codecs
|
||||
import json
|
||||
import csv
|
||||
import xml.etree.ElementTree as ET
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from datetime import datetime, date
|
||||
from beets.plugins import BeetsPlugin
|
||||
|
|
@ -188,18 +188,18 @@ class XMLFormat(ExportFormat):
|
|||
|
||||
def export(self, data, **kwargs):
|
||||
# Creates the XML file structure.
|
||||
library = ET.Element(u'library')
|
||||
tracks = ET.SubElement(library, u'tracks')
|
||||
library = ElementTree.Element(u'library')
|
||||
tracks = ElementTree.SubElement(library, u'tracks')
|
||||
if data and isinstance(data[0], dict):
|
||||
for index, item in enumerate(data):
|
||||
track = ET.SubElement(tracks, u'track')
|
||||
track = ElementTree.SubElement(tracks, u'track')
|
||||
for key, value in item.items():
|
||||
track_details = ET.SubElement(track, key)
|
||||
track_details = ElementTree.SubElement(track, key)
|
||||
track_details.text = value
|
||||
# Depending on the version of python the encoding needs to change
|
||||
try:
|
||||
data = ET.tostring(library, encoding='unicode', **kwargs)
|
||||
data = ElementTree.tostring(library, encoding='unicode', **kwargs)
|
||||
except LookupError:
|
||||
data = ET.tostring(library, encoding='utf-8', **kwargs)
|
||||
data = ElementTree.tostring(library, encoding='utf-8', **kwargs)
|
||||
|
||||
self.out_stream.write(data)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from contextlib import closing
|
|||
import os
|
||||
import re
|
||||
from tempfile import NamedTemporaryFile
|
||||
from collections import OrderedDict
|
||||
|
||||
import requests
|
||||
|
||||
|
|
@ -135,7 +136,8 @@ class Candidate(object):
|
|||
|
||||
def resize(self, plugin):
|
||||
if plugin.maxwidth and self.check == self.CANDIDATE_DOWNSCALE:
|
||||
self.path = ArtResizer.shared.resize(plugin.maxwidth, self.path)
|
||||
self.path = ArtResizer.shared.resize(plugin.maxwidth, self.path,
|
||||
quality=plugin.quality)
|
||||
|
||||
|
||||
def _logged_get(log, *args, **kwargs):
|
||||
|
|
@ -164,9 +166,14 @@ def _logged_get(log, *args, **kwargs):
|
|||
message = 'getting URL'
|
||||
|
||||
req = requests.Request('GET', *args, **req_kwargs)
|
||||
|
||||
with requests.Session() as s:
|
||||
s.headers = {'User-Agent': 'beets'}
|
||||
prepped = s.prepare_request(req)
|
||||
settings = s.merge_environment_settings(
|
||||
prepped.url, {}, None, None, None
|
||||
)
|
||||
send_kwargs.update(settings)
|
||||
log.debug('{}: {}', message, prepped.url)
|
||||
return s.send(prepped, **send_kwargs)
|
||||
|
||||
|
|
@ -203,6 +210,9 @@ class ArtSource(RequestMixin):
|
|||
def fetch_image(self, candidate, plugin):
|
||||
raise NotImplementedError()
|
||||
|
||||
def cleanup(self, candidate):
|
||||
pass
|
||||
|
||||
|
||||
class LocalArtSource(ArtSource):
|
||||
IS_LOCAL = True
|
||||
|
|
@ -284,10 +294,18 @@ class RemoteArtSource(ArtSource):
|
|||
self._log.debug(u'error fetching art: {}', exc)
|
||||
return
|
||||
|
||||
def cleanup(self, candidate):
|
||||
if candidate.path:
|
||||
try:
|
||||
util.remove(path=candidate.path)
|
||||
except util.FilesystemError as exc:
|
||||
self._log.debug(u'error cleaning up tmp art: {}', exc)
|
||||
|
||||
|
||||
class CoverArtArchive(RemoteArtSource):
|
||||
NAME = u"Cover Art Archive"
|
||||
VALID_MATCHING_CRITERIA = ['release', 'releasegroup']
|
||||
VALID_THUMBNAIL_SIZES = [250, 500, 1200]
|
||||
|
||||
if util.SNI_SUPPORTED:
|
||||
URL = 'https://coverartarchive.org/release/{mbid}/front'
|
||||
|
|
@ -300,13 +318,31 @@ class CoverArtArchive(RemoteArtSource):
|
|||
"""Return the Cover Art Archive and Cover Art Archive release group URLs
|
||||
using album MusicBrainz release ID and release group ID.
|
||||
"""
|
||||
release_url = self.URL.format(mbid=album.mb_albumid)
|
||||
release_group_url = self.GROUP_URL.format(mbid=album.mb_releasegroupid)
|
||||
|
||||
# Cover Art Archive API offers pre-resized thumbnails at several sizes.
|
||||
# If the maxwidth config matches one of the already available sizes
|
||||
# fetch it directly intead of fetching the full sized image and
|
||||
# resizing it.
|
||||
size_suffix = None
|
||||
if plugin.maxwidth in self.VALID_THUMBNAIL_SIZES:
|
||||
size_suffix = "-" + str(plugin.maxwidth)
|
||||
|
||||
if 'release' in self.match_by and album.mb_albumid:
|
||||
yield self._candidate(url=self.URL.format(mbid=album.mb_albumid),
|
||||
if size_suffix:
|
||||
release_thumbnail_url = release_url + size_suffix
|
||||
yield self._candidate(url=release_thumbnail_url,
|
||||
match=Candidate.MATCH_EXACT)
|
||||
yield self._candidate(url=release_url,
|
||||
match=Candidate.MATCH_EXACT)
|
||||
if 'releasegroup' in self.match_by and album.mb_releasegroupid:
|
||||
yield self._candidate(
|
||||
url=self.GROUP_URL.format(mbid=album.mb_releasegroupid),
|
||||
match=Candidate.MATCH_FALLBACK)
|
||||
if size_suffix:
|
||||
release_group_thumbnail_url = release_group_url + size_suffix
|
||||
yield self._candidate(url=release_group_thumbnail_url,
|
||||
match=Candidate.MATCH_FALLBACK)
|
||||
yield self._candidate(url=release_group_url,
|
||||
match=Candidate.MATCH_FALLBACK)
|
||||
|
||||
|
||||
class Amazon(RemoteArtSource):
|
||||
|
|
@ -736,11 +772,72 @@ class FileSystem(LocalArtSource):
|
|||
match=Candidate.MATCH_FALLBACK)
|
||||
|
||||
|
||||
class LastFM(RemoteArtSource):
|
||||
NAME = u"Last.fm"
|
||||
|
||||
# Sizes in priority order.
|
||||
SIZES = OrderedDict([
|
||||
('mega', (300, 300)),
|
||||
('extralarge', (300, 300)),
|
||||
('large', (174, 174)),
|
||||
('medium', (64, 64)),
|
||||
('small', (34, 34)),
|
||||
])
|
||||
|
||||
if util.SNI_SUPPORTED:
|
||||
API_URL = 'https://ws.audioscrobbler.com/2.0'
|
||||
else:
|
||||
API_URL = 'http://ws.audioscrobbler.com/2.0'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LastFM, self).__init__(*args, **kwargs)
|
||||
self.key = self._config['lastfm_key'].get(),
|
||||
|
||||
def get(self, album, plugin, paths):
|
||||
if not album.mb_albumid:
|
||||
return
|
||||
|
||||
try:
|
||||
response = self.request(self.API_URL, params={
|
||||
'method': 'album.getinfo',
|
||||
'api_key': self.key,
|
||||
'mbid': album.mb_albumid,
|
||||
'format': 'json',
|
||||
})
|
||||
except requests.RequestException:
|
||||
self._log.debug(u'lastfm: error receiving response')
|
||||
return
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
|
||||
if 'error' in data:
|
||||
if data['error'] == 6:
|
||||
self._log.debug('lastfm: no results for {}',
|
||||
album.mb_albumid)
|
||||
else:
|
||||
self._log.error(
|
||||
'lastfm: failed to get album info: {} ({})',
|
||||
data['message'], data['error'])
|
||||
else:
|
||||
images = {image['size']: image['#text']
|
||||
for image in data['album']['image']}
|
||||
|
||||
# Provide candidates in order of size.
|
||||
for size in self.SIZES.keys():
|
||||
if size in images:
|
||||
yield self._candidate(url=images[size],
|
||||
size=self.SIZES[size])
|
||||
except ValueError:
|
||||
self._log.debug(u'lastfm: error loading response: {}'
|
||||
.format(response.text))
|
||||
return
|
||||
|
||||
# Try each source in turn.
|
||||
|
||||
SOURCES_ALL = [u'filesystem',
|
||||
u'coverart', u'itunes', u'amazon', u'albumart',
|
||||
u'wikipedia', u'google', u'fanarttv']
|
||||
u'wikipedia', u'google', u'fanarttv', u'lastfm']
|
||||
|
||||
ART_SOURCES = {
|
||||
u'filesystem': FileSystem,
|
||||
|
|
@ -751,6 +848,7 @@ ART_SOURCES = {
|
|||
u'wikipedia': Wikipedia,
|
||||
u'google': GoogleImages,
|
||||
u'fanarttv': FanartTV,
|
||||
u'lastfm': LastFM,
|
||||
}
|
||||
SOURCE_NAMES = {v: k for k, v in ART_SOURCES.items()}
|
||||
|
||||
|
|
@ -772,6 +870,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
|
|||
'auto': True,
|
||||
'minwidth': 0,
|
||||
'maxwidth': 0,
|
||||
'quality': 0,
|
||||
'enforce_ratio': False,
|
||||
'cautious': False,
|
||||
'cover_names': ['cover', 'front', 'art', 'album', 'folder'],
|
||||
|
|
@ -780,14 +879,17 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
|
|||
'google_key': None,
|
||||
'google_engine': u'001442825323518660753:hrh5ch1gjzm',
|
||||
'fanarttv_key': None,
|
||||
'lastfm_key': None,
|
||||
'store_source': False,
|
||||
'high_resolution': False,
|
||||
})
|
||||
self.config['google_key'].redact = True
|
||||
self.config['fanarttv_key'].redact = True
|
||||
self.config['lastfm_key'].redact = True
|
||||
|
||||
self.minwidth = self.config['minwidth'].get(int)
|
||||
self.maxwidth = self.config['maxwidth'].get(int)
|
||||
self.quality = self.config['quality'].get(int)
|
||||
|
||||
# allow both pixel and percentage-based margin specifications
|
||||
self.enforce_ratio = self.config['enforce_ratio'].get(
|
||||
|
|
@ -823,6 +925,9 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
|
|||
if not self.config['google_key'].get() and \
|
||||
u'google' in available_sources:
|
||||
available_sources.remove(u'google')
|
||||
if not self.config['lastfm_key'].get() and \
|
||||
u'lastfm' in available_sources:
|
||||
available_sources.remove(u'lastfm')
|
||||
available_sources = [(s, c)
|
||||
for s in available_sources
|
||||
for c in ART_SOURCES[s].VALID_MATCHING_CRITERIA]
|
||||
|
|
@ -903,7 +1008,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
|
|||
cmd.parser.add_option(
|
||||
u'-q', u'--quiet', dest='quiet',
|
||||
action='store_true', default=False,
|
||||
help=u'shows only quiet art'
|
||||
help=u'quiet mode: do not output albums that already have artwork'
|
||||
)
|
||||
|
||||
def func(lib, opts, args):
|
||||
|
|
@ -917,9 +1022,10 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
|
|||
def art_for_album(self, album, paths, local_only=False):
|
||||
"""Given an Album object, returns a path to downloaded art for the
|
||||
album (or None if no art is found). If `maxwidth`, then images are
|
||||
resized to this maximum pixel size. If `local_only`, then only local
|
||||
image files from the filesystem are returned; no network requests
|
||||
are made.
|
||||
resized to this maximum pixel size. If `quality` then resized images
|
||||
are saved at the specified quality level. If `local_only`, then only
|
||||
local image files from the filesystem are returned; no network
|
||||
requests are made.
|
||||
"""
|
||||
out = None
|
||||
|
||||
|
|
@ -940,6 +1046,8 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
|
|||
u'using {0.LOC_STR} image {1}'.format(
|
||||
source, util.displayable_path(out.path)))
|
||||
break
|
||||
# Remove temporary files for invalid candidates.
|
||||
source.cleanup(candidate)
|
||||
if out:
|
||||
break
|
||||
|
||||
|
|
|
|||
276
beetsplug/fish.py
Normal file
276
beetsplug/fish.py
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is part of beets.
|
||||
# Copyright 2015, winters jean-marie.
|
||||
# Copyright 2020, Justin Mayer <https://justinmayer.com>
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
"""This plugin generates tab completions for Beets commands for the Fish shell
|
||||
<https://fishshell.com/>, including completions for Beets commands, plugin
|
||||
commands, and option flags. Also generated are completions for all the album
|
||||
and track fields, suggesting for example `genre:` or `album:` when querying the
|
||||
Beets database. Completions for the *values* of those fields are not generated
|
||||
by default but can be added via the `-e` / `--extravalues` flag. For example:
|
||||
`beet fish -e genre -e albumartist`
|
||||
"""
|
||||
|
||||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets import library, ui
|
||||
from beets.ui import commands
|
||||
from operator import attrgetter
|
||||
import os
|
||||
BL_NEED2 = """complete -c beet -n '__fish_beet_needs_command' {} {}\n"""
|
||||
BL_USE3 = """complete -c beet -n '__fish_beet_using_command {}' {} {}\n"""
|
||||
BL_SUBS = """complete -c beet -n '__fish_at_level {} ""' {} {}\n"""
|
||||
BL_EXTRA3 = """complete -c beet -n '__fish_beet_use_extra {}' {} {}\n"""
|
||||
|
||||
HEAD = '''
|
||||
function __fish_beet_needs_command
|
||||
set cmd (commandline -opc)
|
||||
if test (count $cmd) -eq 1
|
||||
return 0
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
function __fish_beet_using_command
|
||||
set cmd (commandline -opc)
|
||||
set needle (count $cmd)
|
||||
if test $needle -gt 1
|
||||
if begin test $argv[1] = $cmd[2];
|
||||
and not contains -- $cmd[$needle] $FIELDS; end
|
||||
return 0
|
||||
end
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
function __fish_beet_use_extra
|
||||
set cmd (commandline -opc)
|
||||
set needle (count $cmd)
|
||||
if test $argv[2] = $cmd[$needle]
|
||||
return 0
|
||||
end
|
||||
return 1
|
||||
end
|
||||
'''
|
||||
|
||||
|
||||
class FishPlugin(BeetsPlugin):
|
||||
|
||||
def commands(self):
|
||||
cmd = ui.Subcommand('fish', help='generate Fish shell tab completions')
|
||||
cmd.func = self.run
|
||||
cmd.parser.add_option('-f', '--noFields', action='store_true',
|
||||
default=False,
|
||||
help='omit album/track field completions')
|
||||
cmd.parser.add_option(
|
||||
'-e',
|
||||
'--extravalues',
|
||||
action='append',
|
||||
type='choice',
|
||||
choices=library.Item.all_keys() +
|
||||
library.Album.all_keys(),
|
||||
help='include specified field *values* in completions')
|
||||
return [cmd]
|
||||
|
||||
def run(self, lib, opts, args):
|
||||
# Gather the commands from Beets core and its plugins.
|
||||
# Collect the album and track fields.
|
||||
# If specified, also collect the values for these fields.
|
||||
# Make a giant string of all the above, formatted in a way that
|
||||
# allows Fish to do tab completion for the `beet` command.
|
||||
home_dir = os.path.expanduser("~")
|
||||
completion_dir = os.path.join(home_dir, '.config/fish/completions')
|
||||
try:
|
||||
os.makedirs(completion_dir)
|
||||
except OSError:
|
||||
if not os.path.isdir(completion_dir):
|
||||
raise
|
||||
completion_file_path = os.path.join(completion_dir, 'beet.fish')
|
||||
nobasicfields = opts.noFields # Do not complete for album/track fields
|
||||
extravalues = opts.extravalues # e.g., Also complete artists names
|
||||
beetcmds = sorted(
|
||||
(commands.default_commands +
|
||||
commands.plugins.commands()),
|
||||
key=attrgetter('name'))
|
||||
fields = sorted(set(
|
||||
library.Album.all_keys() + library.Item.all_keys()))
|
||||
# Collect commands, their aliases, and their help text
|
||||
cmd_names_help = []
|
||||
for cmd in beetcmds:
|
||||
names = [alias for alias in cmd.aliases]
|
||||
names.append(cmd.name)
|
||||
for name in names:
|
||||
cmd_names_help.append((name, cmd.help))
|
||||
# Concatenate the string
|
||||
totstring = HEAD + "\n"
|
||||
totstring += get_cmds_list([name[0] for name in cmd_names_help])
|
||||
totstring += '' if nobasicfields else get_standard_fields(fields)
|
||||
totstring += get_extravalues(lib, extravalues) if extravalues else ''
|
||||
totstring += "\n" + "# ====== {} =====".format(
|
||||
"setup basic beet completion") + "\n" * 2
|
||||
totstring += get_basic_beet_options()
|
||||
totstring += "\n" + "# ====== {} =====".format(
|
||||
"setup field completion for subcommands") + "\n"
|
||||
totstring += get_subcommands(
|
||||
cmd_names_help, nobasicfields, extravalues)
|
||||
# Set up completion for all the command options
|
||||
totstring += get_all_commands(beetcmds)
|
||||
|
||||
with open(completion_file_path, 'w') as fish_file:
|
||||
fish_file.write(totstring)
|
||||
|
||||
|
||||
def get_cmds_list(cmds_names):
|
||||
# Make a list of all Beets core & plugin commands
|
||||
substr = ''
|
||||
substr += (
|
||||
"set CMDS " + " ".join(cmds_names) + ("\n" * 2)
|
||||
)
|
||||
return substr
|
||||
|
||||
|
||||
def get_standard_fields(fields):
|
||||
# Make a list of album/track fields and append with ':'
|
||||
fields = (field + ":" for field in fields)
|
||||
substr = ''
|
||||
substr += (
|
||||
"set FIELDS " + " ".join(fields) + ("\n" * 2)
|
||||
)
|
||||
return substr
|
||||
|
||||
|
||||
def get_extravalues(lib, extravalues):
|
||||
# Make a list of all values from an album/track field.
|
||||
# 'beet ls albumartist: <TAB>' yields completions for ABBA, Beatles, etc.
|
||||
word = ''
|
||||
values_set = get_set_of_values_for_field(lib, extravalues)
|
||||
for fld in extravalues:
|
||||
extraname = fld.upper() + 'S'
|
||||
word += (
|
||||
"set " + extraname + " " + " ".join(sorted(values_set[fld]))
|
||||
+ ("\n" * 2)
|
||||
)
|
||||
return word
|
||||
|
||||
|
||||
def get_set_of_values_for_field(lib, fields):
|
||||
# Get unique values from a specified album/track field
|
||||
fields_dict = {}
|
||||
for each in fields:
|
||||
fields_dict[each] = set()
|
||||
for item in lib.items():
|
||||
for field in fields:
|
||||
fields_dict[field].add(wrap(item[field]))
|
||||
return fields_dict
|
||||
|
||||
|
||||
def get_basic_beet_options():
|
||||
word = (
|
||||
BL_NEED2.format("-l format-item",
|
||||
"-f -d 'print with custom format'") +
|
||||
BL_NEED2.format("-l format-album",
|
||||
"-f -d 'print with custom format'") +
|
||||
BL_NEED2.format("-s l -l library",
|
||||
"-f -r -d 'library database file to use'") +
|
||||
BL_NEED2.format("-s d -l directory",
|
||||
"-f -r -d 'destination music directory'") +
|
||||
BL_NEED2.format("-s v -l verbose",
|
||||
"-f -d 'print debugging information'") +
|
||||
|
||||
BL_NEED2.format("-s c -l config",
|
||||
"-f -r -d 'path to configuration file'") +
|
||||
BL_NEED2.format("-s h -l help",
|
||||
"-f -d 'print this help message and exit'"))
|
||||
return word
|
||||
|
||||
|
||||
def get_subcommands(cmd_name_and_help, nobasicfields, extravalues):
|
||||
# Formatting for Fish to complete our fields/values
|
||||
word = ""
|
||||
for cmdname, cmdhelp in cmd_name_and_help:
|
||||
word += "\n" + "# ------ {} -------".format(
|
||||
"fieldsetups for " + cmdname) + "\n"
|
||||
word += (
|
||||
BL_NEED2.format(
|
||||
("-a " + cmdname),
|
||||
("-f " + "-d " + wrap(clean_whitespace(cmdhelp)))))
|
||||
|
||||
if nobasicfields is False:
|
||||
word += (
|
||||
BL_USE3.format(
|
||||
cmdname,
|
||||
("-a " + wrap("$FIELDS")),
|
||||
("-f " + "-d " + wrap("fieldname"))))
|
||||
|
||||
if extravalues:
|
||||
for f in extravalues:
|
||||
setvar = wrap("$" + f.upper() + "S")
|
||||
word += " ".join(BL_EXTRA3.format(
|
||||
(cmdname + " " + f + ":"),
|
||||
('-f ' + '-A ' + '-a ' + setvar),
|
||||
('-d ' + wrap(f))).split()) + "\n"
|
||||
return word
|
||||
|
||||
|
||||
def get_all_commands(beetcmds):
|
||||
# Formatting for Fish to complete command options
|
||||
word = ""
|
||||
for cmd in beetcmds:
|
||||
names = [alias for alias in cmd.aliases]
|
||||
names.append(cmd.name)
|
||||
for name in names:
|
||||
word += "\n"
|
||||
word += ("\n" * 2) + "# ====== {} =====".format(
|
||||
"completions for " + name) + "\n"
|
||||
|
||||
for option in cmd.parser._get_all_options()[1:]:
|
||||
cmd_l = (" -l " + option._long_opts[0].replace('--', '')
|
||||
)if option._long_opts else ''
|
||||
cmd_s = (" -s " + option._short_opts[0].replace('-', '')
|
||||
) if option._short_opts else ''
|
||||
cmd_need_arg = ' -r ' if option.nargs in [1] else ''
|
||||
cmd_helpstr = (" -d " + wrap(' '.join(option.help.split()))
|
||||
) if option.help else ''
|
||||
cmd_arglist = (' -a ' + wrap(" ".join(option.choices))
|
||||
) if option.choices else ''
|
||||
|
||||
word += " ".join(BL_USE3.format(
|
||||
name,
|
||||
(cmd_need_arg + cmd_s + cmd_l + " -f " + cmd_arglist),
|
||||
cmd_helpstr).split()) + "\n"
|
||||
|
||||
word = (word + " ".join(BL_USE3.format(
|
||||
name,
|
||||
("-s " + "h " + "-l " + "help" + " -f "),
|
||||
('-d ' + wrap("print help") + "\n")
|
||||
).split()))
|
||||
return word
|
||||
|
||||
|
||||
def clean_whitespace(word):
|
||||
# Remove excess whitespace and tabs in a string
|
||||
return " ".join(word.split())
|
||||
|
||||
|
||||
def wrap(word):
|
||||
# Need " or ' around strings but watch out if they're in the string
|
||||
sptoken = '\"'
|
||||
if ('"') in word and ("'") in word:
|
||||
word.replace('"', sptoken)
|
||||
return '"' + word + '"'
|
||||
|
||||
tok = '"' if "'" in word else "'"
|
||||
return tok + word + tok
|
||||
|
|
@ -151,6 +151,8 @@ class IPFSPlugin(BeetsPlugin):
|
|||
def ipfs_get(self, lib, query):
|
||||
query = query[0]
|
||||
# Check if query is a hash
|
||||
# TODO: generalize to other hashes; probably use a multihash
|
||||
# implementation
|
||||
if query.startswith("Qm") and len(query) == 46:
|
||||
self.ipfs_get_from_hash(lib, query)
|
||||
else:
|
||||
|
|
@ -197,7 +199,7 @@ class IPFSPlugin(BeetsPlugin):
|
|||
else:
|
||||
lib_name = _hash
|
||||
lib_root = os.path.dirname(lib.path)
|
||||
remote_libs = lib_root + "/remotes"
|
||||
remote_libs = os.path.join(lib_root, b"remotes")
|
||||
if not os.path.exists(remote_libs):
|
||||
try:
|
||||
os.makedirs(remote_libs)
|
||||
|
|
@ -205,7 +207,7 @@ class IPFSPlugin(BeetsPlugin):
|
|||
msg = "Could not create {0}. Error: {1}".format(remote_libs, e)
|
||||
self._log.error(msg)
|
||||
return False
|
||||
path = remote_libs + "/" + lib_name + ".db"
|
||||
path = os.path.join(remote_libs, lib_name.encode() + b".db")
|
||||
if not os.path.exists(path):
|
||||
cmd = "ipfs get {0} -o".format(_hash).split()
|
||||
cmd.append(path)
|
||||
|
|
@ -216,7 +218,7 @@ class IPFSPlugin(BeetsPlugin):
|
|||
return False
|
||||
|
||||
# add all albums from remotes into a combined library
|
||||
jpath = remote_libs + "/joined.db"
|
||||
jpath = os.path.join(remote_libs, b"joined.db")
|
||||
jlib = library.Library(jpath)
|
||||
nlib = library.Library(path)
|
||||
for album in nlib.albums():
|
||||
|
|
@ -244,7 +246,7 @@ class IPFSPlugin(BeetsPlugin):
|
|||
return
|
||||
|
||||
for album in albums:
|
||||
ui.print_(format(album, fmt), " : ", album.ipfs)
|
||||
ui.print_(format(album, fmt), " : ", album.ipfs.decode())
|
||||
|
||||
def query(self, lib, args):
|
||||
rlib = self.get_remote_lib(lib)
|
||||
|
|
@ -253,8 +255,8 @@ class IPFSPlugin(BeetsPlugin):
|
|||
|
||||
def get_remote_lib(self, lib):
|
||||
lib_root = os.path.dirname(lib.path)
|
||||
remote_libs = lib_root + "/remotes"
|
||||
path = remote_libs + "/joined.db"
|
||||
remote_libs = os.path.join(lib_root, b"remotes")
|
||||
path = os.path.join(remote_libs, b"joined.db")
|
||||
if not os.path.isfile(path):
|
||||
raise IOError
|
||||
return library.Library(path)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -187,6 +187,9 @@ def search_pairs(item):
|
|||
In addition to the artist and title obtained from the `item` the
|
||||
method tries to strip extra information like paranthesized suffixes
|
||||
and featured artists from the strings and add them as candidates.
|
||||
The artist sort name is added as a fallback candidate to help in
|
||||
cases where artist name includes special characters or is in a
|
||||
non-latin script.
|
||||
The method also tries to split multiple titles separated with `/`.
|
||||
"""
|
||||
def generate_alternatives(string, patterns):
|
||||
|
|
@ -200,12 +203,16 @@ def search_pairs(item):
|
|||
alternatives.append(match.group(1))
|
||||
return alternatives
|
||||
|
||||
title, artist = item.title, item.artist
|
||||
title, artist, artist_sort = item.title, item.artist, item.artist_sort
|
||||
|
||||
patterns = [
|
||||
# Remove any featuring artists from the artists name
|
||||
r"(.*?) {0}".format(plugins.feat_tokens())]
|
||||
artists = generate_alternatives(artist, patterns)
|
||||
# Use the artist_sort as fallback only if it differs from artist to avoid
|
||||
# repeated remote requests with the same search terms
|
||||
if artist != artist_sort:
|
||||
artists.append(artist_sort)
|
||||
|
||||
patterns = [
|
||||
# Remove a parenthesized suffix from a title string. Common
|
||||
|
|
@ -352,56 +359,86 @@ class Genius(Backend):
|
|||
'User-Agent': USER_AGENT,
|
||||
}
|
||||
|
||||
def lyrics_from_song_api_path(self, song_api_path):
|
||||
song_url = self.base_url + song_api_path
|
||||
response = requests.get(song_url, headers=self.headers)
|
||||
json = response.json()
|
||||
path = json["response"]["song"]["path"]
|
||||
|
||||
# Gotta go regular html scraping... come on Genius.
|
||||
page_url = "https://genius.com" + path
|
||||
try:
|
||||
page = requests.get(page_url)
|
||||
except requests.RequestException as exc:
|
||||
self._log.debug(u'Genius page request for {0} failed: {1}',
|
||||
page_url, exc)
|
||||
return None
|
||||
html = BeautifulSoup(page.text, "html.parser")
|
||||
|
||||
# Remove script tags that they put in the middle of the lyrics.
|
||||
[h.extract() for h in html('script')]
|
||||
|
||||
# At least Genius is nice and has a tag called 'lyrics'!
|
||||
# Updated css where the lyrics are based in HTML.
|
||||
lyrics = html.find("div", class_="lyrics").get_text()
|
||||
|
||||
return lyrics
|
||||
|
||||
def fetch(self, artist, title):
|
||||
"""Fetch lyrics from genius.com
|
||||
|
||||
Because genius doesn't allow accesssing lyrics via the api,
|
||||
we first query the api for a url matching our artist & title,
|
||||
then attempt to scrape that url for the lyrics.
|
||||
"""
|
||||
json = self._search(artist, title)
|
||||
if not json:
|
||||
self._log.debug(u'Genius API request returned invalid JSON')
|
||||
return None
|
||||
|
||||
# find a matching artist in the json
|
||||
for hit in json["response"]["hits"]:
|
||||
hit_artist = hit["result"]["primary_artist"]["name"]
|
||||
|
||||
if slug(hit_artist) == slug(artist):
|
||||
return self._scrape_lyrics_from_html(
|
||||
self.fetch_url(hit["result"]["url"]))
|
||||
|
||||
self._log.debug(u'Genius failed to find a matching artist for \'{0}\'',
|
||||
artist)
|
||||
|
||||
def _search(self, artist, title):
|
||||
"""Searches the genius api for a given artist and title
|
||||
|
||||
https://docs.genius.com/#search-h2
|
||||
|
||||
:returns: json response
|
||||
"""
|
||||
search_url = self.base_url + "/search"
|
||||
data = {'q': title}
|
||||
data = {'q': title + " " + artist.lower()}
|
||||
try:
|
||||
response = requests.get(search_url, data=data,
|
||||
headers=self.headers)
|
||||
response = requests.get(
|
||||
search_url, data=data, headers=self.headers)
|
||||
except requests.RequestException as exc:
|
||||
self._log.debug(u'Genius API request failed: {0}', exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
json = response.json()
|
||||
return response.json()
|
||||
except ValueError:
|
||||
self._log.debug(u'Genius API request returned invalid JSON')
|
||||
return None
|
||||
|
||||
song_info = None
|
||||
for hit in json["response"]["hits"]:
|
||||
if hit["result"]["primary_artist"]["name"] == artist:
|
||||
song_info = hit
|
||||
break
|
||||
def _scrape_lyrics_from_html(self, html):
|
||||
"""Scrape lyrics from a given genius.com html"""
|
||||
|
||||
if song_info:
|
||||
song_api_path = song_info["result"]["api_path"]
|
||||
return self.lyrics_from_song_api_path(song_api_path)
|
||||
html = BeautifulSoup(html, "html.parser")
|
||||
|
||||
# Remove script tags that they put in the middle of the lyrics.
|
||||
[h.extract() for h in html('script')]
|
||||
|
||||
# Most of the time, the page contains a div with class="lyrics" where
|
||||
# all of the lyrics can be found already correctly formatted
|
||||
# Sometimes, though, it packages the lyrics into separate divs, most
|
||||
# likely for easier ad placement
|
||||
lyrics_div = html.find("div", class_="lyrics")
|
||||
if not lyrics_div:
|
||||
self._log.debug(u'Received unusual song page html')
|
||||
verse_div = html.find("div",
|
||||
class_=re.compile("Lyrics__Container"))
|
||||
if not verse_div:
|
||||
if html.find("div",
|
||||
class_=re.compile("LyricsPlaceholder__Message"),
|
||||
string="This song is an instrumental"):
|
||||
self._log.debug('Detected instrumental')
|
||||
return "[Instrumental]"
|
||||
else:
|
||||
self._log.debug("Couldn't scrape page using known layouts")
|
||||
return None
|
||||
|
||||
lyrics_div = verse_div.parent
|
||||
for br in lyrics_div.find_all("br"):
|
||||
br.replace_with("\n")
|
||||
ads = lyrics_div.find_all("div",
|
||||
class_=re.compile("InreadAd__Container"))
|
||||
for ad in ads:
|
||||
ad.replace_with("\n")
|
||||
|
||||
return lyrics_div.get_text()
|
||||
|
||||
|
||||
class LyricsWiki(SymbolsReplaced):
|
||||
|
|
@ -526,7 +563,7 @@ class Google(Backend):
|
|||
|
||||
bad_triggers = ['lyrics', 'copyright', 'property', 'links']
|
||||
if artist:
|
||||
bad_triggers_occ += [artist]
|
||||
bad_triggers += [artist]
|
||||
|
||||
for item in bad_triggers:
|
||||
bad_triggers_occ += [item] * len(re.findall(r'\W%s\W' % item,
|
||||
|
|
@ -744,7 +781,8 @@ class LyricsPlugin(plugins.BeetsPlugin):
|
|||
write = ui.should_write()
|
||||
if opts.writerest:
|
||||
self.writerest_indexes(opts.writerest)
|
||||
for item in lib.items(ui.decargs(args)):
|
||||
items = lib.items(ui.decargs(args))
|
||||
for item in items:
|
||||
if not opts.local_only and not self.config['local']:
|
||||
self.fetch_item_lyrics(
|
||||
lib, item, write,
|
||||
|
|
@ -754,10 +792,10 @@ class LyricsPlugin(plugins.BeetsPlugin):
|
|||
if opts.printlyr:
|
||||
ui.print_(item.lyrics)
|
||||
if opts.writerest:
|
||||
self.writerest(opts.writerest, item)
|
||||
if opts.writerest:
|
||||
# flush last artist
|
||||
self.writerest(opts.writerest, None)
|
||||
self.appendrest(opts.writerest, item)
|
||||
if opts.writerest and items:
|
||||
# flush last artist & write to ReST
|
||||
self.writerest(opts.writerest)
|
||||
ui.print_(u'ReST files generated. to build, use one of:')
|
||||
ui.print_(u' sphinx-build -b html %s _build/html'
|
||||
% opts.writerest)
|
||||
|
|
@ -769,26 +807,21 @@ class LyricsPlugin(plugins.BeetsPlugin):
|
|||
cmd.func = func
|
||||
return [cmd]
|
||||
|
||||
def writerest(self, directory, item):
|
||||
"""Write the item to an ReST file
|
||||
def appendrest(self, directory, item):
|
||||
"""Append the item to an ReST file
|
||||
|
||||
This will keep state (in the `rest` variable) in order to avoid
|
||||
writing continuously to the same files.
|
||||
"""
|
||||
|
||||
if item is None or slug(self.artist) != slug(item.albumartist):
|
||||
if self.rest is not None:
|
||||
path = os.path.join(directory, 'artists',
|
||||
slug(self.artist) + u'.rst')
|
||||
with open(path, 'wb') as output:
|
||||
output.write(self.rest.encode('utf-8'))
|
||||
self.rest = None
|
||||
if item is None:
|
||||
return
|
||||
if slug(self.artist) != slug(item.albumartist):
|
||||
# Write current file and start a new one ~ item.albumartist
|
||||
self.writerest(directory)
|
||||
self.artist = item.albumartist.strip()
|
||||
self.rest = u"%s\n%s\n\n.. contents::\n :local:\n\n" \
|
||||
% (self.artist,
|
||||
u'=' * len(self.artist))
|
||||
|
||||
if self.album != item.album:
|
||||
tmpalbum = self.album = item.album.strip()
|
||||
if self.album == '':
|
||||
|
|
@ -800,6 +833,15 @@ class LyricsPlugin(plugins.BeetsPlugin):
|
|||
u'~' * len(title_str),
|
||||
block)
|
||||
|
||||
def writerest(self, directory):
|
||||
"""Write self.rest to a ReST file
|
||||
"""
|
||||
if self.rest is not None and self.artist is not None:
|
||||
path = os.path.join(directory, 'artists',
|
||||
slug(self.artist) + u'.rst')
|
||||
with open(path, 'wb') as output:
|
||||
output.write(self.rest.encode('utf-8'))
|
||||
|
||||
def writerest_indexes(self, directory):
|
||||
"""Write conf.py and index.rst files necessary for Sphinx
|
||||
|
||||
|
|
@ -881,7 +923,7 @@ class LyricsPlugin(plugins.BeetsPlugin):
|
|||
return _scrape_strip_cruft(lyrics, True)
|
||||
|
||||
def append_translation(self, text, to_lang):
|
||||
import xml.etree.ElementTree as ET
|
||||
from xml.etree import ElementTree
|
||||
|
||||
if not self.bing_auth_token:
|
||||
self.bing_auth_token = self.get_bing_access_token()
|
||||
|
|
@ -899,7 +941,8 @@ class LyricsPlugin(plugins.BeetsPlugin):
|
|||
self.bing_auth_token = None
|
||||
return self.append_translation(text, to_lang)
|
||||
return text
|
||||
lines_translated = ET.fromstring(r.text.encode('utf-8')).text
|
||||
lines_translated = ElementTree.fromstring(
|
||||
r.text.encode('utf-8')).text
|
||||
# Use a translation mapping dict to build resulting lyrics
|
||||
translations = dict(zip(text_lines, lines_translated.split('|')))
|
||||
result = ''
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ class Amarok(MetaSource):
|
|||
'amarok_lastplayed': DateType(),
|
||||
}
|
||||
|
||||
queryXML = u'<query version="1.0"> \
|
||||
query_xml = u'<query version="1.0"> \
|
||||
<filters> \
|
||||
<and><include field="filename" value=%s /></and> \
|
||||
</filters> \
|
||||
|
|
@ -72,7 +72,7 @@ class Amarok(MetaSource):
|
|||
# of the result set. So query for the filename and then try to match
|
||||
# the correct item from the results we get back
|
||||
results = self.collection.Query(
|
||||
self.queryXML % quoteattr(basename(path))
|
||||
self.query_xml % quoteattr(basename(path))
|
||||
)
|
||||
for result in results:
|
||||
if result['xesam:url'] != path:
|
||||
|
|
|
|||
|
|
@ -108,8 +108,9 @@ class MPDClientWrapper(object):
|
|||
return self.get(command, retries=retries - 1)
|
||||
|
||||
def currentsong(self):
|
||||
"""Return the path to the currently playing song. Prefixes paths with the
|
||||
music_directory, to get the absolute path.
|
||||
"""Return the path to the currently playing song, along with its
|
||||
songid. Prefixes paths with the music_directory, to get the absolute
|
||||
path.
|
||||
"""
|
||||
result = None
|
||||
entry = self.get('currentsong')
|
||||
|
|
@ -118,7 +119,7 @@ class MPDClientWrapper(object):
|
|||
result = os.path.join(self.music_directory, entry['file'])
|
||||
else:
|
||||
result = entry['file']
|
||||
return result
|
||||
return result, entry.get('id')
|
||||
|
||||
def status(self):
|
||||
"""Return the current status of the MPD.
|
||||
|
|
@ -240,7 +241,9 @@ class MPDStats(object):
|
|||
def on_stop(self, status):
|
||||
self._log.info(u'stop')
|
||||
|
||||
if self.now_playing:
|
||||
# if the current song stays the same it means that we stopped on the
|
||||
# current track and should not record a skip.
|
||||
if self.now_playing and self.now_playing['id'] != status.get('songid'):
|
||||
self.handle_song_change(self.now_playing)
|
||||
|
||||
self.now_playing = None
|
||||
|
|
@ -251,7 +254,7 @@ class MPDStats(object):
|
|||
|
||||
def on_play(self, status):
|
||||
|
||||
path = self.mpd.currentsong()
|
||||
path, songid = self.mpd.currentsong()
|
||||
|
||||
if not path:
|
||||
return
|
||||
|
|
@ -286,6 +289,7 @@ class MPDStats(object):
|
|||
'started': time.time(),
|
||||
'remaining': remaining,
|
||||
'path': path,
|
||||
'id': songid,
|
||||
'beets_item': self.get_item(path),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -89,10 +89,11 @@ class ParentWorkPlugin(BeetsPlugin):
|
|||
write = ui.should_write()
|
||||
|
||||
for item in lib.items(ui.decargs(args)):
|
||||
self.find_work(item, force_parent)
|
||||
item.store()
|
||||
if write:
|
||||
item.try_write()
|
||||
changed = self.find_work(item, force_parent)
|
||||
if changed:
|
||||
item.store()
|
||||
if write:
|
||||
item.try_write()
|
||||
command = ui.Subcommand(
|
||||
'parentwork',
|
||||
help=u'fetche parent works, composers and dates')
|
||||
|
|
@ -130,6 +131,8 @@ class ParentWorkPlugin(BeetsPlugin):
|
|||
if artist['type'] == 'composer':
|
||||
parent_composer.append(artist['artist']['name'])
|
||||
parent_composer_sort.append(artist['artist']['sort-name'])
|
||||
if 'end' in artist.keys():
|
||||
parentwork_info["parentwork_date"] = artist['end']
|
||||
|
||||
parentwork_info['parent_composer'] = u', '.join(parent_composer)
|
||||
parentwork_info['parent_composer_sort'] = u', '.join(
|
||||
|
|
@ -172,13 +175,17 @@ add one at https://musicbrainz.org/recording/{}', item, item.mb_trackid)
|
|||
return
|
||||
|
||||
hasparent = hasattr(item, 'parentwork')
|
||||
if force or not hasparent:
|
||||
work_changed = True
|
||||
if hasattr(item, 'parentwork_workid_current'):
|
||||
work_changed = item.parentwork_workid_current != item.mb_workid
|
||||
if force or not hasparent or work_changed:
|
||||
try:
|
||||
work_info, work_date = find_parentwork_info(item.mb_workid)
|
||||
except musicbrainzngs.musicbrainz.WebServiceError as e:
|
||||
self._log.debug("error fetching work: {}", e)
|
||||
return
|
||||
parent_info = self.get_info(item, work_info)
|
||||
parent_info['parentwork_workid_current'] = item.mb_workid
|
||||
if 'parent_composer' in parent_info:
|
||||
self._log.debug("Work fetched: {} - {}",
|
||||
parent_info['parentwork'],
|
||||
|
|
@ -198,7 +205,8 @@ add one at https://musicbrainz.org/recording/{}', item, item.mb_trackid)
|
|||
|
||||
if work_date:
|
||||
item['work_date'] = work_date
|
||||
ui.show_model_changes(
|
||||
return ui.show_model_changes(
|
||||
item, fields=['parentwork', 'parentwork_disambig',
|
||||
'mb_parentworkid', 'parent_composer',
|
||||
'parent_composer_sort', 'work_date'])
|
||||
'parent_composer_sort', 'work_date',
|
||||
'parentwork_workid_current', 'parentwork_date'])
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ class PlaylistQuery(beets.dbcore.Query):
|
|||
if not self.paths:
|
||||
# Playlist is empty
|
||||
return '0', ()
|
||||
clause = 'path IN ({0})'.format(', '.join('?' for path in self.paths))
|
||||
clause = 'path IN ({0})'.format(', '.join('?' for path in self.paths))
|
||||
return clause, (beets.library.BLOB_TYPE(p) for p in self.paths)
|
||||
|
||||
def match(self, item):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@ def call(args, **kwargs):
|
|||
raise ReplayGainError(u"argument encoding failed")
|
||||
|
||||
|
||||
def after_version(version_a, version_b):
|
||||
return tuple(int(s) for s in version_a.split('.')) \
|
||||
>= tuple(int(s) for s in version_b.split('.'))
|
||||
|
||||
|
||||
def db_to_lufs(db):
|
||||
"""Convert db to LUFS.
|
||||
|
||||
|
|
@ -156,8 +161,12 @@ class Bs1770gainBackend(Backend):
|
|||
|
||||
cmd = 'bs1770gain'
|
||||
try:
|
||||
call([cmd, "--help"])
|
||||
version_out = call([cmd, '--version'])
|
||||
self.command = cmd
|
||||
self.version = re.search(
|
||||
'bs1770gain ([0-9]+.[0-9]+.[0-9]+), ',
|
||||
version_out.stdout.decode('utf-8')
|
||||
).group(1)
|
||||
except OSError:
|
||||
raise FatalReplayGainError(
|
||||
u'Is bs1770gain installed?'
|
||||
|
|
@ -250,17 +259,23 @@ class Bs1770gainBackend(Backend):
|
|||
if self.__method != "":
|
||||
# backward compatibility to `method` option
|
||||
method = self.__method
|
||||
gain_adjustment = target_level \
|
||||
- [k for k, v in self.methods.items() if v == method][0]
|
||||
elif target_level in self.methods:
|
||||
method = self.methods[target_level]
|
||||
gain_adjustment = 0
|
||||
else:
|
||||
method = self.methods[-23]
|
||||
gain_adjustment = target_level - lufs_to_db(-23)
|
||||
lufs_target = -23
|
||||
method = self.methods[lufs_target]
|
||||
gain_adjustment = target_level - lufs_target
|
||||
|
||||
# Construct shell command.
|
||||
cmd = [self.command]
|
||||
cmd += ["--" + method]
|
||||
cmd += ['--xml', '-p']
|
||||
if after_version(self.version, '0.6.0'):
|
||||
cmd += ['--unit=ebu'] # set units to LU
|
||||
cmd += ['--suppress-progress'] # don't print % to XML output
|
||||
|
||||
# Workaround for Windows: the underlying tool fails on paths
|
||||
# with the \\?\ prefix, so we don't use it here. This
|
||||
|
|
@ -295,6 +310,7 @@ class Bs1770gainBackend(Backend):
|
|||
album_gain = {} # mutable variable so it can be set from handlers
|
||||
parser = xml.parsers.expat.ParserCreate(encoding='utf-8')
|
||||
state = {'file': None, 'gain': None, 'peak': None}
|
||||
album_state = {'gain': None, 'peak': None}
|
||||
|
||||
def start_element_handler(name, attrs):
|
||||
if name == u'track':
|
||||
|
|
@ -303,9 +319,13 @@ class Bs1770gainBackend(Backend):
|
|||
raise ReplayGainError(
|
||||
u'duplicate filename in bs1770gain output')
|
||||
elif name == u'integrated':
|
||||
state['gain'] = float(attrs[u'lu'])
|
||||
if 'lu' in attrs:
|
||||
state['gain'] = float(attrs[u'lu'])
|
||||
elif name == u'sample-peak':
|
||||
state['peak'] = float(attrs[u'factor'])
|
||||
if 'factor' in attrs:
|
||||
state['peak'] = float(attrs[u'factor'])
|
||||
elif 'amplitude' in attrs:
|
||||
state['peak'] = float(attrs[u'amplitude'])
|
||||
|
||||
def end_element_handler(name):
|
||||
if name == u'track':
|
||||
|
|
@ -321,6 +341,17 @@ class Bs1770gainBackend(Backend):
|
|||
'the output of bs1770gain')
|
||||
album_gain["album"] = Gain(state['gain'], state['peak'])
|
||||
state['gain'] = state['peak'] = None
|
||||
elif len(per_file_gain) == len(path_list):
|
||||
if state['gain'] is not None:
|
||||
album_state['gain'] = state['gain']
|
||||
if state['peak'] is not None:
|
||||
album_state['peak'] = state['peak']
|
||||
if album_state['gain'] is not None \
|
||||
and album_state['peak'] is not None:
|
||||
album_gain["album"] = Gain(
|
||||
album_state['gain'], album_state['peak'])
|
||||
state['gain'] = state['peak'] = None
|
||||
|
||||
parser.StartElementHandler = start_element_handler
|
||||
parser.EndElementHandler = end_element_handler
|
||||
|
||||
|
|
@ -592,7 +623,7 @@ class FfmpegBackend(Backend):
|
|||
return float(value)
|
||||
except ValueError:
|
||||
raise ReplayGainError(
|
||||
u"ffmpeg output: expected float value, found {1}"
|
||||
u"ffmpeg output: expected float value, found {0}"
|
||||
.format(value)
|
||||
)
|
||||
|
||||
|
|
@ -1336,10 +1367,10 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
|
||||
if (any([self.should_use_r128(item) for item in album.items()]) and not
|
||||
all(([self.should_use_r128(item) for item in album.items()]))):
|
||||
raise ReplayGainError(
|
||||
u"Mix of ReplayGain and EBU R128 detected"
|
||||
u" for some tracks in album {0}".format(album)
|
||||
)
|
||||
self._log.error(
|
||||
u"Cannot calculate gain for album {0} (incompatible formats)",
|
||||
album)
|
||||
return
|
||||
|
||||
self._log.info(u'analyzing {0}', album)
|
||||
|
||||
|
|
|
|||
|
|
@ -105,17 +105,18 @@ class SmartPlaylistPlugin(BeetsPlugin):
|
|||
|
||||
playlist_data = (playlist['name'],)
|
||||
try:
|
||||
for key, Model in (('query', Item), ('album_query', Album)):
|
||||
for key, model_cls in (('query', Item),
|
||||
('album_query', Album)):
|
||||
qs = playlist.get(key)
|
||||
if qs is None:
|
||||
query_and_sort = None, None
|
||||
elif isinstance(qs, six.string_types):
|
||||
query_and_sort = parse_query_string(qs, Model)
|
||||
query_and_sort = parse_query_string(qs, model_cls)
|
||||
elif len(qs) == 1:
|
||||
query_and_sort = parse_query_string(qs[0], Model)
|
||||
query_and_sort = parse_query_string(qs[0], model_cls)
|
||||
else:
|
||||
# multiple queries and sorts
|
||||
queries, sorts = zip(*(parse_query_string(q, Model)
|
||||
queries, sorts = zip(*(parse_query_string(q, model_cls)
|
||||
for q in qs))
|
||||
query = OrQuery(queries)
|
||||
final_sorts = []
|
||||
|
|
|
|||
173
beetsplug/subsonicplaylist.py
Normal file
173
beetsplug/subsonicplaylist.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is part of beets.
|
||||
# Copyright 2019, Joris Jensen
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import random
|
||||
import string
|
||||
from xml.etree import ElementTree
|
||||
from hashlib import md5
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
|
||||
from beets.dbcore import AndQuery
|
||||
from beets.dbcore.query import MatchQuery
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import Subcommand
|
||||
|
||||
__author__ = 'https://github.com/MrNuggelz'
|
||||
|
||||
|
||||
def filter_to_be_removed(items, keys):
|
||||
if len(items) > len(keys):
|
||||
dont_remove = []
|
||||
for artist, album, title in keys:
|
||||
for item in items:
|
||||
if artist == item['artist'] and \
|
||||
album == item['album'] and \
|
||||
title == item['title']:
|
||||
dont_remove.append(item)
|
||||
return [item for item in items if item not in dont_remove]
|
||||
else:
|
||||
def to_be_removed(item):
|
||||
for artist, album, title in keys:
|
||||
if artist == item['artist'] and\
|
||||
album == item['album'] and\
|
||||
title == item['title']:
|
||||
return False
|
||||
return True
|
||||
|
||||
return [item for item in items if to_be_removed(item)]
|
||||
|
||||
|
||||
class SubsonicPlaylistPlugin(BeetsPlugin):
|
||||
|
||||
def __init__(self):
|
||||
super(SubsonicPlaylistPlugin, self).__init__()
|
||||
self.config.add(
|
||||
{
|
||||
'delete': False,
|
||||
'playlist_ids': [],
|
||||
'playlist_names': [],
|
||||
'username': '',
|
||||
'password': ''
|
||||
}
|
||||
)
|
||||
self.config['password'].redact = True
|
||||
|
||||
def update_tags(self, playlist_dict, lib):
|
||||
with lib.transaction():
|
||||
for query, playlist_tag in playlist_dict.items():
|
||||
query = AndQuery([MatchQuery("artist", query[0]),
|
||||
MatchQuery("album", query[1]),
|
||||
MatchQuery("title", query[2])])
|
||||
items = lib.items(query)
|
||||
if not items:
|
||||
self._log.warn(u"{} | track not found ({})", playlist_tag,
|
||||
query)
|
||||
continue
|
||||
for item in items:
|
||||
item.subsonic_playlist = playlist_tag
|
||||
item.try_sync(write=True, move=False)
|
||||
|
||||
def get_playlist(self, playlist_id):
|
||||
xml = self.send('getPlaylist', {'id': playlist_id}).text
|
||||
playlist = ElementTree.fromstring(xml)[0]
|
||||
if playlist.attrib.get('code', '200') != '200':
|
||||
alt_error = 'error getting playlist, but no error message found'
|
||||
self._log.warn(playlist.attrib.get('message', alt_error))
|
||||
return
|
||||
|
||||
name = playlist.attrib.get('name', 'undefined')
|
||||
tracks = [(t.attrib['artist'], t.attrib['album'], t.attrib['title'])
|
||||
for t in playlist]
|
||||
return name, tracks
|
||||
|
||||
def commands(self):
|
||||
def build_playlist(lib, opts, args):
|
||||
self.config.set_args(opts)
|
||||
ids = self.config['playlist_ids'].as_str_seq()
|
||||
if self.config['playlist_names'].as_str_seq():
|
||||
playlists = ElementTree.fromstring(
|
||||
self.send('getPlaylists').text)[0]
|
||||
if playlists.attrib.get('code', '200') != '200':
|
||||
alt_error = 'error getting playlists,' \
|
||||
' but no error message found'
|
||||
self._log.warn(
|
||||
playlists.attrib.get('message', alt_error))
|
||||
return
|
||||
for name in self.config['playlist_names'].as_str_seq():
|
||||
for playlist in playlists:
|
||||
if name == playlist.attrib['name']:
|
||||
ids.append(playlist.attrib['id'])
|
||||
|
||||
playlist_dict = self.get_playlists(ids)
|
||||
|
||||
# delete old tags
|
||||
if self.config['delete']:
|
||||
existing = list(lib.items('subsonic_playlist:";"'))
|
||||
to_be_removed = filter_to_be_removed(
|
||||
existing,
|
||||
playlist_dict.keys())
|
||||
for item in to_be_removed:
|
||||
item['subsonic_playlist'] = ''
|
||||
with lib.transaction():
|
||||
item.try_sync(write=True, move=False)
|
||||
|
||||
self.update_tags(playlist_dict, lib)
|
||||
|
||||
subsonicplaylist_cmds = Subcommand(
|
||||
'subsonicplaylist', help=u'import a subsonic playlist'
|
||||
)
|
||||
subsonicplaylist_cmds.parser.add_option(
|
||||
u'-d',
|
||||
u'--delete',
|
||||
action='store_true',
|
||||
help=u'delete tag from items not in any playlist anymore',
|
||||
)
|
||||
subsonicplaylist_cmds.func = build_playlist
|
||||
return [subsonicplaylist_cmds]
|
||||
|
||||
def generate_token(self):
|
||||
salt = ''.join(random.choices(string.ascii_lowercase + string.digits))
|
||||
return md5(
|
||||
(self.config['password'].get() + salt).encode()).hexdigest(), salt
|
||||
|
||||
def send(self, endpoint, params=None):
|
||||
if params is None:
|
||||
params = dict()
|
||||
a, b = self.generate_token()
|
||||
params['u'] = self.config['username']
|
||||
params['t'] = a
|
||||
params['s'] = b
|
||||
params['v'] = '1.12.0'
|
||||
params['c'] = 'beets'
|
||||
resp = requests.get('{}/rest/{}?{}'.format(
|
||||
self.config['base_url'].get(),
|
||||
endpoint,
|
||||
urlencode(params))
|
||||
)
|
||||
return resp
|
||||
|
||||
def get_playlists(self, ids):
|
||||
output = dict()
|
||||
for playlist_id in ids:
|
||||
name, tracks = self.get_playlist(playlist_id)
|
||||
for track in tracks:
|
||||
if track not in output:
|
||||
output[track] = ';'
|
||||
output[track] += name + ';'
|
||||
return output
|
||||
|
|
@ -17,11 +17,9 @@
|
|||
Your Beets configuration file should contain
|
||||
a "subsonic" section like the following:
|
||||
subsonic:
|
||||
host: 192.168.x.y (Subsonic server IP)
|
||||
port: 4040 (default)
|
||||
user: <your username>
|
||||
pass: <your password>
|
||||
contextpath: /subsonic
|
||||
url: https://mydomain.com:443/subsonic
|
||||
user: username
|
||||
pass: password
|
||||
"""
|
||||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
|
|
@ -37,68 +35,65 @@ from beets.plugins import BeetsPlugin
|
|||
__author__ = 'https://github.com/maffo999'
|
||||
|
||||
|
||||
def create_token():
|
||||
"""Create salt and token from given password.
|
||||
|
||||
:return: The generated salt and hashed token
|
||||
"""
|
||||
password = config['subsonic']['pass'].as_str()
|
||||
|
||||
# Pick the random sequence and salt the password
|
||||
r = string.ascii_letters + string.digits
|
||||
salt = "".join([random.choice(r) for _ in range(6)])
|
||||
salted_password = password + salt
|
||||
token = hashlib.md5().update(salted_password.encode('utf-8')).hexdigest()
|
||||
|
||||
# Put together the payload of the request to the server and the URL
|
||||
return salt, token
|
||||
|
||||
|
||||
def format_url():
|
||||
"""Get the Subsonic URL to trigger a scan. Uses either the url
|
||||
config option or the deprecated host, port, and context_path config
|
||||
options together.
|
||||
|
||||
:return: Endpoint for updating Subsonic
|
||||
"""
|
||||
|
||||
url = config['subsonic']['url'].as_str()
|
||||
if url and url.endsWith('/'):
|
||||
url = url[:-1]
|
||||
|
||||
# @deprecated("Use url config option instead")
|
||||
if not url:
|
||||
host = config['subsonic']['host'].as_str()
|
||||
port = config['subsonic']['port'].get(int)
|
||||
context_path = config['subsonic']['contextpath'].as_str()
|
||||
if context_path == '/':
|
||||
context_path = ''
|
||||
url = "http://{}:{}{}".format(host, port, context_path)
|
||||
|
||||
return url + '/rest/startScan'
|
||||
|
||||
|
||||
class SubsonicUpdate(BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(SubsonicUpdate, self).__init__()
|
||||
|
||||
# Set default configuration values
|
||||
config['subsonic'].add({
|
||||
'host': 'localhost',
|
||||
'port': '4040',
|
||||
'user': 'admin',
|
||||
'pass': 'admin',
|
||||
'contextpath': '/',
|
||||
'url': 'http://localhost:4040',
|
||||
})
|
||||
|
||||
config['subsonic']['pass'].redact = True
|
||||
self.register_listener('import', self.start_scan)
|
||||
|
||||
@staticmethod
|
||||
def __create_token():
|
||||
"""Create salt and token from given password.
|
||||
|
||||
:return: The generated salt and hashed token
|
||||
"""
|
||||
password = config['subsonic']['pass'].as_str()
|
||||
|
||||
# Pick the random sequence and salt the password
|
||||
r = string.ascii_letters + string.digits
|
||||
salt = "".join([random.choice(r) for _ in range(6)])
|
||||
salted_password = password + salt
|
||||
token = hashlib.md5(salted_password.encode('utf-8')).hexdigest()
|
||||
|
||||
# Put together the payload of the request to the server and the URL
|
||||
return salt, token
|
||||
|
||||
@staticmethod
|
||||
def __format_url():
|
||||
"""Get the Subsonic URL to trigger a scan. Uses either the url
|
||||
config option or the deprecated host, port, and context_path config
|
||||
options together.
|
||||
|
||||
:return: Endpoint for updating Subsonic
|
||||
"""
|
||||
|
||||
url = config['subsonic']['url'].as_str()
|
||||
if url and url.endswith('/'):
|
||||
url = url[:-1]
|
||||
|
||||
# @deprecated("Use url config option instead")
|
||||
if not url:
|
||||
host = config['subsonic']['host'].as_str()
|
||||
port = config['subsonic']['port'].get(int)
|
||||
context_path = config['subsonic']['contextpath'].as_str()
|
||||
if context_path == '/':
|
||||
context_path = ''
|
||||
url = "http://{}:{}{}".format(host, port, context_path)
|
||||
|
||||
return url + '/rest/startScan'
|
||||
|
||||
def start_scan(self):
|
||||
user = config['subsonic']['user'].as_str()
|
||||
url = format_url()
|
||||
salt, token = create_token()
|
||||
url = self.__format_url()
|
||||
salt, token = self.__create_token()
|
||||
|
||||
payload = {
|
||||
'u': user,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from beets.plugins import BeetsPlugin
|
|||
__author__ = 'baobab@heresiarch.info'
|
||||
__version__ = '1.1'
|
||||
|
||||
PATTERN_THE = u'^[the]{3}\\s'
|
||||
PATTERN_THE = u'^the\\s'
|
||||
PATTERN_A = u'^[a][n]?\\s'
|
||||
FORMAT = u'{0}, {1}'
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ class IdListConverter(BaseConverter):
|
|||
return ids
|
||||
|
||||
def to_url(self, value):
|
||||
return ','.join(value)
|
||||
return ','.join(str(v) for v in value)
|
||||
|
||||
|
||||
class QueryConverter(PathConverter):
|
||||
|
|
@ -177,10 +177,11 @@ class QueryConverter(PathConverter):
|
|||
"""
|
||||
|
||||
def to_python(self, value):
|
||||
return value.split('/')
|
||||
queries = value.split('/')
|
||||
return [query.replace('\\', os.sep) for query in queries]
|
||||
|
||||
def to_url(self, value):
|
||||
return ','.join(value)
|
||||
return ','.join([v.replace(os.sep, '\\') for v in value])
|
||||
|
||||
|
||||
class EverythingConverter(PathConverter):
|
||||
|
|
|
|||
3
docs/contributing.rst
Normal file
3
docs/contributing.rst
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.. contributing:
|
||||
|
||||
.. include:: ../CONTRIBUTING.rst
|
||||
|
|
@ -32,6 +32,7 @@ Contents
|
|||
reference/index
|
||||
plugins/index
|
||||
faq
|
||||
contributing
|
||||
dev/index
|
||||
|
||||
.. toctree::
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -111,6 +111,8 @@ file. The available options are:
|
|||
This option overrides ``link``. Only works when converting to a directory
|
||||
on the same filesystem as the library.
|
||||
Default: ``false``.
|
||||
- **delete_originals**: Transcoded files will be copied or moved to their destination, depending on the import configuration. By default, the original files are not modified by the plugin. This option deletes the original files after the transcoding step has completed.
|
||||
Default: ``false``.
|
||||
|
||||
You can also configure the format to use for transcoding (see the next
|
||||
section):
|
||||
|
|
|
|||
|
|
@ -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 </plugins/fetchart>` plugin to download art with the purpose of
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ To use the ``fetchart`` plugin, first enable it in your configuration (see
|
|||
|
||||
The plugin uses `requests`_ to fetch album art from the Web.
|
||||
|
||||
.. _requests: https://docs.python-requests.org/en/latest/
|
||||
.. _requests: https://requests.readthedocs.io/en/master/
|
||||
|
||||
Fetching Album Art During Import
|
||||
--------------------------------
|
||||
|
|
@ -42,6 +42,13 @@ file. The available options are:
|
|||
- **maxwidth**: A maximum image width to downscale fetched images if they are
|
||||
too big. The resize operation reduces image width to at most ``maxwidth``
|
||||
pixels. The height is recomputed so that the aspect ratio is preserved.
|
||||
- **quality**: The JPEG quality level to use when compressing images (when
|
||||
``maxwidth`` is set). This should be either a number from 1 to 100 or 0 to
|
||||
use the default quality. 65–75 is usually a good starting point. The default
|
||||
behavior depends on the imaging tool used for scaling: ImageMagick tries to
|
||||
estimate the input image quality and uses 92 if it cannot be determined, and
|
||||
PIL defaults to 75.
|
||||
Default: 0 (disabled)
|
||||
- **enforce_ratio**: Only images with a width:height ratio of 1:1 are
|
||||
considered as valid album art candidates if set to ``yes``.
|
||||
It is also possible to specify a certain deviation to the exact ratio to
|
||||
|
|
@ -51,9 +58,9 @@ file. The available options are:
|
|||
- **sources**: List of sources to search for images. An asterisk `*` expands
|
||||
to all available sources.
|
||||
Default: ``filesystem coverart itunes amazon albumart``, i.e., everything but
|
||||
``wikipedia``, ``google`` and ``fanarttv``. Enable those sources for more
|
||||
matches at the cost of some speed. They are searched in the given order,
|
||||
thus in the default config, no remote (Web) art source are queried if
|
||||
``wikipedia``, ``google``, ``fanarttv`` and ``lastfm``. Enable those sources
|
||||
for more matches at the cost of some speed. They are searched in the given
|
||||
order, thus in the default config, no remote (Web) art source are queried if
|
||||
local art is found in the filesystem. To use a local image as fallback,
|
||||
move it to the end of the list. For even more fine-grained control over
|
||||
the search order, see the section on :ref:`album-art-sources` below.
|
||||
|
|
@ -64,6 +71,8 @@ file. The available options are:
|
|||
Default: The `beets custom search engine`_, which searches the entire web.
|
||||
- **fanarttv_key**: The personal API key for requesting art from
|
||||
fanart.tv. See below.
|
||||
- **lastfm_key**: The personal API key for requesting art from Last.fm. See
|
||||
below.
|
||||
- **store_source**: If enabled, fetchart stores the artwork's source in a
|
||||
flexible tag named ``art_source``. See below for the rationale behind this.
|
||||
Default: ``no``.
|
||||
|
|
@ -117,8 +126,9 @@ art::
|
|||
|
||||
$ beet fetchart [-q] [query]
|
||||
|
||||
By default the command will display all results, the ``-q`` or ``--quiet``
|
||||
switch will only display results for album arts that are still missing.
|
||||
By default the command will display all albums matching the ``query``. When the
|
||||
``-q`` or ``--quiet`` switch is given, only albums for which artwork has been
|
||||
fetched, or for which artwork could not be found will be printed.
|
||||
|
||||
.. _image-resizing:
|
||||
|
||||
|
|
@ -214,6 +224,15 @@ personal key will give you earlier access to new art.
|
|||
|
||||
.. _on their blog: https://fanart.tv/2015/01/personal-api-keys/
|
||||
|
||||
Last.fm
|
||||
'''''''
|
||||
|
||||
To use the Last.fm backend, you need to `register for a Last.fm API key`_. Set
|
||||
the ``lastfm_key`` configuration option to your API key, then add ``lastfm`` to
|
||||
the list of sources in your configutation.
|
||||
|
||||
.. _register for a Last.fm API key: https://www.last.fm/api/account/create
|
||||
|
||||
Storing the Artwork's Source
|
||||
----------------------------
|
||||
|
||||
|
|
|
|||
52
docs/plugins/fish.rst
Normal file
52
docs/plugins/fish.rst
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
Fish Plugin
|
||||
===========
|
||||
|
||||
The ``fish`` plugin adds a ``beet fish`` command that creates a `Fish shell`_
|
||||
tab-completion file named ``beet.fish`` in ``~/.config/fish/completions``.
|
||||
This enables tab-completion of ``beet`` commands for the `Fish shell`_.
|
||||
|
||||
.. _Fish shell: https://fishshell.com/
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
Enable the ``fish`` plugin (see :ref:`using-plugins`) on a system running the
|
||||
`Fish shell`_.
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
Type ``beet fish`` to generate the ``beet.fish`` completions file at:
|
||||
``~/.config/fish/completions/``. If you later install or disable plugins, run
|
||||
``beet fish`` again to update the completions based on the enabled plugins.
|
||||
|
||||
For users not accustomed to tab completion… After you type ``beet`` followed by
|
||||
a space in your shell prompt and then the ``TAB`` key, you should see a list of
|
||||
the beets commands (and their abbreviated versions) that can be invoked in your
|
||||
current environment. Similarly, typing ``beet -<TAB>`` will show you all the
|
||||
option flags available to you, which also applies to subcommands such as
|
||||
``beet import -<TAB>``. If you type ``beet ls`` followed by a space and then the
|
||||
and the ``TAB`` key, you will see a list of all the album/track fields that can
|
||||
be used in beets queries. For example, typing ``beet ls ge<TAB>`` will complete
|
||||
to ``genre:`` and leave you ready to type the rest of your query.
|
||||
|
||||
Options
|
||||
-------
|
||||
|
||||
In addition to beets commands, plugin commands, and option flags, the generated
|
||||
completions also include by default all the album/track fields. If you only want
|
||||
the former and do not want the album/track fields included in the generated
|
||||
completions, use ``beet fish -f`` to only generate completions for beets/plugin
|
||||
commands and option flags.
|
||||
|
||||
If you want generated completions to also contain album/track field *values* for
|
||||
the items in your library, you can use the ``-e`` or ``--extravalues`` option.
|
||||
For example: ``beet fish -e genre`` or ``beet fish -e genre -e albumartist``
|
||||
In the latter case, subsequently typing ``beet list genre: <TAB>`` will display
|
||||
a list of all the genres in your library and ``beet list albumartist: <TAB>``
|
||||
will show a list of the album artists in your library. Keep in mind that all of
|
||||
these values will be put into the generated completions file, so use this option
|
||||
with care when specified fields contain a large number of values. Libraries with,
|
||||
for example, very large numbers of genres/artists may result in higher memory
|
||||
utilization, completion latency, et cetera. This option is not meant to replace
|
||||
database queries altogether.
|
||||
|
|
@ -78,6 +78,7 @@ following to your configuration::
|
|||
export
|
||||
fetchart
|
||||
filefilter
|
||||
fish
|
||||
freedesktop
|
||||
fromfilename
|
||||
ftintitle
|
||||
|
|
@ -115,6 +116,7 @@ following to your configuration::
|
|||
smartplaylist
|
||||
sonosupdate
|
||||
spotify
|
||||
subsonicplaylist
|
||||
subsonicupdate
|
||||
the
|
||||
thumbnails
|
||||
|
|
@ -184,6 +186,7 @@ Interoperability
|
|||
|
||||
* :doc:`badfiles`: Check audio file integrity.
|
||||
* :doc:`embyupdate`: Automatically notifies `Emby`_ whenever the beets library changes.
|
||||
* :doc:`fish`: Adds `Fish shell`_ tab autocompletion to ``beet`` commands.
|
||||
* :doc:`importfeeds`: Keep track of imported files via ``.m3u`` playlist file(s) or symlinks.
|
||||
* :doc:`ipfs`: Import libraries from friends and get albums from them via ipfs.
|
||||
* :doc:`kodiupdate`: Automatically notifies `Kodi`_ whenever the beets library
|
||||
|
|
@ -203,6 +206,7 @@ Interoperability
|
|||
|
||||
|
||||
.. _Emby: https://emby.media
|
||||
.. _Fish shell: https://fishshell.com/
|
||||
.. _Plex: https://plex.tv
|
||||
.. _Kodi: https://kodi.tv
|
||||
.. _Sonos: https://sonos.com
|
||||
|
|
@ -297,7 +301,26 @@ Here are a few of the plugins written by the beets community:
|
|||
* `beet-summarize`_ can compute lots of counts and statistics about your music
|
||||
library.
|
||||
|
||||
* `beets-mosaic`_ generates a montage of a mosiac from cover art.
|
||||
* `beets-mosaic`_ generates a montage of a mosaic from cover art.
|
||||
|
||||
* `beets-goingrunning`_ generates playlists to go with your running sessions.
|
||||
|
||||
* `beets-xtractor`_ extracts low- and high-level musical information from your songs.
|
||||
|
||||
* `beets-yearfixer`_ attempts to fix all missing ``original_year`` and ``year`` fields.
|
||||
|
||||
* `beets-autofix`_ automates repetitive tasks to keep your library in order.
|
||||
|
||||
* `beets-describe`_ gives you the full picture of a single attribute of your library items.
|
||||
|
||||
* `beets-bpmanalyser`_ analyses songs and calculates their tempo (BPM).
|
||||
|
||||
* `beets-originquery`_ augments MusicBrainz queries with locally-sourced data
|
||||
to improve autotagger results.
|
||||
|
||||
* `drop2beets`_ automatically imports singles as soon as they are dropped in a
|
||||
folder (using Linux's ``inotify``). You can also set a sub-folders
|
||||
hierarchy to set flexible attributes by the way.
|
||||
|
||||
.. _beets-barcode: https://github.com/8h2a/beets-barcode
|
||||
.. _beets-check: https://github.com/geigerzaehler/beets-check
|
||||
|
|
@ -321,3 +344,11 @@ Here are a few of the plugins written by the beets community:
|
|||
.. _beets-ydl: https://github.com/vmassuchetto/beets-ydl
|
||||
.. _beet-summarize: https://github.com/steven-murray/beet-summarize
|
||||
.. _beets-mosaic: https://github.com/SusannaMaria/beets-mosaic
|
||||
.. _beets-goingrunning: https://pypi.org/project/beets-goingrunning
|
||||
.. _beets-xtractor: https://github.com/adamjakab/BeetsPluginXtractor
|
||||
.. _beets-yearfixer: https://github.com/adamjakab/BeetsPluginYearFixer
|
||||
.. _beets-autofix: https://github.com/adamjakab/BeetsPluginAutofix
|
||||
.. _beets-describe: https://github.com/adamjakab/BeetsPluginDescribe
|
||||
.. _beets-bpmanalyser: https://github.com/adamjakab/BeetsPluginBpmAnalyser
|
||||
.. _beets-originquery: https://github.com/x1ppy/beets-originquery
|
||||
.. _drop2beets: https://github.com/martinkirch/drop2beets
|
||||
|
|
|
|||
|
|
@ -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``.
|
||||
|
|
|
|||
|
|
@ -18,15 +18,27 @@ example, all the movements of a symphony. This plugin aims to solve this
|
|||
problem by also fetching the parent work, which would be the whole symphony in
|
||||
this example.
|
||||
|
||||
This plugin adds five tags:
|
||||
The plugin can detect changes in ``mb_workid`` so it knows when to re-fetch
|
||||
other metadata, such as ``parentwork``. To do this, when it runs, it stores a
|
||||
copy of ``mb_workid`` in the bookkeeping field ``parentwork_workid_current``.
|
||||
At any later run of ``beet parentwork`` it will check if the tags
|
||||
``mb_workid`` and ``parentwork_workid_current`` are still identical. If it is
|
||||
not the case, it means the work has changed and all the tags need to be
|
||||
fetched again.
|
||||
|
||||
This plugin adds seven tags:
|
||||
|
||||
- **parentwork**: The title of the parent work.
|
||||
- **mb_parentworkid**: The musicbrainz id of the parent work.
|
||||
- **mb_parentworkid**: The MusicBrainz id of the parent work.
|
||||
- **parentwork_disambig**: The disambiguation of the parent work title.
|
||||
- **parent_composer**: The composer of the parent work.
|
||||
- **parent_composer_sort**: The sort name of the parent work composer.
|
||||
- **work_date**: The composition date of the work, or the first parent work
|
||||
that has a composition date. Format: yyyy-mm-dd.
|
||||
- **parentwork_workid_current**: The MusicBrainz id of the work as it was when
|
||||
the parentwork was retrieved. This tag exists only for internal bookkeeping,
|
||||
to keep track of recordings whose works have changed.
|
||||
- **parentwork_date**: The composition date of the parent work.
|
||||
|
||||
To use the ``parentwork`` plugin, enable it in your configuration (see
|
||||
:ref:`using-plugins`).
|
||||
|
|
@ -38,10 +50,11 @@ To configure the plugin, make a ``parentwork:`` section in your
|
|||
configuration file. The available options are:
|
||||
|
||||
- **force**: As a default, ``parentwork`` only fetches work info for
|
||||
recordings that do not already have a ``parentwork`` tag. If ``force``
|
||||
recordings that do not already have a ``parentwork`` tag or where
|
||||
``mb_workid`` differs from ``parentwork_workid_current``. If ``force``
|
||||
is enabled, it fetches it for all recordings.
|
||||
Default: ``no``
|
||||
|
||||
- **auto**: If enabled, automatically fetches works at import. It takes quite
|
||||
some time, because beets is restricted to one musicbrainz query per second.
|
||||
some time, because beets is restricted to one MusicBrainz query per second.
|
||||
Default: ``no``
|
||||
|
|
|
|||
|
|
@ -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``
|
||||
|
|
|
|||
43
docs/plugins/subsonicplaylist.rst
Normal file
43
docs/plugins/subsonicplaylist.rst
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
Subsonic Playlist Plugin
|
||||
========================
|
||||
|
||||
The ``subsonicplaylist`` plugin allows to import playlists from a subsonic server.
|
||||
This is done by retrieving the track info from the subsonic server, searching
|
||||
for them in the beets library, and adding the playlist names to the
|
||||
`subsonic_playlist` tag of the found items. The content of the tag has the format:
|
||||
|
||||
subsonic_playlist: ";first playlist;second playlist;"
|
||||
|
||||
To get all items in a playlist use the query `;playlist name;`.
|
||||
|
||||
Command Line Usage
|
||||
------------------
|
||||
|
||||
To use the ``subsonicplaylist`` plugin, enable it in your configuration (see
|
||||
:ref:`using-plugins`). Then use it by invoking the ``subsonicplaylist`` command.
|
||||
Next, configure the plugin to connect to your Subsonic server, like this::
|
||||
|
||||
subsonicplaylist:
|
||||
base_url: http://subsonic.example.com
|
||||
username: someUser
|
||||
password: somePassword
|
||||
|
||||
After this you can import your playlists by invoking the `subsonicplaylist` command.
|
||||
|
||||
By default only the tags of the items found for playlists will be updated.
|
||||
This means that, if one imported a playlist, then delete one song from it and
|
||||
imported the playlist again, the deleted song will still have the playlist set
|
||||
in its `subsonic_playlist` tag. To solve this problem one can use the `-d/--delete`
|
||||
flag. This resets all `subsonic_playlist` tag before importing playlists.
|
||||
|
||||
Here's an example configuration with all the available options and their default values::
|
||||
|
||||
subsonicplaylist:
|
||||
base_url: "https://your.subsonic.server"
|
||||
delete: no
|
||||
playlist_ids: []
|
||||
playlist_names: []
|
||||
username: ''
|
||||
password: ''
|
||||
|
||||
The `base_url`, `username`, and `password` options are required.
|
||||
|
|
@ -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``
|
||||
++++++++++++++++++++
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ The ``-p`` option makes beets print out filenames of matched items, which might
|
|||
be useful for piping into other Unix commands (such as `xargs`_). Similarly, the
|
||||
``-f`` option lets you specify a specific format with which to print every album
|
||||
or track. This uses the same template syntax as beets' :doc:`path formats
|
||||
<pathformat>`. For example, the command ``beet ls -af '$album: $tracktotal'
|
||||
<pathformat>`. For example, the command ``beet ls -af '$album: $albumtotal'
|
||||
beatles`` prints out the number of tracks on each Beatles album. In Unix shells,
|
||||
remember to enclose the template argument in single quotes to avoid environment
|
||||
variable expansion.
|
||||
|
|
|
|||
|
|
@ -701,6 +701,26 @@ MusicBrainz server.
|
|||
|
||||
Default: ``5``.
|
||||
|
||||
.. _extra_tags:
|
||||
|
||||
extra_tags
|
||||
~~~~~~~~~~
|
||||
|
||||
By default, beets will use only the artist, album, and track count to query
|
||||
MusicBrainz. Additional tags to be queried can be supplied with the
|
||||
``extra_tags`` setting. For example::
|
||||
|
||||
musicbrainz:
|
||||
extra_tags: [year, catalognum, country, media, label]
|
||||
|
||||
This setting should improve the autotagger results if the metadata with the
|
||||
given tags match the metadata returned by MusicBrainz.
|
||||
|
||||
Note that the only tags supported by this setting are the ones listed in the
|
||||
above example.
|
||||
|
||||
Default: ``[]``
|
||||
|
||||
.. _match-config:
|
||||
|
||||
Autotagger Matching Options
|
||||
|
|
|
|||
225
setup.cfg
225
setup.cfg
|
|
@ -1,23 +1,204 @@
|
|||
[nosetests]
|
||||
verbosity=1
|
||||
logging-clear-handlers=1
|
||||
|
||||
[flake8]
|
||||
min-version=2.7
|
||||
accept-encodings=utf-8
|
||||
# Errors we ignore:
|
||||
# - E121,E123,E126,E24,E704,W503,W504 flake8 default ignores, excluding E226 (have to be listed here to not be overridden)
|
||||
# - E221: multiple spaces before operator (used to align visually)
|
||||
# - E731: do not assign a lambda expression, use a def
|
||||
# - F405 object may be undefined, or defined from star imports
|
||||
# - C901: function/method complexity
|
||||
# - E305: spacing after a declaration (might be nice to change eventually)
|
||||
# `flake8-future-import` errors we ignore:
|
||||
# - FI50: `__future__` import "division" present
|
||||
# - FI51: `__future__` import "absolute_import" present
|
||||
# - FI12: `__future__` import "with_statement" missing
|
||||
# - FI53: `__future__` import "print_function" present
|
||||
# - FI14: `__future__` import "unicode_literals" missing
|
||||
# - FI15: `__future__` import "generator_stop" missing
|
||||
# - E741: ambiguous variable name
|
||||
ignore=E121,E123,E126,E24,E704,W503,W504,E305,C901,E221,E731,F405,FI50,FI51,FI12,FI53,FI14,FI15,E741
|
||||
min-version = 2.7
|
||||
accept-encodings = utf-8
|
||||
docstring-convention = google
|
||||
# errors we ignore; see https://www.flake8rules.com/ for more info
|
||||
ignore =
|
||||
# pycodestyle errors
|
||||
E121, # continuation line under-indented for hanging indent
|
||||
E123, # closing bracket does not match indentation of opening bracket's line
|
||||
E126, # continuation line over-indented for hangin indent
|
||||
E241, # multiple spaces after non-arithmetic operators (for vertical alignment)
|
||||
E305, # expected 2 blank lines after end of function or class
|
||||
E731, # do not assign a lamba expression, use a def
|
||||
E741, # do not use variables name 'I', 'O', or 'l'
|
||||
# pycodestyle warnings
|
||||
W503, # line break occurred before a binary operator
|
||||
W504, # line break occurred after a binary operator
|
||||
# pyflakes errors
|
||||
F405, # name be undefined, or defined from star imports: module
|
||||
# mccabe error
|
||||
C901, # function is too complex
|
||||
# future-import errors
|
||||
FI12, # `__future__` import "with_statement" missing
|
||||
FI14, # `__future__` import "unicode_literals" missing
|
||||
FI15, # `__future__` import "generator_stop" missing
|
||||
FI50, # `__future__` import "division" present
|
||||
FI51, # `__future__` import "absolute_import" present
|
||||
FI53, # `__future__` import "print_function" present
|
||||
per-file-ignores =
|
||||
./beet:D
|
||||
./docs/conf.py:D
|
||||
./extra/release.py:D
|
||||
./beetsplug/duplicates.py:D
|
||||
./beetsplug/bpm.py:D
|
||||
./beetsplug/convert.py:D
|
||||
./beetsplug/info.py:D
|
||||
./beetsplug/parentwork.py:D
|
||||
./beetsplug/deezer.py:D
|
||||
./beetsplug/smartplaylist.py:D
|
||||
./beetsplug/absubmit.py:D
|
||||
./beetsplug/subsonicupdate.py:D
|
||||
./beetsplug/fromfilename.py:D
|
||||
./beetsplug/mpdstats.py:D
|
||||
./beetsplug/gmusic.py:D
|
||||
./beetsplug/subsonicplaylist.py:D
|
||||
./beetsplug/rewrite.py:D
|
||||
./beetsplug/hook.py:D
|
||||
./beetsplug/playlist.py:D
|
||||
./beetsplug/ftintitle.py:D
|
||||
./beetsplug/bpd/gstplayer.py:D
|
||||
./beetsplug/bpd/__init__.py:D
|
||||
./beetsplug/scrub.py:D
|
||||
./beetsplug/sonosupdate.py:D
|
||||
./beetsplug/embyupdate.py:D
|
||||
./beetsplug/plexupdate.py:D
|
||||
./beetsplug/mbsync.py:D
|
||||
./beetsplug/lyrics.py:D
|
||||
./beetsplug/inline.py:D
|
||||
./beetsplug/freedesktop.py:D
|
||||
./beetsplug/acousticbrainz.py:D
|
||||
./beetsplug/beatport.py:D
|
||||
./beetsplug/cue.py:D
|
||||
./beetsplug/thumbnails.py:D
|
||||
./beetsplug/random.py:D
|
||||
./beetsplug/loadext.py:D
|
||||
./beetsplug/replaygain.py:D
|
||||
./beetsplug/export.py:D
|
||||
./beetsplug/fuzzy.py:D
|
||||
./beetsplug/importadded.py:D
|
||||
./beetsplug/web/__init__.py:D
|
||||
./beetsplug/bucket.py:D
|
||||
./beetsplug/the.py:D
|
||||
./beetsplug/ihate.py:D
|
||||
./beetsplug/bench.py:D
|
||||
./beetsplug/permissions.py:D
|
||||
./beetsplug/spotify.py:D
|
||||
./beetsplug/lastgenre/__init__.py:D
|
||||
./beetsplug/mbcollection.py:D
|
||||
./beetsplug/metasync/amarok.py:D
|
||||
./beetsplug/metasync/itunes.py:D
|
||||
./beetsplug/metasync/__init__.py:D
|
||||
./beetsplug/importfeeds.py:D
|
||||
./beetsplug/kodiupdate.py:D
|
||||
./beetsplug/zero.py:D
|
||||
./beetsplug/bpsync.py:D
|
||||
./beetsplug/__init__.py:D
|
||||
./beetsplug/edit.py:D
|
||||
./beetsplug/types.py:D
|
||||
./beetsplug/embedart.py:D
|
||||
./beetsplug/mpdupdate.py:D
|
||||
./beetsplug/ipfs.py:D
|
||||
./beetsplug/discogs.py:D
|
||||
./beetsplug/chroma.py:D
|
||||
./beetsplug/fish.py:D
|
||||
./beetsplug/missing.py:D
|
||||
./beetsplug/fetchart.py:D
|
||||
./beetsplug/mbsubmit.py:D
|
||||
./beetsplug/filefilter.py:D
|
||||
./beetsplug/badfiles.py:D
|
||||
./beetsplug/play.py:D
|
||||
./beetsplug/keyfinder.py:D
|
||||
./beetsplug/unimported.py:D
|
||||
./beetsplug/lastimport.py:D
|
||||
./test/test_parentwork.py:D
|
||||
./test/test_hook.py:D
|
||||
./test/test_keyfinder.py:D
|
||||
./test/test_util.py:D
|
||||
./test/test_plexupdate.py:D
|
||||
./test/test_importfeeds.py:D
|
||||
./test/test_discogs.py:D
|
||||
./test/test_acousticbrainz.py:D
|
||||
./test/test_pipeline.py:D
|
||||
./test/test_mb.py:D
|
||||
./test/test_playlist.py:D
|
||||
./test/helper.py:D
|
||||
./test/test_player.py:D
|
||||
./test/test_template.py:D
|
||||
./test/test_web.py:D
|
||||
./test/test_replaygain.py:D
|
||||
./test/test_hidden.py:D
|
||||
./test/test_info.py:D
|
||||
./test/test_dbcore.py:D
|
||||
./test/test_vfs.py:D
|
||||
./test/test_subsonic.py:D
|
||||
./test/test_play.py:D
|
||||
./test/test_types_plugin.py:D
|
||||
./test/test_plugins.py:D
|
||||
./test/test_importer.py:D
|
||||
./test/test_smartplaylist.py:D
|
||||
./test/test_spotify.py:D
|
||||
./test/test_metasync.py:D
|
||||
./test/test_bucket.py:D
|
||||
./test/test_ftintitle.py:D
|
||||
./test/lyrics_download_samples.py:D
|
||||
./test/test_convert.py:D
|
||||
./test/test_mbsubmit.py:D
|
||||
./test/testall.py:D
|
||||
./test/test_fetchart.py:D
|
||||
./test/test_ui_importer.py:D
|
||||
./test/test_mbsync.py:D
|
||||
./test/test_art.py:D
|
||||
./test/test_permissions.py:D
|
||||
./test/test_embedart.py:D
|
||||
./test/test_the.py:D
|
||||
./test/test_export.py:D
|
||||
./test/rsrc/beetsplug/test.py:D
|
||||
./test/rsrc/convert_stub.py:D
|
||||
./test/test_ui_init.py:D
|
||||
./test/test_filefilter.py:D
|
||||
./test/test_logging.py:D
|
||||
./test/test_thumbnails.py:D
|
||||
./test/test_ipfs.py:D
|
||||
./test/test_autotag.py:D
|
||||
./test/__init__.py:D
|
||||
./test/test_plugin_mediafield.py:D
|
||||
./test/test_files.py:D
|
||||
./test/test_lastgenre.py:D
|
||||
./test/_common.py:D
|
||||
./test/test_zero.py:D
|
||||
./test/test_edit.py:D
|
||||
./test/test_ihate.py:D
|
||||
./test/test_ui.py:D
|
||||
./test/test_mpdstats.py:D
|
||||
./test/test_importadded.py:D
|
||||
./test/test_query.py:D
|
||||
./test/test_sort.py:D
|
||||
./test/test_library.py:D
|
||||
./test/test_ui_commands.py:D
|
||||
./test/test_lyrics.py:D
|
||||
./test/test_beatport.py:D
|
||||
./test/test_random.py:D
|
||||
./test/test_embyupdate.py:D
|
||||
./test/test_datequery.py:D
|
||||
./test/test_config_command.py:D
|
||||
./setup.py:D
|
||||
./beets/ui/__init__.py:D
|
||||
./beets/ui/commands.py:D
|
||||
./beets/autotag/mb.py:D
|
||||
./beets/autotag/hooks.py:D
|
||||
./beets/autotag/__init__.py:D
|
||||
./beets/autotag/match.py:D
|
||||
./beets/__main__.py:D
|
||||
./beets/importer.py:D
|
||||
./beets/plugins.py:D
|
||||
./beets/util/bluelet.py:D
|
||||
./beets/util/enumeration.py:D
|
||||
./beets/util/artresizer.py:D
|
||||
./beets/util/functemplate.py:D
|
||||
./beets/util/confit.py:D
|
||||
./beets/util/pipeline.py:D
|
||||
./beets/util/hidden.py:D
|
||||
./beets/util/__init__.py:D
|
||||
./beets/library.py:D
|
||||
./beets/random.py:D
|
||||
./beets/art.py:D
|
||||
./beets/logging.py:D
|
||||
./beets/vfs.py:D
|
||||
./beets/__init__.py:D
|
||||
./beets/dbcore/query.py:D
|
||||
./beets/dbcore/db.py:D
|
||||
./beets/dbcore/__init__.py:D
|
||||
./beets/dbcore/queryparse.py:D
|
||||
./beets/dbcore/types.py:D
|
||||
./beets/mediafile.py:D
|
||||
|
|
|
|||
48
setup.py
48
setup.py
|
|
@ -109,23 +109,35 @@ setup(
|
|||
['colorama'] if (sys.platform == 'win32') else []
|
||||
),
|
||||
|
||||
tests_require=[
|
||||
'beautifulsoup4',
|
||||
'flask',
|
||||
'mock',
|
||||
'pylast',
|
||||
'rarfile',
|
||||
'responses',
|
||||
'pyxdg',
|
||||
'python-mpd2',
|
||||
'discogs-client'
|
||||
] + (
|
||||
# Tests for the thumbnails plugin need pathlib on Python 2 too.
|
||||
['pathlib'] if (sys.version_info < (3, 4, 0)) else []
|
||||
),
|
||||
|
||||
# Plugin (optional) dependencies:
|
||||
extras_require={
|
||||
'test': [
|
||||
'beautifulsoup4',
|
||||
'coverage',
|
||||
'discogs-client',
|
||||
'flask',
|
||||
'mock',
|
||||
'pylast',
|
||||
'pytest',
|
||||
'python-mpd2',
|
||||
'pyxdg',
|
||||
'responses>=0.3.0',
|
||||
'requests_oauthlib',
|
||||
] + (
|
||||
# Tests for the thumbnails plugin need pathlib on Python 2 too.
|
||||
['pathlib'] if (sys.version_info < (3, 4, 0)) else []
|
||||
) + [
|
||||
'rarfile<4' if sys.version_info < (3, 6, 0) else 'rarfile',
|
||||
],
|
||||
'lint': [
|
||||
'flake8',
|
||||
'flake8-blind-except',
|
||||
'flake8-coding',
|
||||
'flake8-docstrings',
|
||||
'flake8-future-import',
|
||||
'pep8-naming',
|
||||
],
|
||||
|
||||
# Plugin (optional) dependencies:
|
||||
'absubmit': ['requests'],
|
||||
'fetchart': ['requests', 'Pillow'],
|
||||
'embedart': ['Pillow'],
|
||||
|
|
@ -141,7 +153,9 @@ setup(
|
|||
'mpdstats': ['python-mpd2>=0.4.2'],
|
||||
'plexupdate': ['requests'],
|
||||
'web': ['flask', 'flask-cors'],
|
||||
'import': ['rarfile'],
|
||||
'import': (
|
||||
['rarfile<4' if (sys.version_info < (3, 6, 0)) else 'rarfile']
|
||||
),
|
||||
'thumbnails': ['pyxdg', 'Pillow'] +
|
||||
(['pathlib'] if (sys.version_info < (3, 4, 0)) else []),
|
||||
'metasync': ['dbus-python'],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
2227
test/rsrc/lyrics/geniuscom/Wutangclancreamlyrics.txt
Normal file
2227
test/rsrc/lyrics/geniuscom/Wutangclancreamlyrics.txt
Normal file
File diff suppressed because one or more lines are too long
270
test/rsrc/lyrics/geniuscom/sample.txt
Normal file
270
test/rsrc/lyrics/geniuscom/sample.txt
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
<!DOCTYPE html>
|
||||
<html class="snarly apple_music_player--enabled bagon_song_page--enabled song_stories_public_launch--enabled react_forums--disabled" xmlns="http://www.w3.org/1999/xhtml" xmlns:fb="http://www.facebook.com/2008/fbml" lang="en" xml:lang="en">
|
||||
<head>
|
||||
<base target='_top' href="//g-example.com/">
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
|
||||
var _sf_startpt=(new Date()).getTime();
|
||||
if (window.performance && performance.mark) {
|
||||
window.performance.mark('parse_start');
|
||||
}
|
||||
|
||||
//]]>
|
||||
</script>
|
||||
|
||||
<title>SAMPLE – SONG Lyrics | g-example Lyrics</title>
|
||||
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta content='width=device-width,initial-scale=1' name='viewport'>
|
||||
|
||||
<meta property="og:site_name" content="g-example"/>
|
||||
|
||||
<link title="g-example" type="application/opensearchdescription+xml" rel="search" href="https://g-example.com/opensearch.xml">
|
||||
|
||||
<script async src="https://www.youtube.com/iframe_api"></script>
|
||||
<script defer src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
|
||||
|
||||
<meta content="https://g-example.com/SAMPLE-SONG-lyrics" property="og:url" />
|
||||
|
||||
<link href="ios-app://#/g-example/songs/#" rel="alternate" />
|
||||
<meta content="/songs/3113595" name="newrelic-resource-path" />
|
||||
<link href="https://g-example.com/SAMPLE-SONG-lyrics" rel="canonical" />
|
||||
<link href="https://g-example.com/amp/SAMPLE-SONG-lyrics" rel="amphtml" />
|
||||
|
||||
<script type="text/javascript">
|
||||
var _qevents = _qevents || [];
|
||||
(function() {
|
||||
var elem = document.createElement('script');
|
||||
elem.src = (document.location.protocol == 'https:' ? 'https://secure' : 'http://edge') + '.quantserve.com/quant.js';
|
||||
elem.async = true;
|
||||
elem.type = 'text/javascript';
|
||||
var scpt = document.getElementsByTagName('script')[0];
|
||||
scpt.parentNode.insertBefore(elem, scpt);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script type="text/javascript">
|
||||
window.ga = window.ga || function() {
|
||||
(window.ga.q = window.ga.q || []).push(arguments);
|
||||
};
|
||||
|
||||
|
||||
(function(g, e, n, i, u, s) {
|
||||
g['GoogleAnalyticsObject'] = 'ga';
|
||||
g.ga.l = Date.now();
|
||||
u = e.createElement(n);
|
||||
s = e.getElementsByTagName(n)[0];
|
||||
u.async = true;
|
||||
u.src = i;
|
||||
s.parentNode.insertBefore(u, s);
|
||||
})(window, document, 'script', 'https://www.google-analytics.com/analytics.js');
|
||||
|
||||
ga('create', "UA-10346621-1", 'auto', {'useAmpClientId': true});
|
||||
ga('set', 'dimension1', "false");
|
||||
ga('set', 'dimension2', "songs#show");
|
||||
ga('set', 'dimension3', "r-b");
|
||||
ga('set', 'dimension4', "true");
|
||||
ga('set', 'dimension5', 'false');
|
||||
ga('set', 'dimension6', "none");
|
||||
ga('send', 'pageview');
|
||||
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="header" ng-controller="HeaderALBUM as header_ALBUM" click-outside="close_mobile_subnav_menu()">
|
||||
<div class="header-primary active">
|
||||
<div class="header-expand_nav_menu" ng-click="toggle_mobile_subnav_menu()"><div class="header-expand_nav_menu-contents"></div></div>
|
||||
|
||||
|
||||
<div class="logo_container">
|
||||
<a href="https://g-example.com/" class="logo_link">g-example</a>
|
||||
</div>
|
||||
|
||||
|
||||
<header-actions></header-actions>
|
||||
|
||||
<search-form search-style="header"></search-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<routable-page>
|
||||
<ng-non-bindable>
|
||||
|
||||
<div class="header_with_cover_art">
|
||||
<div class="header_with_cover_art-inner column_layout">
|
||||
<div class="column_layout-column_span column_layout-column_span--primary">
|
||||
<div class="header_with_cover_art-cover_art ">
|
||||
<div class="cover_art">
|
||||
<img alt="#" class="cover_art-image" src="#" srcset="#" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header_with_cover_art-primary_info_container">
|
||||
<div class="header_with_cover_art-primary_info">
|
||||
<h1 class="header_with_cover_art-primary_info-title ">SONG</h1>
|
||||
<h2>
|
||||
<a href="https://g-example.com/artists/SAMPLE" class="header_with_cover_art-primary_info-primary_artist">
|
||||
SAMPLE
|
||||
</a>
|
||||
</h2>
|
||||
<h3>
|
||||
<div class="metadata_unit ">
|
||||
<span class="metadata_unit-label">Produced by</span>
|
||||
<span class="metadata_unit-info">
|
||||
<a href="https://g-example.com/artists/Person1">Person 1</a> & <a href="https://g-example.com/artists/Person 2">Person 2</a>
|
||||
</span>
|
||||
</div>
|
||||
</h3>
|
||||
<h3>
|
||||
<div class="metadata_unit ">
|
||||
<span class="metadata_unit-label">Album</span>
|
||||
<span class="metadata_unit-info"><a href="https://g-example.com/albums/SAMPLE/ALBUM">ALBUM</a></span>
|
||||
</div>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="song_body column_layout" initial-content-for="song_body">
|
||||
<div class="column_layout-column_span column_layout-column_span--primary">
|
||||
<div class="song_body-lyrics">
|
||||
<h2 class="text_label text_label--gray text_label--x_small_text_size u-top_margin">SONG Lyrics</h2>
|
||||
<div initial-content-for="lyrics">
|
||||
<div class="totally-not-the-lyrics-div">
|
||||
!!!! MISSING LYRICS HERE !!!
|
||||
</div>
|
||||
</div>
|
||||
<div initial-content-for="recirculated_content">
|
||||
<div class="u-xx_large_vertical_margins">
|
||||
<div class="text_label text_label--gray">More on g-example</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metadata_unit metadata_unit--table_row">
|
||||
<span class="metadata_unit-label">Released by</span>
|
||||
|
||||
<span class="metadata_unit-info">
|
||||
<a href="https://g-example.com/artists/records">Records</a> & <a href="https://g-example.com/artists/Top">Top</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="metadata_unit metadata_unit--table_row">
|
||||
<span class="metadata_unit-label">Mixing</span>
|
||||
<span class="metadata_unit-info">
|
||||
<a href="https://g-example.com/artists/Mixed-by-person">Mixed by Person</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="metadata_unit metadata_unit--table_row">
|
||||
<span class="metadata_unit-label">Recorded At</span>
|
||||
<span class="metadata_unit-info metadata_unit-info--text_only">City, Place</span>
|
||||
</div>
|
||||
|
||||
<div class="metadata_unit metadata_unit--table_row">
|
||||
<span class="metadata_unit-label">Release Date</span>
|
||||
<span class="metadata_unit-info metadata_unit-info--text_only">Feb 30, 1290</span>
|
||||
</div>
|
||||
|
||||
<div class="metadata_unit metadata_unit--table_row">
|
||||
<span class="metadata_unit-label">Interpolated By</span>
|
||||
<span class="metadata_unit-info">
|
||||
|
||||
<div class="u-x_small_bottom_margin">
|
||||
<a href="#"> # </a>
|
||||
</div>
|
||||
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div initial-content-for="album">
|
||||
<div class="u-xx_large_vertical_margins">
|
||||
<div class="song_album u-bottom_margin">
|
||||
<a href="https://g-example.com/albums/SAMPLE/ALBUM" class="song_album-album_art" title="ALBUM">
|
||||
<img alt="#" src="#" srcset="#"/>
|
||||
</a>
|
||||
<div class="song_album-info">
|
||||
<a href="https://g-example.com/albums/SAMPLE/ALBUM" title="ALBUM" class="song_album-info-title">
|
||||
ALBUM
|
||||
</a>
|
||||
<a href="https://g-example.com/artists/SAMPLE" class="song_album-info-artist" title="ALBUM">SAMPLE</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-non-bindable>
|
||||
</routable-page>
|
||||
|
||||
<div class="page_footer page_footer--padding-for-sticky-player">
|
||||
<div class="footer">
|
||||
<div>
|
||||
<a href="/about">About g-example</a>
|
||||
<a href="/contributor_guidelines">Contributor Guidelines</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span>g-example</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">_qevents.push({ qacct: "################"});</script>
|
||||
<noscript>
|
||||
<div style="display: none;">
|
||||
<img src="#" height="1" width="1" alt="#"/>
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
<script type="text/javascript">
|
||||
var _sf_async_config={};
|
||||
|
||||
_sf_async_config.uid = 3877;
|
||||
_sf_async_config.domain = 'g-example.com';
|
||||
_sf_async_config.title = 'SAMPLE – SONG Lyrics | g-example Lyrics';
|
||||
_sf_async_config.sections = 'songs,tag:r-b';
|
||||
_sf_async_config.authors = 'SAMPLE';
|
||||
|
||||
var _cbq = window._cbq || [];
|
||||
|
||||
(function(){
|
||||
function loadChartbeat() {
|
||||
window._sf_endpt=(new Date()).getTime();
|
||||
var e = document.createElement('script');
|
||||
e.setAttribute('language', 'javascript');
|
||||
e.setAttribute('type', 'text/javascript');
|
||||
e.setAttribute('src', '#');
|
||||
document.body.appendChild(e);
|
||||
}
|
||||
var oldonload = window.onload;
|
||||
window.onload = (typeof window.onload != 'function') ?
|
||||
loadChartbeat : function() { oldonload(); loadChartbeat(); };
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Begin comScore Tag -->
|
||||
<script>
|
||||
var _comscore = _comscore || [];
|
||||
_comscore.push({ c1: "2", c2: "17151659" });
|
||||
(function() {
|
||||
var s = document.createElement("script"), el = document.getElementsByTagName("script")[0]; s.async = true;
|
||||
s.src = (document.location.protocol == "https:" ? "https://sb" : "http://b") + ".scorecardresearch.com/beacon.js";
|
||||
el.parentNode.insertBefore(s, el);
|
||||
})();
|
||||
</script>
|
||||
<noscript>
|
||||
<img src="#"/>
|
||||
</noscript>
|
||||
<!-- End comScore Tag -->
|
||||
<noscript>
|
||||
<img height="1" width="1" style="display:none" src="#"/>
|
||||
</noscript>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -18,7 +18,6 @@
|
|||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
import re
|
||||
import copy
|
||||
import unittest
|
||||
|
||||
from test import _common
|
||||
|
|
@ -91,7 +90,10 @@ class PluralityTest(_common.TestCase):
|
|||
for i in range(5)]
|
||||
likelies, _ = match.current_metadata(items)
|
||||
for f in fields:
|
||||
self.assertEqual(likelies[f], '%s_1' % f)
|
||||
if isinstance(likelies[f], int):
|
||||
self.assertEqual(likelies[f], 0)
|
||||
else:
|
||||
self.assertEqual(likelies[f], '%s_1' % f)
|
||||
|
||||
|
||||
def _make_item(title, track, artist=u'some artist'):
|
||||
|
|
@ -103,9 +105,12 @@ def _make_item(title, track, artist=u'some artist'):
|
|||
|
||||
def _make_trackinfo():
|
||||
return [
|
||||
TrackInfo(u'one', None, artist=u'some artist', length=1, index=1),
|
||||
TrackInfo(u'two', None, artist=u'some artist', length=1, index=2),
|
||||
TrackInfo(u'three', None, artist=u'some artist', length=1, index=3),
|
||||
TrackInfo(title=u'one', track_id=None, artist=u'some artist',
|
||||
length=1, index=1),
|
||||
TrackInfo(title=u'two', track_id=None, artist=u'some artist',
|
||||
length=1, index=2),
|
||||
TrackInfo(title=u'three', track_id=None, artist=u'some artist',
|
||||
length=1, index=3),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -345,9 +350,7 @@ class AlbumDistanceTest(_common.TestCase):
|
|||
artist=u'some artist',
|
||||
album=u'some album',
|
||||
tracks=_make_trackinfo(),
|
||||
va=False,
|
||||
album_id=None,
|
||||
artist_id=None,
|
||||
va=False
|
||||
)
|
||||
self.assertEqual(self._dist(items, info), 0)
|
||||
|
||||
|
|
@ -359,9 +362,7 @@ class AlbumDistanceTest(_common.TestCase):
|
|||
artist=u'some artist',
|
||||
album=u'some album',
|
||||
tracks=_make_trackinfo(),
|
||||
va=False,
|
||||
album_id=None,
|
||||
artist_id=None,
|
||||
va=False
|
||||
)
|
||||
dist = self._dist(items, info)
|
||||
self.assertNotEqual(dist, 0)
|
||||
|
|
@ -377,9 +378,7 @@ class AlbumDistanceTest(_common.TestCase):
|
|||
artist=u'someone else',
|
||||
album=u'some album',
|
||||
tracks=_make_trackinfo(),
|
||||
va=False,
|
||||
album_id=None,
|
||||
artist_id=None,
|
||||
va=False
|
||||
)
|
||||
self.assertNotEqual(self._dist(items, info), 0)
|
||||
|
||||
|
|
@ -392,9 +391,7 @@ class AlbumDistanceTest(_common.TestCase):
|
|||
artist=u'should be ignored',
|
||||
album=u'some album',
|
||||
tracks=_make_trackinfo(),
|
||||
va=True,
|
||||
album_id=None,
|
||||
artist_id=None,
|
||||
va=True
|
||||
)
|
||||
self.assertEqual(self._dist(items, info), 0)
|
||||
|
||||
|
|
@ -408,9 +405,7 @@ class AlbumDistanceTest(_common.TestCase):
|
|||
artist=u'should be ignored',
|
||||
album=u'some album',
|
||||
tracks=_make_trackinfo(),
|
||||
va=True,
|
||||
album_id=None,
|
||||
artist_id=None,
|
||||
va=True
|
||||
)
|
||||
info.tracks[0].artist = None
|
||||
info.tracks[1].artist = None
|
||||
|
|
@ -426,9 +421,7 @@ class AlbumDistanceTest(_common.TestCase):
|
|||
artist=u'some artist',
|
||||
album=u'some album',
|
||||
tracks=_make_trackinfo(),
|
||||
va=True,
|
||||
album_id=None,
|
||||
artist_id=None,
|
||||
va=True
|
||||
)
|
||||
self.assertNotEqual(self._dist(items, info), 0)
|
||||
|
||||
|
|
@ -441,9 +434,7 @@ class AlbumDistanceTest(_common.TestCase):
|
|||
artist=u'some artist',
|
||||
album=u'some album',
|
||||
tracks=_make_trackinfo(),
|
||||
va=False,
|
||||
album_id=None,
|
||||
artist_id=None,
|
||||
va=False
|
||||
)
|
||||
dist = self._dist(items, info)
|
||||
self.assertTrue(0 < dist < 0.2)
|
||||
|
|
@ -457,9 +448,7 @@ class AlbumDistanceTest(_common.TestCase):
|
|||
artist=u'some artist',
|
||||
album=u'some album',
|
||||
tracks=_make_trackinfo(),
|
||||
va=False,
|
||||
album_id=None,
|
||||
artist_id=None,
|
||||
va=False
|
||||
)
|
||||
info.tracks[0].medium_index = 1
|
||||
info.tracks[1].medium_index = 2
|
||||
|
|
@ -476,9 +465,7 @@ class AlbumDistanceTest(_common.TestCase):
|
|||
artist=u'some artist',
|
||||
album=u'some album',
|
||||
tracks=_make_trackinfo(),
|
||||
va=False,
|
||||
album_id=None,
|
||||
artist_id=None,
|
||||
va=False
|
||||
)
|
||||
info.tracks[0].medium_index = 1
|
||||
info.tracks[1].medium_index = 2
|
||||
|
|
@ -500,9 +487,9 @@ class AssignmentTest(unittest.TestCase):
|
|||
items.append(self.item(u'three', 2))
|
||||
items.append(self.item(u'two', 3))
|
||||
trackinfo = []
|
||||
trackinfo.append(TrackInfo(u'one', None))
|
||||
trackinfo.append(TrackInfo(u'two', None))
|
||||
trackinfo.append(TrackInfo(u'three', None))
|
||||
trackinfo.append(TrackInfo(title=u'one'))
|
||||
trackinfo.append(TrackInfo(title=u'two'))
|
||||
trackinfo.append(TrackInfo(title=u'three'))
|
||||
mapping, extra_items, extra_tracks = \
|
||||
match.assign_items(items, trackinfo)
|
||||
self.assertEqual(extra_items, [])
|
||||
|
|
@ -519,9 +506,9 @@ class AssignmentTest(unittest.TestCase):
|
|||
items.append(self.item(u'three', 1))
|
||||
items.append(self.item(u'two', 1))
|
||||
trackinfo = []
|
||||
trackinfo.append(TrackInfo(u'one', None))
|
||||
trackinfo.append(TrackInfo(u'two', None))
|
||||
trackinfo.append(TrackInfo(u'three', None))
|
||||
trackinfo.append(TrackInfo(title=u'one'))
|
||||
trackinfo.append(TrackInfo(title=u'two'))
|
||||
trackinfo.append(TrackInfo(title=u'three'))
|
||||
mapping, extra_items, extra_tracks = \
|
||||
match.assign_items(items, trackinfo)
|
||||
self.assertEqual(extra_items, [])
|
||||
|
|
@ -537,9 +524,9 @@ class AssignmentTest(unittest.TestCase):
|
|||
items.append(self.item(u'one', 1))
|
||||
items.append(self.item(u'three', 3))
|
||||
trackinfo = []
|
||||
trackinfo.append(TrackInfo(u'one', None))
|
||||
trackinfo.append(TrackInfo(u'two', None))
|
||||
trackinfo.append(TrackInfo(u'three', None))
|
||||
trackinfo.append(TrackInfo(title=u'one'))
|
||||
trackinfo.append(TrackInfo(title=u'two'))
|
||||
trackinfo.append(TrackInfo(title=u'three'))
|
||||
mapping, extra_items, extra_tracks = \
|
||||
match.assign_items(items, trackinfo)
|
||||
self.assertEqual(extra_items, [])
|
||||
|
|
@ -555,8 +542,8 @@ class AssignmentTest(unittest.TestCase):
|
|||
items.append(self.item(u'two', 2))
|
||||
items.append(self.item(u'three', 3))
|
||||
trackinfo = []
|
||||
trackinfo.append(TrackInfo(u'one', None))
|
||||
trackinfo.append(TrackInfo(u'three', None))
|
||||
trackinfo.append(TrackInfo(title=u'one'))
|
||||
trackinfo.append(TrackInfo(title=u'three'))
|
||||
mapping, extra_items, extra_tracks = \
|
||||
match.assign_items(items, trackinfo)
|
||||
self.assertEqual(extra_items, [items[1]])
|
||||
|
|
@ -592,7 +579,8 @@ class AssignmentTest(unittest.TestCase):
|
|||
items.append(item(12, 186.45916150485752))
|
||||
|
||||
def info(index, title, length):
|
||||
return TrackInfo(title, None, length=length, index=index)
|
||||
return TrackInfo(title=title, length=length,
|
||||
index=index)
|
||||
trackinfo = []
|
||||
trackinfo.append(info(1, u'Alone', 238.893))
|
||||
trackinfo.append(info(2, u'The Woman in You', 341.44))
|
||||
|
|
@ -635,8 +623,8 @@ class ApplyTest(_common.TestCase, ApplyTestUtil):
|
|||
self.items.append(Item({}))
|
||||
trackinfo = []
|
||||
trackinfo.append(TrackInfo(
|
||||
u'oneNew',
|
||||
u'dfa939ec-118c-4d0f-84a0-60f3d1e6522c',
|
||||
title=u'oneNew',
|
||||
track_id=u'dfa939ec-118c-4d0f-84a0-60f3d1e6522c',
|
||||
medium=1,
|
||||
medium_index=1,
|
||||
medium_total=1,
|
||||
|
|
@ -645,8 +633,8 @@ class ApplyTest(_common.TestCase, ApplyTestUtil):
|
|||
artist_sort='trackArtistSort',
|
||||
))
|
||||
trackinfo.append(TrackInfo(
|
||||
u'twoNew',
|
||||
u'40130ed1-a27c-42fd-a328-1ebefb6caef4',
|
||||
title=u'twoNew',
|
||||
track_id=u'40130ed1-a27c-42fd-a328-1ebefb6caef4',
|
||||
medium=2,
|
||||
medium_index=1,
|
||||
index=2,
|
||||
|
|
@ -746,13 +734,13 @@ class ApplyTest(_common.TestCase, ApplyTestUtil):
|
|||
self.assertEqual(self.items[1].albumtype, 'album')
|
||||
|
||||
def test_album_artist_overrides_empty_track_artist(self):
|
||||
my_info = copy.deepcopy(self.info)
|
||||
my_info = self.info.copy()
|
||||
self._apply(info=my_info)
|
||||
self.assertEqual(self.items[0].artist, 'artistNew')
|
||||
self.assertEqual(self.items[1].artist, 'artistNew')
|
||||
|
||||
def test_album_artist_overridden_by_nonempty_track_artist(self):
|
||||
my_info = copy.deepcopy(self.info)
|
||||
my_info = self.info.copy()
|
||||
my_info.tracks[0].artist = 'artist1!'
|
||||
my_info.tracks[1].artist = 'artist2!'
|
||||
self._apply(info=my_info)
|
||||
|
|
@ -774,7 +762,7 @@ class ApplyTest(_common.TestCase, ApplyTestUtil):
|
|||
self.assertEqual(self.items[1].artist_sort, 'albumArtistSort')
|
||||
|
||||
def test_full_date_applied(self):
|
||||
my_info = copy.deepcopy(self.info)
|
||||
my_info = self.info.copy()
|
||||
my_info.year = 2013
|
||||
my_info.month = 12
|
||||
my_info.day = 18
|
||||
|
|
@ -789,7 +777,7 @@ class ApplyTest(_common.TestCase, ApplyTestUtil):
|
|||
self.items.append(Item(year=1, month=2, day=3))
|
||||
self.items.append(Item(year=4, month=5, day=6))
|
||||
|
||||
my_info = copy.deepcopy(self.info)
|
||||
my_info = self.info.copy()
|
||||
my_info.year = 2013
|
||||
self._apply(info=my_info)
|
||||
|
||||
|
|
@ -809,7 +797,7 @@ class ApplyTest(_common.TestCase, ApplyTestUtil):
|
|||
self.assertEqual(self.items[0].day, 3)
|
||||
|
||||
def test_data_source_applied(self):
|
||||
my_info = copy.deepcopy(self.info)
|
||||
my_info = self.info.copy()
|
||||
my_info.data_source = 'MusicBrainz'
|
||||
self._apply(info=my_info)
|
||||
|
||||
|
|
@ -825,15 +813,15 @@ class ApplyCompilationTest(_common.TestCase, ApplyTestUtil):
|
|||
self.items.append(Item({}))
|
||||
trackinfo = []
|
||||
trackinfo.append(TrackInfo(
|
||||
u'oneNew',
|
||||
u'dfa939ec-118c-4d0f-84a0-60f3d1e6522c',
|
||||
title=u'oneNew',
|
||||
track_id=u'dfa939ec-118c-4d0f-84a0-60f3d1e6522c',
|
||||
artist=u'artistOneNew',
|
||||
artist_id=u'a05686fc-9db2-4c23-b99e-77f5db3e5282',
|
||||
index=1,
|
||||
))
|
||||
trackinfo.append(TrackInfo(
|
||||
u'twoNew',
|
||||
u'40130ed1-a27c-42fd-a328-1ebefb6caef4',
|
||||
title=u'twoNew',
|
||||
track_id=u'40130ed1-a27c-42fd-a328-1ebefb6caef4',
|
||||
artist=u'artistTwoNew',
|
||||
artist_id=u'80b3cf5e-18fe-4c59-98c7-e5bb87210710',
|
||||
index=2,
|
||||
|
|
@ -871,7 +859,7 @@ class ApplyCompilationTest(_common.TestCase, ApplyTestUtil):
|
|||
self.assertFalse(self.items[1].comp)
|
||||
|
||||
def test_va_flag_sets_comp(self):
|
||||
va_info = copy.deepcopy(self.info)
|
||||
va_info = self.info.copy()
|
||||
va_info.va = True
|
||||
self._apply(info=va_info)
|
||||
self.assertTrue(self.items[0].comp)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
import fnmatch
|
||||
import sys
|
||||
import re
|
||||
import os.path
|
||||
|
|
@ -121,6 +122,15 @@ class ImportConvertTest(unittest.TestCase, TestHelper):
|
|||
self.assertIsNotNone(item)
|
||||
self.assertTrue(os.path.isfile(item.path))
|
||||
|
||||
def test_delete_originals(self):
|
||||
self.config['convert']['delete_originals'] = True
|
||||
self.importer.run()
|
||||
for path in self.importer.paths:
|
||||
for root, dirnames, filenames in os.walk(path):
|
||||
self.assertTrue(len(fnmatch.filter(filenames, '*.mp3')) == 0,
|
||||
u'Non-empty import directory {0}'
|
||||
.format(util.displayable_path(path)))
|
||||
|
||||
|
||||
class ConvertCommand(object):
|
||||
"""A mixin providing a utility method to run the `convert`command
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ class ModelFixture1(dbcore.Model):
|
|||
_fields = {
|
||||
'id': dbcore.types.PRIMARY_ID,
|
||||
'field_one': dbcore.types.INTEGER,
|
||||
'field_two': dbcore.types.STRING,
|
||||
}
|
||||
_types = {
|
||||
'some_float_field': dbcore.types.FLOAT,
|
||||
|
|
@ -355,7 +356,7 @@ class ModelTest(unittest.TestCase):
|
|||
def test_items(self):
|
||||
model = ModelFixture1(self.db)
|
||||
model.id = 5
|
||||
self.assertEqual({('id', 5), ('field_one', 0)},
|
||||
self.assertEqual({('id', 5), ('field_one', 0), ('field_two', '')},
|
||||
set(model.items()))
|
||||
|
||||
def test_delete_internal_field(self):
|
||||
|
|
@ -370,10 +371,28 @@ class ModelTest(unittest.TestCase):
|
|||
|
||||
|
||||
class FormatTest(unittest.TestCase):
|
||||
def test_format_fixed_field(self):
|
||||
def test_format_fixed_field_integer(self):
|
||||
model = ModelFixture1()
|
||||
model.field_one = u'caf\xe9'
|
||||
model.field_one = 155
|
||||
value = model.formatted().get('field_one')
|
||||
self.assertEqual(value, u'155')
|
||||
|
||||
def test_format_fixed_field_integer_normalized(self):
|
||||
"""The normalize method of the Integer class rounds floats
|
||||
"""
|
||||
model = ModelFixture1()
|
||||
model.field_one = 142.432
|
||||
value = model.formatted().get('field_one')
|
||||
self.assertEqual(value, u'142')
|
||||
|
||||
model.field_one = 142.863
|
||||
value = model.formatted().get('field_one')
|
||||
self.assertEqual(value, u'143')
|
||||
|
||||
def test_format_fixed_field_string(self):
|
||||
model = ModelFixture1()
|
||||
model.field_two = u'caf\xe9'
|
||||
value = model.formatted().get('field_two')
|
||||
self.assertEqual(value, u'caf\xe9')
|
||||
|
||||
def test_format_flex_field(self):
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from test.helper import TestHelper
|
|||
import re # used to test csv format
|
||||
import json
|
||||
from xml.etree.ElementTree import Element
|
||||
import xml.etree.ElementTree as ET
|
||||
from xml.etree import ElementTree
|
||||
|
||||
|
||||
class ExportPluginTest(unittest.TestCase, TestHelper):
|
||||
|
|
@ -85,7 +85,7 @@ class ExportPluginTest(unittest.TestCase, TestHelper):
|
|||
format_type='xml',
|
||||
artist=item1.artist
|
||||
)
|
||||
library = ET.fromstring(out)
|
||||
library = ElementTree.fromstring(out)
|
||||
self.assertIsInstance(library, Element)
|
||||
for track in library[0]:
|
||||
for details in track:
|
||||
|
|
|
|||
|
|
@ -102,6 +102,25 @@ class MoveTest(_common.TestCase):
|
|||
self.i.move()
|
||||
self.assertEqual(self.i.path, old_path)
|
||||
|
||||
def test_move_file_with_colon(self):
|
||||
self.i.artist = u'C:DOS'
|
||||
self.i.move()
|
||||
self.assertIn('C_DOS', self.i.path.decode())
|
||||
|
||||
def test_move_file_with_multiple_colons(self):
|
||||
print(beets.config['replace'])
|
||||
self.i.artist = u'COM:DOS'
|
||||
self.i.move()
|
||||
self.assertIn('COM_DOS', self.i.path.decode())
|
||||
|
||||
def test_move_file_with_colon_alt_separator(self):
|
||||
old = beets.config['drive_sep_replace']
|
||||
beets.config["drive_sep_replace"] = '0'
|
||||
self.i.artist = u'C:DOS'
|
||||
self.i.move()
|
||||
self.assertIn('C0DOS', self.i.path.decode())
|
||||
beets.config["drive_sep_replace"] = old
|
||||
|
||||
def test_read_only_file_copied_writable(self):
|
||||
# Make the source file read-only.
|
||||
os.chmod(self.path, 0o444)
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ class AutotagStub(object):
|
|||
autotag.mb.album_for_id = self.mb_album_for_id
|
||||
autotag.mb.track_for_id = self.mb_track_for_id
|
||||
|
||||
def match_album(self, albumartist, album, tracks):
|
||||
def match_album(self, albumartist, album, tracks, extra_tags):
|
||||
if self.matching == self.IDENT:
|
||||
yield self._make_album_match(albumartist, album, tracks)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,28 +17,28 @@
|
|||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import itertools
|
||||
from io import open
|
||||
import os
|
||||
import re
|
||||
import six
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from mock import patch
|
||||
from test import _common
|
||||
import confuse
|
||||
from mock import MagicMock, patch
|
||||
|
||||
from beets import logging
|
||||
from beets.library import Item
|
||||
from beets.util import bytestring_path
|
||||
import confuse
|
||||
|
||||
from beetsplug import lyrics
|
||||
|
||||
from mock import MagicMock
|
||||
from test import _common
|
||||
|
||||
|
||||
log = logging.getLogger('beets.test_lyrics')
|
||||
raw_backend = lyrics.Backend({}, log)
|
||||
google = lyrics.Google(MagicMock(), log)
|
||||
genius = lyrics.Genius(MagicMock(), log)
|
||||
|
||||
|
||||
class LyricsPluginTest(unittest.TestCase):
|
||||
|
|
@ -94,6 +94,27 @@ class LyricsPluginTest(unittest.TestCase):
|
|||
self.assertEqual(('Alice and Bob', ['song']),
|
||||
list(lyrics.search_pairs(item))[0])
|
||||
|
||||
def test_search_artist_sort(self):
|
||||
item = Item(artist='CHVRCHΞS', title='song', artist_sort='CHVRCHES')
|
||||
self.assertIn(('CHVRCHΞS', ['song']),
|
||||
lyrics.search_pairs(item))
|
||||
self.assertIn(('CHVRCHES', ['song']),
|
||||
lyrics.search_pairs(item))
|
||||
|
||||
# Make sure that the original artist name is still the first entry
|
||||
self.assertEqual(('CHVRCHΞS', ['song']),
|
||||
list(lyrics.search_pairs(item))[0])
|
||||
|
||||
item = Item(artist='横山克', title='song', artist_sort='Masaru Yokoyama')
|
||||
self.assertIn(('横山克', ['song']),
|
||||
lyrics.search_pairs(item))
|
||||
self.assertIn(('Masaru Yokoyama', ['song']),
|
||||
lyrics.search_pairs(item))
|
||||
|
||||
# Make sure that the original artist name is still the first entry
|
||||
self.assertEqual(('横山克', ['song']),
|
||||
list(lyrics.search_pairs(item))[0])
|
||||
|
||||
def test_search_pairs_multi_titles(self):
|
||||
item = Item(title='1 / 2', artist='A')
|
||||
self.assertIn(('A', ['1 / 2']), lyrics.search_pairs(item))
|
||||
|
|
@ -209,7 +230,7 @@ class MockFetchUrl(object):
|
|||
def __call__(self, url, filename=None):
|
||||
self.fetched = url
|
||||
fn = url_to_filename(url)
|
||||
with open(fn, 'r') as f:
|
||||
with open(fn, 'r', encoding="utf8") as f:
|
||||
content = f.read()
|
||||
return content
|
||||
|
||||
|
|
@ -248,8 +269,8 @@ class LyricsPluginSourcesTest(LyricsGoogleBaseTest):
|
|||
|
||||
DEFAULT_SOURCES = [
|
||||
dict(DEFAULT_SONG, backend=lyrics.LyricsWiki),
|
||||
dict(artist=u'Santana', title=u'Black magic woman',
|
||||
backend=lyrics.MusiXmatch),
|
||||
# dict(artist=u'Santana', title=u'Black magic woman',
|
||||
# backend=lyrics.MusiXmatch),
|
||||
dict(DEFAULT_SONG, backend=lyrics.Genius),
|
||||
]
|
||||
|
||||
|
|
@ -263,9 +284,9 @@ class LyricsPluginSourcesTest(LyricsGoogleBaseTest):
|
|||
dict(DEFAULT_SONG,
|
||||
url=u'http://www.chartlyrics.com',
|
||||
path=u'/_LsLsZ7P4EK-F-LD4dJgDQ/Lady+Madonna.aspx'),
|
||||
dict(DEFAULT_SONG,
|
||||
url=u'http://www.elyricsworld.com',
|
||||
path=u'/lady_madonna_lyrics_beatles.html'),
|
||||
# dict(DEFAULT_SONG,
|
||||
# url=u'http://www.elyricsworld.com',
|
||||
# path=u'/lady_madonna_lyrics_beatles.html'),
|
||||
dict(url=u'http://www.lacoccinelle.net',
|
||||
artist=u'Jacques Brel', title=u"Amsterdam",
|
||||
path=u'/paroles-officielles/275679.html'),
|
||||
|
|
@ -282,11 +303,11 @@ class LyricsPluginSourcesTest(LyricsGoogleBaseTest):
|
|||
dict(url=u'http://www.lyricsontop.com',
|
||||
artist=u'Amy Winehouse', title=u"Jazz'n'blues",
|
||||
path=u'/amy-winehouse-songs/jazz-n-blues-lyrics.html'),
|
||||
dict(DEFAULT_SONG,
|
||||
url='http://www.metrolyrics.com/',
|
||||
path='lady-madonna-lyrics-beatles.html'),
|
||||
dict(url='http://www.musica.com/', path='letras.asp?letra=2738',
|
||||
artist=u'Santana', title=u'Black magic woman'),
|
||||
# dict(DEFAULT_SONG,
|
||||
# url='http://www.metrolyrics.com/',
|
||||
# path='lady-madonna-lyrics-beatles.html'),
|
||||
# dict(url='http://www.musica.com/', path='letras.asp?letra=2738',
|
||||
# artist=u'Santana', title=u'Black magic woman'),
|
||||
dict(url=u'http://www.paroles.net/',
|
||||
artist=u'Lilly Wood & the prick', title=u"Hey it's ok",
|
||||
path=u'lilly-wood-the-prick/paroles-hey-it-s-ok'),
|
||||
|
|
@ -302,23 +323,28 @@ class LyricsPluginSourcesTest(LyricsGoogleBaseTest):
|
|||
LyricsGoogleBaseTest.setUp(self)
|
||||
self.plugin = lyrics.LyricsPlugin()
|
||||
|
||||
@unittest.skipUnless(os.environ.get(
|
||||
'BEETS_TEST_LYRICS_SOURCES', '0') == '1',
|
||||
'lyrics sources testing not enabled')
|
||||
@unittest.skipUnless(
|
||||
os.environ.get('INTEGRATION_TEST', '0') == '1',
|
||||
'integration testing not enabled')
|
||||
def test_backend_sources_ok(self):
|
||||
"""Test default backends with songs known to exist in respective databases.
|
||||
"""
|
||||
errors = []
|
||||
for s in self.DEFAULT_SOURCES:
|
||||
# GitHub actions seems to be on a Cloudflare blacklist, so we can't
|
||||
# contact genius.
|
||||
sources = [s for s in self.DEFAULT_SOURCES if
|
||||
s['backend'] != lyrics.Genius or
|
||||
os.environ.get('GITHUB_ACTIONS') != 'true']
|
||||
for s in sources:
|
||||
res = s['backend'](self.plugin.config, self.plugin._log).fetch(
|
||||
s['artist'], s['title'])
|
||||
if not is_lyrics_content_ok(s['title'], res):
|
||||
errors.append(s['backend'].__name__)
|
||||
self.assertFalse(errors)
|
||||
|
||||
@unittest.skipUnless(os.environ.get(
|
||||
'BEETS_TEST_LYRICS_SOURCES', '0') == '1',
|
||||
'lyrics sources testing not enabled')
|
||||
@unittest.skipUnless(
|
||||
os.environ.get('INTEGRATION_TEST', '0') == '1',
|
||||
'integration testing not enabled')
|
||||
def test_google_sources_ok(self):
|
||||
"""Test if lyrics present on websites registered in beets google custom
|
||||
search engine are correctly scraped.
|
||||
|
|
@ -395,24 +421,133 @@ class LyricsGooglePluginMachineryTest(LyricsGoogleBaseTest):
|
|||
google.is_page_candidate(url, url_title, s['title'], u'Sunn O)))')
|
||||
|
||||
|
||||
# test Genius backend
|
||||
|
||||
class GeniusBaseTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up configuration."""
|
||||
try:
|
||||
__import__('bs4')
|
||||
except ImportError:
|
||||
self.skipTest('Beautiful Soup 4 not available')
|
||||
if sys.version_info[:3] < (2, 7, 3):
|
||||
self.skipTest("Python's built-in HTML parser is not good enough")
|
||||
|
||||
|
||||
class GeniusScrapeLyricsFromHtmlTest(GeniusBaseTest):
|
||||
"""tests Genius._scrape_lyrics_from_html()"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up configuration"""
|
||||
GeniusBaseTest.setUp(self)
|
||||
self.plugin = lyrics.LyricsPlugin()
|
||||
|
||||
def test_no_lyrics_div(self):
|
||||
"""Ensure we don't crash when the scraping the html for a genius page
|
||||
doesn't contain <div class="lyrics"></div>
|
||||
"""
|
||||
# https://github.com/beetbox/beets/issues/3535
|
||||
# expected return value None
|
||||
url = 'https://genius.com/sample'
|
||||
mock = MockFetchUrl()
|
||||
self.assertEqual(genius._scrape_lyrics_from_html(mock(url)), None)
|
||||
|
||||
def test_good_lyrics(self):
|
||||
"""Ensure we are able to scrape a page with lyrics"""
|
||||
url = 'https://genius.com/Wu-tang-clan-cream-lyrics'
|
||||
mock = MockFetchUrl()
|
||||
self.assertIsNotNone(genius._scrape_lyrics_from_html(mock(url)))
|
||||
|
||||
# TODO: find an example of a lyrics page with multiple divs and test it
|
||||
|
||||
|
||||
class GeniusFetchTest(GeniusBaseTest):
|
||||
"""tests Genius.fetch()"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up configuration"""
|
||||
GeniusBaseTest.setUp(self)
|
||||
self.plugin = lyrics.LyricsPlugin()
|
||||
|
||||
@patch.object(lyrics.Genius, '_scrape_lyrics_from_html')
|
||||
@patch.object(lyrics.Backend, 'fetch_url', return_value=True)
|
||||
def test_json(self, mock_fetch_url, mock_scrape):
|
||||
"""Ensure we're finding artist matches"""
|
||||
with patch.object(
|
||||
lyrics.Genius, '_search', return_value={
|
||||
"response": {
|
||||
"hits": [
|
||||
{
|
||||
"result": {
|
||||
"primary_artist": {
|
||||
"name": u"\u200Bblackbear",
|
||||
},
|
||||
"url": "blackbear_url"
|
||||
}
|
||||
},
|
||||
{
|
||||
"result": {
|
||||
"primary_artist": {
|
||||
"name": u"El\u002Dp"
|
||||
},
|
||||
"url": "El-p_url"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
) as mock_json:
|
||||
# genius uses zero-width-spaces (\u200B) for lowercase
|
||||
# artists so we make sure we can match those
|
||||
self.assertIsNotNone(genius.fetch('blackbear', 'Idfc'))
|
||||
mock_fetch_url.assert_called_once_with("blackbear_url")
|
||||
mock_scrape.assert_called_once_with(True)
|
||||
|
||||
# genius uses the hypen minus (\u002D) as their dash
|
||||
self.assertIsNotNone(genius.fetch('El-p', 'Idfc'))
|
||||
mock_fetch_url.assert_called_with('El-p_url')
|
||||
mock_scrape.assert_called_with(True)
|
||||
|
||||
# test no matching artist
|
||||
self.assertIsNone(genius.fetch('doesntexist', 'none'))
|
||||
|
||||
# test invalid json
|
||||
mock_json.return_value = None
|
||||
self.assertIsNone(genius.fetch('blackbear', 'Idfc'))
|
||||
|
||||
# TODO: add integration test hitting real api
|
||||
|
||||
|
||||
# test utilties
|
||||
|
||||
class SlugTests(unittest.TestCase):
|
||||
|
||||
def test_slug(self):
|
||||
# plain ascii passthrough
|
||||
text = u"test"
|
||||
self.assertEqual(lyrics.slug(text), 'test')
|
||||
|
||||
# german unicode and capitals
|
||||
text = u"Mørdag"
|
||||
self.assertEqual(lyrics.slug(text), 'mordag')
|
||||
|
||||
# more accents and quotes
|
||||
text = u"l'été c'est fait pour jouer"
|
||||
self.assertEqual(lyrics.slug(text), 'l-ete-c-est-fait-pour-jouer')
|
||||
|
||||
# accents, parens and spaces
|
||||
text = u"\xe7afe au lait (boisson)"
|
||||
self.assertEqual(lyrics.slug(text), 'cafe-au-lait-boisson')
|
||||
text = u"Multiple spaces -- and symbols! -- merged"
|
||||
self.assertEqual(lyrics.slug(text),
|
||||
'multiple-spaces-and-symbols-merged')
|
||||
text = u"\u200Bno-width-space"
|
||||
self.assertEqual(lyrics.slug(text), 'no-width-space')
|
||||
|
||||
# variations of dashes should get standardized
|
||||
dashes = [u'\u200D', u'\u2010']
|
||||
for dash1, dash2 in itertools.combinations(dashes, 2):
|
||||
self.assertEqual(lyrics.slug(dash1), lyrics.slug(dash2))
|
||||
|
||||
|
||||
def suite():
|
||||
|
|
|
|||
|
|
@ -62,10 +62,11 @@ class MPDStatsTest(unittest.TestCase, TestHelper):
|
|||
{'state': u'stop'}]
|
||||
EVENTS = [["player"]] * (len(STATUSES) - 1) + [KeyboardInterrupt]
|
||||
item_path = util.normpath('/foo/bar.flac')
|
||||
songid = 1
|
||||
|
||||
@patch("beetsplug.mpdstats.MPDClientWrapper", return_value=Mock(**{
|
||||
"events.side_effect": EVENTS, "status.side_effect": STATUSES,
|
||||
"currentsong.return_value": item_path}))
|
||||
"currentsong.return_value": (item_path, songid)}))
|
||||
def test_run_mpdstats(self, mpd_mock):
|
||||
item = Item(title=u'title', path=self.item_path, id=1)
|
||||
item.add(self.lib)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from test.helper import TestHelper
|
||||
|
||||
|
|
@ -34,6 +35,9 @@ class ParentWorkTest(unittest.TestCase, TestHelper):
|
|||
self.unload_plugins()
|
||||
self.teardown_beets()
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.environ.get('INTEGRATION_TEST', '0') == '1',
|
||||
'integration testing not enabled')
|
||||
def test_normal_case(self):
|
||||
item = Item(path='/file',
|
||||
mb_workid=u'e27bda6e-531e-36d3-9cd7-b8ebc18e8c53')
|
||||
|
|
@ -45,6 +49,9 @@ class ParentWorkTest(unittest.TestCase, TestHelper):
|
|||
self.assertEqual(item['mb_parentworkid'],
|
||||
u'32c8943f-1b27-3a23-8660-4567f4847c94')
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.environ.get('INTEGRATION_TEST', '0') == '1',
|
||||
'integration testing not enabled')
|
||||
def test_force(self):
|
||||
self.config['parentwork']['force'] = True
|
||||
item = Item(path='/file',
|
||||
|
|
@ -58,6 +65,9 @@ class ParentWorkTest(unittest.TestCase, TestHelper):
|
|||
self.assertEqual(item['mb_parentworkid'],
|
||||
u'32c8943f-1b27-3a23-8660-4567f4847c94')
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.environ.get('INTEGRATION_TEST', '0') == '1',
|
||||
'integration testing not enabled')
|
||||
def test_no_force(self):
|
||||
self.config['parentwork']['force'] = True
|
||||
item = Item(path='/file', mb_workid=u'e27bda6e-531e-36d3-9cd7-\
|
||||
|
|
@ -72,6 +82,9 @@ class ParentWorkTest(unittest.TestCase, TestHelper):
|
|||
# test different cases, still with Matthew Passion Ouverture or Mozart
|
||||
# requiem
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.environ.get('INTEGRATION_TEST', '0') == '1',
|
||||
'integration testing not enabled')
|
||||
def test_direct_parent_work(self):
|
||||
mb_workid = u'2e4a3668-458d-3b2a-8be2-0b08e0d8243a'
|
||||
self.assertEqual(u'f04b42df-7251-4d86-a5ee-67cfa49580d1',
|
||||
|
|
|
|||
|
|
@ -241,6 +241,8 @@ def implements(commands, expectedFailure=False): # noqa: N803
|
|||
|
||||
|
||||
bluelet_listener = bluelet.Listener
|
||||
|
||||
|
||||
@mock.patch("beets.util.bluelet.Listener")
|
||||
def start_server(args, assigned_port, listener_patch):
|
||||
"""Start the bpd server, writing the port to `assigned_port`.
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ class PlaylistQueryTestHelper(PlaylistTestHelper):
|
|||
]))
|
||||
|
||||
def test_name_query_with_nonexisting_playlist(self):
|
||||
q = u'playlist:nonexisting'.format(self.playlist_dir)
|
||||
q = u'playlist:nonexisting'
|
||||
results = self.lib.items(q)
|
||||
self.assertEqual(set(results), set())
|
||||
|
||||
|
|
|
|||
|
|
@ -93,7 +93,9 @@ class PlexUpdateTest(unittest.TestCase, TestHelper):
|
|||
self.config['plex']['host'],
|
||||
self.config['plex']['port'],
|
||||
self.config['plex']['token'],
|
||||
self.config['plex']['library_name'].get()), '2')
|
||||
self.config['plex']['library_name'].get(),
|
||||
self.config['plex']['secure'],
|
||||
self.config['plex']['ignore_cert_errors']), '2')
|
||||
|
||||
@responses.activate
|
||||
def test_get_named_music_section(self):
|
||||
|
|
@ -104,7 +106,9 @@ class PlexUpdateTest(unittest.TestCase, TestHelper):
|
|||
self.config['plex']['host'],
|
||||
self.config['plex']['port'],
|
||||
self.config['plex']['token'],
|
||||
'My Music Library'), '2')
|
||||
'My Music Library',
|
||||
self.config['plex']['secure'],
|
||||
self.config['plex']['ignore_cert_errors']), '2')
|
||||
|
||||
@responses.activate
|
||||
def test_update_plex(self):
|
||||
|
|
@ -117,7 +121,9 @@ class PlexUpdateTest(unittest.TestCase, TestHelper):
|
|||
self.config['plex']['host'],
|
||||
self.config['plex']['port'],
|
||||
self.config['plex']['token'],
|
||||
self.config['plex']['library_name'].get()).status_code, 200)
|
||||
self.config['plex']['library_name'].get(),
|
||||
self.config['plex']['secure'],
|
||||
self.config['plex']['ignore_cert_errors']).status_code, 200)
|
||||
|
||||
|
||||
def suite():
|
||||
|
|
|
|||
|
|
@ -772,6 +772,21 @@ class NoneQueryTest(unittest.TestCase, TestHelper):
|
|||
matched = self.lib.items(NoneQuery(u'rg_track_gain'))
|
||||
self.assertInResult(item, matched)
|
||||
|
||||
def test_match_slow(self):
|
||||
item = self.add_item()
|
||||
matched = self.lib.items(NoneQuery(u'rg_track_peak', fast=False))
|
||||
self.assertInResult(item, matched)
|
||||
|
||||
def test_match_slow_after_set_none(self):
|
||||
item = self.add_item(rg_track_gain=0)
|
||||
matched = self.lib.items(NoneQuery(u'rg_track_gain', fast=False))
|
||||
self.assertNotInResult(item, matched)
|
||||
|
||||
item['rg_track_gain'] = None
|
||||
item.store()
|
||||
matched = self.lib.items(NoneQuery(u'rg_track_gain', fast=False))
|
||||
self.assertInResult(item, matched)
|
||||
|
||||
|
||||
class NotQueryMatchTest(_common.TestCase):
|
||||
"""Test `query.NotQuery` matching against a single item, using the same
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ def reset_replaygain(item):
|
|||
|
||||
|
||||
class ReplayGainCliTestBase(TestHelper):
|
||||
|
||||
def setUp(self):
|
||||
self.setup_beets()
|
||||
self.config['replaygain']['backend'] = self.backend
|
||||
|
|
@ -150,7 +151,9 @@ class ReplayGainCliTestBase(TestHelper):
|
|||
self.assertEqual(max(gains), min(gains))
|
||||
|
||||
self.assertNotEqual(max(gains), 0.0)
|
||||
self.assertNotEqual(max(peaks), 0.0)
|
||||
if not self.backend == "bs1770gain":
|
||||
# Actually produces peaks == 0.0 ~ self.add_album_fixture
|
||||
self.assertNotEqual(max(peaks), 0.0)
|
||||
|
||||
def test_cli_writes_only_r128_tags(self):
|
||||
if self.backend == "command":
|
||||
|
|
@ -227,7 +230,9 @@ class ReplayGainLdnsCliMalformedTest(TestHelper, unittest.TestCase):
|
|||
|
||||
# Patch call to return nothing, bypassing the bs1770gain installation
|
||||
# check.
|
||||
call_patch.return_value = CommandOutput(stdout=b"", stderr=b"")
|
||||
call_patch.return_value = CommandOutput(
|
||||
stdout=b'bs1770gain 0.0.0, ', stderr=b''
|
||||
)
|
||||
try:
|
||||
self.load_plugins('replaygain')
|
||||
except Exception:
|
||||
|
|
@ -249,7 +254,7 @@ class ReplayGainLdnsCliMalformedTest(TestHelper, unittest.TestCase):
|
|||
@patch('beetsplug.replaygain.call')
|
||||
def test_malformed_output(self, call_patch):
|
||||
# Return malformed XML (the ampersand should be &)
|
||||
call_patch.return_value = CommandOutput(stdout="""
|
||||
call_patch.return_value = CommandOutput(stdout=b"""
|
||||
<album>
|
||||
<track total="1" number="1" file="&">
|
||||
<integrated lufs="0" lu="0" />
|
||||
|
|
|
|||
111
test/test_subsonic.py
Normal file
111
test/test_subsonic.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""Tests for the 'subsonic' plugin"""
|
||||
|
||||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
import requests
|
||||
import responses
|
||||
import unittest
|
||||
|
||||
from test import _common
|
||||
from beets import config
|
||||
from beetsplug import subsonicupdate
|
||||
from test.helper import TestHelper
|
||||
from six.moves.urllib.parse import parse_qs, urlparse
|
||||
|
||||
|
||||
class ArgumentsMock(object):
|
||||
def __init__(self, mode, show_failures):
|
||||
self.mode = mode
|
||||
self.show_failures = show_failures
|
||||
self.verbose = 1
|
||||
|
||||
|
||||
def _params(url):
|
||||
"""Get the query parameters from a URL."""
|
||||
return parse_qs(urlparse(url).query)
|
||||
|
||||
|
||||
class SubsonicPluginTest(_common.TestCase, TestHelper):
|
||||
@responses.activate
|
||||
def setUp(self):
|
||||
config.clear()
|
||||
self.setup_beets()
|
||||
|
||||
config["subsonic"]["user"] = "admin"
|
||||
config["subsonic"]["pass"] = "admin"
|
||||
config["subsonic"]["url"] = "http://localhost:4040"
|
||||
|
||||
self.subsonicupdate = subsonicupdate.SubsonicUpdate()
|
||||
|
||||
def tearDown(self):
|
||||
self.teardown_beets()
|
||||
|
||||
@responses.activate
|
||||
def test_start_scan(self):
|
||||
responses.add(
|
||||
responses.POST,
|
||||
'http://localhost:4040/rest/startScan',
|
||||
status=200
|
||||
)
|
||||
|
||||
self.subsonicupdate.start_scan()
|
||||
|
||||
@responses.activate
|
||||
def test_url_with_extra_forward_slash_url(self):
|
||||
config["subsonic"]["url"] = "http://localhost:4040/contextPath"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
'http://localhost:4040/contextPath/rest/startScan',
|
||||
status=200
|
||||
)
|
||||
|
||||
self.subsonicupdate.start_scan()
|
||||
|
||||
@responses.activate
|
||||
def test_url_with_context_path(self):
|
||||
config["subsonic"]["url"] = "http://localhost:4040/"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
'http://localhost:4040/rest/startScan',
|
||||
status=200
|
||||
)
|
||||
|
||||
self.subsonicupdate.start_scan()
|
||||
|
||||
@responses.activate
|
||||
def test_url_with_missing_port(self):
|
||||
config["subsonic"]["url"] = "http://localhost/airsonic"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
'http://localhost:4040/rest/startScan',
|
||||
status=200
|
||||
)
|
||||
|
||||
with self.assertRaises(requests.exceptions.ConnectionError):
|
||||
self.subsonicupdate.start_scan()
|
||||
|
||||
@responses.activate
|
||||
def test_url_with_missing_schema(self):
|
||||
config["subsonic"]["url"] = "localhost:4040/airsonic"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
'http://localhost:4040/rest/startScan',
|
||||
status=200
|
||||
)
|
||||
|
||||
with self.assertRaises(requests.exceptions.InvalidSchema):
|
||||
self.subsonicupdate.start_scan()
|
||||
|
||||
|
||||
def suite():
|
||||
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(defaultTest='suite')
|
||||
|
|
@ -36,6 +36,8 @@ class ThePluginTest(_common.TestCase):
|
|||
u'A Thing, An')
|
||||
self.assertEqual(ThePlugin().unthe(u'the An Arse', PATTERN_A),
|
||||
u'the An Arse')
|
||||
self.assertEqual(ThePlugin().unthe(u'TET - Travailleur', PATTERN_THE),
|
||||
u'TET - Travailleur')
|
||||
|
||||
def test_unthe_with_strip(self):
|
||||
config['the']['strip'] = True
|
||||
|
|
|
|||
|
|
@ -284,6 +284,15 @@ class ThumbnailsTest(unittest.TestCase, TestHelper):
|
|||
u'file:///music/%EC%8B%B8%EC%9D%B4')
|
||||
|
||||
|
||||
class TestPathlibURI():
|
||||
"""Test PathlibURI class"""
|
||||
def test_uri(self):
|
||||
test_uri = PathlibURI()
|
||||
|
||||
# test it won't break if we pass it bytes for a path
|
||||
test_uri.uri(b'/')
|
||||
|
||||
|
||||
def suite():
|
||||
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import shutil
|
|||
import re
|
||||
import subprocess
|
||||
import platform
|
||||
from copy import deepcopy
|
||||
import six
|
||||
import unittest
|
||||
|
||||
|
|
@ -1051,8 +1050,10 @@ class ShowChangeTest(_common.TestCase):
|
|||
self.items[0].track = 1
|
||||
self.items[0].path = b'/path/to/file.mp3'
|
||||
self.info = autotag.AlbumInfo(
|
||||
u'the album', u'album id', u'the artist', u'artist id', [
|
||||
autotag.TrackInfo(u'the title', u'track id', index=1)
|
||||
album=u'the album', album_id=u'album id', artist=u'the artist',
|
||||
artist_id=u'artist id', tracks=[
|
||||
autotag.TrackInfo(title=u'the title', track_id=u'track id',
|
||||
index=1)
|
||||
]
|
||||
)
|
||||
|
||||
|
|
@ -1136,7 +1137,9 @@ class SummarizeItemsTest(_common.TestCase):
|
|||
summary = commands.summarize_items([self.item], False)
|
||||
self.assertEqual(summary, u"1 items, F, 4kbps, 10:54, 987.0 B")
|
||||
|
||||
i2 = deepcopy(self.item)
|
||||
# make a copy of self.item
|
||||
i2 = self.item.copy()
|
||||
|
||||
summary = commands.summarize_items([self.item, i2], False)
|
||||
self.assertEqual(summary, u"2 items, F, 4kbps, 21:48, 1.9 KiB")
|
||||
|
||||
|
|
|
|||
54
tox.ini
54
tox.ini
|
|
@ -4,57 +4,31 @@
|
|||
# and then run "tox" from this directory.
|
||||
|
||||
[tox]
|
||||
envlist = py27-test, py37-test, py27-flake8, docs
|
||||
|
||||
# The exhaustive list of environments is:
|
||||
# envlist = py{27,34,35}-{test,cov}, py{27,34,35}-flake8, docs
|
||||
envlist = py27-test, py38-{cov,lint}, docs
|
||||
|
||||
[_test]
|
||||
deps =
|
||||
beautifulsoup4
|
||||
flask
|
||||
mock
|
||||
nose
|
||||
nose-show-skipped
|
||||
pylast
|
||||
rarfile
|
||||
responses>=0.3.0
|
||||
pyxdg
|
||||
jellyfish
|
||||
python-mpd2
|
||||
coverage
|
||||
discogs-client
|
||||
requests_oauthlib
|
||||
deps = .[test]
|
||||
|
||||
[_flake8]
|
||||
deps =
|
||||
flake8
|
||||
flake8-coding
|
||||
flake8-future-import
|
||||
flake8-blind-except
|
||||
pep8-naming~=0.7.0
|
||||
[_lint]
|
||||
deps = .[lint]
|
||||
files = beets beetsplug beet test setup.py docs
|
||||
|
||||
[testenv]
|
||||
passenv =
|
||||
NOSE_SHOW_SKIPPED # Undocumented feature of nose-show-skipped.
|
||||
deps =
|
||||
{test,cov}: {[_test]deps}
|
||||
py27: pathlib
|
||||
py{27,34,35,36,37,38}-flake8: {[_flake8]deps}
|
||||
lint: {[_lint]deps}
|
||||
commands =
|
||||
py27-cov: python -m nose --with-coverage {posargs}
|
||||
py27-test: python -m nose {posargs}
|
||||
py3{4,5,6,7,8}-cov: python -bb -m nose --with-coverage {posargs}
|
||||
py3{4,5,6,7,8}-test: python -bb -m nose {posargs}
|
||||
py27-flake8: flake8 --min-version 2.7 {posargs} {[_flake8]files}
|
||||
py34-flake8: flake8 --min-version 3.4 {posargs} {[_flake8]files}
|
||||
py35-flake8: flake8 --min-version 3.5 {posargs} {[_flake8]files}
|
||||
py36-flake8: flake8 --min-version 3.6 {posargs} {[_flake8]files}
|
||||
py37-flake8: flake8 --min-version 3.7 {posargs} {[_flake8]files}
|
||||
py38-flake8: flake8 --min-version 3.8 {posargs} {[_flake8]files}
|
||||
test: python -bb -m pytest {posargs}
|
||||
cov: coverage run -m pytest {posargs}
|
||||
lint: python -m flake8 {posargs} {[_lint]files}
|
||||
|
||||
[testenv:docs]
|
||||
basepython = python2.7
|
||||
deps = sphinx
|
||||
commands = sphinx-build -W -q -b html docs {envtmpdir}/html {posargs}
|
||||
|
||||
[testenv:int]
|
||||
deps = {[_test]deps}
|
||||
setenv = INTEGRATION_TEST = 1
|
||||
passenv = GITHUB_ACTIONS
|
||||
commands = python -bb -m pytest {posargs}
|
||||
|
|
|
|||
Loading…
Reference in a new issue