Merge branch 'master' into usertag

This commit is contained in:
Šarūnas Nejus 2026-01-13 13:38:56 +00:00 committed by GitHub
commit 0efce4a86b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
388 changed files with 36864 additions and 26891 deletions

View file

@ -43,3 +43,47 @@ a6e5201ff3fad4c69bf24d17bace2ef744b9f51b
f36bc497c8c8f89004f3f6879908d3f0b25123e1
# Remove some lint exclusions and fix the issues
5f78d1b82b2292d5ce0c99623ba0ec444b80d24c
# 2025
# Fix formatting
c490ac5810b70f3cf5fd8649669838e8fdb19f4d
# Importer restructure
9147577b2b19f43ca827e9650261a86fb0450cef
# Move functionality under MusicBrainz plugin
529aaac7dced71266c6d69866748a7d044ec20ff
# musicbrainz: reorder methods
5dc6f45110b99f0cc8dbb94251f9b1f6d69583fa
# Copy paste query, types from library to dbcore
1a045c91668c771686f4c871c84f1680af2e944b
# Library restructure (split library.py into multiple modules)
0ad4e19d4f870db757373f44d12ff3be2441363a
# Docs: fix linting issues
769dcdc88a1263638ae25944ba6b2be3e8933666
# Reformat all docs using docstrfmt
ab5acaabb3cd24c482adb7fa4800c89fd6a2f08d
# Replace format calls with f-strings
4a361bd501e85de12c91c2474c423559ca672852
# Replace percent formatting
9352a79e4108bd67f7e40b1e944c01e0a7353272
# Replace string concatenation (' + ')
1c16b2b3087e9c3635d68d41c9541c4319d0bdbe
# Do not use backslashes to deal with long strings
2fccf64efe82851861e195b521b14680b480a42a
# Do not use explicit indices for logging args when not needed
d93ddf8dd43e4f9ed072a03829e287c78d2570a2
# Moved dev docs
07549ed896d9649562d40b75cd30702e6fa6e975
# Moved plugin docs Further Reading chapter
33f1a5d0bef8ca08be79ee7a0d02a018d502680d
# Moved art.py utility module from beets into beetsplug
28aee0fde463f1e18dfdba1994e2bdb80833722f
# Refactor `ui/commands.py` into multiple modules
59c93e70139f70e9fd1c6f3c1bceb005945bec33
# Moved ui.commands._utils into ui.commands.utils
25ae330044abf04045e3f378f72bbaed739fb30d
# Refactor test_ui_command.py into multiple modules
a59e41a88365e414db3282658d2aa456e0b3468a
# pyupgrade Python 3.10
301637a1609831947cb5dd90270ed46c24b1ab1b
# Fix changelog formatting
658b184c59388635787b447983ecd3a575f4fe56

7
.github/CODEOWNERS vendored Normal file
View file

@ -0,0 +1,7 @@
# assign the entire repo to the maintainers team
* @beetbox/maintainers
# Specific ownerships:
/beets/metadata_plugins.py @semohr
/beetsplug/titlecase.py @henry-oberholtzer
/beetsplug/mbpseudo.py @asardaes

View file

@ -0,0 +1,16 @@
{
"problemMatcher": [
{
"owner": "sphinx-build",
"severity": "error",
"pattern": [
{
"regexp": "^(/[^:]+):((\\d+):)?(\\sWARNING:)?\\s*(.+)$",
"file": 1,
"line": 3,
"message": 5
}
]
}
]
}

View file

@ -0,0 +1,17 @@
{
"problemMatcher": [
{
"owner": "sphinx-lint",
"severity": "error",
"pattern": [
{
"regexp": "^([^:]+):(\\d+):\\s+(.*)\\s\\(([a-z-]+)\\)$",
"file": 1,
"line": 2,
"message": 3,
"code": 4
}
]
}
]
}

View file

@ -1,15 +0,0 @@
{
"problemMatcher": [
{
"owner": "sphinx",
"pattern": [
{
"regexp": "^([^:]+):(\\d+): (WARNING: )?(.+)$",
"file": 1,
"line": 2,
"message": 4
}
]
}
]
}

View file

@ -1,6 +1,6 @@
name: Verify changelog updated
on:
on:
pull_request_target:
types:
- opened
@ -10,24 +10,24 @@ jobs:
check_changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Get all updated Python files
id: changed-python-files
uses: tj-actions/changed-files@v44
uses: tj-actions/changed-files@v46
with:
files: |
**.py
- name: Check for the changelog update
id: changelog-update
uses: tj-actions/changed-files@v44
uses: tj-actions/changed-files@v46
with:
files: docs/changelog.rst
- name: Comment under the PR with a reminder
if: steps.changed-python-files.outputs.any_changed == 'true' && steps.changelog-update.outputs.any_changed == 'false'
uses: thollander/actions-comment-pull-request@v2
with:
message: 'Thank you for the PR! The changelog has not been updated, so here is a friendly reminder to check if you need to add an entry.'
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
message: 'Thank you for the PR! The changelog has not been updated, so here is a friendly reminder to check if you need to add an entry.'
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'

View file

@ -4,6 +4,12 @@ on:
push:
branches:
- master
concurrency:
# Cancel previous workflow run when a new commit is pushed to a feature branch
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
PY_COLORS: 1
@ -14,17 +20,17 @@ jobs:
fail-fast: false
matrix:
platform: [ubuntu-latest, windows-latest]
python-version: ["3.9", "3.10", "3.11", "3.12"]
python-version: ["3.10", "3.11", "3.12", "3.13"]
runs-on: ${{ matrix.platform }}
env:
IS_MAIN_PYTHON: ${{ matrix.python-version == '3.9' && matrix.platform == 'ubuntu-latest' }}
IS_MAIN_PYTHON: ${{ matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest' }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v0.1.1
uses: BrandonLWhite/pipx-install-action@v1.0.3
- name: Setup Python with poetry caching
# poetry cache requires poetry to already be installed, weirdly
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: poetry
@ -33,11 +39,19 @@ jobs:
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt update
sudo apt install ffmpeg gobject-introspection libcairo2-dev libgirepository1.0-dev pandoc
sudo apt install --yes --no-install-recommends \
ffmpeg \
gobject-introspection \
gstreamer1.0-plugins-base \
python3-gst-1.0 \
libcairo2-dev \
libgirepository-2.0-dev \
pandoc \
imagemagick
- name: Get changed lyrics files
id: lyrics-update
uses: tj-actions/changed-files@v45
uses: tj-actions/changed-files@v46
with:
files: |
beetsplug/lyrics.py
@ -52,7 +66,7 @@ jobs:
- if: ${{ env.IS_MAIN_PYTHON != 'true' }}
name: Test without coverage
run: |
poetry install --extras=autobpm --extras=lyrics
poetry install --without=lint --extras=autobpm --extras=lyrics --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate
poe test
- if: ${{ env.IS_MAIN_PYTHON == 'true' }}
@ -60,10 +74,16 @@ jobs:
env:
LYRICS_UPDATED: ${{ steps.lyrics-update.outputs.any_changed }}
run: |
poetry install --extras=autobpm --extras=lyrics --extras=docs --extras=replaygain --extras=reflink
poetry install --extras=autobpm --extras=lyrics --extras=docs --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate
poe docs
poe test-with-coverage
- if: ${{ !cancelled() }}
name: Upload test results to Codecov
uses: codecov/test-results-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
- if: ${{ env.IS_MAIN_PYTHON == 'true' }}
name: Store the coverage report
uses: actions/upload-artifact@v4
@ -78,15 +98,15 @@ jobs:
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Get the coverage report
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: coverage-report
- name: Upload code coverage
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
files: ./coverage.xml
use_oidc: ${{ !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) }}

View file

@ -3,16 +3,20 @@ on:
workflow_dispatch:
schedule:
- cron: "0 0 * * SUN" # run every Sunday at midnight
env:
PYTHON_VERSION: "3.10"
jobs:
test_integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v0.1.1
- uses: actions/setup-python@v5
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v6
with:
python-version: 3.8
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
- name: Install dependencies

View file

@ -6,8 +6,13 @@ on:
branches:
- master
concurrency:
# Cancel previous workflow run when a new commit is pushed to a feature branch
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
PYTHON_VERSION: 3.9
PYTHON_VERSION: "3.10"
jobs:
changed-files:
@ -19,16 +24,16 @@ jobs:
changed_doc_files: ${{ steps.changed-doc-files.outputs.all_changed_files }}
changed_python_files: ${{ steps.changed-python-files.outputs.all_changed_files }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Get changed docs files
id: changed-doc-files
uses: tj-actions/changed-files@v44
uses: tj-actions/changed-files@v46
with:
files: |
docs/**
- name: Get changed python files
id: raw-changed-python-files
uses: tj-actions/changed-files@v44
uses: tj-actions/changed-files@v46
with:
files: |
**.py
@ -51,10 +56,10 @@ jobs:
name: Check formatting
needs: changed-files
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v0.1.1
- uses: actions/setup-python@v5
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -72,10 +77,10 @@ jobs:
name: Check linting
needs: changed-files
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v0.1.1
- uses: actions/setup-python@v5
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -92,10 +97,10 @@ jobs:
name: Check types with mypy
needs: changed-files
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v0.1.1
- uses: actions/setup-python@v5
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -105,10 +110,9 @@ jobs:
- name: Type check code
uses: liskin/gh-problem-matcher-wrap@v3
continue-on-error: true
with:
linters: mypy
run: poe check-types --show-column-numbers --no-error-summary ${{ needs.changed-files.outputs.changed_python_files }}
run: poe check-types --show-column-numbers --no-error-summary .
docs:
if: needs.changed-files.outputs.any_docs_changed == 'true'
@ -116,10 +120,10 @@ jobs:
name: Check docs
needs: changed-files
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v0.1.1
- uses: actions/setup-python@v5
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -127,11 +131,16 @@ jobs:
- name: Install dependencies
run: poetry install --extras=docs
- name: Add Sphinx problem matcher
run: echo "::add-matcher::.github/sphinx-problem-matcher.json"
- name: Add Sphinx problem matchers
run: |
echo "::add-matcher::.github/problem-matchers/sphinx-build.json"
echo "::add-matcher::.github/problem-matchers/sphinx-lint.json"
- name: Check docs formatting
run: poe format-docs --check
- name: Lint docs
run: poe lint-docs
- name: Build docs
run: |-
poe docs |& tee /tmp/output
# fail the job if there are issues
grep -q " WARNING:" /tmp/output && exit 1 || exit 0
run: poe docs -- -e 'SPHINXOPTS=--fail-on-warning --keep-going'

View file

@ -8,7 +8,7 @@ on:
required: true
env:
PYTHON_VERSION: 3.8
PYTHON_VERSION: "3.10"
NEW_VERSION: ${{ inputs.version }}
NEW_TAG: v${{ inputs.version }}
@ -17,16 +17,16 @@ jobs:
name: Bump version, commit and create tag
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v0.1.1
- uses: actions/setup-python@v5
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
- name: Install dependencies
run: poetry install --only=release
run: poetry install --with=release --extras=docs
- name: Bump project version
run: poe bump "${{ env.NEW_VERSION }}"
@ -45,13 +45,13 @@ jobs:
outputs:
changelog: ${{ steps.generate_changelog.outputs.changelog }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
ref: ${{ env.NEW_TAG }}
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v0.1.1
- uses: actions/setup-python@v5
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -92,7 +92,7 @@ jobs:
id-token: write
steps:
- name: Download all the dists
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: python-package-distributions
path: dist/
@ -107,7 +107,7 @@ jobs:
CHANGELOG: ${{ needs.build.outputs.changelog }}
steps:
- name: Download all the dists
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: python-package-distributions
path: dist/

3
.gitignore vendored
View file

@ -94,3 +94,6 @@ ENV/
# pyright
pyrightconfig.json
# Pyrefly
pyrefly.toml

View file

@ -2,7 +2,17 @@
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.1
- repo: local
hooks:
- id: ruff-format
- id: format
name: Format Python files
entry: poe format
language: system
files: '.*.py'
pass_filenames: true
- id: format-docs
name: Format docs
entry: poe format-docs
language: system
files: '.*.rst'
pass_filenames: true

View file

@ -1,9 +1,8 @@
####################################
Contributor Covenant Code of Conduct
####################################
====================================
Our Pledge
==========
----------
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
@ -16,7 +15,7 @@ We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
Our Standards
=============
-------------
Examples of behavior that contributes to a positive environment for our
community include:
@ -41,7 +40,7 @@ Examples of unacceptable behavior include:
professional setting
Enforcement Responsibilities
============================
----------------------------
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
@ -54,7 +53,7 @@ not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
Scope
=====
-----
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
@ -63,7 +62,7 @@ posting via an official social media account, or acting as an appointed
representative at an online or offline event.
Enforcement
===========
-----------
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at here on Github.
@ -73,13 +72,13 @@ All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
Enforcement Guidelines
======================
----------------------
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
1. Correction
-------------
~~~~~~~~~~~~~
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
@ -89,7 +88,7 @@ clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
2. Warning
----------
~~~~~~~~~~
**Community Impact**: A violation through a single incident or series of
actions.
@ -102,7 +101,7 @@ like social media. Violating these terms may lead to a temporary or permanent
ban.
3. Temporary Ban
----------------
~~~~~~~~~~~~~~~~
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
@ -114,7 +113,7 @@ with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
4. Permanent Ban
----------------
~~~~~~~~~~~~~~~~
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
@ -124,7 +123,7 @@ individual, or aggression toward or disparagement of classes of individuals.
community.
Attribution
===========
-----------
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available `here
@ -136,4 +135,3 @@ enforcement ladder.
For answers to common questions about this code of conduct, see the `FAQ
<https://www.contributor-covenant.org/faq>`_. Translations are available at
`translations <https://www.contributor-covenant.org/translations>`_.

View file

@ -1,102 +1,106 @@
############
Contributing
############
============
.. contents::
:depth: 3
Thank you!
==========
----------
First off, thank you for considering contributing to beets! Its people
like you that make beets continue to succeed.
First off, thank you for considering contributing to beets! Its people like you
that make beets continue to succeed.
These guidelines describe how you can help most effectively. By
following these guidelines, you can make life easier for the development
team as it indicates you respect the maintainers time; in return, the
maintainers will reciprocate by helping to address your issue, review
changes, and finalize pull requests.
These guidelines describe how you can help most effectively. By following these
guidelines, you can make life easier for the development team as it indicates
you respect the maintainers time; in return, the maintainers will reciprocate
by helping to address your issue, review changes, and finalize pull requests.
Types of Contributions
======================
----------------------
We love to get contributions from our community—you! There are many ways
to contribute, whether youre a programmer or not.
We love to get contributions from our community—you! There are many ways to
contribute, whether youre a programmer or not.
The first thing to do, regardless of how you'd like to contribute to the
project, is to check out our :doc:`Code of Conduct <code_of_conduct>` and to
keep that in mind while interacting with other contributors and users.
Non-Programming
---------------
~~~~~~~~~~~~~~~
- Promote beets! Help get the word out by telling your friends, writing
a blog post, or discussing it on a forum you frequent.
- Improve the `documentation`_. Its
incredibly easy to contribute here: just find a page you want to
modify and hit the “Edit on GitHub” button in the upper-right. You
can automatically send us a pull request for your changes.
- GUI design. For the time being, beets is a command-line-only affair.
But thats mostly because we dont have any great ideas for what a
good GUI should look like. If you have those great ideas, please get
in touch.
- Benchmarks. Wed like to have a consistent way of measuring speed
improvements in beets tagger and other functionality as well as a
way of comparing beets performance to other tools. You can help by
compiling a library of freely-licensed music files (preferably with
incorrect metadata) for testing and measurement.
- Think you have a nice config or cool use-case for beets? Wed love to
hear about it! Submit a post to our `discussion board
<https://github.com/beetbox/beets/discussions/categories/show-and-tell>`__
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 fellow users by by `responding to support requests
<https://github.com/beetbox/beets/discussions/categories/q-a>`__ .
- Promote beets! Help get the word out by telling your friends, writing a blog
post, or discussing it on a forum you frequent.
- Improve the documentation_. Its incredibly easy to contribute here: just find
a page you want to modify and hit the “Edit on GitHub” button in the
upper-right. You can automatically send us a pull request for your changes.
- GUI design. For the time being, beets is a command-line-only affair. But
thats mostly because we dont have any great ideas for what a good GUI should
look like. If you have those great ideas, please get in touch.
- Benchmarks. Wed like to have a consistent way of measuring speed improvements
in beets tagger and other functionality as well as a way of comparing beets
performance to other tools. You can help by compiling a library of
freely-licensed music files (preferably with incorrect metadata) for testing
and measurement.
- Think you have a nice config or cool use-case for beets? Wed love to hear
about it! Submit a post to our `discussion board
<https://github.com/beetbox/beets/discussions/categories/show-and-tell>`__
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 fellow users by by `responding to support requests
<https://github.com/beetbox/beets/discussions/categories/q-a>`__ .
Programming
-----------
~~~~~~~~~~~
- As a programmer (even if youre just a beginner!), you have a ton of
opportunities to get your feet wet with beets.
- For developing plugins, or hacking away at beets, theres some good
information in the `“For Developers” section of the
docs <https://beets.readthedocs.io/en/stable/dev/>`__.
- As a programmer (even if youre just a beginner!), you have a ton of
opportunities to get your feet wet with beets.
- For developing plugins, or hacking away at beets, theres some good
information in the `“For Developers” section of the docs
<https://beets.readthedocs.io/en/stable/dev/>`__.
.. _development-tools:
Development Tools
^^^^^^^^^^^^^^^^^
+++++++++++++++++
In order to develop beets, you will need a few tools installed:
- `poetry`_ for packaging, virtual environment and dependency management
- `poethepoet`_ to run tasks, such as linting, formatting, testing
- poetry_ for packaging, virtual environment and dependency management
- poethepoet_ to run tasks, such as linting, formatting, testing
Python community recommends using `pipx`_ to install stand-alone command-line
applications such as above. `pipx`_ installs each application in an isolated
Python community recommends using pipx_ to install stand-alone command-line
applications such as above. pipx_ installs each application in an isolated
virtual environment, where its dependencies will not interfere with your system
and other CLI tools.
If you do not have `pipx`_ installed in your system, follow `pipx-installation-instructions`_ or
If you do not have pipx_ installed in your system, follow `pipx installation
instructions <https://pipx.pypa.io/stable/installation/>`__ or
.. code-block:: sh
$ python3 -m pip install --user pipx
Install `poetry`_ and `poethepoet`_ using `pipx`_::
Install poetry_ and poethepoet_ using pipx_:
::
$ pipx install poetry poethepoet
.. _pipx: https://pipx.pypa.io/stable
.. _pipx-installation-instructions: https://pipx.pypa.io/stable/installation/
.. admonition:: Check ``tool.pipx-install`` section in ``pyproject.toml`` to see supported versions
.. code-block:: toml
[tool.pipx-install]
poethepoet = ">=0.26"
poetry = "<2"
.. _getting-the-source:
Getting the Source
^^^^^^^^^^^^^^^^^^
++++++++++++++++++
The easiest way to get started with the latest beets source is to clone the
repository and install ``beets`` in a local virtual environment using `poetry`_.
repository and install ``beets`` in a local virtual environment using poetry_.
This can be done with:
.. code-block:: bash
@ -106,26 +110,32 @@ This can be done with:
$ poetry install
This will install ``beets`` and all development dependencies into its own
virtual environment in your ``$POETRY_CACHE_DIR``. See ``poetry install
--help`` for installation options, including installing ``extra`` dependencies
for plugins.
virtual environment in your ``$POETRY_CACHE_DIR``. See ``poetry install --help``
for installation options, including installing ``extra`` dependencies for
plugins.
In order to run something within this virtual environment, start the command
with ``poetry run`` to them, for example ``poetry run pytest``.
On the other hand, it may get tedious to type ``poetry run`` before every
command. Instead, you can activate the virtual environment in your shell with::
command. Instead, you can activate the virtual environment in your shell with:
::
$ poetry shell
You should see ``(beets-py3.9)`` prefix in your shell prompt. Now you can run
commands directly, for example::
You should see ``(beets-py3.10)`` prefix in your shell prompt. Now you can run
commands directly, for example:
$ (beets-py3.9) pytest
::
Additionally, `poethepoet`_ task runner assists us with the most common
$ (beets-py3.10) pytest
Additionally, poethepoet_ task runner assists us with the most common
operations. Formatting, linting, testing are defined as ``poe`` tasks in
`pyproject.toml`_. Run::
pyproject.toml_. Run:
::
$ poe
@ -140,204 +150,180 @@ to see all available tasks. They can be used like this, for example
$ poe test --lf # re-run failing tests (note the additional pytest option)
$ poe check-types --pretty # check types with an extra option for mypy
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 `library API
documentation <https://beets.readthedocs.io/en/stable/dev/library.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.
.. _poethepoet: https://poethepoet.natn.io/index.html
.. _poetry: https://python-poetry.org/docs/
- We maintain a set of `issues marked as “good first issue”
<https://github.com/beetbox/beets/labels/good%20first%20issue>`__. 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
`library API documentation
<https://beets.readthedocs.io/en/stable/dev/library.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
`discussion board <https://github.com/beetbox/beets/discussions/>`__
if you have any questions.
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
`discussion board`_ 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.
We maintain a list of issues we reserved for those new to open source labeled
`first timers only`_. Since the goal of these issues is to get users comfortable
with contributing to an open source project, please do not hesitate to ask any
questions.
.. _first timers only: https://github.com/beetbox/beets/issues?q=is%3Aopen+is%3Aissue+label%3A%22first+timers+only%22
How to Submit Your Work
=======================
-----------------------
Do you have a great bug fix, new feature, or documentation expansion
youd like to contribute? Follow these steps to create a GitHub pull
request and your code will ship in no time.
Do you have a great bug fix, new feature, or documentation expansion youd like
to contribute? Follow these steps to create a GitHub pull request and your code
will ship in no time.
1. Fork the beets repository and clone it (see above) to create a
workspace.
1. Fork the beets repository and clone it (see above) to create a workspace.
2. Install pre-commit, following the instructions `here
<https://pre-commit.com/>`_.
3. Make your changes.
4. Add tests. If youve fixed a bug, write a test to ensure that youve
actually fixed it. If theres a new feature or plugin, please
contribute tests that show that your code does what it says.
5. Add documentation. If youve added a new command flag, for example,
find the appropriate page under ``docs/`` where it needs to be
listed.
6. Add a changelog entry to ``docs/changelog.rst`` near the top of the
document.
4. Add tests. If youve fixed a bug, write a test to ensure that youve actually
fixed it. If theres a new feature or plugin, please contribute tests that
show that your code does what it says.
5. Add documentation. If youve added a new command flag, for example, find the
appropriate page under ``docs/`` where it needs to be listed.
6. Add a changelog entry to ``docs/changelog.rst`` near the top of the document.
7. Run the tests and style checker, see :ref:`testing`.
8. Push to your fork and open a pull request! Well be in touch shortly.
9. If you add commits to a pull request, please add a comment or
re-request a review after you push them since GitHub doesnt
automatically notify us when commits are added.
9. If you add commits to a pull request, please add a comment or re-request a
review after you push them since GitHub doesnt automatically notify us when
commits are added.
Remember, code contributions have four parts: the code, the tests, the
documentation, and the changelog entry. Thank you for contributing!
The Code
========
.. admonition:: Ownership
The documentation has a section on the
`library API <https://beets.readthedocs.io/en/stable/dev/library.html>`__
that serves as an introduction to beets design.
If you are the owner of a plugin, please consider reviewing pull requests
that affect your plugin. If you are not the owner of a plugin, please
consider becoming one! You can do so by adding an entry to
``.github/CODEOWNERS``. This way, you will automatically receive a review
request for pull requests that adjust the code that you own. If you have any
questions, please ask on our `discussion board`_.
The Code
--------
The documentation has a section on the `library API
<https://beets.readthedocs.io/en/stable/dev/library.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:
- 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
.. 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))
with g.lib.transaction() as tx:
rows = tx.query("SELECT DISTINCT {field} FROM {model._table} ORDER BY {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:
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, for example:
.. code-block:: python
.. code-block:: python
with lib.transaction() as tx:
rows = tx.query("SELECT …")
with lib.transaction() as tx:
rows = tx.query("SELECT path FROM items WHERE album_id = ?", (album_id,))
Transaction objects help control concurrent access to the database
and assist in debugging conflicting accesses.
- ``str.format()`` should be used instead of the ``%`` operator
- Never ``print`` informational messages; use the
`logging <http://docs.python.org/library/logging.html>`__ module
instead. In particular, we have our own logging shim, so youll see
``from beets import logging`` in most files.
Transaction objects help control concurrent access to the database and assist
in debugging conflicting accesses.
- 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("{0}", obj)`` to do your formatting.
- f-strings should be used instead of the ``%`` operator and ``str.format()``
calls.
- Never ``print`` informational messages; use the `logging
<http://docs.python.org/library/logging.html>`__ module instead. In
particular, we have our own logging shim, so youll see ``from beets import
logging`` in most files.
- Exception handlers must use ``except A as B:`` instead of
``except A, B:``.
- 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("{}", obj)`` to do your
formatting.
- Exception handlers must use ``except A as B:`` instead of ``except A, B:``.
Style
-----
~~~~~
We use `ruff`_ to format and lint the codebase.
We use `ruff <https://docs.astral.sh/ruff/>`__ to format and lint the codebase.
Run ``poe check-format`` and ``poe lint`` to check your code for style and
linting errors. Running ``poe format`` will automatically format your code
according to the specifications required by the project.
.. _ruff: https://docs.astral.sh/ruff/
Handling Paths
--------------
A great deal of convention deals with the handling of **paths**. Paths
are stored internally—in the database, for instance—as byte strings
(i.e., ``bytes`` instead of ``str`` in Python 3). This is because POSIX
operating systems path names are only reliably usable as byte
strings—operating systems typically recommend but do not require that
filenames use a given encoding, so violations of any reported encoding
are inevitable. On Windows, the strings are always encoded with UTF-8;
on Unix, the encoding is controlled by the filesystem. Here are some
guidelines to follow:
- If you have a Unicode path or youre not sure whether something is
Unicode or not, pass it through ``bytestring_path`` function in the
``beets.util`` module to convert it to bytes.
- Pass every path name through 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.
Similarly, run ``poe format-docs`` and ``poe lint-docs`` to ensure consistent
documentation formatting and check for any issues.
Editor Settings
---------------
~~~~~~~~~~~~~~~
Personally, I work on beets with `vim`_. Here are
some ``.vimrc`` lines that might help with PEP 8-compliant Python
coding::
Personally, I work on beets with vim_. Here are some ``.vimrc`` lines that might
help with PEP 8-compliant Python coding:
::
filetype indent on
autocmd FileType python setlocal shiftwidth=4 tabstop=4 softtabstop=4 expandtab shiftround autoindent
Consider installing `this alternative Python indentation
plugin <https://github.com/mitsuhiko/vim-python-combined>`__. I also
like `neomake <https://github.com/neomake/neomake>`__ with its flake8
checker.
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
-----------------
~~~~~~~~~~~~~~~~~
Use ``poe`` to run tests::
Use ``poe`` to run tests:
::
$ poe test [pytest options]
You can disable a hand-selected set of "slow" tests by setting the
environment variable ``SKIP_SLOW_TESTS``, for example::
You can disable a hand-selected set of "slow" tests by setting the environment
variable ``SKIP_SLOW_TESTS``, for example:
::
$ SKIP_SLOW_TESTS=1 poe test
Coverage
^^^^^^^^
++++++++
The ``test`` command does not include coverage as it slows down testing. In
order to measure it, use the ``test-with-coverage`` task
@ -350,56 +336,71 @@ You are welcome to explore coverage by opening the HTML report in
Note that for each covered line the report shows **which tests cover it**
(expand the list on the right-hand side of the affected line).
You can find project coverage status on `Codecov`_.
You can find project coverage status on Codecov_.
Red Flags
^^^^^^^^^
+++++++++
The `pytest-random`_ plugin makes it easy to randomize the order of
tests. ``poe test --random`` will occasionally turn up failing tests
that reveal ordering dependencies—which are bad news!
The pytest-random_ plugin makes it easy to randomize the order of tests. ``poe
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 the
``tool.poetry.group.test.dependencies`` section in `pyproject.toml`_.
``tool.poetry.group.test.dependencies`` section in pyproject.toml_.
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. Since we are currently migrating
the tests from `unittest`_ to `pytest`_, new tests should be written using
`pytest`_. Contributions migrating existing tests are welcome!
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. Since we are currently migrating the
tests from unittest_ to pytest_, new tests should be written using pytest_.
Contributions migrating existing tests are welcome!
External API requests under test should be mocked with `requests-mock`_,
However, we still want to know whether external APIs are up and that they
return expected responses, therefore we test them weekly with our `integration
test`_ suite.
External API requests under test should be mocked with requests-mock_, However,
we still want to know whether external APIs are up and that they return expected
responses, therefore we test them weekly with our `integration test`_ suite.
In order to add such a test, mark your test with the ``integration_test`` marker
.. code-block:: python
@pytest.mark.integration_test
def test_external_api_call():
...
@pytest.mark.integration_test
def test_external_api_call(): ...
This way, the test will be run only in the integration test suite.
.. _Codecov: https://codecov.io/github/beetbox/beets
.. _pytest-random: https://github.com/klrmn/pytest-random
.. _pytest: https://docs.pytest.org/en/stable/
.. _pyproject.toml: https://github.com/beetbox/beets/tree/master/pyproject.toml
.. _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/library/unittest.html
.. _integration test: https://github.com/beetbox/beets/actions?query=workflow%3A%22integration+tests%22
.. _requests-mock: https://requests-mock.readthedocs.io/en/latest/response.html
.. _codecov: https://codecov.io/github/beetbox/beets
.. _discussion board: https://github.com/beetbox/beets/discussions
.. _documentation: https://beets.readthedocs.io/en/stable/
.. _https://github.com/beetbox/beets/blob/master/test/test_template.py#l224: https://github.com/beetbox/beets/blob/master/test/test_template.py#L224
.. _integration test: https://github.com/beetbox/beets/actions?query=workflow%3A%22integration+tests%22
.. _pipx: https://pipx.pypa.io/stable
.. _poethepoet: https://poethepoet.natn.io/index.html
.. _poetry: https://python-poetry.org/docs/
.. _pyproject.toml: https://github.com/beetbox/beets/tree/master/pyproject.toml
.. _pytest: https://docs.pytest.org/en/stable/
.. _pytest-random: https://github.com/klrmn/pytest-random
.. _requests-mock: https://requests-mock.readthedocs.io/en/latest/response.html
.. _test: https://github.com/beetbox/beets/tree/master/test
.. _unittest: https://docs.python.org/3/library/unittest.html
.. _vim: https://www.vim.org/

View file

@ -10,115 +10,132 @@
.. image:: https://repology.org/badge/tiny-repos/beets.svg
:target: https://repology.org/project/beets/versions
beets
=====
Beets is the media library management system for obsessive music geeks.
The purpose of beets is to get your music collection right once and for all.
It catalogs your collection, automatically improving its metadata as it goes.
It then provides a bouquet of tools for manipulating and accessing your music.
The purpose of beets is to get your music collection right once and for all. It
catalogs your collection, automatically improving its metadata as it goes. It
then provides a suite of tools for manipulating and accessing your music.
Here's an example of beets' brainy tag corrector doing its thing::
Here's an example of beets' brainy tag corrector doing its thing:
$ beet import ~/music/ladytron
Tagging:
Ladytron - Witching Hour
(Similarity: 98.4%)
* Last One Standing -> The Last One Standing
* Beauty -> Beauty*2
* White Light Generation -> Whitelightgenerator
* All the Way -> All the Way...
::
$ beet import ~/music/ladytron
Tagging:
Ladytron - Witching Hour
(Similarity: 98.4%)
* Last One Standing -> The Last One Standing
* Beauty -> Beauty*2
* White Light Generation -> Whitelightgenerator
* All the Way -> All the Way...
Because beets is designed as a library, it can do almost anything you can
imagine for your music collection. Via `plugins`_, beets becomes a panacea:
imagine for your music collection. Via plugins_, beets becomes a panacea:
- Fetch or calculate all the metadata you could possibly need: `album art`_,
`lyrics`_, `genres`_, `tempos`_, `ReplayGain`_ levels, or `acoustic
fingerprints`_.
- Get metadata from `MusicBrainz`_, `Discogs`_, and `Beatport`_. Or guess
metadata using songs' filenames or their acoustic fingerprints.
lyrics_, genres_, tempos_, ReplayGain_ levels, or `acoustic fingerprints`_.
- Get metadata from MusicBrainz_, Discogs_, and Beatport_. Or guess metadata
using songs' filenames or their acoustic fingerprints.
- `Transcode audio`_ to any format you like.
- Check your library for `duplicate tracks and albums`_ or for `albums that
are missing tracks`_.
- Check your library for `duplicate tracks and albums`_ or for `albums that are
missing tracks`_.
- Clean up crufty tags left behind by other, less-awesome tools.
- Embed and extract album art from files' metadata.
- Browse your music library graphically through a Web browser and play it in any
browser that supports `HTML5 Audio`_.
- Analyze music files' metadata from the command line.
- Listen to your library with a music player that speaks the `MPD`_ protocol
and works with a staggering variety of interfaces.
- Listen to your library with a music player that speaks the MPD_ protocol and
works with a staggering variety of interfaces.
If beets doesn't do what you want yet, `writing your own plugin`_ is
shockingly simple if you know a little Python.
If beets doesn't do what you want yet, `writing your own plugin`_ is shockingly
simple if you know a little Python.
.. _acoustic fingerprints: https://beets.readthedocs.org/page/plugins/chroma.html
.. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html
.. _albums that are missing tracks: https://beets.readthedocs.org/page/plugins/missing.html
.. _beatport: https://www.beatport.com
.. _discogs: https://www.discogs.com/
.. _duplicate tracks and albums: https://beets.readthedocs.org/page/plugins/duplicates.html
.. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html
.. _html5 audio: https://html.spec.whatwg.org/multipage/media.html#the-audio-element
.. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html
.. _mpd: https://www.musicpd.org/
.. _musicbrainz: https://musicbrainz.org/
.. _musicbrainz music collection: https://musicbrainz.org/doc/Collections/
.. _plugins: https://beets.readthedocs.org/page/plugins/
.. _MPD: https://www.musicpd.org/
.. _MusicBrainz music collection: https://musicbrainz.org/doc/Collections/
.. _writing your own plugin:
https://beets.readthedocs.org/page/dev/plugins.html
.. _HTML5 Audio:
https://html.spec.whatwg.org/multipage/media.html#the-audio-element
.. _albums that are missing tracks:
https://beets.readthedocs.org/page/plugins/missing.html
.. _duplicate tracks and albums:
https://beets.readthedocs.org/page/plugins/duplicates.html
.. _Transcode audio:
https://beets.readthedocs.org/page/plugins/convert.html
.. _Discogs: https://www.discogs.com/
.. _acoustic fingerprints:
https://beets.readthedocs.org/page/plugins/chroma.html
.. _ReplayGain: https://beets.readthedocs.org/page/plugins/replaygain.html
.. _replaygain: https://beets.readthedocs.org/page/plugins/replaygain.html
.. _tempos: https://beets.readthedocs.org/page/plugins/acousticbrainz.html
.. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html
.. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html
.. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html
.. _MusicBrainz: https://musicbrainz.org/
.. _Beatport: https://www.beatport.com
.. _transcode audio: https://beets.readthedocs.org/page/plugins/convert.html
.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins/index.html
Install
-------
You can install beets by typing ``pip install beets`` or directly from Github (see details `here`_).
Beets has also been packaged in the `software repositories`_ of several
distributions. Check out the `Getting Started`_ guide for more information.
You can install beets by typing ``pip install beets`` or directly from Github
(see details here_). Beets has also been packaged in the `software
repositories`_ of several distributions. Check out the `Getting Started`_ guide
for more information.
.. _getting started: https://beets.readthedocs.org/page/guides/main.html
.. _here: https://beets.readthedocs.io/en/latest/faq.html#run-the-latest-source-version-of-beets
.. _Getting Started: https://beets.readthedocs.org/page/guides/main.html
.. _software repositories: https://repology.org/project/beets/versions
Contribute
----------
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`_.
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_.
.. _CONTRIBUTING.rst: https://github.com/beetbox/beets/blob/master/CONTRIBUTING.rst
.. _contributing.rst: https://github.com/beetbox/beets/blob/master/CONTRIBUTING.rst
Read More
---------
Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Mastodon for
news and updates.
Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Mastodon for news
and updates.
.. _its Web site: https://beets.io/
.. _@b33ts: https://fosstodon.org/@beets
.. _its web site: https://beets.io/
Contact
-------
* Encountered a bug you'd like to report? Check out our `issue tracker`_!
* If your issue 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, have an idea for a new
feature, or would just like to introduce yourself to the team? Check out
`GitHub Discussions`_!
.. _GitHub Discussions: https://github.com/beetbox/beets/discussions
- Encountered a bug you'd like to report? Check out our `issue tracker`_!
- If your issue 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, have an idea for a new
feature, or would just like to introduce yourself to the team? Check out
`GitHub Discussions`_!
.. _github discussions: https://github.com/beetbox/beets/discussions
.. _issue tracker: https://github.com/beetbox/beets/issues
.. _open a new ticket: https://github.com/beetbox/beets/issues/new/choose
Authors
@ -126,4 +143,4 @@ Authors
Beets is by `Adrian Sampson`_ with a supporting cast of thousands.
.. _Adrian Sampson: https://www.cs.cornell.edu/~asampson/
.. _adrian sampson: https://www.cs.cornell.edu/~asampson/

View file

@ -1,108 +1,119 @@
.. image:: https://img.shields.io/pypi/v/beets.svg
:target: https://pypi.python.org/pypi/beets
.. 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
beets
=====
Beets는 강박적인 음악을 듣는 사람들을 위한 미디어 라이브러리 관리 시스템이다.
Beets의 목적은 음악들을 한번에 다 받는 것이다.
음악들을 카탈로그화 하고, 자동으로 메타 데이터를 개선한다.
그리고 음악에 접근하고 조작할 수 있는 도구들을 제공한다.
다음은 Beets의 brainy tag corrector가 한 일의 예시이다.
$ beet import ~/music/ladytron
Tagging:
Ladytron - Witching Hour
(Similarity: 98.4%)
* Last One Standing -> The Last One Standing
* Beauty -> Beauty*2
* White Light Generation -> Whitelightgenerator
* All the Way -> All the Way...
Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들에 대해 상상하는 모든 것을 할 수 있다.
`plugins`_ 을 통해서 모든 것을 할 수 있는 것이다!
- 필요하는 메타 데이터를 계산하거나 패치 할 때: `album art`_,
`lyrics`_, `genres`_, `tempos`_, `ReplayGain`_ levels, or `acoustic
fingerprints`_.
- `MusicBrainz`_, `Discogs`_,`Beatport`_로부터 메타데이터를 가져오거나,
노래 제목이나 음향 특징으로 메타데이터를 추측한다
- `Transcode audio`_ 당신이 좋아하는 어떤 포맷으로든 변경한다.
- 당신의 라이브러리에서 `duplicate tracks and albums`_ 이나 `albums that are missing tracks`_ 를 검사한다.
- 남이 남기거나, 좋지 않은 도구로 남긴 잡다한 태그들을 지운다.
- 파일의 메타데이터에서 앨범 아트를 삽입이나 추출한다.
- 당신의 음악들을 `HTML5 Audio`_ 를 지원하는 어떤 브라우저든 재생할 수 있고,
웹 브라우저에 표시 할 수 있다.
- 명령어로부터 음악 파일의 메타데이터를 분석할 수 있다.
- `MPD`_ 프로토콜을 사용하여 음악 플레이어로 음악을 들으면, 엄청나게 다양한 인터페이스로 작동한다.
만약 Beets에 당신이 원하는게 아직 없다면,
당신이 python을 안다면 `writing your own plugin`_ _은 놀라울정도로 간단하다.
.. _plugins: https://beets.readthedocs.org/page/plugins/
.. _MPD: https://www.musicpd.org/
.. _MusicBrainz music collection: https://musicbrainz.org/doc/Collections/
.. _writing your own plugin:
https://beets.readthedocs.org/page/dev/plugins.html
.. _HTML5 Audio:
https://html.spec.whatwg.org/multipage/media.html#the-audio-element
.. _albums that are missing tracks:
https://beets.readthedocs.org/page/plugins/missing.html
.. _duplicate tracks and albums:
https://beets.readthedocs.org/page/plugins/duplicates.html
.. _Transcode audio:
https://beets.readthedocs.org/page/plugins/convert.html
.. _Discogs: https://www.discogs.com/
.. _acoustic fingerprints:
https://beets.readthedocs.org/page/plugins/chroma.html
.. _ReplayGain: https://beets.readthedocs.org/page/plugins/replaygain.html
.. _tempos: https://beets.readthedocs.org/page/plugins/acousticbrainz.html
.. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html
.. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html
.. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html
.. _MusicBrainz: https://musicbrainz.org/
.. _Beatport: https://www.beatport.com
설치
-------
당신은 ``pip install beets`` 을 사용해서 Beets를 설치할 수 있다.
그리고 `Getting Started`_ 가이드를 확인할 수 있다.
.. _Getting Started: https://beets.readthedocs.org/page/guides/main.html
컨트리뷰션
----------
어떻게 도우려는지 알고싶다면 `Hacking`_ 위키페이지를 확인하라.
당신은 docs 안에 `For Developers`_ 에도 관심이 있을수 있다.
.. _Hacking: https://github.com/beetbox/beets/wiki/Hacking
.. _For Developers: https://beets.readthedocs.io/en/stable/dev/
Read More
---------
`its Web site`_ 에서 Beets에 대해 조금 더 알아볼 수 있다.
트위터에서 `@b33ts`_ 를 팔로우하면 새 소식을 볼 수 있다.
.. _its Web site: https://beets.io/
.. _@b33ts: https://twitter.com/b33ts/
저자들
-------
`Adrian Sampson`_ 와 많은 사람들의 지지를 받아 Beets를 만들었다.
돕고 싶다면 `forum`_.를 방문하면 된다.
.. _forum: https://github.com/beetbox/beets/discussions/
.. _Adrian Sampson: https://www.cs.cornell.edu/~asampson/
.. image:: https://img.shields.io/pypi/v/beets.svg
:target: https://pypi.python.org/pypi/beets
.. 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
beets
=====
Beets는 강박적인 음악을 듣는 사람들을 위한 미디어 라이브러리 관리 시스템이다.
Beets의 목적은 음악들을 한번에 다 받는 것이다. 음악들을 카탈로그화 하고, 자동으로 메타 데이터를 개선한다. 그리고 음악에 접근하고 조작할
수 있는 도구들을 제공한다.
다음은 Beets의 brainy tag corrector가 한 일의 예시이다.
::
$ beet import ~/music/ladytron
Tagging:
Ladytron - Witching Hour
(Similarity: 98.4%)
* Last One Standing -> The Last One Standing
* Beauty -> Beauty*2
* White Light Generation -> Whitelightgenerator
* All the Way -> All the Way...
Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들에 대해 상상하는 모든 것을 할 수 있다. plugins_ 을 통해서 모든 것을 할
수 있는 것이다!
- 필요하는 메타 데이터를 계산하거나 패치 할 때: `album art`_, lyrics_, genres_, tempos_,
ReplayGain_ levels, or `acoustic fingerprints`_.
- MusicBrainz_, Discogs_,`Beatport`_로부터 메타데이터를 가져오거나, 노래 제목이나 음향 특징으로 메타데이터를
추측한다
- `Transcode audio`_ 당신이 좋아하는 어떤 포맷으로든 변경한다.
- 당신의 라이브러리에서 `duplicate tracks and albums`_ 이나 `albums that are missing
tracks`_ 를 검사한다.
- 남이 남기거나, 좋지 않은 도구로 남긴 잡다한 태그들을 지운다.
- 파일의 메타데이터에서 앨범 아트를 삽입이나 추출한다.
- 당신의 음악들을 `HTML5 Audio`_ 를 지원하는 어떤 브라우저든 재생할 수 있고, 웹 브라우저에 표시 할 수 있다.
- 명령어로부터 음악 파일의 메타데이터를 분석할 수 있다.
- MPD_ 프로토콜을 사용하여 음악 플레이어로 음악을 들으면, 엄청나게 다양한 인터페이스로 작동한다.
만약 Beets에 당신이 원하는게 아직 없다면, 당신이 python을 안다면 `writing your own plugin`_ _은 놀라울정도로
간단하다.
.. _acoustic fingerprints: https://beets.readthedocs.org/page/plugins/chroma.html
.. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html
.. _albums that are missing tracks: https://beets.readthedocs.org/page/plugins/missing.html
.. _beatport: https://www.beatport.com
.. _discogs: https://www.discogs.com/
.. _duplicate tracks and albums: https://beets.readthedocs.org/page/plugins/duplicates.html
.. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html
.. _html5 audio: https://html.spec.whatwg.org/multipage/media.html#the-audio-element
.. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html
.. _mpd: https://www.musicpd.org/
.. _musicbrainz: https://musicbrainz.org/
.. _musicbrainz music collection: https://musicbrainz.org/doc/Collections/
.. _plugins: https://beets.readthedocs.org/page/plugins/
.. _replaygain: https://beets.readthedocs.org/page/plugins/replaygain.html
.. _tempos: https://beets.readthedocs.org/page/plugins/acousticbrainz.html
.. _transcode audio: https://beets.readthedocs.org/page/plugins/convert.html
.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins/index.html
설치
-------
당신은 ``pip install beets`` 을 사용해서 Beets를 설치할 수 있다. 그리고 `Getting Started`_ 가이드를
확인할 수 있다.
.. _getting started: https://beets.readthedocs.org/page/guides/main.html
컨트리뷰션
----------
어떻게 도우려는지 알고싶다면 Hacking_ 위키페이지를 확인하라. 당신은 docs 안에 `For Developers`_ 에도 관심이 있을수
있다.
.. _for developers: https://beets.readthedocs.io/en/stable/dev/
.. _hacking: https://github.com/beetbox/beets/wiki/Hacking
Read More
---------
`its Web site`_ 에서 Beets에 대해 조금 더 알아볼 수 있다. 트위터에서 `@b33ts`_ 를 팔로우하면 새 소식을 볼 수
있다.
.. _@b33ts: https://twitter.com/b33ts/
.. _its web site: https://beets.io/
저자들
-------
`Adrian Sampson`_ 와 많은 사람들의 지지를 받아 Beets를 만들었다. 돕고 싶다면 forum_.를 방문하면 된다.
.. _adrian sampson: https://www.cs.cornell.edu/~asampson/
.. _forum: https://github.com/beetbox/beets/discussions/

View file

@ -17,10 +17,21 @@ from sys import stderr
import confuse
__version__ = "2.2.0"
from .util.deprecation import deprecate_imports
__version__ = "2.5.1"
__author__ = "Adrian Sampson <adrian@radbox.org>"
def __getattr__(name: str):
"""Handle deprecated imports."""
return deprecate_imports(
__name__,
{"art": "beetsplug._utils", "vfs": "beetsplug._utils"},
name,
)
class IncludeLazyConfig(confuse.LazyConfig):
"""A version of Confuse's LazyConfig that also merges in data from
YAML files specified in an `include` setting.
@ -35,7 +46,7 @@ class IncludeLazyConfig(confuse.LazyConfig):
except confuse.NotFoundError:
pass
except confuse.ConfigReadError as err:
stderr.write("configuration `import` failed: {}".format(err.reason))
stderr.write(f"configuration `import` failed: {err.reason}")
config = IncludeLazyConfig("beets", __name__)

View file

@ -14,36 +14,48 @@
"""Facilities for automatically determining files' correct metadata."""
from collections.abc import Mapping, Sequence
from typing import Union
from __future__ import annotations
from importlib import import_module
from typing import TYPE_CHECKING
from beets import config, logging
from beets.library import Album, Item, LibModel
# Parts of external interface.
from beets.util import unique_list
from beets.util.deprecation import deprecate_for_maintainers, deprecate_imports
from .hooks import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch
from .match import Proposal, Recommendation, tag_album, tag_item
if TYPE_CHECKING:
from collections.abc import Sequence
from beets.library import Album, Item, LibModel
def __getattr__(name: str):
if name == "current_metadata":
deprecate_for_maintainers(
f"'beets.autotag.{name}'", "'beets.util.get_most_common_tags'"
)
return import_module("beets.util").get_most_common_tags
return deprecate_imports(
__name__, {"Distance": "beets.autotag.distance"}, name
)
from .hooks import AlbumInfo, AlbumMatch, Distance, TrackInfo, TrackMatch
from .match import (
Proposal,
Recommendation,
current_metadata,
tag_album,
tag_item,
)
__all__ = [
"AlbumInfo",
"AlbumMatch",
"Distance",
"TrackInfo",
"TrackMatch",
"Proposal",
"Recommendation",
"TrackInfo",
"TrackMatch",
"apply_album_metadata",
"apply_item_metadata",
"apply_metadata",
"current_metadata",
"tag_album",
"tag_item",
]
@ -99,8 +111,8 @@ SPECIAL_FIELDS = {
def _apply_metadata(
info: Union[AlbumInfo, TrackInfo],
db_obj: Union[Album, Item],
info: AlbumInfo | TrackInfo,
db_obj: Album | Item,
nullable_fields: Sequence[str] = [],
):
"""Set the db_obj's metadata to match the info."""
@ -192,11 +204,11 @@ def apply_album_metadata(album_info: AlbumInfo, album: Album):
correct_list_fields(album)
def apply_metadata(album_info: AlbumInfo, mapping: Mapping[Item, TrackInfo]):
"""Set the items' metadata to match an AlbumInfo object using a
mapping from Items to TrackInfo objects.
"""
for item, track_info in mapping.items():
def apply_metadata(
album_info: AlbumInfo, item_info_pairs: list[tuple[Item, TrackInfo]]
):
"""Set items metadata to match corresponding tagged info."""
for item, track_info in item_info_pairs:
# Artist or artist credit.
if config["artist_credit"]:
item.artist = (
@ -243,7 +255,7 @@ def apply_metadata(album_info: AlbumInfo, mapping: Mapping[Item, TrackInfo]):
continue
for suffix in "year", "month", "day":
key = prefix + suffix
key = f"{prefix}{suffix}"
value = getattr(album_info, key) or 0
# If we don't even have a year, apply nothing.

535
beets/autotag/distance.py Normal file
View file

@ -0,0 +1,535 @@
from __future__ import annotations
import datetime
import re
from functools import cache, total_ordering
from typing import TYPE_CHECKING, Any
from jellyfish import levenshtein_distance
from unidecode import unidecode
from beets import config, metadata_plugins
from beets.util import as_string, cached_classproperty, get_most_common_tags
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence
from beets.library import Item
from .hooks import AlbumInfo, TrackInfo
# Candidate distance scoring.
# Artist signals that indicate "various artists". These are used at the
# album level to determine whether a given release is likely a VA
# release and also on the track level to to remove the penalty for
# differing artists.
VA_ARTISTS = ("", "various artists", "various", "va", "unknown")
# Parameters for string distance function.
# Words that can be moved to the end of a string using a comma.
SD_END_WORDS = ["the", "a", "an"]
# Reduced weights for certain portions of the string.
SD_PATTERNS = [
(r"^the ", 0.1),
(r"[\[\(]?(ep|single)[\]\)]?", 0.0),
(r"[\[\(]?(featuring|feat|ft)[\. :].+", 0.1),
(r"\(.*?\)", 0.3),
(r"\[.*?\]", 0.3),
(r"(, )?(pt\.|part) .+", 0.2),
]
# Replacements to use before testing distance.
SD_REPLACE = [
(r"&", "and"),
]
def _string_dist_basic(str1: str, str2: str) -> float:
"""Basic edit distance between two strings, ignoring
non-alphanumeric characters and case. Comparisons are based on a
transliteration/lowering to ASCII characters. Normalized by string
length.
"""
assert isinstance(str1, str)
assert isinstance(str2, str)
str1 = as_string(unidecode(str1))
str2 = as_string(unidecode(str2))
str1 = re.sub(r"[^a-z0-9]", "", str1.lower())
str2 = re.sub(r"[^a-z0-9]", "", str2.lower())
if not str1 and not str2:
return 0.0
return levenshtein_distance(str1, str2) / float(max(len(str1), len(str2)))
def string_dist(str1: str | None, str2: str | None) -> float:
"""Gives an "intuitive" edit distance between two strings. This is
an edit distance, normalized by the string length, with a number of
tweaks that reflect intuition about text.
"""
if str1 is None and str2 is None:
return 0.0
if str1 is None or str2 is None:
return 1.0
str1 = str1.lower()
str2 = str2.lower()
# Don't penalize strings that move certain words to the end. For
# example, "the something" should be considered equal to
# "something, the".
for word in SD_END_WORDS:
if str1.endswith(f", {word}"):
str1 = f"{word} {str1[: -len(word) - 2]}"
if str2.endswith(f", {word}"):
str2 = f"{word} {str2[: -len(word) - 2]}"
# Perform a couple of basic normalizing substitutions.
for pat, repl in SD_REPLACE:
str1 = re.sub(pat, repl, str1)
str2 = re.sub(pat, repl, str2)
# Change the weight for certain string portions matched by a set
# of regular expressions. We gradually change the strings and build
# up penalties associated with parts of the string that were
# deleted.
base_dist = _string_dist_basic(str1, str2)
penalty = 0.0
for pat, weight in SD_PATTERNS:
# Get strings that drop the pattern.
case_str1 = re.sub(pat, "", str1)
case_str2 = re.sub(pat, "", str2)
if case_str1 != str1 or case_str2 != str2:
# If the pattern was present (i.e., it is deleted in the
# the current case), recalculate the distances for the
# modified strings.
case_dist = _string_dist_basic(case_str1, case_str2)
case_delta = max(0.0, base_dist - case_dist)
if case_delta == 0.0:
continue
# Shift our baseline strings down (to avoid rematching the
# same part of the string) and add a scaled distance
# amount to the penalties.
str1 = case_str1
str2 = case_str2
base_dist = case_dist
penalty += weight * case_delta
return base_dist + penalty
@total_ordering
class Distance:
"""Keeps track of multiple distance penalties. Provides a single
weighted distance for all penalties as well as a weighted distance
for each individual penalty.
"""
def __init__(self) -> None:
self._penalties: dict[str, list[float]] = {}
self.tracks: dict[TrackInfo, Distance] = {}
@cached_classproperty
def _weights(cls) -> dict[str, float]:
"""A dictionary from keys to floating-point weights."""
weights_view = config["match"]["distance_weights"]
weights = {}
for key in weights_view.keys():
weights[key] = weights_view[key].as_number()
return weights
# Access the components and their aggregates.
@property
def distance(self) -> float:
"""Return a weighted and normalized distance across all
penalties.
"""
dist_max = self.max_distance
if dist_max:
return self.raw_distance / self.max_distance
return 0.0
@property
def max_distance(self) -> float:
"""Return the maximum distance penalty (normalization factor)."""
dist_max = 0.0
for key, penalty in self._penalties.items():
dist_max += len(penalty) * self._weights[key]
return dist_max
@property
def raw_distance(self) -> float:
"""Return the raw (denormalized) distance."""
dist_raw = 0.0
for key, penalty in self._penalties.items():
dist_raw += sum(penalty) * self._weights[key]
return dist_raw
def items(self) -> list[tuple[str, float]]:
"""Return a list of (key, dist) pairs, with `dist` being the
weighted distance, sorted from highest to lowest. Does not
include penalties with a zero value.
"""
list_ = []
for key in self._penalties:
dist = self[key]
if dist:
list_.append((key, dist))
# Convert distance into a negative float we can sort items in
# ascending order (for keys, when the penalty is equal) and
# still get the items with the biggest distance first.
return sorted(
list_, key=lambda key_and_dist: (-key_and_dist[1], key_and_dist[0])
)
def __hash__(self) -> int:
return id(self)
def __eq__(self, other) -> bool:
return self.distance == other
# Behave like a float.
def __lt__(self, other) -> bool:
return self.distance < other
def __float__(self) -> float:
return self.distance
def __sub__(self, other) -> float:
return self.distance - other
def __rsub__(self, other) -> float:
return other - self.distance
def __str__(self) -> str:
return f"{self.distance:.2f}"
# Behave like a dict.
def __getitem__(self, key) -> float:
"""Returns the weighted distance for a named penalty."""
dist = sum(self._penalties[key]) * self._weights[key]
dist_max = self.max_distance
if dist_max:
return dist / dist_max
return 0.0
def __iter__(self) -> Iterator[tuple[str, float]]:
return iter(self.items())
def __len__(self) -> int:
return len(self.items())
def keys(self) -> list[str]:
return [key for key, _ in self.items()]
def update(self, dist: Distance):
"""Adds all the distance penalties from `dist`."""
if not isinstance(dist, Distance):
raise ValueError(
f"`dist` must be a Distance object, not {type(dist)}"
)
for key, penalties in dist._penalties.items():
self._penalties.setdefault(key, []).extend(penalties)
# Adding components.
def _eq(self, value1: re.Pattern[str] | Any, value2: Any) -> bool:
"""Returns True if `value1` is equal to `value2`. `value1` may
be a compiled regular expression, in which case it will be
matched against `value2`.
"""
if isinstance(value1, re.Pattern):
return bool(value1.match(value2))
return value1 == value2
def add(self, key: str, dist: float):
"""Adds a distance penalty. `key` must correspond with a
configured weight setting. `dist` must be a float between 0.0
and 1.0, and will be added to any existing distance penalties
for the same key.
"""
if not 0.0 <= dist <= 1.0:
raise ValueError(f"`dist` must be between 0.0 and 1.0, not {dist}")
self._penalties.setdefault(key, []).append(dist)
def add_equality(
self,
key: str,
value: Any,
options: list[Any] | tuple[Any, ...] | Any,
):
"""Adds a distance penalty of 1.0 if `value` doesn't match any
of the values in `options`. If an option is a compiled regular
expression, it will be considered equal if it matches against
`value`.
"""
if not isinstance(options, (list, tuple)):
options = [options]
for opt in options:
if self._eq(opt, value):
dist = 0.0
break
else:
dist = 1.0
self.add(key, dist)
def add_expr(self, key: str, expr: bool):
"""Adds a distance penalty of 1.0 if `expr` evaluates to True,
or 0.0.
"""
if expr:
self.add(key, 1.0)
else:
self.add(key, 0.0)
def add_number(self, key: str, number1: int, number2: int):
"""Adds a distance penalty of 1.0 for each number of difference
between `number1` and `number2`, or 0.0 when there is no
difference. Use this when there is no upper limit on the
difference between the two numbers.
"""
diff = abs(number1 - number2)
if diff:
for i in range(diff):
self.add(key, 1.0)
else:
self.add(key, 0.0)
def add_priority(
self,
key: str,
value: Any,
options: list[Any] | tuple[Any, ...] | Any,
):
"""Adds a distance penalty that corresponds to the position at
which `value` appears in `options`. A distance penalty of 0.0
for the first option, or 1.0 if there is no matching option. If
an option is a compiled regular expression, it will be
considered equal if it matches against `value`.
"""
if not isinstance(options, (list, tuple)):
options = [options]
unit = 1.0 / (len(options) or 1)
for i, opt in enumerate(options):
if self._eq(opt, value):
dist = i * unit
break
else:
dist = 1.0
self.add(key, dist)
def add_ratio(
self,
key: str,
number1: int | float,
number2: int | float,
):
"""Adds a distance penalty for `number1` as a ratio of `number2`.
`number1` is bound at 0 and `number2`.
"""
number = float(max(min(number1, number2), 0))
if number2:
dist = number / number2
else:
dist = 0.0
self.add(key, dist)
def add_string(self, key: str, str1: str | None, str2: str | None):
"""Adds a distance penalty based on the edit distance between
`str1` and `str2`.
"""
dist = string_dist(str1, str2)
self.add(key, dist)
def add_data_source(self, before: str | None, after: str | None) -> None:
if before != after and (
before or len(metadata_plugins.find_metadata_source_plugins()) > 1
):
self.add("data_source", metadata_plugins.get_penalty(after))
@cache
def get_track_length_grace() -> float:
"""Get cached grace period for track length matching."""
return config["match"]["track_length_grace"].as_number()
@cache
def get_track_length_max() -> float:
"""Get cached maximum track length for track length matching."""
return config["match"]["track_length_max"].as_number()
def track_index_changed(item: Item, track_info: TrackInfo) -> bool:
"""Returns True if the item and track info index is different. Tolerates
per disc and per release numbering.
"""
return item.track not in (track_info.medium_index, track_info.index)
def track_distance(
item: Item,
track_info: TrackInfo,
incl_artist: bool = False,
) -> Distance:
"""Determines the significance of a track metadata change. Returns a
Distance object. `incl_artist` indicates that a distance component should
be included for the track artist (i.e., for various-artist releases).
``track_length_grace`` and ``track_length_max`` configuration options are
cached because this function is called many times during the matching
process and their access comes with a performance overhead.
"""
dist = Distance()
# Length.
if info_length := track_info.length:
diff = abs(item.length - info_length) - get_track_length_grace()
dist.add_ratio("track_length", diff, get_track_length_max())
# Title.
dist.add_string("track_title", item.title, track_info.title)
# Artist. Only check if there is actually an artist in the track data.
if (
incl_artist
and track_info.artist
and item.artist.lower() not in VA_ARTISTS
):
dist.add_string("track_artist", item.artist, track_info.artist)
# Track index.
if track_info.index and item.track:
dist.add_expr("track_index", track_index_changed(item, track_info))
# Track ID.
if item.mb_trackid:
dist.add_expr("track_id", item.mb_trackid != track_info.track_id)
# Penalize mismatching disc numbers.
if track_info.medium and item.disc:
dist.add_expr("medium", item.disc != track_info.medium)
dist.add_data_source(item.get("data_source"), track_info.data_source)
return dist
def distance(
items: Sequence[Item],
album_info: AlbumInfo,
item_info_pairs: list[tuple[Item, TrackInfo]],
) -> Distance:
"""Determines how "significant" an album metadata change would be.
Returns a Distance object. `album_info` is an AlbumInfo object
reflecting the album to be compared. `items` is a sequence of all
Item objects that will be matched (order is not important).
`mapping` is a dictionary mapping Items to TrackInfo objects; the
keys are a subset of `items` and the values are a subset of
`album_info.tracks`.
"""
likelies, _ = get_most_common_tags(items)
dist = Distance()
# Artist, if not various.
if not album_info.va:
dist.add_string("artist", likelies["artist"], album_info.artist)
# Album.
dist.add_string("album", likelies["album"], album_info.album)
preferred_config = config["match"]["preferred"]
# Current or preferred media.
if album_info.media:
# Preferred media options.
media_patterns: Sequence[str] = preferred_config["media"].as_str_seq()
options = [
re.compile(rf"(\d+x)?({pat})", re.I) for pat in media_patterns
]
if options:
dist.add_priority("media", album_info.media, options)
# Current media.
elif likelies["media"]:
dist.add_equality("media", album_info.media, likelies["media"])
# Mediums.
if likelies["disctotal"] and album_info.mediums:
dist.add_number("mediums", likelies["disctotal"], album_info.mediums)
# Prefer earliest release.
if album_info.year and preferred_config["original_year"]:
# Assume 1889 (earliest first gramophone discs) if we don't know the
# original year.
original = album_info.original_year or 1889
diff = abs(album_info.year - original)
diff_max = abs(datetime.date.today().year - original)
dist.add_ratio("year", diff, diff_max)
# Year.
elif likelies["year"] and album_info.year:
if likelies["year"] in (album_info.year, album_info.original_year):
# No penalty for matching release or original year.
dist.add("year", 0.0)
elif album_info.original_year:
# Prefer matchest closest to the release year.
diff = abs(likelies["year"] - album_info.year)
diff_max = abs(
datetime.date.today().year - album_info.original_year
)
dist.add_ratio("year", diff, diff_max)
else:
# Full penalty when there is no original year.
dist.add("year", 1.0)
# Preferred countries.
country_patterns: Sequence[str] = preferred_config["countries"].as_str_seq()
options = [re.compile(pat, re.I) for pat in country_patterns]
if album_info.country and options:
dist.add_priority("country", album_info.country, options)
# Country.
elif likelies["country"] and album_info.country:
dist.add_string("country", likelies["country"], album_info.country)
# Label.
if likelies["label"] and album_info.label:
dist.add_string("label", likelies["label"], album_info.label)
# Catalog number.
if likelies["catalognum"] and album_info.catalognum:
dist.add_string(
"catalognum", likelies["catalognum"], album_info.catalognum
)
# Disambiguation.
if likelies["albumdisambig"] and album_info.albumdisambig:
dist.add_string(
"albumdisambig", likelies["albumdisambig"], album_info.albumdisambig
)
# Album ID.
if likelies["mb_albumid"]:
dist.add_equality(
"album_id", likelies["mb_albumid"], album_info.album_id
)
# Tracks.
dist.tracks = {}
for item, track in item_info_pairs:
dist.tracks[track] = track_distance(item, track, album_info.va)
dist.add("tracks", dist.tracks[track].distance)
# Missing tracks.
for _ in range(len(album_info.tracks) - len(item_info_pairs)):
dist.add("missing_tracks", 1.0)
# Unmatched tracks.
for _ in range(len(items) - len(item_info_pairs)):
dist.add("unmatched_tracks", 1.0)
dist.add_data_source(likelies["data_source"], album_info.data_source)
return dist

View file

@ -16,675 +16,246 @@
from __future__ import annotations
import re
from functools import total_ordering
from typing import TYPE_CHECKING, Any, Callable, NamedTuple, TypeVar, cast
from copy import deepcopy
from dataclasses import dataclass
from functools import cached_property
from typing import TYPE_CHECKING, Any, TypeVar
from jellyfish import levenshtein_distance
from unidecode import unidecode
from typing_extensions import Self
from beets import config, logging, plugins
from beets.autotag import mb
from beets.util import as_string, cached_classproperty
from beets.util import cached_classproperty
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from beets.library import Item
log = logging.getLogger("beets")
from .distance import Distance
V = TypeVar("V")
# Classes used to represent candidate options.
class AttrDict(dict[str, V]):
"""A dictionary that supports attribute ("dot") access, so `d.field`
is equivalent to `d['field']`.
"""
"""Mapping enabling attribute-style access to stored metadata values."""
def copy(self) -> Self:
return deepcopy(self)
def __getattr__(self, attr: str) -> V:
if attr in self:
return self[attr]
else:
raise AttributeError
def __setattr__(self, key: str, value: V):
raise AttributeError(
f"'{self.__class__.__name__}' object has no attribute '{attr}'"
)
def __setattr__(self, key: str, value: V) -> None:
self.__setitem__(key, value)
def __hash__(self):
def __hash__(self) -> int: # type: ignore[override]
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:
class Info(AttrDict[Any]):
"""Container for metadata about a musical entity."""
- ``album``: the release title
- ``album_id``: MusicBrainz ID; UUID fragment only
- ``artist``: name of the release's primary artist
- ``artist_id``
- ``tracks``: list of TrackInfo objects making up the release
@cached_property
def name(self) -> str:
raise NotImplementedError
``mediums`` along with the fields up through ``tracks`` are required.
The others are optional and may be None.
def __init__(
self,
album: str | None = None,
artist_credit: str | None = None,
artist_id: str | None = None,
artist: str | None = None,
artists_credit: list[str] | None = None,
artists_ids: list[str] | None = None,
artists: list[str] | None = None,
artist_sort: str | None = None,
artists_sort: list[str] | None = None,
data_source: str | None = None,
data_url: str | None = None,
genre: str | None = None,
media: str | None = None,
**kwargs,
) -> None:
self.album = album
self.artist = artist
self.artist_credit = artist_credit
self.artist_id = artist_id
self.artists = artists or []
self.artists_credit = artists_credit or []
self.artists_ids = artists_ids or []
self.artist_sort = artist_sort
self.artists_sort = artists_sort or []
self.data_source = data_source
self.data_url = data_url
self.genre = genre
self.media = media
self.update(kwargs)
class AlbumInfo(Info):
"""Metadata snapshot representing a single album candidate.
Aggregates track entries and album-wide context gathered from an external
provider. Used during matching to evaluate similarity against a group of
user items, and later to drive tagging decisions once selected.
"""
# TYPING: are all of these correct? I've assumed optional strings
@cached_property
def name(self) -> str:
return self.album or ""
def __init__(
self,
tracks: list[TrackInfo],
album: str | None = None,
*,
album_id: str | None = None,
artist: str | None = None,
artist_id: str | None = None,
artists: list[str] | None = None,
artists_ids: list[str] | None = None,
asin: str | None = None,
albumdisambig: str | None = None,
albumstatus: str | None = None,
albumtype: str | None = None,
albumtypes: list[str] | None = None,
asin: str | None = None,
barcode: str | None = None,
catalognum: str | None = None,
country: str | None = None,
day: int | None = None,
discogs_albumid: str | None = None,
discogs_artistid: str | None = None,
discogs_labelid: str | None = None,
label: str | None = None,
language: str | None = None,
mediums: int | None = None,
month: int | None = None,
original_day: int | None = None,
original_month: int | None = None,
original_year: int | None = None,
release_group_title: str | None = None,
releasegroup_id: str | None = None,
releasegroupdisambig: str | None = None,
script: str | None = None,
style: str | None = None,
va: bool = False,
year: int | None = None,
month: int | None = None,
day: int | None = None,
label: str | None = None,
barcode: str | None = None,
mediums: int | None = None,
artist_sort: str | None = None,
artists_sort: list[str] | None = None,
releasegroup_id: str | None = None,
release_group_title: str | None = None,
catalognum: str | None = None,
script: str | None = None,
language: str | None = None,
country: str | None = None,
style: str | None = None,
genre: str | None = None,
albumstatus: str | None = None,
media: str | None = None,
albumdisambig: str | None = None,
releasegroupdisambig: str | None = None,
artist_credit: str | None = None,
artists_credit: list[str] | None = None,
original_year: int | None = None,
original_month: int | None = None,
original_day: int | None = None,
data_source: str | None = None,
data_url: str | None = None,
discogs_albumid: str | None = None,
discogs_labelid: str | None = None,
discogs_artistid: str | None = None,
**kwargs,
):
self.album = album
self.album_id = album_id
self.artist = artist
self.artist_id = artist_id
self.artists = artists or []
self.artists_ids = artists_ids or []
) -> None:
self.tracks = tracks
self.asin = asin
self.album_id = album_id
self.albumdisambig = albumdisambig
self.albumstatus = albumstatus
self.albumtype = albumtype
self.albumtypes = albumtypes or []
self.asin = asin
self.barcode = barcode
self.catalognum = catalognum
self.country = country
self.day = day
self.discogs_albumid = discogs_albumid
self.discogs_artistid = discogs_artistid
self.discogs_labelid = discogs_labelid
self.label = label
self.language = language
self.mediums = mediums
self.month = month
self.original_day = original_day
self.original_month = original_month
self.original_year = original_year
self.release_group_title = release_group_title
self.releasegroup_id = releasegroup_id
self.releasegroupdisambig = releasegroupdisambig
self.script = script
self.style = style
self.va = va
self.year = year
self.month = month
self.day = day
self.label = label
self.barcode = barcode
self.mediums = mediums
self.artist_sort = artist_sort
self.artists_sort = artists_sort or []
self.releasegroup_id = releasegroup_id
self.release_group_title = release_group_title
self.catalognum = catalognum
self.script = script
self.language = language
self.country = country
self.style = style
self.genre = genre
self.albumstatus = albumstatus
self.media = media
self.albumdisambig = albumdisambig
self.releasegroupdisambig = releasegroupdisambig
self.artist_credit = artist_credit
self.artists_credit = artists_credit or []
self.original_year = original_year
self.original_month = original_month
self.original_day = original_day
self.data_source = data_source
self.data_url = data_url
self.discogs_albumid = discogs_albumid
self.discogs_labelid = discogs_labelid
self.discogs_artistid = discogs_artistid
self.update(kwargs)
def copy(self) -> AlbumInfo:
dupe = AlbumInfo([])
dupe.update(self)
dupe.tracks = [track.copy() for track in self.tracks]
return dupe
super().__init__(**kwargs)
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:
class TrackInfo(Info):
"""Metadata snapshot for a single track candidate.
- ``title``: name of the track
- ``track_id``: MusicBrainz ID; UUID fragment only
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.
Captures identifying details and creative credits used to compare against
a user's item. Instances often originate within an AlbumInfo but may also
stand alone for singleton matching.
"""
# TYPING: are all of these correct? I've assumed optional strings
@cached_property
def name(self) -> str:
return self.title or ""
def __init__(
self,
title: str | None = None,
track_id: str | None = None,
release_track_id: str | None = None,
artist: str | None = None,
artist_id: str | None = None,
artists: list[str] | None = None,
artists_ids: list[str] | None = None,
length: float | None = None,
*,
arranger: str | None = None,
bpm: str | None = None,
composer: str | None = None,
composer_sort: str | None = None,
disctitle: str | None = None,
index: int | None = None,
initial_key: str | None = None,
length: float | None = None,
lyricist: str | None = None,
mb_workid: str | None = None,
medium: int | None = None,
medium_index: int | None = None,
medium_total: int | None = None,
artist_sort: str | None = None,
artists_sort: list[str] | None = None,
disctitle: str | None = None,
artist_credit: str | None = None,
artists_credit: list[str] | None = None,
data_source: str | None = None,
data_url: str | None = None,
media: str | None = None,
lyricist: str | None = None,
composer: str | None = None,
composer_sort: str | None = None,
arranger: str | None = None,
release_track_id: str | None = None,
title: str | None = None,
track_alt: str | None = None,
track_id: str | None = None,
work: str | None = None,
mb_workid: str | None = None,
work_disambig: str | None = None,
bpm: str | None = None,
initial_key: str | None = None,
genre: str | None = None,
album: str | None = None,
**kwargs,
):
self.title = title
self.track_id = track_id
self.release_track_id = release_track_id
self.artist = artist
self.artist_id = artist_id
self.artists = artists or []
self.artists_ids = artists_ids or []
self.length = length
) -> None:
self.arranger = arranger
self.bpm = bpm
self.composer = composer
self.composer_sort = composer_sort
self.disctitle = disctitle
self.index = index
self.media = media
self.initial_key = initial_key
self.length = length
self.lyricist = lyricist
self.mb_workid = mb_workid
self.medium = medium
self.medium_index = medium_index
self.medium_total = medium_total
self.artist_sort = artist_sort
self.artists_sort = artists_sort or []
self.disctitle = disctitle
self.artist_credit = artist_credit
self.artists_credit = artists_credit or []
self.data_source = data_source
self.data_url = data_url
self.lyricist = lyricist
self.composer = composer
self.composer_sort = composer_sort
self.arranger = arranger
self.release_track_id = release_track_id
self.title = title
self.track_alt = track_alt
self.track_id = track_id
self.work = work
self.mb_workid = mb_workid
self.work_disambig = work_disambig
self.bpm = bpm
self.initial_key = initial_key
self.genre = genre
self.album = album
self.update(kwargs)
def copy(self) -> TrackInfo:
dupe = TrackInfo()
dupe.update(self)
return dupe
# Candidate distance scoring.
# Parameters for string distance function.
# Words that can be moved to the end of a string using a comma.
SD_END_WORDS = ["the", "a", "an"]
# Reduced weights for certain portions of the string.
SD_PATTERNS = [
(r"^the ", 0.1),
(r"[\[\(]?(ep|single)[\]\)]?", 0.0),
(r"[\[\(]?(featuring|feat|ft)[\. :].+", 0.1),
(r"\(.*?\)", 0.3),
(r"\[.*?\]", 0.3),
(r"(, )?(pt\.|part) .+", 0.2),
]
# Replacements to use before testing distance.
SD_REPLACE = [
(r"&", "and"),
]
def _string_dist_basic(str1: str, str2: str) -> float:
"""Basic edit distance between two strings, ignoring
non-alphanumeric characters and case. Comparisons are based on a
transliteration/lowering to ASCII characters. Normalized by string
length.
"""
assert isinstance(str1, str)
assert isinstance(str2, str)
str1 = as_string(unidecode(str1))
str2 = as_string(unidecode(str2))
str1 = re.sub(r"[^a-z0-9]", "", str1.lower())
str2 = re.sub(r"[^a-z0-9]", "", str2.lower())
if not str1 and not str2:
return 0.0
return levenshtein_distance(str1, str2) / float(max(len(str1), len(str2)))
def string_dist(str1: str | None, str2: str | None) -> float:
"""Gives an "intuitive" edit distance between two strings. This is
an edit distance, normalized by the string length, with a number of
tweaks that reflect intuition about text.
"""
if str1 is None and str2 is None:
return 0.0
if str1 is None or str2 is None:
return 1.0
str1 = str1.lower()
str2 = str2.lower()
# Don't penalize strings that move certain words to the end. For
# example, "the something" should be considered equal to
# "something, the".
for word in SD_END_WORDS:
if str1.endswith(", %s" % word):
str1 = "{} {}".format(word, str1[: -len(word) - 2])
if str2.endswith(", %s" % word):
str2 = "{} {}".format(word, str2[: -len(word) - 2])
# Perform a couple of basic normalizing substitutions.
for pat, repl in SD_REPLACE:
str1 = re.sub(pat, repl, str1)
str2 = re.sub(pat, repl, str2)
# Change the weight for certain string portions matched by a set
# of regular expressions. We gradually change the strings and build
# up penalties associated with parts of the string that were
# deleted.
base_dist = _string_dist_basic(str1, str2)
penalty = 0.0
for pat, weight in SD_PATTERNS:
# Get strings that drop the pattern.
case_str1 = re.sub(pat, "", str1)
case_str2 = re.sub(pat, "", str2)
if case_str1 != str1 or case_str2 != str2:
# If the pattern was present (i.e., it is deleted in the
# the current case), recalculate the distances for the
# modified strings.
case_dist = _string_dist_basic(case_str1, case_str2)
case_delta = max(0.0, base_dist - case_dist)
if case_delta == 0.0:
continue
# Shift our baseline strings down (to avoid rematching the
# same part of the string) and add a scaled distance
# amount to the penalties.
str1 = case_str1
str2 = case_str2
base_dist = case_dist
penalty += weight * case_delta
return base_dist + penalty
@total_ordering
class Distance:
"""Keeps track of multiple distance penalties. Provides a single
weighted distance for all penalties as well as a weighted distance
for each individual penalty.
"""
def __init__(self):
self._penalties = {}
self.tracks: dict[TrackInfo, Distance] = {}
@cached_classproperty
def _weights(cls) -> dict[str, float]:
"""A dictionary from keys to floating-point weights."""
weights_view = config["match"]["distance_weights"]
weights = {}
for key in weights_view.keys():
weights[key] = weights_view[key].as_number()
return weights
# Access the components and their aggregates.
@property
def distance(self) -> float:
"""Return a weighted and normalized distance across all
penalties.
"""
dist_max = self.max_distance
if dist_max:
return self.raw_distance / self.max_distance
return 0.0
@property
def max_distance(self) -> float:
"""Return the maximum distance penalty (normalization factor)."""
dist_max = 0.0
for key, penalty in self._penalties.items():
dist_max += len(penalty) * self._weights[key]
return dist_max
@property
def raw_distance(self) -> float:
"""Return the raw (denormalized) distance."""
dist_raw = 0.0
for key, penalty in self._penalties.items():
dist_raw += sum(penalty) * self._weights[key]
return dist_raw
def items(self) -> list[tuple[str, float]]:
"""Return a list of (key, dist) pairs, with `dist` being the
weighted distance, sorted from highest to lowest. Does not
include penalties with a zero value.
"""
list_ = []
for key in self._penalties:
dist = self[key]
if dist:
list_.append((key, dist))
# Convert distance into a negative float we can sort items in
# ascending order (for keys, when the penalty is equal) and
# still get the items with the biggest distance first.
return sorted(
list_, key=lambda key_and_dist: (-key_and_dist[1], key_and_dist[0])
)
def __hash__(self) -> int:
return id(self)
def __eq__(self, other) -> bool:
return self.distance == other
# Behave like a float.
def __lt__(self, other) -> bool:
return self.distance < other
def __float__(self) -> float:
return self.distance
def __sub__(self, other) -> float:
return self.distance - other
def __rsub__(self, other) -> float:
return other - self.distance
def __str__(self) -> str:
return f"{self.distance:.2f}"
# Behave like a dict.
def __getitem__(self, key) -> float:
"""Returns the weighted distance for a named penalty."""
dist = sum(self._penalties[key]) * self._weights[key]
dist_max = self.max_distance
if dist_max:
return dist / dist_max
return 0.0
def __iter__(self) -> Iterator[tuple[str, float]]:
return iter(self.items())
def __len__(self) -> int:
return len(self.items())
def keys(self) -> list[str]:
return [key for key, _ in self.items()]
def update(self, dist: Distance):
"""Adds all the distance penalties from `dist`."""
if not isinstance(dist, Distance):
raise ValueError(
"`dist` must be a Distance object, not {}".format(type(dist))
)
for key, penalties in dist._penalties.items():
self._penalties.setdefault(key, []).extend(penalties)
# Adding components.
def _eq(self, value1: re.Pattern[str] | Any, value2: Any) -> bool:
"""Returns True if `value1` is equal to `value2`. `value1` may
be a compiled regular expression, in which case it will be
matched against `value2`.
"""
if isinstance(value1, re.Pattern):
value2 = cast(str, value2)
return bool(value1.match(value2))
return value1 == value2
def add(self, key: str, dist: float):
"""Adds a distance penalty. `key` must correspond with a
configured weight setting. `dist` must be a float between 0.0
and 1.0, and will be added to any existing distance penalties
for the same key.
"""
if not 0.0 <= dist <= 1.0:
raise ValueError(f"`dist` must be between 0.0 and 1.0, not {dist}")
self._penalties.setdefault(key, []).append(dist)
def add_equality(
self,
key: str,
value: Any,
options: list[Any] | tuple[Any, ...] | Any,
):
"""Adds a distance penalty of 1.0 if `value` doesn't match any
of the values in `options`. If an option is a compiled regular
expression, it will be considered equal if it matches against
`value`.
"""
if not isinstance(options, (list, tuple)):
options = [options]
for opt in options:
if self._eq(opt, value):
dist = 0.0
break
else:
dist = 1.0
self.add(key, dist)
def add_expr(self, key: str, expr: bool):
"""Adds a distance penalty of 1.0 if `expr` evaluates to True,
or 0.0.
"""
if expr:
self.add(key, 1.0)
else:
self.add(key, 0.0)
def add_number(self, key: str, number1: int, number2: int):
"""Adds a distance penalty of 1.0 for each number of difference
between `number1` and `number2`, or 0.0 when there is no
difference. Use this when there is no upper limit on the
difference between the two numbers.
"""
diff = abs(number1 - number2)
if diff:
for i in range(diff):
self.add(key, 1.0)
else:
self.add(key, 0.0)
def add_priority(
self,
key: str,
value: Any,
options: list[Any] | tuple[Any, ...] | Any,
):
"""Adds a distance penalty that corresponds to the position at
which `value` appears in `options`. A distance penalty of 0.0
for the first option, or 1.0 if there is no matching option. If
an option is a compiled regular expression, it will be
considered equal if it matches against `value`.
"""
if not isinstance(options, (list, tuple)):
options = [options]
unit = 1.0 / (len(options) or 1)
for i, opt in enumerate(options):
if self._eq(opt, value):
dist = i * unit
break
else:
dist = 1.0
self.add(key, dist)
def add_ratio(
self,
key: str,
number1: int | float,
number2: int | float,
):
"""Adds a distance penalty for `number1` as a ratio of `number2`.
`number1` is bound at 0 and `number2`.
"""
number = float(max(min(number1, number2), 0))
if number2:
dist = number / number2
else:
dist = 0.0
self.add(key, dist)
def add_string(self, key: str, str1: str | None, str2: str | None):
"""Adds a distance penalty based on the edit distance between
`str1` and `str2`.
"""
dist = string_dist(str1, str2)
self.add(key, dist)
super().__init__(**kwargs)
# Structures that compose all the information for a candidate match.
class AlbumMatch(NamedTuple):
@dataclass
class Match:
distance: Distance
info: Info
@cached_classproperty
def type(cls) -> str:
return cls.__name__.removesuffix("Match") # type: ignore[attr-defined]
@dataclass
class AlbumMatch(Match):
info: AlbumInfo
mapping: dict[Item, TrackInfo]
extra_items: list[Item]
extra_tracks: list[TrackInfo]
@property
def item_info_pairs(self) -> list[tuple[Item, TrackInfo]]:
return list(self.mapping.items())
class TrackMatch(NamedTuple):
distance: Distance
@property
def items(self) -> list[Item]:
return [i for i, _ in self.item_info_pairs]
@dataclass
class TrackMatch(Match):
info: TrackInfo
# Aggregation of sources.
def album_for_mbid(release_id: str) -> AlbumInfo | None:
"""Get an AlbumInfo object for a MusicBrainz release ID. Return None
if the ID is not found.
"""
try:
if album := mb.album_for_id(release_id):
plugins.send("albuminfo_received", info=album)
return album
except mb.MusicBrainzAPIError as exc:
exc.log(log)
return None
def track_for_mbid(recording_id: str) -> TrackInfo | None:
"""Get a TrackInfo object for a MusicBrainz recording ID. Return None
if the ID is not found.
"""
try:
if track := mb.track_for_id(recording_id):
plugins.send("trackinfo_received", info=track)
return track
except mb.MusicBrainzAPIError as exc:
exc.log(log)
return None
def album_for_id(_id: str) -> AlbumInfo | None:
"""Get AlbumInfo object for the given ID string."""
return album_for_mbid(_id) or plugins.album_for_id(_id)
def track_for_id(_id: str) -> TrackInfo | None:
"""Get TrackInfo object for the given ID string."""
return track_for_mbid(_id) or plugins.track_for_id(_id)
def invoke_mb(call_func: Callable, *args):
try:
return call_func(*args)
except mb.MusicBrainzAPIError as exc:
exc.log(log)
return ()
@plugins.notify_info_yielded("albuminfo_received")
def album_candidates(
items: list[Item],
artist: str,
album: str,
va_likely: bool,
extra_tags: dict,
) -> Iterable[tuple]:
"""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. ``extra_tags``
is an optional dictionary of additional tags used to further
constrain the search.
"""
if config["musicbrainz"]["enabled"]:
# Base candidates if we have album and artist to match.
if artist and album:
yield from invoke_mb(
mb.match_album, artist, album, len(items), extra_tags
)
# Also add VA matches from MusicBrainz where appropriate.
if va_likely and album:
yield from invoke_mb(
mb.match_album, None, album, len(items), extra_tags
)
# Candidates from plugins.
yield from plugins.candidates(items, artist, album, va_likely, extra_tags)
@plugins.notify_info_yielded("trackinfo_received")
def item_candidates(item: Item, artist: str, title: str) -> Iterable[tuple]:
"""Search for item matches. ``item`` is the Item to be matched.
``artist`` and ``title`` are strings and either reflect the item or
are specified by the user.
"""
# MusicBrainz candidates.
if config["musicbrainz"]["enabled"] and artist and title:
yield from invoke_mb(mb.match_track, artist, title)
# Plugin candidates.
yield from plugins.item_candidates(item, artist, title)

View file

@ -18,35 +18,22 @@ releases and tracks.
from __future__ import annotations
import datetime
import re
from collections.abc import Iterable, Sequence
from enum import IntEnum
from functools import cache
from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast
from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
import lap
import numpy as np
from beets import config, logging, plugins
from beets.autotag import (
AlbumInfo,
AlbumMatch,
Distance,
TrackInfo,
TrackMatch,
hooks,
)
from beets.util import plurality
from beets import config, logging, metadata_plugins, plugins
from beets.autotag import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch, hooks
from beets.util import get_most_common_tags
from .distance import VA_ARTISTS, distance, track_distance
if TYPE_CHECKING:
from beets.library import Item
from collections.abc import Iterable, Sequence
# Artist signals that indicate "various artists". These are used at the
# album level to determine whether a given release is likely a VA
# release and also on the track level to to remove the penalty for
# differing artists.
VA_ARTISTS = ("", "various artists", "various", "va", "unknown")
from beets.library import Item
# Global logger.
log = logging.getLogger("beets")
@ -79,48 +66,10 @@ class Proposal(NamedTuple):
# Primary matching functionality.
def current_metadata(
items: Iterable[Item],
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Extract the likely current metadata for an album given a list of its
items. Return two dictionaries:
- The most common value for each field.
- Whether each field's value was unanimous (values are booleans).
"""
assert items # Must be nonempty.
likelies = {}
consensus = {}
fields = [
"artist",
"album",
"albumartist",
"year",
"disctotal",
"mb_albumid",
"label",
"barcode",
"catalognum",
"country",
"media",
"albumdisambig",
]
for field in fields:
values = [item[field] for item in items if item]
likelies[field], freq = plurality(values)
consensus[field] = freq == len(values)
# If there's an album artist consensus, use this for the artist.
if consensus["albumartist"] and likelies["albumartist"]:
likelies["artist"] = likelies["albumartist"]
return likelies, consensus
def assign_items(
items: Sequence[Item],
tracks: Sequence[TrackInfo],
) -> tuple[dict[Item, TrackInfo], list[Item], list[TrackInfo]]:
) -> tuple[list[tuple[Item, TrackInfo]], list[Item], list[TrackInfo]]:
"""Given a list of Items and a list of TrackInfo objects, find the
best mapping between them. Returns a mapping from Items to TrackInfo
objects, a set of extra Items, and a set of extra TrackInfo
@ -146,195 +95,11 @@ def assign_items(
extra_items.sort(key=lambda i: (i.disc, i.track, i.title))
extra_tracks = list(set(tracks) - set(mapping.values()))
extra_tracks.sort(key=lambda t: (t.index, t.title))
return mapping, extra_items, extra_tracks
return list(mapping.items()), extra_items, extra_tracks
def track_index_changed(item: Item, track_info: TrackInfo) -> bool:
"""Returns True if the item and track info index is different. Tolerates
per disc and per release numbering.
"""
return item.track not in (track_info.medium_index, track_info.index)
@cache
def get_track_length_grace() -> float:
"""Get cached grace period for track length matching."""
return config["match"]["track_length_grace"].as_number()
@cache
def get_track_length_max() -> float:
"""Get cached maximum track length for track length matching."""
return config["match"]["track_length_max"].as_number()
def track_distance(
item: Item,
track_info: TrackInfo,
incl_artist: bool = False,
) -> Distance:
"""Determines the significance of a track metadata change. Returns a
Distance object. `incl_artist` indicates that a distance component should
be included for the track artist (i.e., for various-artist releases).
``track_length_grace`` and ``track_length_max`` configuration options are
cached because this function is called many times during the matching
process and their access comes with a performance overhead.
"""
dist = hooks.Distance()
# Length.
if info_length := track_info.length:
diff = abs(item.length - info_length) - get_track_length_grace()
dist.add_ratio("track_length", diff, get_track_length_max())
# Title.
dist.add_string("track_title", item.title, track_info.title)
# Artist. Only check if there is actually an artist in the track data.
if (
incl_artist
and track_info.artist
and item.artist.lower() not in VA_ARTISTS
):
dist.add_string("track_artist", item.artist, track_info.artist)
# Track index.
if track_info.index and item.track:
dist.add_expr("track_index", track_index_changed(item, track_info))
# Track ID.
if item.mb_trackid:
dist.add_expr("track_id", item.mb_trackid != track_info.track_id)
# Penalize mismatching disc numbers.
if track_info.medium and item.disc:
dist.add_expr("medium", item.disc != track_info.medium)
# Plugins.
dist.update(plugins.track_distance(item, track_info))
return dist
def distance(
items: Sequence[Item],
album_info: AlbumInfo,
mapping: dict[Item, TrackInfo],
) -> Distance:
"""Determines how "significant" an album metadata change would be.
Returns a Distance object. `album_info` is an AlbumInfo object
reflecting the album to be compared. `items` is a sequence of all
Item objects that will be matched (order is not important).
`mapping` is a dictionary mapping Items to TrackInfo objects; the
keys are a subset of `items` and the values are a subset of
`album_info.tracks`.
"""
likelies, _ = current_metadata(items)
dist = hooks.Distance()
# Artist, if not various.
if not album_info.va:
dist.add_string("artist", likelies["artist"], album_info.artist)
# Album.
dist.add_string("album", likelies["album"], album_info.album)
# Current or preferred media.
if album_info.media:
# Preferred media options.
patterns = config["match"]["preferred"]["media"].as_str_seq()
patterns = cast(Sequence[str], patterns)
options = [re.compile(r"(\d+x)?(%s)" % pat, re.I) for pat in patterns]
if options:
dist.add_priority("media", album_info.media, options)
# Current media.
elif likelies["media"]:
dist.add_equality("media", album_info.media, likelies["media"])
# Mediums.
if likelies["disctotal"] and album_info.mediums:
dist.add_number("mediums", likelies["disctotal"], album_info.mediums)
# Prefer earliest release.
if album_info.year and config["match"]["preferred"]["original_year"]:
# Assume 1889 (earliest first gramophone discs) if we don't know the
# original year.
original = album_info.original_year or 1889
diff = abs(album_info.year - original)
diff_max = abs(datetime.date.today().year - original)
dist.add_ratio("year", diff, diff_max)
# Year.
elif likelies["year"] and album_info.year:
if likelies["year"] in (album_info.year, album_info.original_year):
# No penalty for matching release or original year.
dist.add("year", 0.0)
elif album_info.original_year:
# Prefer matchest closest to the release year.
diff = abs(likelies["year"] - album_info.year)
diff_max = abs(
datetime.date.today().year - album_info.original_year
)
dist.add_ratio("year", diff, diff_max)
else:
# Full penalty when there is no original year.
dist.add("year", 1.0)
# Preferred countries.
patterns = config["match"]["preferred"]["countries"].as_str_seq()
patterns = cast(Sequence[str], patterns)
options = [re.compile(pat, re.I) for pat in patterns]
if album_info.country and options:
dist.add_priority("country", album_info.country, options)
# Country.
elif likelies["country"] and album_info.country:
dist.add_string("country", likelies["country"], album_info.country)
# Label.
if likelies["label"] and album_info.label:
dist.add_string("label", likelies["label"], album_info.label)
# Catalog number.
if likelies["catalognum"] and album_info.catalognum:
dist.add_string(
"catalognum", likelies["catalognum"], album_info.catalognum
)
# Disambiguation.
if likelies["albumdisambig"] and album_info.albumdisambig:
dist.add_string(
"albumdisambig", likelies["albumdisambig"], album_info.albumdisambig
)
# Album ID.
if likelies["mb_albumid"]:
dist.add_equality(
"album_id", likelies["mb_albumid"], album_info.album_id
)
# Tracks.
dist.tracks = {}
for item, track in mapping.items():
dist.tracks[track] = track_distance(item, track, album_info.va)
dist.add("tracks", dist.tracks[track].distance)
# Missing tracks.
for _ in range(len(album_info.tracks) - len(mapping)):
dist.add("missing_tracks", 1.0)
# Unmatched tracks.
for _ in range(len(items) - len(mapping)):
dist.add("unmatched_tracks", 1.0)
# Plugins.
dist.update(plugins.album_distance(items, album_info, mapping))
return dist
def match_by_id(items: Iterable[Item]):
"""If the items are tagged with a MusicBrainz album ID, returns an
def match_by_id(items: Iterable[Item]) -> AlbumInfo | None:
"""If the items are tagged with an external source ID, return an
AlbumInfo object for the corresponding album. Otherwise, returns
None.
"""
@ -353,8 +118,8 @@ def match_by_id(items: Iterable[Item]):
log.debug("No album ID consensus.")
return None
# If all album IDs are equal, look up the album.
log.debug("Searching for discovered album ID: {0}", first)
return hooks.album_for_mbid(first)
log.debug("Searching for discovered album ID: {}", first)
return metadata_plugins.album_for_id(first)
def _recommendation(
@ -432,9 +197,7 @@ def _add_candidate(
checking the track count, ordering the items, checking for
duplicates, and calculating the distance.
"""
log.debug(
"Candidate: {0} - {1} ({2})", info.artist, info.album, info.album_id
)
log.debug("Candidate: {0.artist} - {0.album} ({0.album_id})", info)
# Discard albums with zero tracks.
if not info.tracks:
@ -447,37 +210,38 @@ def _add_candidate(
return
# Discard matches without required tags.
for req_tag in cast(
Sequence[str], config["match"]["required"].as_str_seq()
):
required_tags: Sequence[str] = config["match"]["required"].as_str_seq()
for req_tag in required_tags:
if getattr(info, req_tag) is None:
log.debug("Ignored. Missing required tag: {0}", req_tag)
log.debug("Ignored. Missing required tag: {}", req_tag)
return
# Find mapping between the items and the track info.
mapping, extra_items, extra_tracks = assign_items(items, info.tracks)
item_info_pairs, extra_items, extra_tracks = assign_items(
items, info.tracks
)
# Get the change distance.
dist = distance(items, info, mapping)
dist = distance(items, info, item_info_pairs)
# Skip matches with ignored penalties.
penalties = [key for key, _ in dist]
ignored = cast(Sequence[str], config["match"]["ignored"].as_str_seq())
for penalty in ignored:
ignored_tags: Sequence[str] = config["match"]["ignored"].as_str_seq()
for penalty in ignored_tags:
if penalty in penalties:
log.debug("Ignored. Penalty: {0}", penalty)
log.debug("Ignored. Penalty: {}", penalty)
return
log.debug("Success. Distance: {0}", dist)
log.debug("Success. Distance: {}", dist)
results[info.album_id] = hooks.AlbumMatch(
dist, info, mapping, extra_items, extra_tracks
dist, info, dict(item_info_pairs), extra_items, extra_tracks
)
def tag_album(
items,
search_artist: str | None = None,
search_album: str | None = None,
search_name: str | None = None,
search_ids: list[str] = [],
) -> tuple[str, str, Proposal]:
"""Return a tuple of the current artist name, the current album
@ -498,10 +262,10 @@ def tag_album(
candidates.
"""
# Get current metadata.
likelies, consensus = current_metadata(items)
cur_artist = cast(str, likelies["artist"])
cur_album = cast(str, likelies["album"])
log.debug("Tagging {0} - {1}", cur_artist, cur_album)
likelies, consensus = get_most_common_tags(items)
cur_artist: str = likelies["artist"]
cur_album: str = likelies["album"]
log.debug("Tagging {} - {}", cur_artist, cur_album)
# The output result, keys are the MB album ID.
candidates: dict[Any, AlbumMatch] = {}
@ -509,18 +273,22 @@ def tag_album(
# Search by explicit ID.
if search_ids:
for search_id in search_ids:
log.debug("Searching for album ID: {0}", search_id)
if info := hooks.album_for_id(search_id):
log.debug("Searching for album ID: {}", search_id)
if info := metadata_plugins.album_for_id(search_id):
_add_candidate(items, candidates, info)
if opt_candidate := candidates.get(info.album_id):
plugins.send("album_matched", match=opt_candidate)
# Use existing metadata or text search.
else:
# Try search based on current ID.
id_info = match_by_id(items)
if id_info:
_add_candidate(items, candidates, id_info)
if info := match_by_id(items):
_add_candidate(items, candidates, info)
for candidate in candidates.values():
plugins.send("album_matched", match=candidate)
rec = _recommendation(list(candidates.values()))
log.debug("Album ID match recommendation is {0}", rec)
log.debug("Album ID match recommendation is {}", rec)
if candidates and not config["import"]["timid"]:
# If we have a very good MBID match, return immediately.
# Otherwise, this match will compete against metadata-based
@ -534,16 +302,10 @@ def tag_album(
)
# Search terms.
if not (search_artist and search_album):
if not (search_artist and search_name):
# No explicit search terms -- use current metadata.
search_artist, search_album = cur_artist, cur_album
log.debug("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("Additional search terms: {0}", extra_tags)
search_artist, search_name = cur_artist, cur_album
log.debug("Search terms: {} - {}", search_artist, search_name)
# Is this album likely to be a "various artist" release?
va_likely = (
@ -551,15 +313,17 @@ def tag_album(
or (search_artist.lower() in VA_ARTISTS)
or any(item.comp for item in items)
)
log.debug("Album might be VA: {0}", va_likely)
log.debug("Album might be VA: {}", va_likely)
# Get the results from the data sources.
for matched_candidate in hooks.album_candidates(
items, search_artist, search_album, va_likely, extra_tags
for matched_candidate in metadata_plugins.candidates(
items, search_artist, search_name, va_likely
):
_add_candidate(items, candidates, matched_candidate)
if opt_candidate := candidates.get(matched_candidate.album_id):
plugins.send("album_matched", match=opt_candidate)
log.debug("Evaluating {0} candidates.", len(candidates))
log.debug("Evaluating {} candidates.", len(candidates))
# Sort and get the recommendation.
candidates_sorted = _sort_candidates(candidates.values())
rec = _recommendation(candidates_sorted)
@ -569,28 +333,27 @@ def tag_album(
def tag_item(
item,
search_artist: str | None = None,
search_title: str | None = None,
search_name: str | None = None,
search_ids: list[str] | None = None,
) -> Proposal:
"""Find metadata for a single track. Return a `Proposal` consisting
of `TrackMatch` objects.
`search_artist` and `search_title` may be used
to override the current metadata for the purposes of the MusicBrainz
title. `search_ids` may be used for restricting the search to a list
of metadata backend IDs.
`search_artist` and `search_title` may be used to override the item
metadata in the search query. `search_ids` may be used for restricting the
search to a list of metadata backend IDs.
"""
# Holds candidates found so far: keys are MBIDs; values are
# (distance, TrackInfo) pairs.
candidates = {}
rec: Recommendation | None = None
# First, try matching by MusicBrainz ID.
# First, try matching by the external source ID.
trackids = search_ids or [t for t in [item.mb_trackid] if t]
if trackids:
for trackid in trackids:
log.debug("Searching for track ID: {0}", trackid)
if info := hooks.track_for_id(trackid):
log.debug("Searching for track ID: {}", trackid)
if info := metadata_plugins.track_for_id(trackid):
dist = track_distance(item, info, incl_artist=True)
candidates[info.track_id] = hooks.TrackMatch(dist, info)
# If this is a good match, then don't keep searching.
@ -611,17 +374,19 @@ def tag_item(
return Proposal([], Recommendation.none)
# Search terms.
if not (search_artist and search_title):
search_artist, search_title = item.artist, item.title
log.debug("Item search terms: {0} - {1}", search_artist, search_title)
search_artist = search_artist or item.artist
search_name = search_name or item.title
log.debug("Item search terms: {} - {}", search_artist, search_name)
# Get and evaluate candidate metadata.
for track_info in hooks.item_candidates(item, search_artist, search_title):
for track_info in metadata_plugins.item_candidates(
item, search_artist, search_name
):
dist = track_distance(item, track_info, incl_artist=True)
candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info)
# Sort by distance and return with recommendation.
log.debug("Found {0} candidates.", len(candidates))
log.debug("Found {} candidates.", len(candidates))
candidates_sorted = _sort_candidates(candidates.values())
rec = _recommendation(candidates_sorted)
return Proposal(candidates_sorted, rec)

View file

@ -1,884 +0,0 @@
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
# 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.
"""Searches for albums in the MusicBrainz database."""
from __future__ import annotations
import re
import traceback
from collections import Counter
from collections.abc import Iterator, Sequence
from itertools import product
from typing import Any, cast
from urllib.parse import urljoin
import musicbrainzngs
import beets
import beets.autotag.hooks
from beets import config, logging, plugins, util
from beets.plugins import MetadataSourcePlugin
from beets.util.id_extractors import (
beatport_id_regex,
deezer_id_regex,
extract_discogs_id_regex,
spotify_id_regex,
)
VARIOUS_ARTISTS_ID = "89ad4ac3-39f7-470e-963a-56509c546377"
BASE_URL = "https://musicbrainz.org/"
SKIPPED_TRACKS = ["[data track]"]
FIELDS_TO_MB_KEYS = {
"catalognum": "catno",
"country": "country",
"label": "label",
"barcode": "barcode",
"media": "format",
"year": "date",
}
musicbrainzngs.set_useragent("beets", beets.__version__, "https://beets.io/")
class MusicBrainzAPIError(util.HumanReadableError):
"""An error while talking to MusicBrainz. The `query` field is the
parameter to the action and may have any type.
"""
def __init__(self, reason, verb, query, tb=None):
self.query = query
if isinstance(reason, musicbrainzngs.WebServiceError):
reason = "MusicBrainz not reachable"
super().__init__(reason, verb, tb)
def get_message(self):
return "{} in {} with query {}".format(
self._reasonstr(), self.verb, repr(self.query)
)
log = logging.getLogger("beets")
RELEASE_INCLUDES = list(
{
"artists",
"media",
"recordings",
"release-groups",
"labels",
"artist-credits",
"aliases",
"recording-level-rels",
"work-rels",
"work-level-rels",
"artist-rels",
"isrcs",
"url-rels",
"release-rels",
"tags",
}
& set(musicbrainzngs.VALID_INCLUDES["release"])
)
TRACK_INCLUDES = list(
{
"artists",
"aliases",
"isrcs",
"work-level-rels",
"artist-rels",
}
& set(musicbrainzngs.VALID_INCLUDES["recording"])
)
BROWSE_INCLUDES = [
"artist-credits",
"work-rels",
"artist-rels",
"recording-rels",
"release-rels",
]
if "work-level-rels" in musicbrainzngs.VALID_BROWSE_INCLUDES["recording"]:
BROWSE_INCLUDES.append("work-level-rels")
BROWSE_CHUNKSIZE = 100
BROWSE_MAXTRACKS = 500
def track_url(trackid: str) -> str:
return urljoin(BASE_URL, "recording/" + trackid)
def album_url(albumid: str) -> str:
return urljoin(BASE_URL, "release/" + albumid)
def configure():
"""Set up the python-musicbrainz-ngs module according to settings
from the beets configuration. This should be called at startup.
"""
hostname = config["musicbrainz"]["host"].as_str()
https = config["musicbrainz"]["https"].get(bool)
# Only call set_hostname when a custom server is configured. Since
# musicbrainz-ngs connects to musicbrainz.org with HTTPS by default
if hostname != "musicbrainz.org":
musicbrainzngs.set_hostname(hostname, https)
musicbrainzngs.set_rate_limit(
config["musicbrainz"]["ratelimit_interval"].as_number(),
config["musicbrainz"]["ratelimit"].get(int),
)
def _preferred_alias(aliases: list):
"""Given an list of alias structures for an artist credit, select
and return the user's preferred alias alias or None if no matching
alias is found.
"""
if not aliases:
return
# Only consider aliases that have locales set.
aliases = [a for a in aliases if "locale" in a]
# Get any ignored alias types and lower case them to prevent case issues
ignored_alias_types = config["import"]["ignored_alias_types"].as_str_seq()
ignored_alias_types = [a.lower() for a in ignored_alias_types]
# Search configured locales in order.
for locale in config["import"]["languages"].as_str_seq():
# Find matching primary aliases for this locale that are not
# being ignored
matches = []
for a in aliases:
if (
a["locale"] == locale
and "primary" in a
and a.get("type", "").lower() not in ignored_alias_types
):
matches.append(a)
# Skip to the next locale if we have no matches
if not matches:
continue
return matches[0]
def _preferred_release_event(release: dict[str, Any]) -> tuple[str, str]:
"""Given a release, select and return the user's preferred release
event as a tuple of (country, release_date). Fall back to the
default release event if a preferred event is not found.
"""
countries = config["match"]["preferred"]["countries"].as_str_seq()
countries = cast(Sequence, countries)
for country in countries:
for event in release.get("release-event-list", {}):
try:
if country in event["area"]["iso-3166-1-code-list"]:
return country, event["date"]
except KeyError:
pass
return (cast(str, release.get("country")), cast(str, release.get("date")))
def _multi_artist_credit(
credit: list[dict], include_join_phrase: bool
) -> tuple[list[str], list[str], list[str]]:
"""Given a list representing an ``artist-credit`` block, accumulate
data into a triple of joined artist name lists: canonical, sort, and
credit.
"""
artist_parts = []
artist_sort_parts = []
artist_credit_parts = []
for el in credit:
if isinstance(el, str):
# Join phrase.
if include_join_phrase:
artist_parts.append(el)
artist_credit_parts.append(el)
artist_sort_parts.append(el)
else:
alias = _preferred_alias(el["artist"].get("alias-list", ()))
# An artist.
if alias:
cur_artist_name = alias["alias"]
else:
cur_artist_name = el["artist"]["name"]
artist_parts.append(cur_artist_name)
# Artist sort name.
if alias:
artist_sort_parts.append(alias["sort-name"])
elif "sort-name" in el["artist"]:
artist_sort_parts.append(el["artist"]["sort-name"])
else:
artist_sort_parts.append(cur_artist_name)
# Artist credit.
if "name" in el:
artist_credit_parts.append(el["name"])
else:
artist_credit_parts.append(cur_artist_name)
return (
artist_parts,
artist_sort_parts,
artist_credit_parts,
)
def _flatten_artist_credit(credit: list[dict]) -> tuple[str, str, str]:
"""Given a list representing an ``artist-credit`` block, flatten the
data into a triple of joined artist name strings: canonical, sort, and
credit.
"""
artist_parts, artist_sort_parts, artist_credit_parts = _multi_artist_credit(
credit, include_join_phrase=True
)
return (
"".join(artist_parts),
"".join(artist_sort_parts),
"".join(artist_credit_parts),
)
def _artist_ids(credit: list[dict]) -> list[str]:
"""
Given a list representing an ``artist-credit``,
return a list of artist IDs
"""
artist_ids: list[str] = []
for el in credit:
if isinstance(el, dict):
artist_ids.append(el["artist"]["id"])
return artist_ids
def _get_related_artist_names(relations, relation_type):
"""Given a list representing the artist relationships extract the names of
the remixers and concatenate them.
"""
related_artists = []
for relation in relations:
if relation["type"] == relation_type:
related_artists.append(relation["artist"]["name"])
return ", ".join(related_artists)
def track_info(
recording: dict,
index: int | None = None,
medium: int | None = None,
medium_index: int | None = None,
medium_total: int | None = None,
) -> beets.autotag.hooks.TrackInfo:
"""Translates a MusicBrainz recording result dictionary into a beets
``TrackInfo`` object. Three parameters are optional and are used
only for tracks that appear on releases (non-singletons): ``index``,
the overall track number; ``medium``, the disc number;
``medium_index``, the track's index on its medium; ``medium_total``,
the number of tracks on the medium. Each number is a 1-based index.
"""
info = beets.autotag.hooks.TrackInfo(
title=recording["title"],
track_id=recording["id"],
index=index,
medium=medium,
medium_index=medium_index,
medium_total=medium_total,
data_source="MusicBrainz",
data_url=track_url(recording["id"]),
)
if recording.get("artist-credit"):
# Get the artist names.
(
info.artist,
info.artist_sort,
info.artist_credit,
) = _flatten_artist_credit(recording["artist-credit"])
(
info.artists,
info.artists_sort,
info.artists_credit,
) = _multi_artist_credit(
recording["artist-credit"], include_join_phrase=False
)
info.artists_ids = _artist_ids(recording["artist-credit"])
info.artist_id = info.artists_ids[0]
if recording.get("artist-relation-list"):
info.remixer = _get_related_artist_names(
recording["artist-relation-list"], relation_type="remixer"
)
if recording.get("length"):
info.length = int(recording["length"]) / 1000.0
info.trackdisambig = recording.get("disambiguation")
if recording.get("isrc-list"):
info.isrc = ";".join(recording["isrc-list"])
lyricist = []
composer = []
composer_sort = []
for work_relation in recording.get("work-relation-list", ()):
if work_relation["type"] != "performance":
continue
info.work = work_relation["work"]["title"]
info.mb_workid = work_relation["work"]["id"]
if "disambiguation" in work_relation["work"]:
info.work_disambig = work_relation["work"]["disambiguation"]
for artist_relation in work_relation["work"].get(
"artist-relation-list", ()
):
if "type" in artist_relation:
type = artist_relation["type"]
if type == "lyricist":
lyricist.append(artist_relation["artist"]["name"])
elif type == "composer":
composer.append(artist_relation["artist"]["name"])
composer_sort.append(artist_relation["artist"]["sort-name"])
if lyricist:
info.lyricist = ", ".join(lyricist)
if composer:
info.composer = ", ".join(composer)
info.composer_sort = ", ".join(composer_sort)
arranger = []
for artist_relation in recording.get("artist-relation-list", ()):
if "type" in artist_relation:
type = artist_relation["type"]
if type == "arranger":
arranger.append(artist_relation["artist"]["name"])
if arranger:
info.arranger = ", ".join(arranger)
# Supplementary fields provided by plugins
extra_trackdatas = plugins.send("mb_track_extract", data=recording)
for extra_trackdata in extra_trackdatas:
info.update(extra_trackdata)
return info
def _set_date_str(
info: beets.autotag.hooks.AlbumInfo,
date_str: str,
original: bool = False,
):
"""Given a (possibly partial) YYYY-MM-DD string and an AlbumInfo
object, set the object's release date fields appropriately. If
`original`, then set the original_year, etc., fields.
"""
if date_str:
date_parts = date_str.split("-")
for key in ("year", "month", "day"):
if date_parts:
date_part = date_parts.pop(0)
try:
date_num = int(date_part)
except ValueError:
continue
if original:
key = "original_" + key
setattr(info, key, date_num)
def album_info(release: dict) -> beets.autotag.hooks.AlbumInfo:
"""Takes a MusicBrainz release result dictionary and returns a beets
AlbumInfo object containing the interesting data about that release.
"""
# Get artist name using join phrases.
artist_name, artist_sort_name, artist_credit_name = _flatten_artist_credit(
release["artist-credit"]
)
(
artists_names,
artists_sort_names,
artists_credit_names,
) = _multi_artist_credit(
release["artist-credit"], include_join_phrase=False
)
ntracks = sum(len(m["track-list"]) for m in release["medium-list"])
# The MusicBrainz API omits 'artist-relation-list' and 'work-relation-list'
# when the release has more than 500 tracks. So we use browse_recordings
# on chunks of tracks to recover the same information in this case.
if ntracks > BROWSE_MAXTRACKS:
log.debug("Album {} has too many tracks", release["id"])
recording_list = []
for i in range(0, ntracks, BROWSE_CHUNKSIZE):
log.debug("Retrieving tracks starting at {}", i)
recording_list.extend(
musicbrainzngs.browse_recordings(
release=release["id"],
limit=BROWSE_CHUNKSIZE,
includes=BROWSE_INCLUDES,
offset=i,
)["recording-list"]
)
track_map = {r["id"]: r for r in recording_list}
for medium in release["medium-list"]:
for recording in medium["track-list"]:
recording_info = track_map[recording["recording"]["id"]]
recording["recording"] = recording_info
# Basic info.
track_infos = []
index = 0
for medium in release["medium-list"]:
disctitle = medium.get("title")
format = medium.get("format")
if format in config["match"]["ignored_media"].as_str_seq():
continue
all_tracks = medium["track-list"]
if (
"data-track-list" in medium
and not config["match"]["ignore_data_tracks"]
):
all_tracks += medium["data-track-list"]
track_count = len(all_tracks)
if "pregap" in medium:
all_tracks.insert(0, medium["pregap"])
for track in all_tracks:
if (
"title" in track["recording"]
and track["recording"]["title"] in SKIPPED_TRACKS
):
continue
if (
"video" in track["recording"]
and track["recording"]["video"] == "true"
and config["match"]["ignore_video_tracks"]
):
continue
# Basic information from the recording.
index += 1
ti = track_info(
track["recording"],
index,
int(medium["position"]),
int(track["position"]),
track_count,
)
ti.release_track_id = track["id"]
ti.disctitle = disctitle
ti.media = format
ti.track_alt = track["number"]
# Prefer track data, where present, over recording data.
if track.get("title"):
ti.title = track["title"]
if track.get("artist-credit"):
# Get the artist names.
(
ti.artist,
ti.artist_sort,
ti.artist_credit,
) = _flatten_artist_credit(track["artist-credit"])
(
ti.artists,
ti.artists_sort,
ti.artists_credit,
) = _multi_artist_credit(
track["artist-credit"], include_join_phrase=False
)
ti.artists_ids = _artist_ids(track["artist-credit"])
ti.artist_id = ti.artists_ids[0]
if track.get("length"):
ti.length = int(track["length"]) / (1000.0)
track_infos.append(ti)
album_artist_ids = _artist_ids(release["artist-credit"])
info = beets.autotag.hooks.AlbumInfo(
album=release["title"],
album_id=release["id"],
artist=artist_name,
artist_id=album_artist_ids[0],
artists=artists_names,
artists_ids=album_artist_ids,
tracks=track_infos,
mediums=len(release["medium-list"]),
artist_sort=artist_sort_name,
artists_sort=artists_sort_names,
artist_credit=artist_credit_name,
artists_credit=artists_credit_names,
data_source="MusicBrainz",
data_url=album_url(release["id"]),
barcode=release.get("barcode"),
)
info.va = info.artist_id == VARIOUS_ARTISTS_ID
if info.va:
info.artist = config["va_name"].as_str()
info.asin = release.get("asin")
info.releasegroup_id = release["release-group"]["id"]
info.albumstatus = release.get("status")
if release["release-group"].get("title"):
info.release_group_title = release["release-group"].get("title")
# Get the disambiguation strings at the release and release group level.
if release["release-group"].get("disambiguation"):
info.releasegroupdisambig = release["release-group"].get(
"disambiguation"
)
if release.get("disambiguation"):
info.albumdisambig = release.get("disambiguation")
# Get the "classic" Release type. This data comes from a legacy API
# feature before MusicBrainz supported multiple release types.
if "type" in release["release-group"]:
reltype = release["release-group"]["type"]
if reltype:
info.albumtype = reltype.lower()
# Set the new-style "primary" and "secondary" release types.
albumtypes = []
if "primary-type" in release["release-group"]:
rel_primarytype = release["release-group"]["primary-type"]
if rel_primarytype:
albumtypes.append(rel_primarytype.lower())
if "secondary-type-list" in release["release-group"]:
if release["release-group"]["secondary-type-list"]:
for sec_type in release["release-group"]["secondary-type-list"]:
albumtypes.append(sec_type.lower())
info.albumtypes = albumtypes
# Release events.
info.country, release_date = _preferred_release_event(release)
release_group_date = release["release-group"].get("first-release-date")
if not release_date:
# Fall back if release-specific date is not available.
release_date = release_group_date
_set_date_str(info, release_date, False)
_set_date_str(info, release_group_date, True)
# Label name.
if release.get("label-info-list"):
label_info = release["label-info-list"][0]
if label_info.get("label"):
label = label_info["label"]["name"]
if label != "[no label]":
info.label = label
info.catalognum = label_info.get("catalog-number")
# Text representation data.
if release.get("text-representation"):
rep = release["text-representation"]
info.script = rep.get("script")
info.language = rep.get("language")
# Media (format).
if release["medium-list"]:
# If all media are the same, use that medium name
if len({m.get("format") for m in release["medium-list"]}) == 1:
info.media = release["medium-list"][0].get("format")
# Otherwise, let's just call it "Media"
else:
info.media = "Media"
if config["musicbrainz"]["genres"]:
sources = [
release["release-group"].get("tag-list", []),
release.get("tag-list", []),
]
genres: Counter[str] = Counter()
for source in sources:
for genreitem in source:
genres[genreitem["name"]] += int(genreitem["count"])
info.genre = "; ".join(
genre
for genre, _count in sorted(genres.items(), key=lambda g: -g[1])
)
# We might find links to external sources (Discogs, Bandcamp, ...)
external_ids = config["musicbrainz"]["external_ids"].get()
wanted_sources = {site for site, wanted in external_ids.items() if wanted}
if wanted_sources and (url_rels := release.get("url-relation-list")):
urls = {}
for source, url in product(wanted_sources, url_rels):
if f"{source}.com" in (target := url["target"]):
urls[source] = target
log.debug(
"Found link to {} release via MusicBrainz",
source.capitalize(),
)
if "discogs" in urls:
info.discogs_albumid = extract_discogs_id_regex(urls["discogs"])
if "bandcamp" in urls:
info.bandcamp_album_id = urls["bandcamp"]
if "spotify" in urls:
info.spotify_album_id = MetadataSourcePlugin._get_id(
"album", urls["spotify"], spotify_id_regex
)
if "deezer" in urls:
info.deezer_album_id = MetadataSourcePlugin._get_id(
"album", urls["deezer"], deezer_id_regex
)
if "beatport" in urls:
info.beatport_album_id = MetadataSourcePlugin._get_id(
"album", urls["beatport"], beatport_id_regex
)
if "tidal" in urls:
info.tidal_album_id = urls["tidal"].split("/")[-1]
extra_albumdatas = plugins.send("mb_album_extract", data=release)
for extra_albumdata in extra_albumdatas:
info.update(extra_albumdata)
return info
def match_album(
artist: str,
album: str,
tracks: int | None = None,
extra_tags: dict[str, Any] | None = None,
) -> Iterator[beets.autotag.hooks.AlbumInfo]:
"""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 and any other extra tags.
"""
# Build search criteria.
criteria = {"release": album.lower().strip()}
if artist is not None:
criteria["artist"] = artist.lower().strip()
else:
# Various Artists search.
criteria["arid"] = VARIOUS_ARTISTS_ID
if tracks is not None:
criteria["tracks"] = str(tracks)
# Additional search cues from existing metadata.
if extra_tags:
for tag, value in extra_tags.items():
key = FIELDS_TO_MB_KEYS[tag]
value = str(value).lower().strip()
if key == "catno":
value = value.replace(" ", "")
if value:
criteria[key] = value
# Abort if we have no search terms.
if not any(criteria.values()):
return
try:
log.debug("Searching for MusicBrainz releases with: {!r}", criteria)
res = musicbrainzngs.search_releases(
limit=config["musicbrainz"]["searchlimit"].get(int), **criteria
)
except musicbrainzngs.MusicBrainzError as exc:
raise MusicBrainzAPIError(
exc, "release search", criteria, traceback.format_exc()
)
for release in res["release-list"]:
# The search result is missing some data (namely, the tracks),
# so we just use the ID and fetch the rest of the information.
albuminfo = album_for_id(release["id"])
if albuminfo is not None:
yield albuminfo
def match_track(
artist: str,
title: str,
) -> Iterator[beets.autotag.hooks.TrackInfo]:
"""Searches for a single track and returns an iterable of TrackInfo
objects. May raise a MusicBrainzAPIError.
"""
criteria = {
"artist": artist.lower().strip(),
"recording": title.lower().strip(),
}
if not any(criteria.values()):
return
try:
res = musicbrainzngs.search_recordings(
limit=config["musicbrainz"]["searchlimit"].get(int), **criteria
)
except musicbrainzngs.MusicBrainzError as exc:
raise MusicBrainzAPIError(
exc, "recording search", criteria, traceback.format_exc()
)
for recording in res["recording-list"]:
yield track_info(recording)
def _parse_id(s: str) -> str | None:
"""Search for a MusicBrainz ID in the given string and return it. If
no ID can be found, return None.
"""
# Find the first thing that looks like a UUID/MBID.
match = re.search("[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}", s)
if match is not None:
return match.group() if match else None
return None
def _is_translation(r):
_trans_key = "transl-tracklisting"
return r["type"] == _trans_key and r["direction"] == "backward"
def _find_actual_release_from_pseudo_release(
pseudo_rel: dict,
) -> dict | None:
try:
relations = pseudo_rel["release"]["release-relation-list"]
except KeyError:
return None
# currently we only support trans(liter)ation's
translations = [r for r in relations if _is_translation(r)]
if not translations:
return None
actual_id = translations[0]["target"]
return musicbrainzngs.get_release_by_id(actual_id, RELEASE_INCLUDES)
def _merge_pseudo_and_actual_album(
pseudo: beets.autotag.hooks.AlbumInfo, actual: beets.autotag.hooks.AlbumInfo
) -> beets.autotag.hooks.AlbumInfo | None:
"""
Merges a pseudo release with its actual release.
This implementation is naive, it doesn't overwrite fields,
like status or ids.
According to the ticket PICARD-145, the main release id should be used.
But the ticket has been in limbo since over a decade now.
It also suggests the introduction of the tag `musicbrainz_pseudoreleaseid`,
but as of this field can't be found in any official Picard docs,
hence why we did not implement that for now.
"""
merged = pseudo.copy()
from_actual = {
k: actual[k]
for k in [
"media",
"mediums",
"country",
"catalognum",
"year",
"month",
"day",
"original_year",
"original_month",
"original_day",
"label",
"barcode",
"asin",
"style",
"genre",
]
}
merged.update(from_actual)
return merged
def album_for_id(releaseid: str) -> beets.autotag.hooks.AlbumInfo | None:
"""Fetches an album by its MusicBrainz ID and returns an AlbumInfo
object or None if the album is not found. May raise a
MusicBrainzAPIError.
"""
log.debug("Requesting MusicBrainz release {}", releaseid)
albumid = _parse_id(releaseid)
if not albumid:
log.debug("Invalid MBID ({0}).", releaseid)
return None
try:
res = musicbrainzngs.get_release_by_id(albumid, RELEASE_INCLUDES)
# resolve linked release relations
actual_res = None
if res["release"].get("status") == "Pseudo-Release":
actual_res = _find_actual_release_from_pseudo_release(res)
except musicbrainzngs.ResponseError:
log.debug("Album ID match failed.")
return None
except musicbrainzngs.MusicBrainzError as exc:
raise MusicBrainzAPIError(
exc, "get release by ID", albumid, traceback.format_exc()
)
# release is potentially a pseudo release
release = album_info(res["release"])
# should be None unless we're dealing with a pseudo release
if actual_res is not None:
actual_release = album_info(actual_res["release"])
return _merge_pseudo_and_actual_album(release, actual_release)
else:
return release
def track_for_id(releaseid: str) -> beets.autotag.hooks.TrackInfo | None:
"""Fetches a track by its MusicBrainz ID. Returns a TrackInfo object
or None if no track is found. May raise a MusicBrainzAPIError.
"""
trackid = _parse_id(releaseid)
if not trackid:
log.debug("Invalid MBID ({0}).", releaseid)
return None
try:
res = musicbrainzngs.get_recording_by_id(trackid, TRACK_INCLUDES)
except musicbrainzngs.ResponseError:
log.debug("Track ID match failed.")
return None
except musicbrainzngs.MusicBrainzError as exc:
raise MusicBrainzAPIError(
exc, "get recording by ID", trackid, traceback.format_exc()
)
return track_info(res["recording"])

View file

@ -6,7 +6,8 @@ statefile: state.pickle
# --------------- Plugins ---------------
plugins: []
plugins: [musicbrainz]
pluginpath: []
# --------------- Import ---------------
@ -126,19 +127,12 @@ ui:
action_default: ['bold', 'cyan']
action: ['bold', 'cyan']
# New Colors
text: ['normal']
text_faint: ['faint']
import_path: ['bold', 'blue']
import_path_items: ['bold', 'blue']
added: ['green']
removed: ['red']
changed: ['yellow']
added_highlight: ['bold', 'green']
removed_highlight: ['bold', 'red']
changed_highlight: ['bold', 'yellow']
text_diff_added: ['bold', 'red']
text_diff_added: ['bold', 'green']
text_diff_removed: ['bold', 'red']
text_diff_changed: ['bold', 'red']
action_description: ['white']
import:
indentation:
@ -163,22 +157,6 @@ sort_case_insensitive: yes
overwrite_null:
album: []
track: []
musicbrainz:
enabled: yes
host: musicbrainz.org
https: no
ratelimit: 1
ratelimit_interval: 1.0
searchlimit: 5
extra_tags: []
genres: no
external_ids:
discogs: no
bandcamp: no
spotify: no
deezer: no
beatport: no
tidal: no
match:
strong_rec_thresh: 0.04
@ -188,7 +166,7 @@ match:
missing_tracks: medium
unmatched_tracks: medium
distance_weights:
source: 2.0
data_source: 2.0
artist: 3.0
album: 3.0
media: 1.0

View file

@ -17,17 +17,31 @@
from __future__ import annotations
import contextlib
import functools
import os
import re
import sqlite3
import sys
import threading
import time
from abc import ABC
from collections import defaultdict
from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence
from sqlite3 import Connection
from typing import TYPE_CHECKING, Any, AnyStr, Callable, Generic, TypeVar, cast
from collections.abc import (
Callable,
Generator,
Iterable,
Iterator,
Mapping,
Sequence,
)
from functools import cached_property
from sqlite3 import Connection, sqlite_version_info
from typing import TYPE_CHECKING, Any, AnyStr, Generic
from typing_extensions import (
Self,
TypeVar, # default value support
)
from unidecode import unidecode
import beets
@ -49,10 +63,7 @@ if TYPE_CHECKING:
from .query import SQLiteType
D = TypeVar("D", bound="Database", default=Any)
else:
D = TypeVar("D", bound="Database")
D = TypeVar("D", bound="Database", default=Any)
FlexAttrs = dict[str, str]
@ -66,6 +77,20 @@ class DBAccessError(Exception):
"""
class DBCustomFunctionError(Exception):
"""A sqlite function registered by beets failed."""
def __init__(self):
super().__init__(
"beets defined SQLite function failed; "
"see the other errors above for details"
)
class NotFoundError(LookupError):
pass
class FormattedMapping(Mapping[str, str]):
"""A `dict`-like formatted view of a model.
@ -80,6 +105,8 @@ class FormattedMapping(Mapping[str, str]):
are replaced.
"""
model: Model
ALL_KEYS = "*"
def __init__(
@ -126,8 +153,8 @@ class FormattedMapping(Mapping[str, str]):
value = value.decode("utf-8", "ignore")
if self.for_path:
sep_repl = cast(str, beets.config["path_sep_replace"].as_str())
sep_drive = cast(str, beets.config["drive_sep_replace"].as_str())
sep_repl: str = beets.config["path_sep_replace"].as_str()
sep_drive: str = beets.config["drive_sep_replace"].as_str()
if re.match(r"^\w:", value):
value = re.sub(r"(?<=^\w):", sep_drive, value)
@ -289,19 +316,22 @@ class Model(ABC, Generic[D]):
terms.
"""
_types: dict[str, types.Type] = {}
"""Optional Types for non-fixed (i.e., flexible and computed) fields.
"""
@cached_classproperty
def _types(cls) -> dict[str, types.Type]:
"""Optional types for non-fixed (flexible and computed) fields."""
return {}
_sorts: dict[str, type[FieldSort]] = {}
"""Optional named sort criteria. The keys are strings and the values
are subclasses of `Sort`.
"""
_queries: dict[str, FieldQueryType] = {}
"""Named queries that use a field-like `name:value` syntax but which
do not relate to any specific field.
"""
@cached_classproperty
def _queries(cls) -> dict[str, FieldQueryType]:
"""Named queries that use a field-like `name:value` syntax but which
do not relate to any specific field.
"""
return {}
_always_dirty = False
"""By default, fields only become "dirty" when their value actually
@ -340,6 +370,22 @@ class Model(ABC, Generic[D]):
"""Fields in the related table."""
return cls._relation._fields.keys() - cls.shared_db_fields
@cached_property
def db(self) -> D:
"""Get the database associated with this object.
This validates that the database is attached and the object has an id.
"""
return self._check_db()
def get_fresh_from_db(self) -> Self:
"""Load this object from the database."""
model_cls = self.__class__
if obj := self.db._get(model_cls, self.id):
return obj
raise NotFoundError(f"No matching {model_cls.__name__} found") from None
@classmethod
def _getters(cls: type[Model]):
"""Return a mapping from field names to getter functions."""
@ -389,9 +435,9 @@ class Model(ABC, Generic[D]):
return obj
def __repr__(self) -> str:
return "{}({})".format(
type(self).__name__,
", ".join(f"{k}={v!r}" for k, v in dict(self).items()),
return (
f"{type(self).__name__}"
f"({', '.join(f'{k}={v!r}' for k, v in dict(self).items())})"
)
def clear_dirty(self):
@ -408,9 +454,9 @@ class Model(ABC, Generic[D]):
exception is raised otherwise.
"""
if not self._db:
raise ValueError("{} has no database".format(type(self).__name__))
raise ValueError(f"{type(self).__name__} has no database")
if need_id and not self.id:
raise ValueError("{} has no id".format(type(self).__name__))
raise ValueError(f"{type(self).__name__} has no id")
return self._db
@ -579,7 +625,6 @@ class Model(ABC, Generic[D]):
"""
if fields is None:
fields = self._fields
db = self._check_db()
# Build assignments for query.
assignments = []
@ -587,16 +632,14 @@ class Model(ABC, Generic[D]):
for key in fields:
if key != "id" and key in self._dirty:
self._dirty.remove(key)
assignments.append(key + "=?")
assignments.append(f"{key}=?")
value = self._type(key).to_sql(self[key])
subvars.append(value)
with db.transaction() as tx:
with self.db.transaction() as tx:
# Main table update.
if assignments:
query = "UPDATE {} SET {} WHERE id=?".format(
self._table, ",".join(assignments)
)
query = f"UPDATE {self._table} SET {','.join(assignments)} WHERE id=?"
subvars.append(self.id)
tx.mutate(query, subvars)
@ -604,10 +647,11 @@ class Model(ABC, Generic[D]):
for key, value in self._values_flex.items():
if key in self._dirty:
self._dirty.remove(key)
value = self._type(key).to_sql(value)
tx.mutate(
"INSERT INTO {} "
f"INSERT INTO {self._flex_table} "
"(entity_id, key, value) "
"VALUES (?, ?, ?);".format(self._flex_table),
"VALUES (?, ?, ?);",
(self.id, key, value),
)
@ -626,21 +670,16 @@ class Model(ABC, Generic[D]):
If check_revision is true, the database is only queried loaded when a
transaction has been committed since the item was last loaded.
"""
db = self._check_db()
if not self._dirty and db.revision == self._revision:
if not self._dirty and self.db.revision == self._revision:
# Exit early
return
stored_obj = db._get(type(self), self.id)
assert stored_obj is not None, f"object {self.id} not in DB"
self._values_fixed = LazyConvertDict(self)
self._values_flex = LazyConvertDict(self)
self.update(dict(stored_obj))
self.__dict__.update(self.get_fresh_from_db().__dict__)
self.clear_dirty()
def remove(self):
"""Remove the object's associated rows from the database."""
db = self._check_db()
with db.transaction() as tx:
with self.db.transaction() as tx:
tx.mutate(f"DELETE FROM {self._table} WHERE id=?", (self.id,))
tx.mutate(
f"DELETE FROM {self._flex_table} WHERE entity_id=?", (self.id,)
@ -656,7 +695,7 @@ class Model(ABC, Generic[D]):
"""
if db:
self._db = db
db = self._check_db(False)
db = self._check_db(need_id=False)
with db.transaction() as tx:
new_id = tx.mutate(f"INSERT INTO {self._table} DEFAULT VALUES")
@ -677,7 +716,7 @@ class Model(ABC, Generic[D]):
self,
included_keys: str = _formatter.ALL_KEYS,
for_path: bool = False,
):
) -> FormattedMapping:
"""Get a mapping containing all values on this object formatted
as human-readable unicode strings.
"""
@ -721,9 +760,9 @@ class Model(ABC, Generic[D]):
Remove the database connection as sqlite connections are not
picklable.
"""
state = self.__dict__.copy()
state["_db"] = None
return state
return {
k: v for k, v in self.__dict__.items() if k not in {"_db", "db"}
}
# Database controller and supporting interfaces.
@ -928,10 +967,10 @@ class Transaction:
def __exit__(
self,
exc_type: type[Exception],
exc_value: Exception,
traceback: TracebackType,
):
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> bool | None:
"""Complete a transaction. This must be the most recently
entered but not yet exited transaction. If it is the last active
transaction, the database updates are committed.
@ -947,6 +986,14 @@ class Transaction:
self._mutated = False
self.db._db_lock.release()
if (
isinstance(exc_value, sqlite3.OperationalError)
and exc_value.args[0] == "user-defined function raised exception"
):
raise DBCustomFunctionError()
return None
def query(
self, statement: str, subvals: Sequence[SQLiteType] = ()
) -> list[sqlite3.Row]:
@ -1007,6 +1054,13 @@ class Database:
"sqlite3 must be compiled with multi-threading support"
)
# Print tracebacks for exceptions in user defined functions
# See also `self.add_functions` and `DBCustomFunctionError`.
#
# `if`: use feature detection because PyPy doesn't support this.
if hasattr(sqlite3, "enable_callback_tracebacks"):
sqlite3.enable_callback_tracebacks(True)
self.path = path
self.timeout = timeout
@ -1102,9 +1156,16 @@ class Database:
return bytestring
conn.create_function("regexp", 2, regexp)
conn.create_function("unidecode", 1, unidecode)
conn.create_function("bytelower", 1, bytelower)
create_function = conn.create_function
if sys.version_info >= (3, 8) and sqlite_version_info >= (3, 8, 3):
# Let sqlite make extra optimizations
create_function = functools.partial(
conn.create_function, deterministic=True
)
create_function("regexp", 2, regexp)
create_function("unidecode", 1, unidecode)
create_function("bytelower", 1, bytelower)
def _close(self):
"""Close the all connections to the underlying SQLite database
@ -1158,7 +1219,7 @@ class Database:
"""
# Get current schema.
with self.transaction() as tx:
rows = tx.query("PRAGMA table_info(%s)" % table)
rows = tx.query(f"PRAGMA table_info({table})")
current_fields = {row[1] for row in rows}
field_names = set(fields.keys())
@ -1171,9 +1232,7 @@ class Database:
columns = []
for name, typ in fields.items():
columns.append(f"{name} {typ.sql}")
setup_sql = "CREATE TABLE {} ({});\n".format(
table, ", ".join(columns)
)
setup_sql = f"CREATE TABLE {table} ({', '.join(columns)});\n"
else:
# Table exists does not match the field set.
@ -1181,8 +1240,8 @@ class Database:
for name, typ in fields.items():
if name in current_fields:
continue
setup_sql += "ALTER TABLE {} ADD COLUMN {} {};\n".format(
table, name, typ.sql
setup_sql += (
f"ALTER TABLE {table} ADD COLUMN {name} {typ.sql};\n"
)
with self.transaction() as tx:
@ -1193,18 +1252,16 @@ class Database:
for the given entity (if they don't exist).
"""
with self.transaction() as tx:
tx.script(
"""
CREATE TABLE IF NOT EXISTS {0} (
tx.script(f"""
CREATE TABLE IF NOT EXISTS {flex_table} (
id INTEGER PRIMARY KEY,
entity_id INTEGER,
key TEXT,
value TEXT,
UNIQUE(entity_id, key) ON CONFLICT REPLACE);
CREATE INDEX IF NOT EXISTS {0}_by_entity
ON {0} (entity_id);
""".format(flex_table)
)
CREATE INDEX IF NOT EXISTS {flex_table}_by_entity
ON {flex_table} (entity_id);
""")
# Querying.
@ -1266,12 +1323,6 @@ class Database:
sort if sort.is_slow() else None, # Slow sort component.
)
def _get(
self,
model_cls: type[AnyModel],
id,
) -> AnyModel | None:
"""Get a Model object by its id or None if the id does not
exist.
"""
return self._fetch(model_cls, MatchQuery("id", id)).get()
def _get(self, model_cls: type[AnyModel], id_: int) -> AnyModel | None:
"""Get a Model object by its id or None if the id does not exist."""
return self._fetch(model_cls, MatchQuery("id", id_)).get()

View file

@ -16,26 +16,34 @@
from __future__ import annotations
import os
import re
import unicodedata
from abc import ABC, abstractmethod
from collections.abc import Iterator, MutableSequence, Sequence
from datetime import datetime, timedelta
from functools import reduce
from functools import cached_property, reduce
from operator import mul, or_
from re import Pattern
from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union
from beets import util
from beets.util.units import raw_seconds_short
if TYPE_CHECKING:
from beets.dbcore import Model
from beets.dbcore.db import AnyModel
from beets.dbcore.db import AnyModel, Model
P = TypeVar("P", default=Any)
else:
P = TypeVar("P")
# To use the SQLite "blob" type, it doesn't suffice to provide a byte
# string; SQLite treats that as encoded text. Wrapping it in a
# `memoryview` tells it that we actually mean non-text data.
# needs to be defined in here due to circular import.
# TODO: remove it from this module and define it in dbcore/types.py instead
BLOB_TYPE = memoryview
class ParsingError(ValueError):
"""Abstract class for any unparsable user-requested album/query
@ -78,6 +86,7 @@ class Query(ABC):
"""Return a set with field names that this query operates on."""
return set()
@abstractmethod
def clause(self) -> tuple[str | None, Sequence[Any]]:
"""Generate an SQLite expression implementing the query.
@ -88,14 +97,12 @@ class Query(ABC):
The default implementation returns None, falling back to a slow query
using `match()`.
"""
return None, ()
@abstractmethod
def match(self, obj: Model):
"""Check whether this query matches a given Model. Can be used to
perform queries on arbitrary sets of Model.
"""
...
def __and__(self, other: Query) -> AndQuery:
return AndQuery([self, other])
@ -145,7 +152,7 @@ class FieldQuery(Query, Generic[P]):
self.fast = fast
def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
return self.field, ()
raise NotImplementedError
def clause(self) -> tuple[str | None, Sequence[SQLiteType]]:
if self.fast:
@ -157,7 +164,7 @@ class FieldQuery(Query, Generic[P]):
@classmethod
def value_match(cls, pattern: P, value: Any):
"""Determine whether the value matches the pattern."""
raise NotImplementedError()
raise NotImplementedError
def match(self, obj: Model) -> bool:
return self.value_match(self.pattern, obj.get(self.field_name))
@ -183,7 +190,7 @@ class MatchQuery(FieldQuery[AnySQLiteType]):
"""A query that looks for exact matches in an Model field."""
def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
return self.field + " = ?", [self.pattern]
return f"{self.field} = ?", [self.pattern]
@classmethod
def value_match(cls, pattern: AnySQLiteType, value: Any) -> bool:
@ -197,7 +204,7 @@ class NoneQuery(FieldQuery[None]):
super().__init__(field, None, fast)
def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
return self.field + " IS NULL", ()
return f"{self.field} IS NULL", ()
def match(self, obj: Model) -> bool:
return obj.get(self.field_name) is None
@ -227,7 +234,7 @@ class StringFieldQuery(FieldQuery[P]):
"""Determine whether the value matches the pattern. Both
arguments are strings. Subclasses implement this method.
"""
raise NotImplementedError()
raise NotImplementedError
class StringQuery(StringFieldQuery[str]):
@ -239,7 +246,7 @@ class StringQuery(StringFieldQuery[str]):
.replace("%", "\\%")
.replace("_", "\\_")
)
clause = self.field + " like ? escape '\\'"
clause = f"{self.field} like ? escape '\\'"
subvals = [search]
return clause, subvals
@ -257,8 +264,8 @@ class SubstringQuery(StringFieldQuery[str]):
.replace("%", "\\%")
.replace("_", "\\_")
)
search = "%" + pattern + "%"
clause = self.field + " like ? escape '\\'"
search = f"%{pattern}%"
clause = f"{self.field} like ? escape '\\'"
subvals = [search]
return clause, subvals
@ -267,6 +274,91 @@ class SubstringQuery(StringFieldQuery[str]):
return pattern.lower() in value.lower()
class PathQuery(FieldQuery[bytes]):
"""A query that matches all items under a given path.
Matching can either be case-insensitive or case-sensitive. By
default, the behavior depends on the OS: case-insensitive on Windows
and case-sensitive otherwise.
"""
def __init__(self, field: str, pattern: bytes, fast: bool = True) -> None:
"""Create a path query.
`pattern` must be a path, either to a file or a directory.
"""
path = util.normpath(pattern)
# Case sensitivity depends on the filesystem that the query path is located on.
self.case_sensitive = util.case_sensitive(path)
# Use a normalized-case pattern for case-insensitive matches.
if not self.case_sensitive:
# We need to lowercase the entire path, not just the pattern.
# In particular, on Windows, the drive letter is otherwise not
# lowercased.
# This also ensures that the `match()` method below and the SQL
# from `col_clause()` do the same thing.
path = path.lower()
super().__init__(field, path, fast)
@cached_property
def dir_path(self) -> bytes:
return os.path.join(self.pattern, b"")
@staticmethod
def is_path_query(query_part: str) -> bool:
"""Try to guess whether a unicode query part is a path query.
The path query must
1. precede the colon in the query, if a colon is present
2. contain either ``os.sep`` or ``os.altsep`` (Windows)
3. this path must exist on the filesystem.
"""
query_part = query_part.split(":")[0]
return (
# make sure the query part contains a path separator
bool(set(query_part) & {os.sep, os.altsep})
and os.path.exists(util.normpath(query_part))
)
def match(self, obj: Model) -> bool:
"""Check whether a model object's path matches this query.
Performs either an exact match against the pattern or checks if the path
starts with the given directory path. Case sensitivity depends on the object's
filesystem as determined during initialization.
"""
path = obj.path if self.case_sensitive else obj.path.lower()
return (path == self.pattern) or path.startswith(self.dir_path)
def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
"""Generate an SQL clause that implements path matching in the database.
Returns a tuple of SQL clause string and parameter values list that matches
paths either exactly or by directory prefix. Handles case sensitivity
appropriately using BYTELOWER for case-insensitive matches.
"""
if self.case_sensitive:
left, right = self.field, "?"
else:
left, right = f"BYTELOWER({self.field})", "BYTELOWER(?)"
return f"({left} = {right}) || (substr({left}, 1, ?) = {right})", [
BLOB_TYPE(self.pattern),
len(dir_blob := BLOB_TYPE(self.dir_path)),
dir_blob,
]
def __repr__(self) -> str:
return (
f"{self.__class__.__name__}({self.field!r}, {self.pattern!r}, "
f"fast={self.fast}, case_sensitive={self.case_sensitive})"
)
class RegexpQuery(StringFieldQuery[Pattern[str]]):
"""A query that matches a regular expression in a specific Model field.
@ -320,39 +412,6 @@ class BooleanQuery(MatchQuery[int]):
super().__init__(field_name, pattern_int, fast)
class BytesQuery(FieldQuery[bytes]):
"""Match a raw bytes field (i.e., a path). This is a necessary hack
to work around the `sqlite3` module's desire to treat `bytes` and
`unicode` equivalently in Python 2. Always use this query instead of
`MatchQuery` when matching on BLOB values.
"""
def __init__(self, field_name: str, pattern: bytes | str | memoryview):
# Use a buffer/memoryview representation of the pattern for SQLite
# matching. This instructs SQLite to treat the blob as binary
# rather than encoded Unicode.
if isinstance(pattern, (str, bytes)):
if isinstance(pattern, str):
bytes_pattern = pattern.encode("utf-8")
else:
bytes_pattern = pattern
self.buf_pattern = memoryview(bytes_pattern)
elif isinstance(pattern, memoryview):
self.buf_pattern = pattern
bytes_pattern = bytes(pattern)
else:
raise ValueError("pattern must be bytes, str, or memoryview")
super().__init__(field_name, bytes_pattern)
def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
return self.field + " = ?", [self.buf_pattern]
@classmethod
def value_match(cls, pattern: bytes, value: Any) -> bool:
return pattern == value
class NumericQuery(FieldQuery[str]):
"""Matches numeric fields. A syntax using Ruby-style range ellipses
(``..``) lets users specify one- or two-sided ranges. For example,
@ -412,11 +471,11 @@ class NumericQuery(FieldQuery[str]):
def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
if self.point is not None:
return self.field + "=?", (self.point,)
return f"{self.field}=?", (self.point,)
else:
if self.rangemin is not None and self.rangemax is not None:
return (
"{0} >= ? AND {0} <= ?".format(self.field),
f"{self.field} >= ? AND {self.field} <= ?",
(self.rangemin, self.rangemax),
)
elif self.rangemin is not None:
@ -490,9 +549,9 @@ class CollectionQuery(Query):
if not subq_clause:
# Fall back to slow query.
return None, ()
clause_parts.append("(" + subq_clause + ")")
clause_parts.append(f"({subq_clause})")
subvals += subq_subvals
clause = (" " + joiner + " ").join(clause_parts)
clause = f" {joiner} ".join(clause_parts)
return clause, subvals
def __repr__(self) -> str:
@ -631,9 +690,7 @@ class Period:
("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"), # second
)
relative_units = {"y": 365, "m": 30, "w": 7, "d": 1}
relative_re = (
"(?P<sign>[+|-]?)(?P<quantity>[0-9]+)" + "(?P<timespan>[y|m|w|d])"
)
relative_re = "(?P<sign>[+|-]?)(?P<quantity>[0-9]+)(?P<timespan>[y|m|w|d])"
def __init__(self, date: datetime, precision: str):
"""Create a period with the given date (a `datetime` object) and
@ -741,9 +798,7 @@ class DateInterval:
def __init__(self, start: datetime | None, end: datetime | None):
if start is not None and end is not None and not start < end:
raise ValueError(
"start date {} is not before end date {}".format(start, end)
)
raise ValueError(f"start date {start} is not before end date {end}")
self.start = start
self.end = end
@ -791,8 +846,6 @@ class DateQuery(FieldQuery[str]):
date = datetime.fromtimestamp(timestamp)
return self.interval.contains(date)
_clause_tmpl = "{0} {1} ?"
def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
clause_parts = []
subvals = []
@ -800,11 +853,11 @@ class DateQuery(FieldQuery[str]):
# Convert the `datetime` objects to an integer number of seconds since
# the (local) Unix epoch using `datetime.timestamp()`.
if self.interval.start:
clause_parts.append(self._clause_tmpl.format(self.field, ">="))
clause_parts.append(f"{self.field} >= ?")
subvals.append(int(self.interval.start.timestamp()))
if self.interval.end:
clause_parts.append(self._clause_tmpl.format(self.field, "<"))
clause_parts.append(f"{self.field} < ?")
subvals.append(int(self.interval.end.timestamp()))
if clause_parts:
@ -834,7 +887,7 @@ class DurationQuery(NumericQuery):
if not s:
return None
try:
return util.raw_seconds_short(s)
return raw_seconds_short(s)
except ValueError:
try:
return float(s)
@ -844,6 +897,24 @@ class DurationQuery(NumericQuery):
)
class SingletonQuery(FieldQuery[str]):
"""This query is responsible for the 'singleton' lookup.
It is based on the FieldQuery and constructs a SQL clause
'album_id is NULL' which yields the same result as the previous filter
in Python but is more performant since it's done in SQL.
Using util.str2bool ensures that lookups like singleton:true, singleton:1
and singleton:false, singleton:0 are handled consistently.
"""
def __new__(cls, field: str, value: str, *args, **kwargs):
query = NoneQuery("album_id")
if util.str2bool(value):
return query
return NotQuery(query)
# Sorting.
@ -997,9 +1068,9 @@ class FixedFieldSort(FieldSort):
if self.case_insensitive:
field = (
"(CASE "
"WHEN TYPEOF({0})='text' THEN LOWER({0}) "
"WHEN TYPEOF({0})='blob' THEN LOWER({0}) "
"ELSE {0} END)".format(self.field)
f"WHEN TYPEOF({self.field})='text' THEN LOWER({self.field}) "
f"WHEN TYPEOF({self.field})='blob' THEN LOWER({self.field}) "
f"ELSE {self.field} END)"
)
else:
field = self.field

View file

@ -16,19 +16,20 @@
from __future__ import annotations
import re
import time
import typing
from abc import ABC
from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast
from beets.util import str2bool
import beets
from beets import util
from beets.util.units import human_seconds_short, raw_seconds_short
from .query import (
BooleanQuery,
FieldQueryType,
NumericQuery,
SQLiteType,
SubstringQuery,
)
from . import query
SQLiteType = query.SQLiteType
BLOB_TYPE = query.BLOB_TYPE
class ModelType(typing.Protocol):
@ -61,7 +62,7 @@ class Type(ABC, Generic[T, N]):
"""The SQLite column type for the value.
"""
query: FieldQueryType = SubstringQuery
query: query.FieldQueryType = query.SubstringQuery
"""The `Query` subclass to be used when querying the field.
"""
@ -160,7 +161,7 @@ class BaseInteger(Type[int, N]):
"""A basic integer type."""
sql = "INTEGER"
query = NumericQuery
query = query.NumericQuery
model_type = int
def normalize(self, value: Any) -> int | N:
@ -193,7 +194,7 @@ class BasePaddedInt(BaseInteger[N]):
self.digits = digits
def format(self, value: int | N) -> str:
return "{0:0{1}d}".format(value or 0, self.digits)
return f"{value or 0:0{self.digits}d}"
class PaddedInt(BasePaddedInt[int]):
@ -218,7 +219,7 @@ class ScaledInt(Integer):
self.suffix = suffix
def format(self, value: int) -> str:
return "{}{}".format((value or 0) // self.unit, self.suffix)
return f"{(value or 0) // self.unit}{self.suffix}"
class Id(NullInteger):
@ -241,14 +242,14 @@ class BaseFloat(Type[float, N]):
"""
sql = "REAL"
query: FieldQueryType = NumericQuery
query: query.FieldQueryType = query.NumericQuery
model_type = float
def __init__(self, digits: int = 1):
self.digits = digits
def format(self, value: float | N) -> str:
return "{0:.{1}f}".format(value or 0, self.digits)
return f"{value or 0:.{self.digits}f}"
class Float(BaseFloat[float]):
@ -271,7 +272,7 @@ class BaseString(Type[T, N]):
"""A Unicode string type."""
sql = "TEXT"
query = SubstringQuery
query = query.SubstringQuery
def normalize(self, value: Any) -> T | N:
if value is None:
@ -291,7 +292,7 @@ class DelimitedString(BaseString[list[str], list[str]]):
containing delimiter-separated values.
"""
model_type = list
model_type = list[str]
def __init__(self, delimiter: str):
self.delimiter = delimiter
@ -312,14 +313,145 @@ class Boolean(Type):
"""A boolean type."""
sql = "INTEGER"
query = BooleanQuery
query = query.BooleanQuery
model_type = bool
def format(self, value: bool) -> str:
return str(bool(value))
def parse(self, string: str) -> bool:
return str2bool(string)
return util.str2bool(string)
class DateType(Float):
# TODO representation should be `datetime` object
# TODO distinguish between date and time types
query = query.DateQuery
def format(self, value):
return time.strftime(
beets.config["time_format"].as_str(), time.localtime(value or 0)
)
def parse(self, string):
try:
# Try a formatted date string.
return time.mktime(
time.strptime(string, beets.config["time_format"].as_str())
)
except ValueError:
# Fall back to a plain timestamp number.
try:
return float(string)
except ValueError:
return self.null
class BasePathType(Type[bytes, N]):
"""A dbcore type for filesystem paths.
These are represented as `bytes` objects, in keeping with
the Unix filesystem abstraction.
"""
sql = "BLOB"
query = query.PathQuery
model_type = bytes
def parse(self, string: str) -> bytes:
return util.normpath(string)
def normalize(self, value: Any) -> bytes | N:
if isinstance(value, str):
# Paths stored internally as encoded bytes.
return util.bytestring_path(value)
elif isinstance(value, BLOB_TYPE):
# We unwrap buffers to bytes.
return bytes(value)
else:
return value
def from_sql(self, sql_value):
return self.normalize(sql_value)
def to_sql(self, value: bytes) -> BLOB_TYPE:
if isinstance(value, bytes):
value = BLOB_TYPE(value)
return value
class NullPathType(BasePathType[None]):
@property
def null(self) -> None:
return None
def format(self, value: bytes | None) -> str:
return util.displayable_path(value or b"")
class PathType(BasePathType[bytes]):
@property
def null(self) -> bytes:
return b""
def format(self, value: bytes) -> str:
return util.displayable_path(value or b"")
class MusicalKey(String):
"""String representing the musical key of a song.
The standard format is C, Cm, C#, C#m, etc.
"""
ENHARMONIC = {
r"db": "c#",
r"eb": "d#",
r"gb": "f#",
r"ab": "g#",
r"bb": "a#",
}
null = None
def parse(self, key):
key = key.lower()
for flat, sharp in self.ENHARMONIC.items():
key = re.sub(flat, sharp, key)
key = re.sub(r"[\W\s]+minor", "m", key)
key = re.sub(r"[\W\s]+major", "", key)
return key.capitalize()
def normalize(self, key):
if key is None:
return None
else:
return self.parse(key)
class DurationType(Float):
"""Human-friendly (M:SS) representation of a time interval."""
query = query.DurationQuery
def format(self, value):
if not beets.config["format_raw_length"].get(bool):
return human_seconds_short(value or 0.0)
else:
return value
def parse(self, string):
try:
# Try to format back hh:ss to seconds.
return raw_seconds_short(string)
except ValueError:
# Fall back to a plain float.
try:
return float(string)
except ValueError:
return self.null
# Shared instances of common types.
@ -331,6 +463,7 @@ FLOAT = Float()
NULL_FLOAT = NullFloat()
STRING = String()
BOOLEAN = Boolean()
DATE = DateType()
SEMICOLON_SPACE_DSV = DelimitedString(delimiter="; ")
# Will set the proper null char in mediafile

View file

@ -1,4 +1,5 @@
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
@ -11,17 +12,27 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Deprecation warning for the removed gmusic plugin."""
"""Provides the basic, interface-agnostic workflow for importing and
autotagging music files.
"""
from beets.plugins import BeetsPlugin
from .session import ImportAbortError, ImportSession
from .tasks import (
Action,
ArchiveImportTask,
ImportTask,
SentinelImportTask,
SingletonImportTask,
)
# Note: Stages are not exposed to the public API
class Gmusic(BeetsPlugin):
def __init__(self):
super().__init__()
self._log.warning(
"The 'gmusic' plugin has been removed following the"
" shutdown of Google Play Music. Remove the plugin"
" from your configuration to silence this warning."
)
__all__ = [
"ImportSession",
"ImportAbortError",
"Action",
"ImportTask",
"ArchiveImportTask",
"SentinelImportTask",
"SingletonImportTask",
]

308
beets/importer/session.py Normal file
View file

@ -0,0 +1,308 @@
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
# 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 annotations
import os
import time
from typing import TYPE_CHECKING
from beets import config, dbcore, library, logging, plugins, util
from beets.importer.tasks import Action
from beets.util import displayable_path, normpath, pipeline, syspath
from . import stages as stagefuncs
from .state import ImportState
if TYPE_CHECKING:
from collections.abc import Sequence
from beets.util import PathBytes
from .tasks import ImportTask
QUEUE_SIZE = 128
# Global logger.
log = logging.getLogger("beets")
class ImportAbortError(Exception):
"""Raised when the user aborts the tagging operation."""
pass
class ImportSession:
"""Controls an import action. Subclasses should implement methods to
communicate with the user or otherwise make decisions.
"""
logger: logging.Logger
paths: list[PathBytes]
lib: library.Library
_is_resuming: dict[bytes, bool]
_merged_items: set[PathBytes]
_merged_dirs: set[PathBytes]
def __init__(
self,
lib: library.Library,
loghandler: logging.Handler | None,
paths: Sequence[PathBytes] | None,
query: dbcore.Query | None,
):
"""Create a session.
Parameters
----------
lib : library.Library
The library instance to which items will be imported.
loghandler : logging.Handler or None
A logging handler to use for the session's logger. If None, a
NullHandler will be used.
paths : os.PathLike or None
The paths to be imported.
query : dbcore.Query or None
A query to filter items for import.
"""
self.lib = lib
self.logger = self._setup_logging(loghandler)
self.query = query
self._is_resuming = {}
self._merged_items = set()
self._merged_dirs = set()
# Normalize the paths.
self.paths = list(map(normpath, paths or []))
def _setup_logging(self, loghandler: logging.Handler | None):
logger = logging.getLogger(__name__)
logger.propagate = False
if not loghandler:
loghandler = logging.NullHandler()
logger.handlers = [loghandler]
return logger
def set_config(self, config):
"""Set `config` property from global import config and make
implied changes.
"""
# FIXME: Maybe this function should not exist and should instead
# provide "decision wrappers" like "should_resume()", etc.
iconfig = dict(config)
self.config = iconfig
# Incremental and progress are mutually exclusive.
if iconfig["incremental"]:
iconfig["resume"] = False
# When based on a query instead of directories, never
# save progress or try to resume.
if self.query is not None:
iconfig["resume"] = False
iconfig["incremental"] = False
if iconfig["reflink"]:
iconfig["reflink"] = iconfig["reflink"].as_choice(
["auto", True, False]
)
# Copy, move, reflink, link, and hardlink are mutually exclusive.
if iconfig["move"]:
iconfig["copy"] = False
iconfig["link"] = False
iconfig["hardlink"] = False
iconfig["reflink"] = False
elif iconfig["link"]:
iconfig["copy"] = False
iconfig["move"] = False
iconfig["hardlink"] = False
iconfig["reflink"] = False
elif iconfig["hardlink"]:
iconfig["copy"] = False
iconfig["move"] = False
iconfig["link"] = False
iconfig["reflink"] = False
elif iconfig["reflink"]:
iconfig["copy"] = False
iconfig["move"] = False
iconfig["link"] = False
iconfig["hardlink"] = False
# Only delete when copying.
if not iconfig["copy"]:
iconfig["delete"] = False
self.want_resume = config["resume"].as_choice([True, False, "ask"])
def tag_log(self, status, paths: Sequence[PathBytes]):
"""Log a message about a given album to the importer log. The status
should reflect the reason the album couldn't be tagged.
"""
self.logger.info("{} {}", status, displayable_path(paths))
def log_choice(self, task: ImportTask, duplicate=False):
"""Logs the task's current choice if it should be logged. If
``duplicate``, then this is a secondary choice after a duplicate was
detected and a decision was made.
"""
paths = task.paths
if duplicate:
# Duplicate: log all three choices (skip, keep both, and trump).
if task.should_remove_duplicates:
self.tag_log("duplicate-replace", paths)
elif task.choice_flag in (Action.ASIS, Action.APPLY):
self.tag_log("duplicate-keep", paths)
elif task.choice_flag is Action.SKIP:
self.tag_log("duplicate-skip", paths)
else:
# Non-duplicate: log "skip" and "asis" choices.
if task.choice_flag is Action.ASIS:
self.tag_log("asis", paths)
elif task.choice_flag is Action.SKIP:
self.tag_log("skip", paths)
def should_resume(self, path: PathBytes):
raise NotImplementedError
def choose_match(self, task: ImportTask):
raise NotImplementedError
def resolve_duplicate(self, task: ImportTask, found_duplicates):
raise NotImplementedError
def choose_item(self, task: ImportTask):
raise NotImplementedError
def run(self):
"""Run the import task."""
self.logger.info("import started {}", time.asctime())
self.set_config(config["import"])
# Set up the pipeline.
if self.query is None:
stages = [stagefuncs.read_tasks(self)]
else:
stages = [stagefuncs.query_tasks(self)]
# In pretend mode, just log what would otherwise be imported.
if self.config["pretend"]:
stages += [stagefuncs.log_files(self)]
else:
if self.config["group_albums"] and not self.config["singletons"]:
# Split directory tasks into one task for each album.
stages += [stagefuncs.group_albums(self)]
# These stages either talk to the user to get a decision or,
# in the case of a non-autotagged import, just choose to
# import everything as-is. In *both* cases, these stages
# also add the music to the library database, so later
# stages need to read and write data from there.
if self.config["autotag"]:
stages += [
stagefuncs.lookup_candidates(self),
stagefuncs.user_query(self),
]
else:
stages += [stagefuncs.import_asis(self)]
# Plugin stages.
for stage_func in plugins.early_import_stages():
stages.append(stagefuncs.plugin_stage(self, stage_func))
for stage_func in plugins.import_stages():
stages.append(stagefuncs.plugin_stage(self, stage_func))
stages += [stagefuncs.manipulate_files(self)]
pl = pipeline.Pipeline(stages)
# Run the pipeline.
plugins.send("import_begin", session=self)
try:
if config["threaded"]:
pl.run_parallel(QUEUE_SIZE)
else:
pl.run_sequential()
except ImportAbortError:
# User aborted operation. Silently stop.
pass
# Incremental and resumed imports
def already_imported(self, toppath: PathBytes, paths: Sequence[PathBytes]):
"""Returns true if the files belonging to this task have already
been imported in a previous session.
"""
if self.is_resuming(toppath) and all(
[ImportState().progress_has_element(toppath, p) for p in paths]
):
return True
if self.config["incremental"] and tuple(paths) in self.history_dirs:
return True
return False
_history_dirs = None
@property
def history_dirs(self) -> set[tuple[PathBytes, ...]]:
# FIXME: This could be simplified to a cached property
if self._history_dirs is None:
self._history_dirs = ImportState().taghistory
return self._history_dirs
def already_merged(self, paths: Sequence[PathBytes]):
"""Returns true if all the paths being imported were part of a merge
during previous tasks.
"""
for path in paths:
if path not in self._merged_items and path not in self._merged_dirs:
return False
return True
def mark_merged(self, paths: Sequence[PathBytes]):
"""Mark paths and directories as merged for future reimport tasks."""
self._merged_items.update(paths)
dirs = {
os.path.dirname(path) if os.path.isfile(syspath(path)) else path
for path in paths
}
self._merged_dirs.update(dirs)
def is_resuming(self, toppath: PathBytes):
"""Return `True` if user wants to resume import of this path.
You have to call `ask_resume` first to determine the return value.
"""
return self._is_resuming.get(toppath, False)
def ask_resume(self, toppath: PathBytes):
"""If import of `toppath` was aborted in an earlier session, ask
user if they want to resume the import.
Determines the return value of `is_resuming(toppath)`.
"""
if self.want_resume and ImportState().progress_has(toppath):
# Either accept immediately or prompt for input to decide.
if self.want_resume is True or self.should_resume(toppath):
log.warning(
"Resuming interrupted import of {}",
util.displayable_path(toppath),
)
self._is_resuming[toppath] = True
else:
# Clear progress; we're starting from the top.
ImportState().progress_reset(toppath)

392
beets/importer/stages.py Normal file
View file

@ -0,0 +1,392 @@
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
# 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 annotations
import itertools
import logging
from typing import TYPE_CHECKING
from beets import config, plugins
from beets.util import MoveOperation, displayable_path, pipeline
from .tasks import (
Action,
ImportTask,
ImportTaskFactory,
SentinelImportTask,
SingletonImportTask,
)
if TYPE_CHECKING:
from collections.abc import Callable
from beets import library
from .session import ImportSession
# Global logger.
log = logging.getLogger("beets")
# ---------------------------- Producer functions ---------------------------- #
# Functions that are called first i.e. they generate import tasks
def read_tasks(session: ImportSession):
"""A generator yielding all the albums (as ImportTask objects) found
in the user-specified list of paths. In the case of a singleton
import, yields single-item tasks instead.
"""
skipped = 0
for toppath in session.paths:
# Check whether we need to resume the import.
session.ask_resume(toppath)
# Generate tasks.
task_factory = ImportTaskFactory(toppath, session)
yield from task_factory.tasks()
skipped += task_factory.skipped
if not task_factory.imported:
log.warning("No files imported from {}", displayable_path(toppath))
# Show skipped directories (due to incremental/resume).
if skipped:
log.info("Skipped {} paths.", skipped)
def query_tasks(session: ImportSession):
"""A generator that works as a drop-in-replacement for read_tasks.
Instead of finding files from the filesystem, a query is used to
match items from the library.
"""
task: ImportTask
if session.config["singletons"]:
# Search for items.
for item in session.lib.items(session.query):
task = SingletonImportTask(None, item)
for task in task.handle_created(session):
yield task
else:
# Search for albums.
for album in session.lib.albums(session.query):
log.debug(
"yielding album {0.id}: {0.albumartist} - {0.album}", album
)
items = list(album.items())
_freshen_items(items)
task = ImportTask(None, [album.item_dir()], items)
for task in task.handle_created(session):
yield task
# ---------------------------------- Stages ---------------------------------- #
# Functions that process import tasks, may transform or filter them
# They are chained together in the pipeline e.g. stage2(stage1(task)) -> task
def group_albums(session: ImportSession):
"""A pipeline stage that groups the items of each task into albums
using their metadata.
Groups are identified using their artist and album fields. The
pipeline stage emits new album tasks for each discovered group.
"""
def group(item):
return (item.albumartist or item.artist, item.album)
task = None
while True:
task = yield task
if task.skip:
continue
tasks = []
sorted_items: list[library.Item] = sorted(task.items, key=group)
for _, items in itertools.groupby(sorted_items, group):
l_items = list(items)
task = ImportTask(task.toppath, [i.path for i in l_items], l_items)
tasks += task.handle_created(session)
tasks.append(SentinelImportTask(task.toppath, task.paths))
task = pipeline.multiple(tasks)
@pipeline.mutator_stage
def lookup_candidates(session: ImportSession, task: ImportTask):
"""A coroutine for performing the initial MusicBrainz lookup for an
album. It accepts lists of Items and yields
(items, cur_artist, cur_album, candidates, rec) tuples. If no match
is found, all of the yielded parameters (except items) are None.
"""
if task.skip:
# FIXME This gets duplicated a lot. We need a better
# abstraction.
return
plugins.send("import_task_start", session=session, task=task)
log.debug("Looking up: {}", displayable_path(task.paths))
# Restrict the initial lookup to IDs specified by the user via the -m
# option. Currently all the IDs are passed onto the tasks directly.
task.lookup_candidates(session.config["search_ids"].as_str_seq())
@pipeline.stage
def user_query(session: ImportSession, task: ImportTask):
"""A coroutine for interfacing with the user about the tagging
process.
The coroutine accepts an ImportTask objects. It uses the
session's `choose_match` method to determine the `action` for
this task. Depending on the action additional stages are executed
and the processed task is yielded.
It emits the ``import_task_choice`` event for plugins. Plugins have
access to the choice via the ``task.choice_flag`` property and may
choose to change it.
"""
if task.skip:
return task
if session.already_merged(task.paths):
return pipeline.BUBBLE
# Ask the user for a choice.
task.choose_match(session)
plugins.send("import_task_choice", session=session, task=task)
# As-tracks: transition to singleton workflow.
if task.choice_flag is Action.TRACKS:
# Set up a little pipeline for dealing with the singletons.
def emitter(task):
for item in task.items:
task = SingletonImportTask(task.toppath, item)
yield from task.handle_created(session)
yield SentinelImportTask(task.toppath, task.paths)
return _extend_pipeline(
emitter(task), lookup_candidates(session), user_query(session)
)
# As albums: group items by albums and create task for each album
if task.choice_flag is Action.ALBUMS:
return _extend_pipeline(
[task],
group_albums(session),
lookup_candidates(session),
user_query(session),
)
_resolve_duplicates(session, task)
if task.should_merge_duplicates:
# Create a new task for tagging the current items
# and duplicates together
duplicate_items = task.duplicate_items(session.lib)
# Duplicates would be reimported so make them look "fresh"
_freshen_items(duplicate_items)
duplicate_paths = [item.path for item in duplicate_items]
# Record merged paths in the session so they are not reimported
session.mark_merged(duplicate_paths)
merged_task = ImportTask(
None, task.paths + duplicate_paths, task.items + duplicate_items
)
return _extend_pipeline(
[merged_task], lookup_candidates(session), user_query(session)
)
_apply_choice(session, task)
return task
@pipeline.mutator_stage
def import_asis(session: ImportSession, task: ImportTask):
"""Select the `action.ASIS` choice for all tasks.
This stage replaces the initial_lookup and user_query stages
when the importer is run without autotagging.
"""
if task.skip:
return
log.info("{}", displayable_path(task.paths))
task.set_choice(Action.ASIS)
_apply_choice(session, task)
@pipeline.mutator_stage
def plugin_stage(
session: ImportSession,
func: Callable[[ImportSession, ImportTask], None],
task: ImportTask,
):
"""A coroutine (pipeline stage) that calls the given function with
each non-skipped import task. These stages occur between applying
metadata changes and moving/copying/writing files.
"""
if task.skip:
return
func(session, task)
# Stage may modify DB, so re-load cached item data.
# FIXME Importer plugins should not modify the database but instead
# the albums and items attached to tasks.
task.reload()
@pipeline.stage
def log_files(session: ImportSession, task: ImportTask):
"""A coroutine (pipeline stage) to log each file to be imported."""
if isinstance(task, SingletonImportTask):
log.info("Singleton: {}", displayable_path(task.item["path"]))
elif task.items:
log.info("Album: {}", displayable_path(task.paths[0]))
for item in task.items:
log.info(" {}", displayable_path(item["path"]))
# --------------------------------- Consumer --------------------------------- #
# Anything that should be placed last in the pipeline
# In theory every stage could be a consumer, but in practice there are some
# functions which are typically placed last in the pipeline
@pipeline.stage
def manipulate_files(session: ImportSession, task: ImportTask):
"""A coroutine (pipeline stage) that performs necessary file
manipulations *after* items have been added to the library and
finalizes each task.
"""
if not task.skip:
if task.should_remove_duplicates:
task.remove_duplicates(session.lib)
if session.config["move"]:
operation = MoveOperation.MOVE
elif session.config["copy"]:
operation = MoveOperation.COPY
elif session.config["link"]:
operation = MoveOperation.LINK
elif session.config["hardlink"]:
operation = MoveOperation.HARDLINK
elif session.config["reflink"] == "auto":
operation = MoveOperation.REFLINK_AUTO
elif session.config["reflink"]:
operation = MoveOperation.REFLINK
else:
operation = None
task.manipulate_files(
session=session,
operation=operation,
write=session.config["write"],
)
# Progress, cleanup, and event.
task.finalize(session)
# ---------------------------- Utility functions ----------------------------- #
# Private functions only used in the stages above
def _apply_choice(session: ImportSession, task: ImportTask):
"""Apply the task's choice to the Album or Item it contains and add
it to the library.
"""
if task.skip:
return
# Change metadata.
if task.apply:
task.apply_metadata()
plugins.send("import_task_apply", session=session, task=task)
task.add(session.lib)
# If ``set_fields`` is set, set those fields to the
# configured values.
# NOTE: This cannot be done before the ``task.add()`` call above,
# because then the ``ImportTask`` won't have an `album` for which
# it can set the fields.
if config["import"]["set_fields"]:
task.set_fields(session.lib)
def _resolve_duplicates(session: ImportSession, task: ImportTask):
"""Check if a task conflicts with items or albums already imported
and ask the session to resolve this.
"""
if task.choice_flag in (Action.ASIS, Action.APPLY, Action.RETAG):
found_duplicates = task.find_duplicates(session.lib)
if found_duplicates:
log.debug("found duplicates: {}", [o.id for o in found_duplicates])
# Get the default action to follow from config.
duplicate_action = config["import"]["duplicate_action"].as_choice(
{
"skip": "s",
"keep": "k",
"remove": "r",
"merge": "m",
"ask": "a",
}
)
log.debug("default action for duplicates: {}", duplicate_action)
if duplicate_action == "s":
# Skip new.
task.set_choice(Action.SKIP)
elif duplicate_action == "k":
# Keep both. Do nothing; leave the choice intact.
pass
elif duplicate_action == "r":
# Remove old.
task.should_remove_duplicates = True
elif duplicate_action == "m":
# Merge duplicates together
task.should_merge_duplicates = True
else:
# No default action set; ask the session.
session.resolve_duplicate(task, found_duplicates)
session.log_choice(task, True)
def _freshen_items(items):
# Clear IDs from re-tagged items so they appear "fresh" when
# we add them back to the library.
for item in items:
item.id = None
item.album_id = None
def _extend_pipeline(tasks, *stages):
# Return pipeline extension for stages with list of tasks
if isinstance(tasks, list):
task_iter = iter(tasks)
else:
task_iter = tasks
ipl = pipeline.Pipeline([task_iter] + list(stages))
return pipeline.multiple(ipl.pull())

142
beets/importer/state.py Normal file
View file

@ -0,0 +1,142 @@
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
# 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 annotations
import logging
import os
import pickle
from bisect import bisect_left, insort
from dataclasses import dataclass
from typing import TYPE_CHECKING
from beets import config
if TYPE_CHECKING:
from beets.util import PathBytes
# Global logger.
log = logging.getLogger("beets")
@dataclass
class ImportState:
"""Representing the progress of an import task.
Opens the state file on creation of the class. If you want
to ensure the state is written to disk, you should use the
context manager protocol.
Tagprogress allows long tagging tasks to be resumed when they pause.
Taghistory is a utility for manipulating the "incremental" import log.
This keeps track of all directories that were ever imported, which
allows the importer to only import new stuff.
Usage
-----
```
# Readonly
progress = ImportState().tagprogress
# Read and write
with ImportState() as state:
state["key"] = "value"
```
"""
tagprogress: dict[PathBytes, list[PathBytes]]
taghistory: set[tuple[PathBytes, ...]]
path: PathBytes
def __init__(self, readonly=False, path: PathBytes | None = None):
self.path = path or os.fsencode(config["statefile"].as_filename())
self.tagprogress = {}
self.taghistory = set()
self._open()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self._save()
def _open(
self,
):
try:
with open(self.path, "rb") as f:
state = pickle.load(f)
# Read the states
self.tagprogress = state.get("tagprogress", {})
self.taghistory = state.get("taghistory", set())
except Exception as exc:
# The `pickle` module can emit all sorts of exceptions during
# unpickling, including ImportError. We use a catch-all
# exception to avoid enumerating them all (the docs don't even have a
# full list!).
log.debug("state file could not be read: {}", exc)
def _save(self):
try:
with open(self.path, "wb") as f:
pickle.dump(
{
"tagprogress": self.tagprogress,
"taghistory": self.taghistory,
},
f,
)
except OSError as exc:
log.error("state file could not be written: {}", exc)
# -------------------------------- Tagprogress ------------------------------- #
def progress_add(self, toppath: PathBytes, *paths: PathBytes):
"""Record that the files under all of the `paths` have been imported
under `toppath`.
"""
with self as state:
imported = state.tagprogress.setdefault(toppath, [])
for path in paths:
if imported and imported[-1] <= path:
imported.append(path)
else:
insort(imported, path)
def progress_has_element(self, toppath: PathBytes, path: PathBytes) -> bool:
"""Return whether `path` has been imported in `toppath`."""
imported = self.tagprogress.get(toppath, [])
i = bisect_left(imported, path)
return i != len(imported) and imported[i] == path
def progress_has(self, toppath: PathBytes) -> bool:
"""Return `True` if there exist paths that have already been
imported under `toppath`.
"""
return toppath in self.tagprogress
def progress_reset(self, toppath: PathBytes | None):
"""Reset the progress for `toppath`."""
with self as state:
if toppath in state.tagprogress:
del state.tagprogress[toppath]
# -------------------------------- Taghistory -------------------------------- #
def history_add(self, paths: list[PathBytes]):
"""Add the paths to the history."""
with self as state:
state.taghistory.add(tuple(paths))

File diff suppressed because it is too large Load diff

29
beets/library/__init__.py Normal file
View file

@ -0,0 +1,29 @@
from beets.util.deprecation import deprecate_imports
from .exceptions import FileOperationError, ReadError, WriteError
from .library import Library
from .models import Album, Item, LibModel
from .queries import parse_query_parts, parse_query_string
NEW_MODULE_BY_NAME = dict.fromkeys(
("DateType", "DurationType", "MusicalKey", "PathType"), "beets.dbcore.types"
) | dict.fromkeys(
("BLOB_TYPE", "SingletonQuery", "PathQuery"), "beets.dbcore.query"
)
def __getattr__(name: str):
return deprecate_imports(__name__, NEW_MODULE_BY_NAME, name)
__all__ = [
"Library",
"LibModel",
"Album",
"Item",
"parse_query_parts",
"parse_query_string",
"FileOperationError",
"ReadError",
"WriteError",
]

View file

@ -0,0 +1,38 @@
from beets import util
class FileOperationError(Exception):
"""Indicate an error when interacting with a file on disk.
Possibilities include an unsupported media type, a permissions
error, and an unhandled Mutagen exception.
"""
def __init__(self, path, reason):
"""Create an exception describing an operation on the file at
`path` with the underlying (chained) exception `reason`.
"""
super().__init__(path, reason)
self.path = path
self.reason = reason
def __str__(self):
"""Get a string representing the error.
Describe both the underlying reason and the file path in question.
"""
return f"{util.displayable_path(self.path)}: {self.reason}"
class ReadError(FileOperationError):
"""An error while reading a file (i.e. in `Item.read`)."""
def __str__(self):
return f"error reading {super()}"
class WriteError(FileOperationError):
"""An error while writing a file (i.e. in `Item.write`)."""
def __str__(self):
return f"error writing {super()}"

144
beets/library/library.py Normal file
View file

@ -0,0 +1,144 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import platformdirs
import beets
from beets import dbcore
from beets.util import normpath
from .models import Album, Item
from .queries import PF_KEY_DEFAULT, parse_query_parts, parse_query_string
if TYPE_CHECKING:
from beets.dbcore import Results
class Library(dbcore.Database):
"""A database of music containing songs and albums."""
_models = (Item, Album)
def __init__(
self,
path="library.blb",
directory: str | None = None,
path_formats=((PF_KEY_DEFAULT, "$artist/$album/$track $title"),),
replacements=None,
):
timeout = beets.config["timeout"].as_number()
super().__init__(path, timeout=timeout)
self.directory = normpath(directory or platformdirs.user_music_path())
self.path_formats = path_formats
self.replacements = replacements
# Used for template substitution performance.
self._memotable: dict[tuple[str, ...], str] = {}
# Adding objects to the database.
def add(self, obj):
"""Add the :class:`Item` or :class:`Album` object to the library
database.
Return the object's new id.
"""
obj.add(self)
self._memotable = {}
return obj.id
def add_album(self, items):
"""Create a new album consisting of a list of items.
The items are added to the database if they don't yet have an
ID. Return a new :class:`Album` object. The list items must not
be empty.
"""
if not items:
raise ValueError("need at least one item")
# Create the album structure using metadata from the first item.
values = {key: items[0][key] for key in Album.item_keys}
album = Album(self, **values)
# Add the album structure and set the items' album_id fields.
# Store or add the items.
with self.transaction():
album.add(self)
for item in items:
item.album_id = album.id
if item.id is None:
item.add(self)
else:
item.store()
return album
# Querying.
def _fetch(self, model_cls, query, sort=None):
"""Parse a query and fetch.
If an order specification is present in the query string
the `sort` argument is ignored.
"""
# Parse the query, if necessary.
try:
parsed_sort = None
if isinstance(query, str):
query, parsed_sort = parse_query_string(query, model_cls)
elif isinstance(query, (list, tuple)):
query, parsed_sort = parse_query_parts(query, model_cls)
except dbcore.query.InvalidQueryArgumentValueError as exc:
raise dbcore.InvalidQueryError(query, exc)
# Any non-null sort specified by the parsed query overrides the
# provided sort.
if parsed_sort and not isinstance(parsed_sort, dbcore.query.NullSort):
sort = parsed_sort
return super()._fetch(model_cls, query, sort)
@staticmethod
def get_default_album_sort():
"""Get a :class:`Sort` object for albums from the config option."""
return dbcore.sort_from_strings(
Album, beets.config["sort_album"].as_str_seq()
)
@staticmethod
def get_default_item_sort():
"""Get a :class:`Sort` object for items from the config option."""
return dbcore.sort_from_strings(
Item, beets.config["sort_item"].as_str_seq()
)
def albums(self, query=None, sort=None) -> Results[Album]:
"""Get :class:`Album` objects matching the query."""
return self._fetch(Album, query, sort or self.get_default_album_sort())
def items(self, query=None, sort=None) -> Results[Item]:
"""Get :class:`Item` objects matching the query."""
return self._fetch(Item, query, sort or self.get_default_item_sort())
# Convenience accessors.
def get_item(self, id_: int) -> Item | None:
"""Fetch a :class:`Item` by its ID.
Return `None` if no match is found.
"""
return self._get(Item, id_)
def get_album(self, item_or_id: Item | int) -> Album | None:
"""Given an album ID or an item associated with an album, return
a :class:`Album` object for the album.
If no such album exists, return `None`.
"""
album_id = (
item_or_id if isinstance(item_or_id, int) else item_or_id.album_id
)
return self._get(Album, album_id) if album_id else None

File diff suppressed because it is too large Load diff

61
beets/library/queries.py Normal file
View file

@ -0,0 +1,61 @@
from __future__ import annotations
import shlex
import beets
from beets import dbcore, logging, plugins
log = logging.getLogger("beets")
# Special path format key.
PF_KEY_DEFAULT = "default"
# Query construction helpers.
def parse_query_parts(parts, model_cls):
"""Given a beets query string as a list of components, return the
`Query` and `Sort` they represent.
Like `dbcore.parse_sorted_query`, with beets query prefixes and
ensuring that implicit path queries are made explicit with 'path::<query>'
"""
# Get query types and their prefix characters.
prefixes = {
":": dbcore.query.RegexpQuery,
"=~": dbcore.query.StringQuery,
"=": dbcore.query.MatchQuery,
}
prefixes.update(plugins.queries())
# Special-case path-like queries, which are non-field queries
# containing path separators (/).
parts = [
f"path:{s}" if dbcore.query.PathQuery.is_path_query(s) else s
for s in parts
]
case_insensitive = beets.config["sort_case_insensitive"].get(bool)
query, sort = dbcore.parse_sorted_query(
model_cls, parts, prefixes, case_insensitive
)
log.debug("Parsed query: {!r}", query)
log.debug("Parsed sort: {!r}", sort)
return query, sort
def parse_query_string(s, model_cls):
"""Given a beets query string, return the `Query` and `Sort` they
represent.
The string is split into components using shell-like syntax.
"""
message = f"Query is not unicode: {s!r}"
assert isinstance(s, str), message
try:
parts = shlex.split(s)
except ValueError as exc:
raise dbcore.InvalidQueryError(s, exc)
return parse_query_parts(parts, model_cls)

View file

@ -20,6 +20,9 @@ use {}-style formatting and can interpolate keywords arguments to the logging
calls (`debug`, `info`, etc).
"""
from __future__ import annotations
import re
import threading
from copy import copy
from logging import (
@ -32,8 +35,10 @@ from logging import (
Handler,
Logger,
NullHandler,
RootLogger,
StreamHandler,
)
from typing import TYPE_CHECKING, Any, TypeVar, Union, overload
__all__ = [
"DEBUG",
@ -49,8 +54,31 @@ __all__ = [
"getLogger",
]
if TYPE_CHECKING:
from collections.abc import Mapping
def logsafe(val):
T = TypeVar("T")
from types import TracebackType
# see https://github.com/python/typeshed/blob/main/stdlib/logging/__init__.pyi
_SysExcInfoType = Union[
tuple[type[BaseException], BaseException, Union[TracebackType, None]],
tuple[None, None, None],
]
_ExcInfoType = Union[None, bool, _SysExcInfoType, BaseException]
_ArgsType = Union[tuple[object, ...], Mapping[str, object]]
# Regular expression to match:
# - C0 control characters (0x00-0x1F) except useful whitespace (\t, \n, \r)
# - DEL control character (0x7f)
# - C1 control characters (0x80-0x9F)
# Used to sanitize log messages that could disrupt terminal output
_CONTROL_CHAR_REGEX = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\x80-\x9f]")
_UNICODE_REPLACEMENT_CHARACTER = "\ufffd"
def _logsafe(val: T) -> str | T:
"""Coerce `bytes` to `str` to avoid crashes solely due to logging.
This is particularly relevant for bytestring paths. Much of our code
@ -64,6 +92,10 @@ def logsafe(val):
# type, and (b) warn the developer if they do this for other
# bytestrings.
return val.decode("utf-8", "replace")
if isinstance(val, str):
# Sanitize log messages by replacing control characters that can disrupt
# terminals.
return _CONTROL_CHAR_REGEX.sub(_UNICODE_REPLACEMENT_CHARACTER, val)
# Other objects are used as-is so field access, etc., still works in
# the format string. Relies on a working __str__ implementation.
@ -83,40 +115,45 @@ class StrFormatLogger(Logger):
"""
class _LogMessage:
def __init__(self, msg, args, kwargs):
def __init__(
self,
msg: str,
args: _ArgsType,
kwargs: dict[str, Any],
):
self.msg = msg
self.args = args
self.kwargs = kwargs
def __str__(self):
args = [logsafe(a) for a in self.args]
kwargs = {k: logsafe(v) for (k, v) in self.kwargs.items()}
args = [_logsafe(a) for a in self.args]
kwargs = {k: _logsafe(v) for (k, v) in self.kwargs.items()}
return self.msg.format(*args, **kwargs)
def _log(
self,
level,
msg,
args,
exc_info=None,
extra=None,
stack_info=False,
level: int,
msg: object,
args: _ArgsType,
exc_info: _ExcInfoType = None,
extra: Mapping[str, Any] | None = None,
stack_info: bool = False,
stacklevel: int = 1,
**kwargs,
):
"""Log msg.format(*args, **kwargs)"""
m = self._LogMessage(msg, args, kwargs)
stacklevel = kwargs.pop("stacklevel", 1)
stacklevel = {"stacklevel": stacklevel}
if isinstance(msg, str):
msg = self._LogMessage(msg, args, kwargs)
return super()._log(
level,
m,
msg,
(),
exc_info=exc_info,
extra=extra,
stack_info=stack_info,
**stacklevel,
stacklevel=stacklevel,
)
@ -156,9 +193,12 @@ my_manager = copy(Logger.manager)
my_manager.loggerClass = BeetsLogger
# Override the `getLogger` to use our machinery.
def getLogger(name=None): # noqa
@overload
def getLogger(name: str) -> BeetsLogger: ...
@overload
def getLogger(name: None = ...) -> RootLogger: ...
def getLogger(name=None) -> BeetsLogger | RootLogger: # noqa: N802
if name:
return my_manager.getLogger(name)
return my_manager.getLogger(name) # type: ignore[return-value]
else:
return Logger.root

View file

@ -13,17 +13,11 @@
# included in all copies or substantial portions of the Software.
import warnings
import mediafile
warnings.warn(
"beets.mediafile is deprecated; use mediafile instead",
# Show the location of the `import mediafile` statement as the warning's
# source, rather than this file, such that the offending module can be
# identified easily.
stacklevel=2,
)
from .util.deprecation import deprecate_for_maintainers
deprecate_for_maintainers("'beets.mediafile'", "'mediafile'", stacklevel=2)
# Import everything from the mediafile module into this module.
for key, value in mediafile.__dict__.items():
@ -31,4 +25,4 @@ for key, value in mediafile.__dict__.items():
globals()[key] = value
# Cleanup namespace.
del key, value, warnings, mediafile
del key, value, mediafile

359
beets/metadata_plugins.py Normal file
View file

@ -0,0 +1,359 @@
"""Metadata source plugin interface.
This allows beets to lookup metadata from various sources. We define
a common interface for all metadata sources which need to be
implemented as plugins.
"""
from __future__ import annotations
import abc
import re
from functools import cache, cached_property
from typing import TYPE_CHECKING, Generic, Literal, TypedDict, TypeVar
import unidecode
from confuse import NotFoundError
from typing_extensions import NotRequired
from beets.util import cached_classproperty
from beets.util.id_extractors import extract_release_id
from .plugins import BeetsPlugin, find_plugins, notify_info_yielded, send
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from .autotag.hooks import AlbumInfo, Item, TrackInfo
@cache
def find_metadata_source_plugins() -> list[MetadataSourcePlugin]:
"""Return a list of all loaded metadata source plugins."""
# TODO: Make this an isinstance(MetadataSourcePlugin, ...) check in v3.0.0
return [p for p in find_plugins() if hasattr(p, "data_source")] # type: ignore[misc]
@notify_info_yielded("albuminfo_received")
def candidates(*args, **kwargs) -> Iterable[AlbumInfo]:
"""Return matching album candidates from all metadata source plugins."""
for plugin in find_metadata_source_plugins():
yield from plugin.candidates(*args, **kwargs)
@notify_info_yielded("trackinfo_received")
def item_candidates(*args, **kwargs) -> Iterable[TrackInfo]:
"""Return matching track candidates fromm all metadata source plugins."""
for plugin in find_metadata_source_plugins():
yield from plugin.item_candidates(*args, **kwargs)
def album_for_id(_id: str) -> AlbumInfo | None:
"""Get AlbumInfo object for the given ID string.
A single ID can yield just a single album, so we return the first match.
"""
for plugin in find_metadata_source_plugins():
if info := plugin.album_for_id(album_id=_id):
send("albuminfo_received", info=info)
return info
return None
def track_for_id(_id: str) -> TrackInfo | None:
"""Get TrackInfo object for the given ID string.
A single ID can yield just a single track, so we return the first match.
"""
for plugin in find_metadata_source_plugins():
if info := plugin.track_for_id(_id):
send("trackinfo_received", info=info)
return info
return None
@cache
def get_penalty(data_source: str | None) -> float:
"""Get the penalty value for the given data source."""
return next(
(
p.data_source_mismatch_penalty
for p in find_metadata_source_plugins()
if p.data_source == data_source
),
MetadataSourcePlugin.DEFAULT_DATA_SOURCE_MISMATCH_PENALTY,
)
class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta):
"""A plugin that provides metadata from a specific source.
This base class implements a contract for plugins that provide metadata
from a specific source. The plugin must implement the methods to search for albums
and tracks, and to retrieve album and track information by ID.
"""
DEFAULT_DATA_SOURCE_MISMATCH_PENALTY = 0.5
@cached_classproperty
def data_source(cls) -> str:
"""The data source name for this plugin.
This is inferred from the plugin name.
"""
return cls.__name__.replace("Plugin", "") # type: ignore[attr-defined]
@cached_property
def data_source_mismatch_penalty(self) -> float:
try:
return self.config["source_weight"].as_number()
except NotFoundError:
return self.config["data_source_mismatch_penalty"].as_number()
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.config.add(
{
"search_limit": 5,
"data_source_mismatch_penalty": self.DEFAULT_DATA_SOURCE_MISMATCH_PENALTY, # noqa: E501
}
)
@abc.abstractmethod
def album_for_id(self, album_id: str) -> AlbumInfo | None:
"""Return :py:class:`AlbumInfo` object or None if no matching release was
found."""
raise NotImplementedError
@abc.abstractmethod
def track_for_id(self, track_id: str) -> TrackInfo | None:
"""Return a :py:class:`TrackInfo` object or None if no matching release was
found.
"""
raise NotImplementedError
# ---------------------------------- search ---------------------------------- #
@abc.abstractmethod
def candidates(
self,
items: Sequence[Item],
artist: str,
album: str,
va_likely: bool,
) -> Iterable[AlbumInfo]:
"""Return :py:class:`AlbumInfo` candidates that match the given album.
Used in the autotag functionality to search for albums.
:param items: List of items in the album
:param artist: Album artist
:param album: Album name
:param va_likely: Whether the album is likely to be by various artists
"""
raise NotImplementedError
@abc.abstractmethod
def item_candidates(
self, item: Item, artist: str, title: str
) -> Iterable[TrackInfo]:
"""Return :py:class:`TrackInfo` candidates that match the given track.
Used in the autotag functionality to search for tracks.
:param item: Track item
:param artist: Track artist
:param title: Track title
"""
raise NotImplementedError
def albums_for_ids(self, ids: Sequence[str]) -> Iterable[AlbumInfo | None]:
"""Batch lookup of album metadata for a list of album IDs.
Given a list of album identifiers, yields corresponding AlbumInfo objects.
Missing albums result in None values in the output iterator.
Plugins may implement this for optimized batched lookups instead of
single calls to album_for_id.
"""
return (self.album_for_id(id) for id in ids)
def tracks_for_ids(self, ids: Sequence[str]) -> Iterable[TrackInfo | None]:
"""Batch lookup of track metadata for a list of track IDs.
Given a list of track identifiers, yields corresponding TrackInfo objects.
Missing tracks result in None values in the output iterator.
Plugins may implement this for optimized batched lookups instead of
single calls to track_for_id.
"""
return (self.track_for_id(id) for id in ids)
def _extract_id(self, url: str) -> str | None:
"""Extract an ID from a URL for this metadata source plugin.
Uses the plugin's data source name to determine the ID format and
extracts the ID from a given URL.
"""
return extract_release_id(self.data_source, url)
@staticmethod
def get_artist(
artists: Iterable[dict[str | int, str]],
id_key: str | int = "id",
name_key: str | int = "name",
join_key: str | int | None = None,
) -> tuple[str, str | None]:
"""Returns an artist string (all artists) and an artist_id (the main
artist) for a list of artist object dicts.
For each artist, this function moves articles (such as 'a', 'an', and 'the')
to the front. It returns a tuple containing the comma-separated string
of all normalized artists and the ``id`` of the main/first artist.
Alternatively a keyword can be used to combine artists together into a
single string by passing the join_key argument.
:param artists: Iterable of artist dicts or lists returned by API.
:param id_key: Key or index corresponding to the value of ``id`` for
the main/first artist. Defaults to 'id'.
:param name_key: Key or index corresponding to values of names
to concatenate for the artist string (containing all artists).
Defaults to 'name'.
:param join_key: Key or index corresponding to a field containing a
keyword to use for combining artists into a single string, for
example "Feat.", "Vs.", "And" or similar. The default is None
which keeps the default behaviour (comma-separated).
:return: Normalized artist string.
"""
artist_id = None
artist_string = ""
artists = list(artists) # In case a generator was passed.
total = len(artists)
for idx, artist in enumerate(artists):
if not artist_id:
artist_id = artist[id_key]
name = artist[name_key]
# Move articles to the front.
name = re.sub(r"^(.*?), (a|an|the)$", r"\2 \1", name, flags=re.I)
# Use a join keyword if requested and available.
if idx < (total - 1): # Skip joining on last.
if join_key and artist.get(join_key, None):
name += f" {artist[join_key]} "
else:
name += ", "
artist_string += name
return artist_string, artist_id
class IDResponse(TypedDict):
"""Response from the API containing an ID."""
id: str
class SearchFilter(TypedDict):
artist: NotRequired[str]
album: NotRequired[str]
R = TypeVar("R", bound=IDResponse)
class SearchApiMetadataSourcePlugin(
Generic[R], MetadataSourcePlugin, metaclass=abc.ABCMeta
):
"""Helper class to implement a metadata source plugin with an API.
Plugins using this ABC must implement an API search method to
retrieve album and track information by ID,
i.e. `album_for_id` and `track_for_id`, and a search method to
perform a search on the API. The search method should return a list
of identifiers for the requested type (album or track).
"""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.config.add(
{
"search_query_ascii": False,
}
)
@abc.abstractmethod
def _search_api(
self,
query_type: Literal["album", "track"],
filters: SearchFilter,
query_string: str = "",
) -> Sequence[R]:
"""Perform a search on the API.
:param query_type: The type of query to perform.
:param filters: A dictionary of filters to apply to the search.
:param query_string: Additional query to include in the search.
Should return a list of identifiers for the requested type (album or track).
"""
raise NotImplementedError
def candidates(
self,
items: Sequence[Item],
artist: str,
album: str,
va_likely: bool,
) -> Iterable[AlbumInfo]:
query_filters: SearchFilter = {}
if album:
query_filters["album"] = album
if not va_likely:
query_filters["artist"] = artist
results = self._search_api("album", query_filters)
if not results:
return []
return filter(
None, self.albums_for_ids([result["id"] for result in results])
)
def item_candidates(
self, item: Item, artist: str, title: str
) -> Iterable[TrackInfo]:
results = self._search_api(
"track", {"artist": artist}, query_string=title
)
if not results:
return []
return filter(
None,
self.tracks_for_ids([result["id"] for result in results if result]),
)
def _construct_search_query(
self, filters: SearchFilter, query_string: str
) -> str:
"""Construct a query string with the specified filters and keywords to
be provided to the spotify (or similar) search API.
The returned format was initially designed for spotify's search API but
we found is also useful with other APIs that support similar query structures.
see `spotify <https://developer.spotify.com/documentation/web-api/reference/search>`_
and `deezer <https://developers.deezer.com/api/search>`_.
:param filters: Field filters to apply.
:param query_string: Query keywords to use.
:return: Query string to be provided to the search API.
"""
components = [query_string, *(f"{k}:'{v}'" for k, v in filters.items())]
query = " ".join(filter(None, components))
if self.config["search_query_ascii"].get():
query = unidecode.unidecode(query)
return query

File diff suppressed because it is too large Load diff

0
beets/py.typed Normal file
View file

View file

@ -63,8 +63,8 @@ HAVE_SYMLINK = sys.platform != "win32"
HAVE_HARDLINK = sys.platform != "win32"
def item(lib=None):
i = beets.library.Item(
def item(lib=None, **kwargs):
defaults = dict(
title="the title",
artist="the artist",
albumartist="the album artist",
@ -99,6 +99,7 @@ def item(lib=None):
album_id=None,
mtime=12345,
)
i = beets.library.Item(**{**defaults, **kwargs})
if lib:
lib.add(i)
return i
@ -106,38 +107,14 @@ def item(lib=None):
# Dummy import session.
def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False):
cls = commands.TerminalImportSession if cli else importer.ImportSession
cls = (
commands.import_.session.TerminalImportSession
if cli
else importer.ImportSession
)
return cls(lib, loghandler, paths, query)
class Assertions:
"""A mixin with additional unit test assertions."""
def assertExists(self, path):
assert os.path.exists(syspath(path)), f"file does not exist: {path!r}"
def assertNotExists(self, path):
assert not os.path.exists(syspath(path)), f"file exists: {path!r}"
def assertIsFile(self, path):
self.assertExists(path)
assert os.path.isfile(
syspath(path)
), "path exists, but is not a regular file: {!r}".format(path)
def assertIsDir(self, path):
self.assertExists(path)
assert os.path.isdir(
syspath(path)
), "path exists, but is not a directory: {!r}".format(path)
def assert_equal_path(self, a, b):
"""Check that two paths are equal."""
a_bytes, b_bytes = util.normpath(a), util.normpath(b)
assert a_bytes == b_bytes, f"{a_bytes=} != {b_bytes=}"
# Mock I/O.
@ -180,7 +157,7 @@ class DummyIn:
self.out = out
def add(self, s):
self.buf.append(s + "\n")
self.buf.append(f"{s}\n")
def close(self):
pass

View file

@ -35,6 +35,7 @@ import subprocess
import sys
import unittest
from contextlib import contextmanager
from dataclasses import dataclass
from enum import Enum
from functools import cached_property
from io import StringIO
@ -48,12 +49,12 @@ from mediafile import Image, MediaFile
import beets
import beets.plugins
from beets import autotag, importer, logging, util
from beets import importer, logging, util
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.importer import ImportSession
from beets.library import Album, Item, Library
from beets.library import Item, Library
from beets.test import _common
from beets.ui.commands import TerminalImportSession
from beets.ui.commands.import_.session import TerminalImportSession
from beets.util import (
MoveOperation,
bytestring_path,
@ -162,15 +163,49 @@ NEEDS_REFLINK = unittest.skipUnless(
)
class TestHelper(_common.Assertions, ConfigMixin):
class IOMixin:
@cached_property
def io(self) -> _common.DummyIO:
return _common.DummyIO()
def setUp(self):
super().setUp()
self.io.install()
def tearDown(self):
super().tearDown()
self.io.restore()
class TestHelper(ConfigMixin):
"""Helper mixin for high-level cli and plugin tests.
This mixin provides methods to isolate beets' global state provide
fixtures.
"""
resource_path = Path(os.fsdecode(_common.RSRC)) / "full.mp3"
db_on_disk: ClassVar[bool] = False
@cached_property
def temp_dir_path(self) -> Path:
return Path(self.create_temp_dir())
@cached_property
def temp_dir(self) -> bytes:
return util.bytestring_path(self.temp_dir_path)
@cached_property
def lib_path(self) -> Path:
lib_path = self.temp_dir_path / "libdir"
lib_path.mkdir(exist_ok=True)
return lib_path
@cached_property
def libdir(self) -> bytes:
return bytestring_path(self.lib_path)
# TODO automate teardown through hook registration
def setup_beets(self):
@ -193,8 +228,7 @@ class TestHelper(_common.Assertions, ConfigMixin):
Make sure you call ``teardown_beets()`` afterwards.
"""
self.create_temp_dir()
temp_dir_str = os.fsdecode(self.temp_dir)
temp_dir_str = str(self.temp_dir_path)
self.env_patcher = patch.dict(
"os.environ",
{
@ -204,9 +238,7 @@ class TestHelper(_common.Assertions, ConfigMixin):
)
self.env_patcher.start()
self.libdir = os.path.join(self.temp_dir, b"libdir")
os.mkdir(syspath(self.libdir))
self.config["directory"] = os.fsdecode(self.libdir)
self.config["directory"] = str(self.lib_path)
if self.db_on_disk:
dbpath = util.bytestring_path(self.config["library"].as_filename())
@ -214,12 +246,8 @@ class TestHelper(_common.Assertions, ConfigMixin):
dbpath = ":memory:"
self.lib = Library(dbpath, self.libdir)
# Initialize, but don't install, a DummyIO.
self.io = _common.DummyIO()
def teardown_beets(self):
self.env_patcher.stop()
self.io.restore()
self.lib._close()
self.remove_temp_dir()
@ -238,7 +266,7 @@ class TestHelper(_common.Assertions, ConfigMixin):
The item is attached to the database from `self.lib`.
"""
values_ = {
"title": "t\u00eftle {0}",
"title": "t\u00eftle {}",
"artist": "the \u00e4rtist",
"album": "the \u00e4lbum",
"track": 1,
@ -249,7 +277,7 @@ class TestHelper(_common.Assertions, ConfigMixin):
values_["db"] = self.lib
item = Item(**values_)
if "path" not in values:
item["path"] = "audio." + item["format"].lower()
item["path"] = f"audio.{item['format'].lower()}"
# mtime needs to be set last since other assignments reset it.
item.mtime = 12345
return item
@ -281,7 +309,7 @@ class TestHelper(_common.Assertions, ConfigMixin):
item = self.create_item(**values)
extension = item["format"].lower()
item["path"] = os.path.join(
_common.RSRC, util.bytestring_path("min." + extension)
_common.RSRC, util.bytestring_path(f"min.{extension}")
)
item.add(self.lib)
item.move(operation=MoveOperation.COPY)
@ -296,7 +324,7 @@ class TestHelper(_common.Assertions, ConfigMixin):
"""Add a number of items with files to the database."""
# TODO base this on `add_item()`
items = []
path = os.path.join(_common.RSRC, util.bytestring_path("full." + ext))
path = os.path.join(_common.RSRC, util.bytestring_path(f"full.{ext}"))
for i in range(count):
item = Item.from_path(path)
item.album = f"\u00e4lbum {i}" # Check unicode paths
@ -343,7 +371,7 @@ class TestHelper(_common.Assertions, ConfigMixin):
specified extension a cover art image is added to the media
file.
"""
src = os.path.join(_common.RSRC, util.bytestring_path("full." + ext))
src = os.path.join(_common.RSRC, util.bytestring_path(f"full.{ext}"))
handle, path = mkstemp(dir=self.temp_dir)
path = bytestring_path(path)
os.close(handle)
@ -383,16 +411,12 @@ class TestHelper(_common.Assertions, ConfigMixin):
# Safe file operations
def create_temp_dir(self, **kwargs):
"""Create a temporary directory and assign it into
`self.temp_dir`. Call `remove_temp_dir` later to delete it.
"""
temp_dir = mkdtemp(**kwargs)
self.temp_dir = util.bytestring_path(temp_dir)
def create_temp_dir(self, **kwargs) -> str:
return mkdtemp(**kwargs)
def remove_temp_dir(self):
"""Delete the temporary directory created by `create_temp_dir`."""
shutil.rmtree(syspath(self.temp_dir))
shutil.rmtree(self.temp_dir_path)
def touch(self, path, dir=None, content=""):
"""Create a file at `path` with given content.
@ -456,6 +480,11 @@ class PluginMixin(ConfigMixin):
super().teardown_beets()
self.unload_plugins()
def register_plugin(
self, plugin_class: type[beets.plugins.BeetsPlugin]
) -> None:
beets.plugins._instances.append(plugin_class())
def load_plugins(self, *plugins: str) -> None:
"""Load and initialize plugins by names.
@ -465,33 +494,15 @@ class PluginMixin(ConfigMixin):
# FIXME this should eventually be handled by a plugin manager
plugins = (self.plugin,) if hasattr(self, "plugin") else plugins
self.config["plugins"] = plugins
beets.plugins.load_plugins(plugins)
beets.plugins.find_plugins()
# Take a backup of the original _types and _queries to restore
# when unloading.
Item._original_types = dict(Item._types)
Album._original_types = dict(Album._types)
Item._types.update(beets.plugins.types(Item))
Album._types.update(beets.plugins.types(Album))
Item._original_queries = dict(Item._queries)
Album._original_queries = dict(Album._queries)
Item._queries.update(beets.plugins.named_queries(Item))
Album._queries.update(beets.plugins.named_queries(Album))
beets.plugins.load_plugins()
def unload_plugins(self) -> None:
"""Unload all plugins and remove them from the configuration."""
# FIXME this should eventually be handled by a plugin manager
for plugin_class in beets.plugins._instances:
plugin_class.listeners = None
beets.plugins.BeetsPlugin.listeners.clear()
beets.plugins.BeetsPlugin._raw_listeners.clear()
self.config["plugins"] = []
beets.plugins._classes = set()
beets.plugins._instances = {}
Item._types = getattr(Item, "_original_types", {})
Album._types = getattr(Album, "_original_types", {})
Item._queries = getattr(Item, "_original_queries", {})
Album._queries = getattr(Album, "_original_queries", {})
beets.plugins._instances.clear()
@contextmanager
def configure_plugin(self, config: Any):
@ -513,7 +524,6 @@ class ImportHelper(TestHelper):
autotagging library and several assertions for the library.
"""
resource_path = syspath(os.path.join(_common.RSRC, b"full.mp3"))
default_import_config = {
"autotag": True,
"copy": True,
@ -530,7 +540,7 @@ class ImportHelper(TestHelper):
@cached_property
def import_path(self) -> Path:
import_path = Path(os.fsdecode(self.temp_dir)) / "import"
import_path = self.temp_dir_path / "import"
import_path.mkdir(exist_ok=True)
return import_path
@ -558,7 +568,7 @@ class ImportHelper(TestHelper):
medium = MediaFile(track_path)
medium.update(
{
"album": "Tag Album" + (f" {album_id}" if album_id else ""),
"album": f"Tag Album{f' {album_id}' if album_id else ''}",
"albumartist": None,
"mb_albumid": None,
"comp": None,
@ -598,7 +608,7 @@ class ImportHelper(TestHelper):
]
def prepare_albums_for_import(self, count: int = 1) -> None:
album_dirs = Path(os.fsdecode(self.import_dir)).glob("album_*")
album_dirs = self.import_path.glob("album_*")
base_idx = int(str(max(album_dirs, default="0")).split("_")[-1]) + 1
for album_id in range(base_idx, count + base_idx):
@ -622,21 +632,6 @@ class ImportHelper(TestHelper):
def setup_singleton_importer(self, **kwargs) -> ImportSession:
return self.setup_importer(singletons=True, **kwargs)
def assert_file_in_lib(self, *segments):
"""Join the ``segments`` and assert that this path exists in the
library directory.
"""
self.assertExists(os.path.join(self.libdir, *segments))
def assert_file_not_in_lib(self, *segments):
"""Join the ``segments`` and assert that this path does not
exist in the library directory.
"""
self.assertNotExists(os.path.join(self.libdir, *segments))
def assert_lib_dir_empty(self):
assert not os.listdir(syspath(self.libdir))
class AsIsImporterMixin:
def setUp(self):
@ -658,9 +653,9 @@ class ImportSessionFixture(ImportSession):
>>> lib = Library(':memory:')
>>> importer = ImportSessionFixture(lib, paths=['/path/to/import'])
>>> importer.add_choice(importer.action.SKIP)
>>> importer.add_choice(importer.action.ASIS)
>>> importer.default_choice = importer.action.APPLY
>>> importer.add_choice(importer.Action.SKIP)
>>> importer.add_choice(importer.Action.ASIS)
>>> importer.default_choice = importer.Action.APPLY
>>> importer.run()
This imports ``/path/to/import`` into `lib`. It skips the first
@ -673,7 +668,7 @@ class ImportSessionFixture(ImportSession):
self._choices = []
self._resolutions = []
default_choice = importer.action.APPLY
default_choice = importer.Action.APPLY
def add_choice(self, choice):
self._choices.append(choice)
@ -687,7 +682,7 @@ class ImportSessionFixture(ImportSession):
except IndexError:
choice = self.default_choice
if choice == importer.action.APPLY:
if choice == importer.Action.APPLY:
return task.candidates[0]
elif isinstance(choice, int):
return task.candidates[choice - 1]
@ -707,7 +702,7 @@ class ImportSessionFixture(ImportSession):
res = self.default_resolution
if res == self.Resolution.SKIP:
task.set_choice(importer.action.SKIP)
task.set_choice(importer.Action.SKIP)
elif res == self.Resolution.REMOVE:
task.should_remove_duplicates = True
elif res == self.Resolution.MERGE:
@ -720,7 +715,7 @@ class TerminalImportSessionFixture(TerminalImportSession):
super().__init__(*args, **kwargs)
self._choices = []
default_choice = importer.action.APPLY
default_choice = importer.Action.APPLY
def add_choice(self, choice):
self._choices.append(choice)
@ -742,15 +737,15 @@ class TerminalImportSessionFixture(TerminalImportSession):
except IndexError:
choice = self.default_choice
if choice == importer.action.APPLY:
if choice == importer.Action.APPLY:
self.io.addinput("A")
elif choice == importer.action.ASIS:
elif choice == importer.Action.ASIS:
self.io.addinput("U")
elif choice == importer.action.ALBUMS:
elif choice == importer.Action.ALBUMS:
self.io.addinput("G")
elif choice == importer.action.TRACKS:
elif choice == importer.Action.TRACKS:
self.io.addinput("T")
elif choice == importer.action.SKIP:
elif choice == importer.Action.SKIP:
self.io.addinput("S")
else:
self.io.addinput("M")
@ -758,7 +753,7 @@ class TerminalImportSessionFixture(TerminalImportSession):
self._add_choice_input()
class TerminalImportMixin(ImportHelper):
class TerminalImportMixin(IOMixin, ImportHelper):
"""Provides_a terminal importer for the import session."""
io: _common.DummyIO
@ -774,6 +769,7 @@ class TerminalImportMixin(ImportHelper):
)
@dataclass
class AutotagStub:
"""Stub out MusicBrainz album and track matcher and control what the
autotagger returns.
@ -784,47 +780,44 @@ class AutotagStub:
GOOD = "GOOD"
BAD = "BAD"
MISSING = "MISSING"
"""Generate an album match for all but one track
"""
matching: str
length = 2
matching = IDENT
def install(self):
self.mb_match_album = autotag.mb.match_album
self.mb_match_track = autotag.mb.match_track
self.mb_album_for_id = autotag.mb.album_for_id
self.mb_track_for_id = autotag.mb.track_for_id
autotag.mb.match_album = self.match_album
autotag.mb.match_track = self.match_track
autotag.mb.album_for_id = self.album_for_id
autotag.mb.track_for_id = self.track_for_id
self.patchers = [
patch("beets.metadata_plugins.album_for_id", lambda *_: None),
patch("beets.metadata_plugins.track_for_id", lambda *_: None),
patch("beets.metadata_plugins.candidates", self.candidates),
patch(
"beets.metadata_plugins.item_candidates", self.item_candidates
),
]
for p in self.patchers:
p.start()
return self
def restore(self):
autotag.mb.match_album = self.mb_match_album
autotag.mb.match_track = self.mb_match_track
autotag.mb.album_for_id = self.mb_album_for_id
autotag.mb.track_for_id = self.mb_track_for_id
for p in self.patchers:
p.stop()
def match_album(self, albumartist, album, tracks, extra_tags):
def candidates(self, items, artist, album, va_likely):
if self.matching == self.IDENT:
yield self._make_album_match(albumartist, album, tracks)
yield self._make_album_match(artist, album, len(items))
elif self.matching == self.GOOD:
for i in range(self.length):
yield self._make_album_match(albumartist, album, tracks, i)
yield self._make_album_match(artist, album, len(items), i)
elif self.matching == self.BAD:
for i in range(self.length):
yield self._make_album_match(albumartist, album, tracks, i + 1)
yield self._make_album_match(artist, album, len(items), i + 1)
elif self.matching == self.MISSING:
yield self._make_album_match(albumartist, album, tracks, missing=1)
yield self._make_album_match(artist, album, len(items), missing=1)
def match_track(self, artist, title):
def item_candidates(self, item, artist, title):
yield TrackInfo(
title=title.replace("Tag", "Applied"),
track_id="trackid",
@ -834,31 +827,23 @@ class AutotagStub:
index=0,
)
def album_for_id(self, mbid):
return None
def track_for_id(self, mbid):
return None
def _make_track_match(self, artist, album, number):
return TrackInfo(
title="Applied Track %d" % number,
track_id="match %d" % number,
title=f"Applied Track {number}",
track_id=f"match {number}",
artist=artist,
length=1,
index=0,
)
def _make_album_match(self, artist, album, tracks, distance=0, missing=0):
if distance:
id = " " + "M" * distance
else:
id = ""
id = f" {'M' * distance}" if distance else ""
if artist is None:
artist = "Various Artists"
else:
artist = artist.replace("Tag", "Applied") + id
album = album.replace("Tag", "Applied") + id
artist = f"{artist.replace('Tag', 'Applied')}{id}"
album = f"{album.replace('Tag', 'Applied')}{id}"
track_infos = []
for i in range(tracks - missing):
@ -869,14 +854,23 @@ class AutotagStub:
album=album,
tracks=track_infos,
va=False,
album_id="albumid" + id,
artist_id="artistid" + id,
album_id=f"albumid{id}",
artist_id=f"artistid{id}",
albumtype="soundtrack",
data_source="match_source",
bandcamp_album_id="bc_url",
)
class AutotagImportTestCase(ImportTestCase):
matching = AutotagStub.IDENT
def setUp(self):
super().setUp()
self.matcher = AutotagStub(self.matching).install()
self.addCleanup(self.matcher.restore)
class FetchImageHelper:
"""Helper mixin for mocking requests when fetching images
with remote art sources.
@ -886,20 +880,43 @@ class FetchImageHelper:
def run(self, *args, **kwargs):
super().run(*args, **kwargs)
IMAGEHEADER = {
"image/jpeg": b"\xff\xd8\xff" + b"\x00" * 3 + b"JFIF",
IMAGEHEADER: dict[str, bytes] = {
"image/jpeg": b"\xff\xd8\xff\x00\x00\x00JFIF",
"image/png": b"\211PNG\r\n\032\n",
"image/gif": b"GIF89a",
# dummy type that is definitely not a valid image content type
"image/watercolour": b"watercolour",
"text/html": (
b"<!DOCTYPE html>\n<html>\n<head>\n</head>\n"
b"<body>\n</body>\n</html>"
),
}
def mock_response(self, url, content_type="image/jpeg", file_type=None):
def mock_response(
self,
url: str,
content_type: str = "image/jpeg",
file_type: None | str = None,
) -> None:
# Potentially return a file of a type that differs from the
# server-advertised content type to mimic misbehaving servers.
if file_type is None:
file_type = content_type
try:
# imghdr reads 32 bytes
header = self.IMAGEHEADER[file_type].ljust(32, b"\x00")
except KeyError:
# If we can't return a file that looks like real file of the requested
# type, better fail the test than returning something else, which might
# violate assumption made when writing a test.
raise AssertionError(f"Mocking {file_type} responses not supported")
responses.add(
responses.GET,
url,
content_type=content_type,
# imghdr reads 32 bytes
body=self.IMAGEHEADER.get(file_type, b"").ljust(32, b"\x00"),
body=header,
)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,67 @@
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
# 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 module provides the default commands for beets' command-line
interface.
"""
from beets.util.deprecation import deprecate_imports
from .completion import completion_cmd
from .config import config_cmd
from .fields import fields_cmd
from .help import HelpCommand
from .import_ import import_cmd
from .list import list_cmd
from .modify import modify_cmd
from .move import move_cmd
from .remove import remove_cmd
from .stats import stats_cmd
from .update import update_cmd
from .version import version_cmd
from .write import write_cmd
def __getattr__(name: str):
"""Handle deprecated imports."""
return deprecate_imports(
__name__,
{
"TerminalImportSession": "beets.ui.commands.import_.session",
"PromptChoice": "beets.util",
},
name,
)
# The list of default subcommands. This is populated with Subcommand
# objects that can be fed to a SubcommandsOptionParser.
default_commands = [
fields_cmd,
HelpCommand(),
import_cmd,
list_cmd,
update_cmd,
remove_cmd,
stats_cmd,
version_cmd,
modify_cmd,
move_cmd,
write_cmd,
config_cmd,
completion_cmd,
]
__all__ = ["default_commands"]

View file

@ -0,0 +1,117 @@
"""The 'completion' command: print shell script for command line completion."""
import os
import re
from beets import library, logging, plugins, ui
from beets.util import syspath
# Global logger.
log = logging.getLogger("beets")
def print_completion(*args):
from beets.ui.commands import default_commands
for line in completion_script(default_commands + plugins.commands()):
ui.print_(line, end="")
if not any(os.path.isfile(syspath(p)) for p in BASH_COMPLETION_PATHS):
log.warning(
"Warning: Unable to find the bash-completion package. "
"Command line completion might not work."
)
completion_cmd = ui.Subcommand(
"completion",
help="print shell script that provides command line completion",
)
completion_cmd.func = print_completion
completion_cmd.hide = True
BASH_COMPLETION_PATHS = [
b"/etc/bash_completion",
b"/usr/share/bash-completion/bash_completion",
b"/usr/local/share/bash-completion/bash_completion",
# SmartOS
b"/opt/local/share/bash-completion/bash_completion",
# Homebrew (before bash-completion2)
b"/usr/local/etc/bash_completion",
]
def completion_script(commands):
"""Yield the full completion shell script as strings.
``commands`` is alist of ``ui.Subcommand`` instances to generate
completion data for.
"""
base_script = os.path.join(
os.path.dirname(__file__), "./completion_base.sh"
)
with open(base_script) as base_script:
yield base_script.read()
options = {}
aliases = {}
command_names = []
# Collect subcommands
for cmd in commands:
name = cmd.name
command_names.append(name)
for alias in cmd.aliases:
if re.match(r"^\w+$", alias):
aliases[alias] = name
options[name] = {"flags": [], "opts": []}
for opts in cmd.parser._get_all_options()[1:]:
if opts.action in ("store_true", "store_false"):
option_type = "flags"
else:
option_type = "opts"
options[name][option_type].extend(
opts._short_opts + opts._long_opts
)
# Add global options
options["_global"] = {
"flags": ["-v", "--verbose"],
"opts": "-l --library -c --config -d --directory -h --help".split(" "),
}
# Add flags common to all commands
options["_common"] = {"flags": ["-h", "--help"]}
# Start generating the script
yield "_beet() {\n"
# Command names
yield f" local commands={' '.join(command_names)!r}\n"
yield "\n"
# Command aliases
yield f" local aliases={' '.join(aliases.keys())!r}\n"
for alias, cmd in aliases.items():
yield f" local alias__{alias.replace('-', '_')}={cmd}\n"
yield "\n"
# Fields
fields = library.Item._fields.keys() | library.Album._fields.keys()
yield f" fields={' '.join(fields)!r}\n"
# Command options
for cmd, opts in options.items():
for option_type, option_list in opts.items():
if option_list:
option_list = " ".join(option_list)
yield (
" local"
f" {option_type}__{cmd.replace('-', '_')}='{option_list}'\n"
)
yield " _beet_dispatch\n"
yield "}\n"

View file

@ -0,0 +1,93 @@
"""The 'config' command: show and edit user configuration."""
import os
from beets import config, ui
from beets.util import displayable_path, editor_command, interactive_open
def config_func(lib, opts, args):
# Make sure lazy configuration is loaded
config.resolve()
# Print paths.
if opts.paths:
filenames = []
for source in config.sources:
if not opts.defaults and source.default:
continue
if source.filename:
filenames.append(source.filename)
# In case the user config file does not exist, prepend it to the
# list.
user_path = config.user_config_path()
if user_path not in filenames:
filenames.insert(0, user_path)
for filename in filenames:
ui.print_(displayable_path(filename))
# Open in editor.
elif opts.edit:
# Note: This branch *should* be unreachable
# since the normal flow should be short-circuited
# by the special case in ui._raw_main
config_edit(opts)
# Dump configuration.
else:
config_out = config.dump(full=opts.defaults, redact=opts.redact)
if config_out.strip() != "{}":
ui.print_(config_out)
else:
print("Empty configuration")
def config_edit(cli_options):
"""Open a program to edit the user configuration.
An empty config file is created if no existing config file exists.
"""
path = cli_options.config or config.user_config_path()
editor = editor_command()
try:
if not os.path.isfile(path):
open(path, "w+").close()
interactive_open([path], editor)
except OSError as exc:
message = f"Could not edit configuration: {exc}"
if not editor:
message += (
". Please set the VISUAL (or EDITOR) environment variable"
)
raise ui.UserError(message)
config_cmd = ui.Subcommand("config", help="show or edit the user configuration")
config_cmd.parser.add_option(
"-p",
"--paths",
action="store_true",
help="show files that configuration was loaded from",
)
config_cmd.parser.add_option(
"-e",
"--edit",
action="store_true",
help="edit user configuration with $VISUAL (or $EDITOR)",
)
config_cmd.parser.add_option(
"-d",
"--defaults",
action="store_true",
help="include the default configuration",
)
config_cmd.parser.add_option(
"-c",
"--clear",
action="store_false",
dest="redact",
default=True,
help="do not redact sensitive fields",
)
config_cmd.func = config_func

View file

@ -0,0 +1,41 @@
"""The `fields` command: show available fields for queries and format strings."""
import textwrap
from beets import library, ui
def _print_keys(query):
"""Given a SQLite query result, print the `key` field of each
returned row, with indentation of 2 spaces.
"""
for row in query:
ui.print_(f" {row['key']}")
def fields_func(lib, opts, args):
def _print_rows(names):
names.sort()
ui.print_(textwrap.indent("\n".join(names), " "))
ui.print_("Item fields:")
_print_rows(library.Item.all_keys())
ui.print_("Album fields:")
_print_rows(library.Album.all_keys())
with lib.transaction() as tx:
# The SQL uses the DISTINCT to get unique values from the query
unique_fields = "SELECT DISTINCT key FROM ({})"
ui.print_("Item flexible attributes:")
_print_keys(tx.query(unique_fields.format(library.Item._flex_table)))
ui.print_("Album flexible attributes:")
_print_keys(tx.query(unique_fields.format(library.Album._flex_table)))
fields_cmd = ui.Subcommand(
"fields", help="show fields available for queries and format strings"
)
fields_cmd.func = fields_func

22
beets/ui/commands/help.py Normal file
View file

@ -0,0 +1,22 @@
"""The 'help' command: show help information for commands."""
from beets import ui
class HelpCommand(ui.Subcommand):
def __init__(self):
super().__init__(
"help",
aliases=("?",),
help="give detailed help on a specific sub-command",
)
def func(self, lib, opts, args):
if args:
cmdname = args[0]
helpcommand = self.root_parser._subcommand_for_name(cmdname)
if not helpcommand:
raise ui.UserError(f"unknown command '{cmdname}'")
helpcommand.print_help()
else:
self.root_parser.print_help()

View file

@ -0,0 +1,341 @@
"""The `import` command: import new music into the library."""
import os
from beets import config, logging, plugins, ui
from beets.util import displayable_path, normpath, syspath
from .session import TerminalImportSession
# Global logger.
log = logging.getLogger("beets")
def paths_from_logfile(path):
"""Parse the logfile and yield skipped paths to pass to the `import`
command.
"""
with open(path, encoding="utf-8") as fp:
for i, line in enumerate(fp, start=1):
verb, sep, paths = line.rstrip("\n").partition(" ")
if not sep:
raise ValueError(f"line {i} is invalid")
# Ignore informational lines that don't need to be re-imported.
if verb in {"import", "duplicate-keep", "duplicate-replace"}:
continue
if verb not in {"asis", "skip", "duplicate-skip"}:
raise ValueError(f"line {i} contains unknown verb {verb}")
yield os.path.commonpath(paths.split("; "))
def parse_logfiles(logfiles):
"""Parse all `logfiles` and yield paths from it."""
for logfile in logfiles:
try:
yield from paths_from_logfile(syspath(normpath(logfile)))
except ValueError as err:
raise ui.UserError(
f"malformed logfile {displayable_path(logfile)}: {err}"
) from err
except OSError as err:
raise ui.UserError(
f"unreadable logfile {displayable_path(logfile)}: {err}"
) from err
def import_files(lib, paths: list[bytes], query):
"""Import the files in the given list of paths or matching the
query.
"""
# Check parameter consistency.
if config["import"]["quiet"] and config["import"]["timid"]:
raise ui.UserError("can't be both quiet and timid")
# Open the log.
if config["import"]["log"].get() is not None:
logpath = syspath(config["import"]["log"].as_filename())
try:
loghandler = logging.FileHandler(logpath, encoding="utf-8")
except OSError:
raise ui.UserError(
"Could not open log file for writing:"
f" {displayable_path(logpath)}"
)
else:
loghandler = None
# Never ask for input in quiet mode.
if config["import"]["resume"].get() == "ask" and config["import"]["quiet"]:
config["import"]["resume"] = False
session = TerminalImportSession(lib, loghandler, paths, query)
session.run()
# Emit event.
plugins.send("import", lib=lib, paths=paths)
def import_func(lib, opts, args: list[str]):
config["import"].set_args(opts)
# Special case: --copy flag suppresses import_move (which would
# otherwise take precedence).
if opts.copy:
config["import"]["move"] = False
if opts.library:
query = args
byte_paths = []
else:
query = None
paths = args
# The paths from the logfiles go into a separate list to allow handling
# errors differently from user-specified paths.
paths_from_logfiles = list(parse_logfiles(opts.from_logfiles or []))
if not paths and not paths_from_logfiles:
raise ui.UserError("no path specified")
byte_paths = [os.fsencode(p) for p in paths]
paths_from_logfiles = [os.fsencode(p) for p in paths_from_logfiles]
# Check the user-specified directories.
for path in byte_paths:
if not os.path.exists(syspath(normpath(path))):
raise ui.UserError(
f"no such file or directory: {displayable_path(path)}"
)
# Check the directories from the logfiles, but don't throw an error in
# case those paths don't exist. Maybe some of those paths have already
# been imported and moved separately, so logging a warning should
# suffice.
for path in paths_from_logfiles:
if not os.path.exists(syspath(normpath(path))):
log.warning(
"No such file or directory: {}", displayable_path(path)
)
continue
byte_paths.append(path)
# If all paths were read from a logfile, and none of them exist, throw
# an error
if not byte_paths:
raise ui.UserError("none of the paths are importable")
import_files(lib, byte_paths, query)
def _store_dict(option, opt_str, value, parser):
"""Custom action callback to parse options which have ``key=value``
pairs as values. All such pairs passed for this option are
aggregated into a dictionary.
"""
dest = option.dest
option_values = getattr(parser.values, dest, None)
if option_values is None:
# This is the first supplied ``key=value`` pair of option.
# Initialize empty dictionary and get a reference to it.
setattr(parser.values, dest, {})
option_values = getattr(parser.values, dest)
try:
key, value = value.split("=", 1)
if not (key and value):
raise ValueError
except ValueError:
raise ui.UserError(
f"supplied argument `{value}' is not of the form `key=value'"
)
option_values[key] = value
import_cmd = ui.Subcommand(
"import", help="import new music", aliases=("imp", "im")
)
import_cmd.parser.add_option(
"-c",
"--copy",
action="store_true",
default=None,
help="copy tracks into library directory (default)",
)
import_cmd.parser.add_option(
"-C",
"--nocopy",
action="store_false",
dest="copy",
help="don't copy tracks (opposite of -c)",
)
import_cmd.parser.add_option(
"-m",
"--move",
action="store_true",
dest="move",
help="move tracks into the library (overrides -c)",
)
import_cmd.parser.add_option(
"-w",
"--write",
action="store_true",
default=None,
help="write new metadata to files' tags (default)",
)
import_cmd.parser.add_option(
"-W",
"--nowrite",
action="store_false",
dest="write",
help="don't write metadata (opposite of -w)",
)
import_cmd.parser.add_option(
"-a",
"--autotag",
action="store_true",
dest="autotag",
help="infer tags for imported files (default)",
)
import_cmd.parser.add_option(
"-A",
"--noautotag",
action="store_false",
dest="autotag",
help="don't infer tags for imported files (opposite of -a)",
)
import_cmd.parser.add_option(
"-p",
"--resume",
action="store_true",
default=None,
help="resume importing if interrupted",
)
import_cmd.parser.add_option(
"-P",
"--noresume",
action="store_false",
dest="resume",
help="do not try to resume importing",
)
import_cmd.parser.add_option(
"-q",
"--quiet",
action="store_true",
dest="quiet",
help="never prompt for input: skip albums instead",
)
import_cmd.parser.add_option(
"--quiet-fallback",
type="string",
dest="quiet_fallback",
help="decision in quiet mode when no strong match: skip or asis",
)
import_cmd.parser.add_option(
"-l",
"--log",
dest="log",
help="file to log untaggable albums for later review",
)
import_cmd.parser.add_option(
"-s",
"--singletons",
action="store_true",
help="import individual tracks instead of full albums",
)
import_cmd.parser.add_option(
"-t",
"--timid",
dest="timid",
action="store_true",
help="always confirm all actions",
)
import_cmd.parser.add_option(
"-L",
"--library",
dest="library",
action="store_true",
help="retag items matching a query",
)
import_cmd.parser.add_option(
"-i",
"--incremental",
dest="incremental",
action="store_true",
help="skip already-imported directories",
)
import_cmd.parser.add_option(
"-I",
"--noincremental",
dest="incremental",
action="store_false",
help="do not skip already-imported directories",
)
import_cmd.parser.add_option(
"-R",
"--incremental-skip-later",
action="store_true",
dest="incremental_skip_later",
help="do not record skipped files during incremental import",
)
import_cmd.parser.add_option(
"-r",
"--noincremental-skip-later",
action="store_false",
dest="incremental_skip_later",
help="record skipped files during incremental import",
)
import_cmd.parser.add_option(
"--from-scratch",
dest="from_scratch",
action="store_true",
help="erase existing metadata before applying new metadata",
)
import_cmd.parser.add_option(
"--flat",
dest="flat",
action="store_true",
help="import an entire tree as a single album",
)
import_cmd.parser.add_option(
"-g",
"--group-albums",
dest="group_albums",
action="store_true",
help="group tracks in a folder into separate albums",
)
import_cmd.parser.add_option(
"--pretend",
dest="pretend",
action="store_true",
help="just print the files to import",
)
import_cmd.parser.add_option(
"-S",
"--search-id",
dest="search_ids",
action="append",
metavar="ID",
help="restrict matching to a specific metadata backend ID",
)
import_cmd.parser.add_option(
"--from-logfile",
dest="from_logfiles",
action="append",
metavar="PATH",
help="read skipped paths from an existing logfile",
)
import_cmd.parser.add_option(
"--set",
dest="set_fields",
action="callback",
callback=_store_dict,
metavar="FIELD=VALUE",
help="set the given fields to the supplied values",
)
import_cmd.func = import_func

View file

@ -0,0 +1,566 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from functools import cached_property
from typing import TYPE_CHECKING, TypedDict
from typing_extensions import NotRequired
from beets import autotag, config, ui
from beets.autotag import hooks
from beets.util import displayable_path
from beets.util.units import human_seconds_short
if TYPE_CHECKING:
from collections.abc import Sequence
import confuse
from beets.autotag.distance import Distance
from beets.library.models import Item
from beets.ui import ColorName
VARIOUS_ARTISTS = "Various Artists"
class Side(TypedDict):
prefix: str
contents: str
suffix: str
width: NotRequired[int]
@dataclass
class ChangeRepresentation:
"""Keeps track of all information needed to generate a (colored) text
representation of the changes that will be made if an album or singleton's
tags are changed according to `match`, which must be an AlbumMatch or
TrackMatch object, accordingly.
"""
cur_artist: str
cur_name: str
match: autotag.hooks.Match
@cached_property
def changed_prefix(self) -> str:
return ui.colorize("changed", "\u2260")
@cached_property
def _indentation_config(self) -> confuse.Subview:
return config["ui"]["import"]["indentation"]
@cached_property
def indent_header(self) -> str:
return ui.indent(self._indentation_config["match_header"].as_number())
@cached_property
def indent_detail(self) -> str:
return ui.indent(self._indentation_config["match_details"].as_number())
@cached_property
def indent_tracklist(self) -> str:
return ui.indent(
self._indentation_config["match_tracklist"].as_number()
)
@cached_property
def layout(self) -> int:
return config["ui"]["import"]["layout"].as_choice(
{"column": 0, "newline": 1}
)
def print_layout(
self,
indent: str,
left: Side,
right: Side,
separator: str = " -> ",
max_width: int | None = None,
) -> None:
if not max_width:
# If no max_width provided, use terminal width
max_width = ui.term_width()
if self.layout == 0:
ui.print_column_layout(indent, left, right, separator, max_width)
else:
ui.print_newline_layout(indent, left, right, separator, max_width)
def show_match_header(self) -> None:
"""Print out a 'header' identifying the suggested match (album name,
artist name,...) and summarizing the changes that would be made should
the user accept the match.
"""
# Print newline at beginning of change block.
ui.print_("")
# 'Match' line and similarity.
ui.print_(
f"{self.indent_header}Match ({dist_string(self.match.distance)}):"
)
artist_name_str = f"{self.match.info.artist} - {self.match.info.name}"
ui.print_(
self.indent_header
+ dist_colorize(artist_name_str, self.match.distance)
)
# Penalties.
penalties = penalty_string(self.match.distance)
if penalties:
ui.print_(f"{self.indent_header}{penalties}")
# Disambiguation.
disambig = disambig_string(self.match.info)
if disambig:
ui.print_(f"{self.indent_header}{disambig}")
# Data URL.
if self.match.info.data_url:
url = ui.colorize("text_faint", f"{self.match.info.data_url}")
ui.print_(f"{self.indent_header}{url}")
def show_match_details(self) -> None:
"""Print out the details of the match, including changes in album name
and artist name.
"""
# Artist.
artist_l, artist_r = self.cur_artist or "", self.match.info.artist
if artist_r == VARIOUS_ARTISTS:
# Hide artists for VA releases.
artist_l, artist_r = "", ""
left: Side
right: Side
if artist_l != artist_r:
artist_l, artist_r = ui.colordiff(artist_l, artist_r)
left = {
"prefix": f"{self.changed_prefix} Artist: ",
"contents": artist_l,
"suffix": "",
}
right = {"prefix": "", "contents": artist_r, "suffix": ""}
self.print_layout(self.indent_detail, left, right)
else:
ui.print_(f"{self.indent_detail}*", "Artist:", artist_r)
if self.cur_name:
type_ = self.match.type
name_l, name_r = self.cur_name or "", self.match.info.name
if self.cur_name != self.match.info.name != VARIOUS_ARTISTS:
name_l, name_r = ui.colordiff(name_l, name_r)
left = {
"prefix": f"{self.changed_prefix} {type_}: ",
"contents": name_l,
"suffix": "",
}
right = {"prefix": "", "contents": name_r, "suffix": ""}
self.print_layout(self.indent_detail, left, right)
else:
ui.print_(f"{self.indent_detail}*", f"{type_}:", name_r)
def make_medium_info_line(self, track_info: hooks.TrackInfo) -> str:
"""Construct a line with the current medium's info."""
track_media = track_info.get("media", "Media")
# Build output string.
if self.match.info.mediums > 1 and track_info.disctitle:
return (
f"* {track_media} {track_info.medium}: {track_info.disctitle}"
)
elif self.match.info.mediums > 1:
return f"* {track_media} {track_info.medium}"
elif track_info.disctitle:
return f"* {track_media}: {track_info.disctitle}"
else:
return ""
def format_index(self, track_info: hooks.TrackInfo | Item) -> str:
"""Return a string representing the track index of the given
TrackInfo or Item object.
"""
if isinstance(track_info, hooks.TrackInfo):
index = track_info.index
medium_index = track_info.medium_index
medium = track_info.medium
mediums = self.match.info.mediums
else:
index = medium_index = track_info.track
medium = track_info.disc
mediums = track_info.disctotal
if config["per_disc_numbering"]:
if mediums and mediums > 1:
return f"{medium}-{medium_index}"
else:
return str(medium_index if medium_index is not None else index)
else:
return str(index)
def make_track_numbers(
self, item: Item, track_info: hooks.TrackInfo
) -> tuple[str, str, bool]:
"""Format colored track indices."""
cur_track = self.format_index(item)
new_track = self.format_index(track_info)
changed = False
# Choose color based on change.
highlight_color: ColorName
if cur_track != new_track:
changed = True
if item.track in (track_info.index, track_info.medium_index):
highlight_color = "text_highlight_minor"
else:
highlight_color = "text_highlight"
else:
highlight_color = "text_faint"
lhs_track = ui.colorize(highlight_color, f"(#{cur_track})")
rhs_track = ui.colorize(highlight_color, f"(#{new_track})")
return lhs_track, rhs_track, changed
@staticmethod
def make_track_titles(
item: Item, track_info: hooks.TrackInfo
) -> tuple[str, str, bool]:
"""Format colored track titles."""
new_title = track_info.name
if not item.title.strip():
# If there's no title, we use the filename. Don't colordiff.
cur_title = displayable_path(os.path.basename(item.path))
return cur_title, new_title, True
else:
# If there is a title, highlight differences.
cur_title = item.title.strip()
cur_col, new_col = ui.colordiff(cur_title, new_title)
return cur_col, new_col, cur_title != new_title
@staticmethod
def make_track_lengths(
item: Item, track_info: hooks.TrackInfo
) -> tuple[str, str, bool]:
"""Format colored track lengths."""
changed = False
highlight_color: ColorName
if (
item.length
and track_info.length
and abs(item.length - track_info.length)
>= config["ui"]["length_diff_thresh"].as_number()
):
highlight_color = "text_highlight"
changed = True
else:
highlight_color = "text_highlight_minor"
# Handle nonetype lengths by setting to 0
cur_length0 = item.length if item.length else 0
new_length0 = track_info.length if track_info.length else 0
# format into string
cur_length = f"({human_seconds_short(cur_length0)})"
new_length = f"({human_seconds_short(new_length0)})"
# colorize
lhs_length = ui.colorize(highlight_color, cur_length)
rhs_length = ui.colorize(highlight_color, new_length)
return lhs_length, rhs_length, changed
def make_line(
self, item: Item, track_info: hooks.TrackInfo
) -> tuple[Side, Side]:
"""Extract changes from item -> new TrackInfo object, and colorize
appropriately. Returns (lhs, rhs) for column printing.
"""
# Track titles.
lhs_title, rhs_title, diff_title = self.make_track_titles(
item, track_info
)
# Track number change.
lhs_track, rhs_track, diff_track = self.make_track_numbers(
item, track_info
)
# Length change.
lhs_length, rhs_length, diff_length = self.make_track_lengths(
item, track_info
)
changed = diff_title or diff_track or diff_length
# Construct lhs and rhs dicts.
# Previously, we printed the penalties, however this is no longer
# the case, thus the 'info' dictionary is unneeded.
# penalties = penalty_string(self.match.distance.tracks[track_info])
lhs: Side = {
"prefix": f"{self.changed_prefix if changed else '*'} {lhs_track} ",
"contents": lhs_title,
"suffix": f" {lhs_length}",
}
rhs: Side = {"prefix": "", "contents": "", "suffix": ""}
if not changed:
# Only return the left side, as nothing changed.
return (lhs, rhs)
else:
# Construct a dictionary for the "changed to" side
rhs = {
"prefix": f"{rhs_track} ",
"contents": rhs_title,
"suffix": f" {rhs_length}",
}
return (lhs, rhs)
def print_tracklist(self, lines: list[tuple[Side, Side]]) -> None:
"""Calculates column widths for tracks stored as line tuples:
(left, right). Then prints each line of tracklist.
"""
if len(lines) == 0:
# If no lines provided, e.g. details not required, do nothing.
return
def get_width(side: Side) -> int:
"""Return the width of left or right in uncolorized characters."""
try:
return len(
ui.uncolorize(
" ".join(
[side["prefix"], side["contents"], side["suffix"]]
)
)
)
except KeyError:
# An empty dictionary -> Nothing to report
return 0
# Check how to fit content into terminal window
indent_width = len(self.indent_tracklist)
terminal_width = ui.term_width()
joiner_width = len("".join(["* ", " -> "]))
col_width = (terminal_width - indent_width - joiner_width) // 2
max_width_l = max(get_width(line_tuple[0]) for line_tuple in lines)
max_width_r = max(get_width(line_tuple[1]) for line_tuple in lines)
if (
(max_width_l <= col_width)
and (max_width_r <= col_width)
or (
((max_width_l > col_width) or (max_width_r > col_width))
and ((max_width_l + max_width_r) <= col_width * 2)
)
):
# All content fits. Either both maximum widths are below column
# widths, or one of the columns is larger than allowed but the
# other is smaller than allowed.
# In this case we can afford to shrink the columns to fit their
# largest string
col_width_l = max_width_l
col_width_r = max_width_r
else:
# Not all content fits - stick with original half/half split
col_width_l = col_width
col_width_r = col_width
# Print out each line, using the calculated width from above.
for left, right in lines:
left["width"] = col_width_l
right["width"] = col_width_r
self.print_layout(self.indent_tracklist, left, right)
class AlbumChange(ChangeRepresentation):
match: autotag.hooks.AlbumMatch
def show_match_tracks(self) -> None:
"""Print out the tracks of the match, summarizing changes the match
suggests for them.
"""
pairs = sorted(
self.match.item_info_pairs, key=lambda pair: pair[1].index or 0
)
# Build up LHS and RHS for track difference display. The `lines` list
# contains `(left, right)` tuples.
lines: list[tuple[Side, Side]] = []
medium = disctitle = None
for item, track_info in pairs:
# If the track is the first on a new medium, show medium
# number and title.
if medium != track_info.medium or disctitle != track_info.disctitle:
# Create header for new medium
header = self.make_medium_info_line(track_info)
if header != "":
# Print tracks from previous medium
self.print_tracklist(lines)
lines = []
ui.print_(f"{self.indent_detail}{header}")
# Save new medium details for future comparison.
medium, disctitle = track_info.medium, track_info.disctitle
# Construct the line tuple for the track.
left, right = self.make_line(item, track_info)
if right["contents"] != "":
lines.append((left, right))
else:
if config["import"]["detail"]:
lines.append((left, right))
self.print_tracklist(lines)
# Missing and unmatched tracks.
if self.match.extra_tracks:
ui.print_(
"Missing tracks"
f" ({len(self.match.extra_tracks)}/{len(self.match.info.tracks)} -"
f" {len(self.match.extra_tracks) / len(self.match.info.tracks):.1%}):"
)
for track_info in self.match.extra_tracks:
line = f" ! {track_info.title} (#{self.format_index(track_info)})"
if track_info.length:
line += f" ({human_seconds_short(track_info.length)})"
ui.print_(ui.colorize("text_warning", line))
if self.match.extra_items:
ui.print_(f"Unmatched tracks ({len(self.match.extra_items)}):")
for item in self.match.extra_items:
line = f" ! {item.title} (#{self.format_index(item)})"
if item.length:
line += f" ({human_seconds_short(item.length)})"
ui.print_(ui.colorize("text_warning", line))
class TrackChange(ChangeRepresentation):
"""Track change representation, comparing item with match."""
match: autotag.hooks.TrackMatch
def show_change(
cur_artist: str, cur_album: str, match: hooks.AlbumMatch
) -> None:
"""Print out a representation of the changes that will be made if an
album's tags are changed according to `match`, which must be an AlbumMatch
object.
"""
change = AlbumChange(cur_artist, cur_album, match)
# Print the match header.
change.show_match_header()
# Print the match details.
change.show_match_details()
# Print the match tracks.
change.show_match_tracks()
def show_item_change(item: Item, match: hooks.TrackMatch) -> None:
"""Print out the change that would occur by tagging `item` with the
metadata from `match`, a TrackMatch object.
"""
change = TrackChange(item.artist, item.title, match)
# Print the match header.
change.show_match_header()
# Print the match details.
change.show_match_details()
def disambig_string(info: hooks.Info) -> str:
"""Generate a string for an AlbumInfo or TrackInfo object that
provides context that helps disambiguate similar-looking albums and
tracks.
"""
if isinstance(info, hooks.AlbumInfo):
disambig = get_album_disambig_fields(info)
elif isinstance(info, hooks.TrackInfo):
disambig = get_singleton_disambig_fields(info)
else:
return ""
return ", ".join(disambig)
def get_singleton_disambig_fields(info: hooks.TrackInfo) -> Sequence[str]:
out = []
chosen_fields = config["match"]["singleton_disambig_fields"].as_str_seq()
calculated_values = {
"index": f"Index {info.index}",
"track_alt": f"Track {info.track_alt}",
"album": (
f"[{info.album}]"
if (
config["import"]["singleton_album_disambig"].get()
and info.get("album")
)
else ""
),
}
for field in chosen_fields:
if field in calculated_values:
out.append(str(calculated_values[field]))
else:
try:
out.append(str(info[field]))
except (AttributeError, KeyError):
print(f"Disambiguation string key {field} does not exist.")
return out
def get_album_disambig_fields(info: hooks.AlbumInfo) -> Sequence[str]:
out = []
chosen_fields = config["match"]["album_disambig_fields"].as_str_seq()
calculated_values = {
"media": (
f"{info.mediums}x{info.media}"
if (info.mediums and info.mediums > 1)
else info.media
),
}
for field in chosen_fields:
if field in calculated_values:
out.append(str(calculated_values[field]))
else:
try:
out.append(str(info[field]))
except (AttributeError, KeyError):
print(f"Disambiguation string key {field} does not exist.")
return out
def dist_colorize(string: str, dist: Distance) -> str:
"""Formats a string as a colorized similarity string according to
a distance.
"""
if dist <= config["match"]["strong_rec_thresh"].as_number():
string = ui.colorize("text_success", string)
elif dist <= config["match"]["medium_rec_thresh"].as_number():
string = ui.colorize("text_warning", string)
else:
string = ui.colorize("text_error", string)
return string
def dist_string(dist: Distance) -> str:
"""Formats a distance (a float) as a colorized similarity percentage
string.
"""
string = f"{(1 - dist) * 100:.1f}%"
return dist_colorize(string, dist)
def penalty_string(distance: Distance, limit: int | None = None) -> str:
"""Returns a colorized string that indicates all the penalties
applied to a distance object.
"""
penalties = []
for key in distance.keys():
key = key.replace("album_", "")
key = key.replace("track_", "")
key = key.replace("_", " ")
penalties.append(key)
if penalties:
if limit and len(penalties) > limit:
penalties = penalties[:limit] + ["..."]
# Prefix penalty string with U+2260: Not Equal To
penalty_string = f"\u2260 {', '.join(penalties)}"
return ui.colorize("changed", penalty_string)
return ""

View file

@ -0,0 +1,547 @@
from collections import Counter
from itertools import chain
from beets import autotag, config, importer, logging, plugins, ui
from beets.autotag import Recommendation
from beets.util import PromptChoice, displayable_path
from beets.util.units import human_bytes, human_seconds_short
from .display import (
disambig_string,
dist_colorize,
penalty_string,
show_change,
show_item_change,
)
# Global logger.
log = logging.getLogger("beets")
class TerminalImportSession(importer.ImportSession):
"""An import session that runs in a terminal."""
def choose_match(self, task):
"""Given an initial autotagging of items, go through an interactive
dance with the user to ask for a choice of metadata. Returns an
AlbumMatch object, ASIS, or SKIP.
"""
# Show what we're tagging.
ui.print_()
path_str0 = displayable_path(task.paths, "\n")
path_str = ui.colorize("import_path", path_str0)
items_str0 = f"({len(task.items)} items)"
items_str = ui.colorize("import_path_items", items_str0)
ui.print_(" ".join([path_str, items_str]))
# Let plugins display info or prompt the user before we go through the
# process of selecting candidate.
results = plugins.send(
"import_task_before_choice", session=self, task=task
)
actions = [action for action in results if action]
if len(actions) == 1:
return actions[0]
elif len(actions) > 1:
raise plugins.PluginConflictError(
"Only one handler for `import_task_before_choice` may return "
"an action."
)
# Take immediate action if appropriate.
action = _summary_judgment(task.rec)
if action == importer.Action.APPLY:
match = task.candidates[0]
show_change(task.cur_artist, task.cur_album, match)
return match
elif action is not None:
return action
# Loop until we have a choice.
while True:
# Ask for a choice from the user. The result of
# `choose_candidate` may be an `importer.Action`, an
# `AlbumMatch` object for a specific selection, or a
# `PromptChoice`.
choices = self._get_choices(task)
choice = choose_candidate(
task.candidates,
False,
task.rec,
task.cur_artist,
task.cur_album,
itemcount=len(task.items),
choices=choices,
)
# Basic choices that require no more action here.
if choice in (importer.Action.SKIP, importer.Action.ASIS):
# Pass selection to main control flow.
return choice
# Plugin-provided choices. We invoke the associated callback
# function.
elif choice in choices:
post_choice = choice.callback(self, task)
if isinstance(post_choice, importer.Action):
return post_choice
elif isinstance(post_choice, autotag.Proposal):
# Use the new candidates and continue around the loop.
task.candidates = post_choice.candidates
task.rec = post_choice.recommendation
# Otherwise, we have a specific match selection.
else:
# We have a candidate! Finish tagging. Here, choice is an
# AlbumMatch object.
assert isinstance(choice, autotag.AlbumMatch)
return choice
def choose_item(self, task):
"""Ask the user for a choice about tagging a single item. Returns
either an action constant or a TrackMatch object.
"""
ui.print_()
ui.print_(displayable_path(task.item.path))
candidates, rec = task.candidates, task.rec
# Take immediate action if appropriate.
action = _summary_judgment(task.rec)
if action == importer.Action.APPLY:
match = candidates[0]
show_item_change(task.item, match)
return match
elif action is not None:
return action
while True:
# Ask for a choice.
choices = self._get_choices(task)
choice = choose_candidate(
candidates, True, rec, item=task.item, choices=choices
)
if choice in (importer.Action.SKIP, importer.Action.ASIS):
return choice
elif choice in choices:
post_choice = choice.callback(self, task)
if isinstance(post_choice, importer.Action):
return post_choice
elif isinstance(post_choice, autotag.Proposal):
candidates = post_choice.candidates
rec = post_choice.recommendation
else:
# Chose a candidate.
assert isinstance(choice, autotag.TrackMatch)
return choice
def resolve_duplicate(self, task, found_duplicates):
"""Decide what to do when a new album or item seems similar to one
that's already in the library.
"""
log.warning(
"This {} is already in the library!",
("album" if task.is_album else "item"),
)
if config["import"]["quiet"]:
# In quiet mode, don't prompt -- just skip.
log.info("Skipping.")
sel = "s"
else:
# Print some detail about the existing and new items so the
# user can make an informed decision.
for duplicate in found_duplicates:
ui.print_(
"Old: "
+ summarize_items(
(
list(duplicate.items())
if task.is_album
else [duplicate]
),
not task.is_album,
)
)
if config["import"]["duplicate_verbose_prompt"]:
if task.is_album:
for dup in duplicate.items():
print(f" {dup}")
else:
print(f" {duplicate}")
ui.print_(
"New: "
+ summarize_items(
task.imported_items(),
not task.is_album,
)
)
if config["import"]["duplicate_verbose_prompt"]:
for item in task.imported_items():
print(f" {item}")
sel = ui.input_options(
("Skip new", "Keep all", "Remove old", "Merge all")
)
if sel == "s":
# Skip new.
task.set_choice(importer.Action.SKIP)
elif sel == "k":
# Keep both. Do nothing; leave the choice intact.
pass
elif sel == "r":
# Remove old.
task.should_remove_duplicates = True
elif sel == "m":
task.should_merge_duplicates = True
else:
assert False
def should_resume(self, path):
return ui.input_yn(
f"Import of the directory:\n{displayable_path(path)}\n"
"was interrupted. Resume (Y/n)?"
)
def _get_choices(self, task):
"""Get the list of prompt choices that should be presented to the
user. This consists of both built-in choices and ones provided by
plugins.
The `before_choose_candidate` event is sent to the plugins, with
session and task as its parameters. Plugins are responsible for
checking the right conditions and returning a list of `PromptChoice`s,
which is flattened and checked for conflicts.
If two or more choices have the same short letter, a warning is
emitted and all but one choices are discarded, giving preference
to the default importer choices.
Returns a list of `PromptChoice`s.
"""
# Standard, built-in choices.
choices = [
PromptChoice("s", "Skip", lambda s, t: importer.Action.SKIP),
PromptChoice("u", "Use as-is", lambda s, t: importer.Action.ASIS),
]
if task.is_album:
choices += [
PromptChoice(
"t", "as Tracks", lambda s, t: importer.Action.TRACKS
),
PromptChoice(
"g", "Group albums", lambda s, t: importer.Action.ALBUMS
),
]
choices += [
PromptChoice("e", "Enter search", manual_search),
PromptChoice("i", "enter Id", manual_id),
PromptChoice("b", "aBort", abort_action),
]
# Send the before_choose_candidate event and flatten list.
extra_choices = list(
chain(
*plugins.send(
"before_choose_candidate", session=self, task=task
)
)
)
# Add a "dummy" choice for the other baked-in option, for
# duplicate checking.
all_choices = (
[
PromptChoice("a", "Apply", None),
]
+ choices
+ extra_choices
)
# Check for conflicts.
short_letters = [c.short for c in all_choices]
if len(short_letters) != len(set(short_letters)):
# Duplicate short letter has been found.
duplicates = [
i for i, count in Counter(short_letters).items() if count > 1
]
for short in duplicates:
# Keep the first of the choices, removing the rest.
dup_choices = [c for c in all_choices if c.short == short]
for c in dup_choices[1:]:
log.warning(
"Prompt choice '{0.long}' removed due to conflict "
"with '{1[0].long}' (short letter: '{0.short}')",
c,
dup_choices,
)
extra_choices.remove(c)
return choices + extra_choices
def summarize_items(items, singleton):
"""Produces a brief summary line describing a set of items. Used for
manually resolving duplicates during import.
`items` is a list of `Item` objects. `singleton` indicates whether
this is an album or single-item import (if the latter, them `items`
should only have one element).
"""
summary_parts = []
if not singleton:
summary_parts.append(f"{len(items)} items")
format_counts = {}
for item in items:
format_counts[item.format] = format_counts.get(item.format, 0) + 1
if len(format_counts) == 1:
# A single format.
summary_parts.append(items[0].format)
else:
# Enumerate all the formats by decreasing frequencies:
for fmt, count in sorted(
format_counts.items(),
key=lambda fmt_and_count: (-fmt_and_count[1], fmt_and_count[0]),
):
summary_parts.append(f"{fmt} {count}")
if items:
average_bitrate = sum([item.bitrate for item in items]) / len(items)
total_duration = sum([item.length for item in items])
total_filesize = sum([item.filesize for item in items])
summary_parts.append(f"{int(average_bitrate / 1000)}kbps")
if items[0].format == "FLAC":
sample_bits = (
f"{round(int(items[0].samplerate) / 1000, 1)}kHz"
f"/{items[0].bitdepth} bit"
)
summary_parts.append(sample_bits)
summary_parts.append(human_seconds_short(total_duration))
summary_parts.append(human_bytes(total_filesize))
return ", ".join(summary_parts)
def _summary_judgment(rec):
"""Determines whether a decision should be made without even asking
the user. This occurs in quiet mode and when an action is chosen for
NONE recommendations. Return None if the user should be queried.
Otherwise, returns an action. May also print to the console if a
summary judgment is made.
"""
if config["import"]["quiet"]:
if rec == Recommendation.strong:
return importer.Action.APPLY
else:
action = config["import"]["quiet_fallback"].as_choice(
{
"skip": importer.Action.SKIP,
"asis": importer.Action.ASIS,
}
)
elif config["import"]["timid"]:
return None
elif rec == Recommendation.none:
action = config["import"]["none_rec_action"].as_choice(
{
"skip": importer.Action.SKIP,
"asis": importer.Action.ASIS,
"ask": None,
}
)
else:
return None
if action == importer.Action.SKIP:
ui.print_("Skipping.")
elif action == importer.Action.ASIS:
ui.print_("Importing as-is.")
return action
def choose_candidate(
candidates,
singleton,
rec,
cur_artist=None,
cur_album=None,
item=None,
itemcount=None,
choices=[],
):
"""Given a sorted list of candidates, ask the user for a selection
of which candidate to use. Applies to both full albums and
singletons (tracks). Candidates are either AlbumMatch or TrackMatch
objects depending on `singleton`. for albums, `cur_artist`,
`cur_album`, and `itemcount` must be provided. For singletons,
`item` must be provided.
`choices` is a list of `PromptChoice`s to be used in each prompt.
Returns one of the following:
* the result of the choice, which may be SKIP or ASIS
* a candidate (an AlbumMatch/TrackMatch object)
* a chosen `PromptChoice` from `choices`
"""
# Sanity check.
if singleton:
assert item is not None
else:
assert cur_artist is not None
assert cur_album is not None
# Build helper variables for the prompt choices.
choice_opts = tuple(c.long for c in choices)
choice_actions = {c.short: c for c in choices}
# Zero candidates.
if not candidates:
if singleton:
ui.print_("No matching recordings found.")
else:
ui.print_(f"No matching release found for {itemcount} tracks.")
ui.print_(
"For help, see: "
"https://beets.readthedocs.org/en/latest/faq.html#nomatch"
)
sel = ui.input_options(choice_opts)
if sel in choice_actions:
return choice_actions[sel]
else:
assert False
# Is the change good enough?
bypass_candidates = False
if rec != Recommendation.none:
match = candidates[0]
bypass_candidates = True
while True:
# Display and choose from candidates.
require = rec <= Recommendation.low
if not bypass_candidates:
# Display list of candidates.
ui.print_("")
ui.print_(
f"Finding tags for {'track' if singleton else 'album'} "
f'"{item.artist if singleton else cur_artist} -'
f' {item.title if singleton else cur_album}".'
)
ui.print_(" Candidates:")
for i, match in enumerate(candidates):
# Index, metadata, and distance.
index0 = f"{i + 1}."
index = dist_colorize(index0, match.distance)
dist = f"({(1 - match.distance) * 100:.1f}%)"
distance = dist_colorize(dist, match.distance)
metadata = f"{match.info.artist} - {match.info.name}"
if i == 0:
metadata = dist_colorize(metadata, match.distance)
else:
metadata = ui.colorize("text_highlight_minor", metadata)
line1 = [index, distance, metadata]
ui.print_(f" {' '.join(line1)}")
# Penalties.
penalties = penalty_string(match.distance, 3)
if penalties:
ui.print_(f"{' ' * 13}{penalties}")
# Disambiguation
disambig = disambig_string(match.info)
if disambig:
ui.print_(f"{' ' * 13}{disambig}")
# Ask the user for a choice.
sel = ui.input_options(choice_opts, numrange=(1, len(candidates)))
if sel == "m":
pass
elif sel in choice_actions:
return choice_actions[sel]
else: # Numerical selection.
match = candidates[sel - 1]
if sel != 1:
# When choosing anything but the first match,
# disable the default action.
require = True
bypass_candidates = False
# Show what we're about to do.
if singleton:
show_item_change(item, match)
else:
show_change(cur_artist, cur_album, match)
# Exact match => tag automatically if we're not in timid mode.
if rec == Recommendation.strong and not config["import"]["timid"]:
return match
# Ask for confirmation.
default = config["import"]["default_action"].as_choice(
{
"apply": "a",
"skip": "s",
"asis": "u",
"none": None,
}
)
if default is None:
require = True
# Bell ring when user interaction is needed.
if config["import"]["bell"]:
ui.print_("\a", end="")
sel = ui.input_options(
("Apply", "More candidates") + choice_opts,
require=require,
default=default,
)
if sel == "a":
return match
elif sel in choice_actions:
return choice_actions[sel]
def manual_search(session, task):
"""Get a new `Proposal` using manual search criteria.
Input either an artist and album (for full albums) or artist and
track name (for singletons) for manual search.
"""
artist = ui.input_("Artist:").strip()
name = ui.input_("Album:" if task.is_album else "Track:").strip()
if task.is_album:
_, _, prop = autotag.tag_album(task.items, artist, name)
return prop
else:
return autotag.tag_item(task.item, artist, name)
def manual_id(session, task):
"""Get a new `Proposal` using a manually-entered ID.
Input an ID, either for an album ("release") or a track ("recording").
"""
prompt = f"Enter {'release' if task.is_album else 'recording'} ID:"
search_id = ui.input_(prompt).strip()
if task.is_album:
_, _, prop = autotag.tag_album(task.items, search_ids=search_id.split())
return prop
else:
return autotag.tag_item(task.item, search_ids=search_id.split())
def abort_action(session, task):
"""A prompt choice callback that aborts the importer."""
raise importer.ImportAbortError()

25
beets/ui/commands/list.py Normal file
View file

@ -0,0 +1,25 @@
"""The 'list' command: query and show library contents."""
from beets import ui
def list_items(lib, query, album, fmt=""):
"""Print out items in lib matching query. If album, then search for
albums instead of single items.
"""
if album:
for album in lib.albums(query):
ui.print_(format(album, fmt))
else:
for item in lib.items(query):
ui.print_(format(item, fmt))
def list_func(lib, opts, args):
list_items(lib, args, opts.album)
list_cmd = ui.Subcommand("list", help="query the library", aliases=("ls",))
list_cmd.parser.usage += "\nExample: %prog -f '$album: $title' artist:beatles"
list_cmd.parser.add_all_common_options()
list_cmd.func = list_func

162
beets/ui/commands/modify.py Normal file
View file

@ -0,0 +1,162 @@
"""The `modify` command: change metadata fields."""
from beets import library, ui
from beets.util import functemplate
from .utils import do_query
def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit):
"""Modifies matching items according to user-specified assignments and
deletions.
`mods` is a dictionary of field and value pairse indicating
assignments. `dels` is a list of fields to be deleted.
"""
# Parse key=value specifications into a dictionary.
model_cls = library.Album if album else library.Item
# Get the items to modify.
items, albums = do_query(lib, query, album, False)
objs = albums if album else items
# Apply changes *temporarily*, preview them, and collect modified
# objects.
ui.print_(f"Modifying {len(objs)} {'album' if album else 'item'}s.")
changed = []
templates = {
key: functemplate.template(value) for key, value in mods.items()
}
for obj in objs:
obj_mods = {
key: model_cls._parse(key, obj.evaluate_template(templates[key]))
for key in mods.keys()
}
if print_and_modify(obj, obj_mods, dels) and obj not in changed:
changed.append(obj)
# Still something to do?
if not changed:
ui.print_("No changes to make.")
return
# Confirm action.
if confirm:
if write and move:
extra = ", move and write tags"
elif write:
extra = " and write tags"
elif move:
extra = " and move"
else:
extra = ""
changed = ui.input_select_objects(
f"Really modify{extra}",
changed,
lambda o: print_and_modify(o, mods, dels),
)
# Apply changes to database and files
with lib.transaction():
for obj in changed:
obj.try_sync(write, move, inherit)
def print_and_modify(obj, mods, dels):
"""Print the modifications to an item and return a bool indicating
whether any changes were made.
`mods` is a dictionary of fields and values to update on the object;
`dels` is a sequence of fields to delete.
"""
obj.update(mods)
for field in dels:
try:
del obj[field]
except KeyError:
pass
return ui.show_model_changes(obj)
def modify_parse_args(args):
"""Split the arguments for the modify subcommand into query parts,
assignments (field=value), and deletions (field!). Returns the result as
a three-tuple in that order.
"""
mods = {}
dels = []
query = []
for arg in args:
if arg.endswith("!") and "=" not in arg and ":" not in arg:
dels.append(arg[:-1]) # Strip trailing !.
elif "=" in arg and ":" not in arg.split("=", 1)[0]:
key, val = arg.split("=", 1)
mods[key] = val
else:
query.append(arg)
return query, mods, dels
def modify_func(lib, opts, args):
query, mods, dels = modify_parse_args(args)
if not mods and not dels:
raise ui.UserError("no modifications specified")
modify_items(
lib,
mods,
dels,
query,
ui.should_write(opts.write),
ui.should_move(opts.move),
opts.album,
not opts.yes,
opts.inherit,
)
modify_cmd = ui.Subcommand(
"modify", help="change metadata fields", aliases=("mod",)
)
modify_cmd.parser.add_option(
"-m",
"--move",
action="store_true",
dest="move",
help="move files in the library directory",
)
modify_cmd.parser.add_option(
"-M",
"--nomove",
action="store_false",
dest="move",
help="don't move files in library",
)
modify_cmd.parser.add_option(
"-w",
"--write",
action="store_true",
default=None,
help="write new metadata to files' tags (default)",
)
modify_cmd.parser.add_option(
"-W",
"--nowrite",
action="store_false",
dest="write",
help="don't write metadata (opposite of -w)",
)
modify_cmd.parser.add_album_option()
modify_cmd.parser.add_format_option(target="item")
modify_cmd.parser.add_option(
"-y", "--yes", action="store_true", help="skip confirmation"
)
modify_cmd.parser.add_option(
"-I",
"--noinherit",
action="store_false",
dest="inherit",
default=True,
help="when modifying albums, don't also change item data",
)
modify_cmd.func = modify_func

200
beets/ui/commands/move.py Normal file
View file

@ -0,0 +1,200 @@
"""The 'move' command: Move/copy files to the library or a new base directory."""
import os
from beets import logging, ui
from beets.util import (
MoveOperation,
PathLike,
displayable_path,
normpath,
syspath,
)
from .utils import do_query
# Global logger.
log = logging.getLogger("beets")
def show_path_changes(path_changes):
"""Given a list of tuples (source, destination) that indicate the
path changes, log the changes as INFO-level output to the beets log.
The output is guaranteed to be unicode.
Every pair is shown on a single line if the terminal width permits it,
else it is split over two lines. E.g.,
Source -> Destination
vs.
Source
-> Destination
"""
sources, destinations = zip(*path_changes)
# Ensure unicode output
sources = list(map(displayable_path, sources))
destinations = list(map(displayable_path, destinations))
# Calculate widths for terminal split
col_width = (ui.term_width() - len(" -> ")) // 2
max_width = len(max(sources + destinations, key=len))
if max_width > col_width:
# Print every change over two lines
for source, dest in zip(sources, destinations):
color_source, color_dest = ui.colordiff(source, dest)
ui.print_(f"{color_source} \n -> {color_dest}")
else:
# Print every change on a single line, and add a header
title_pad = max_width - len("Source ") + len(" -> ")
ui.print_(f"Source {' ' * title_pad} Destination")
for source, dest in zip(sources, destinations):
pad = max_width - len(source)
color_source, color_dest = ui.colordiff(source, dest)
ui.print_(f"{color_source} {' ' * pad} -> {color_dest}")
def move_items(
lib,
dest_path: PathLike,
query,
copy,
album,
pretend,
confirm=False,
export=False,
):
"""Moves or copies items to a new base directory, given by dest. If
dest is None, then the library's base directory is used, making the
command "consolidate" files.
"""
dest = os.fsencode(dest_path) if dest_path else dest_path
items, albums = do_query(lib, query, album, False)
objs = albums if album else items
num_objs = len(objs)
# Filter out files that don't need to be moved.
def isitemmoved(item):
return item.path != item.destination(basedir=dest)
def isalbummoved(album):
return any(isitemmoved(i) for i in album.items())
objs = [o for o in objs if (isalbummoved if album else isitemmoved)(o)]
num_unmoved = num_objs - len(objs)
# Report unmoved files that match the query.
unmoved_msg = ""
if num_unmoved > 0:
unmoved_msg = f" ({num_unmoved} already in place)"
copy = copy or export # Exporting always copies.
action = "Copying" if copy else "Moving"
act = "copy" if copy else "move"
entity = "album" if album else "item"
log.info(
"{} {} {}{}{}.",
action,
len(objs),
entity,
"s" if len(objs) != 1 else "",
unmoved_msg,
)
if not objs:
return
if pretend:
if album:
show_path_changes(
[
(item.path, item.destination(basedir=dest))
for obj in objs
for item in obj.items()
]
)
else:
show_path_changes(
[(obj.path, obj.destination(basedir=dest)) for obj in objs]
)
else:
if confirm:
objs = ui.input_select_objects(
f"Really {act}",
objs,
lambda o: show_path_changes(
[(o.path, o.destination(basedir=dest))]
),
)
for obj in objs:
log.debug("moving: {.filepath}", obj)
if export:
# Copy without affecting the database.
obj.move(
operation=MoveOperation.COPY, basedir=dest, store=False
)
else:
# Ordinary move/copy: store the new path.
if copy:
obj.move(operation=MoveOperation.COPY, basedir=dest)
else:
obj.move(operation=MoveOperation.MOVE, basedir=dest)
def move_func(lib, opts, args):
dest = opts.dest
if dest is not None:
dest = normpath(dest)
if not os.path.isdir(syspath(dest)):
raise ui.UserError(f"no such directory: {displayable_path(dest)}")
move_items(
lib,
dest,
args,
opts.copy,
opts.album,
opts.pretend,
opts.timid,
opts.export,
)
move_cmd = ui.Subcommand("move", help="move or copy items", aliases=("mv",))
move_cmd.parser.add_option(
"-d", "--dest", metavar="DIR", dest="dest", help="destination directory"
)
move_cmd.parser.add_option(
"-c",
"--copy",
default=False,
action="store_true",
help="copy instead of moving",
)
move_cmd.parser.add_option(
"-p",
"--pretend",
default=False,
action="store_true",
help="show how files would be moved, but don't touch anything",
)
move_cmd.parser.add_option(
"-t",
"--timid",
dest="timid",
action="store_true",
help="always confirm all actions",
)
move_cmd.parser.add_option(
"-e",
"--export",
default=False,
action="store_true",
help="copy without changing the database path",
)
move_cmd.parser.add_album_option()
move_cmd.func = move_func

View file

@ -0,0 +1,84 @@
"""The `remove` command: remove items from the library (and optionally delete files)."""
from beets import ui
from .utils import do_query
def remove_items(lib, query, album, delete, force):
"""Remove items matching query from lib. If album, then match and
remove whole albums. If delete, also remove files from disk.
"""
# Get the matching items.
items, albums = do_query(lib, query, album)
objs = albums if album else items
# Confirm file removal if not forcing removal.
if not force:
# Prepare confirmation with user.
album_str = (
f" in {len(albums)} album{'s' if len(albums) > 1 else ''}"
if album
else ""
)
if delete:
fmt = "$path - $title"
prompt = "Really DELETE"
prompt_all = (
"Really DELETE"
f" {len(items)} file{'s' if len(items) > 1 else ''}{album_str}"
)
else:
fmt = ""
prompt = "Really remove from the library?"
prompt_all = (
"Really remove"
f" {len(items)} item{'s' if len(items) > 1 else ''}{album_str}"
" from the library?"
)
# Helpers for printing affected items
def fmt_track(t):
ui.print_(format(t, fmt))
def fmt_album(a):
ui.print_()
for i in a.items():
fmt_track(i)
fmt_obj = fmt_album if album else fmt_track
# Show all the items.
for o in objs:
fmt_obj(o)
# Confirm with user.
objs = ui.input_select_objects(
prompt, objs, fmt_obj, prompt_all=prompt_all
)
if not objs:
return
# Remove (and possibly delete) items.
with lib.transaction():
for obj in objs:
obj.remove(delete)
def remove_func(lib, opts, args):
remove_items(lib, args, opts.album, opts.delete, opts.force)
remove_cmd = ui.Subcommand(
"remove", help="remove matching items from the library", aliases=("rm",)
)
remove_cmd.parser.add_option(
"-d", "--delete", action="store_true", help="also remove files from disk"
)
remove_cmd.parser.add_option(
"-f", "--force", action="store_true", help="do not ask when removing items"
)
remove_cmd.parser.add_album_option()
remove_cmd.func = remove_func

View file

@ -0,0 +1,62 @@
"""The 'stats' command: show library statistics."""
import os
from beets import logging, ui
from beets.util import syspath
from beets.util.units import human_bytes, human_seconds
# Global logger.
log = logging.getLogger("beets")
def show_stats(lib, query, exact):
"""Shows some statistics about the matched items."""
items = lib.items(query)
total_size = 0
total_time = 0.0
total_items = 0
artists = set()
albums = set()
album_artists = set()
for item in items:
if exact:
try:
total_size += os.path.getsize(syspath(item.path))
except OSError as exc:
log.info("could not get size of {.path}: {}", item, exc)
else:
total_size += int(item.length * item.bitrate / 8)
total_time += item.length
total_items += 1
artists.add(item.artist)
album_artists.add(item.albumartist)
if item.album_id:
albums.add(item.album_id)
size_str = human_bytes(total_size)
if exact:
size_str += f" ({total_size} bytes)"
ui.print_(f"""Tracks: {total_items}
Total time: {human_seconds(total_time)}
{f" ({total_time:.2f} seconds)" if exact else ""}
{"Total size" if exact else "Approximate total size"}: {size_str}
Artists: {len(artists)}
Albums: {len(albums)}
Album artists: {len(album_artists)}""")
def stats_func(lib, opts, args):
show_stats(lib, args, opts.exact)
stats_cmd = ui.Subcommand(
"stats", help="show statistics about the library or a query"
)
stats_cmd.parser.add_option(
"-e", "--exact", action="store_true", help="exact size and time"
)
stats_cmd.func = stats_func

196
beets/ui/commands/update.py Normal file
View file

@ -0,0 +1,196 @@
"""The `update` command: Update library contents according to on-disk tags."""
import os
from beets import library, logging, ui
from beets.util import ancestry, syspath
from .utils import do_query
# Global logger.
log = logging.getLogger("beets")
def update_items(lib, query, album, move, pretend, fields, exclude_fields=None):
"""For all the items matched by the query, update the library to
reflect the item's embedded tags.
:param fields: The fields to be stored. If not specified, all fields will
be.
:param exclude_fields: The fields to not be stored. If not specified, all
fields will be.
"""
with lib.transaction():
items, _ = do_query(lib, query, album)
if move and fields is not None and "path" not in fields:
# Special case: if an item needs to be moved, the path field has to
# updated; otherwise the new path will not be reflected in the
# database.
fields.append("path")
if fields is None:
# no fields were provided, update all media fields
item_fields = fields or library.Item._media_fields
if move and "path" not in item_fields:
# move is enabled, add 'path' to the list of fields to update
item_fields.add("path")
else:
# fields was provided, just update those
item_fields = fields
# get all the album fields to update
album_fields = fields or library.Album._fields.keys()
if exclude_fields:
# remove any excluded fields from the item and album sets
item_fields = [f for f in item_fields if f not in exclude_fields]
album_fields = [f for f in album_fields if f not in exclude_fields]
# Walk through the items and pick up their changes.
affected_albums = set()
for item in items:
# Item deleted?
if not item.path or not os.path.exists(syspath(item.path)):
ui.print_(format(item))
ui.print_(ui.colorize("text_error", " deleted"))
if not pretend:
item.remove(True)
affected_albums.add(item.album_id)
continue
# Did the item change since last checked?
if item.current_mtime() <= item.mtime:
log.debug(
"skipping {0.filepath} because mtime is up to date ({0.mtime})",
item,
)
continue
# Read new data.
try:
item.read()
except library.ReadError as exc:
log.error("error reading {.filepath}: {}", item, exc)
continue
# Special-case album artist when it matches track artist. (Hacky
# but necessary for preserving album-level metadata for non-
# autotagged imports.)
if not item.albumartist:
old_item = lib.get_item(item.id)
if old_item.albumartist == old_item.artist == item.artist:
item.albumartist = old_item.albumartist
item._dirty.discard("albumartist")
# Check for and display changes.
changed = ui.show_model_changes(item, fields=item_fields)
# Save changes.
if not pretend:
if changed:
# Move the item if it's in the library.
if move and lib.directory in ancestry(item.path):
item.move(store=False)
item.store(fields=item_fields)
affected_albums.add(item.album_id)
else:
# The file's mtime was different, but there were no
# changes to the metadata. Store the new mtime,
# which is set in the call to read(), so we don't
# check this again in the future.
item.store(fields=item_fields)
# Skip album changes while pretending.
if pretend:
return
# Modify affected albums to reflect changes in their items.
for album_id in affected_albums:
if album_id is None: # Singletons.
continue
album = lib.get_album(album_id)
if not album: # Empty albums have already been removed.
log.debug("emptied album {}", album_id)
continue
first_item = album.items().get()
# Update album structure to reflect an item in it.
for key in library.Album.item_keys:
album[key] = first_item[key]
album.store(fields=album_fields)
# Move album art (and any inconsistent items).
if move and lib.directory in ancestry(first_item.path):
log.debug("moving album {}", album_id)
# Manually moving and storing the album.
items = list(album.items())
for item in items:
item.move(store=False, with_album=False)
item.store(fields=item_fields)
album.move(store=False)
album.store(fields=album_fields)
def update_func(lib, opts, args):
# Verify that the library folder exists to prevent accidental wipes.
if not os.path.isdir(syspath(lib.directory)):
ui.print_("Library path is unavailable or does not exist.")
ui.print_(lib.directory)
if not ui.input_yn("Are you sure you want to continue (y/n)?", True):
return
update_items(
lib,
args,
opts.album,
ui.should_move(opts.move),
opts.pretend,
opts.fields,
opts.exclude_fields,
)
update_cmd = ui.Subcommand(
"update",
help="update the library",
aliases=(
"upd",
"up",
),
)
update_cmd.parser.add_album_option()
update_cmd.parser.add_format_option()
update_cmd.parser.add_option(
"-m",
"--move",
action="store_true",
dest="move",
help="move files in the library directory",
)
update_cmd.parser.add_option(
"-M",
"--nomove",
action="store_false",
dest="move",
help="don't move files in library",
)
update_cmd.parser.add_option(
"-p",
"--pretend",
action="store_true",
help="show all changes but do nothing",
)
update_cmd.parser.add_option(
"-F",
"--field",
default=None,
action="append",
dest="fields",
help="list of fields to update",
)
update_cmd.parser.add_option(
"-e",
"--exclude-field",
default=None,
action="append",
dest="exclude_fields",
help="list of fields to exclude from updates",
)
update_cmd.func = update_func

View file

@ -0,0 +1,29 @@
"""Utility functions for beets UI commands."""
from beets import ui
def do_query(lib, query, album, also_items=True):
"""For commands that operate on matched items, performs a query
and returns a list of matching items and a list of matching
albums. (The latter is only nonempty when album is True.) Raises
a UserError if no items match. also_items controls whether, when
fetching albums, the associated items should be fetched also.
"""
if album:
albums = list(lib.albums(query))
items = []
if also_items:
for al in albums:
items += al.items()
else:
albums = []
items = list(lib.items(query))
if album and not albums:
raise ui.UserError("No matching albums found.")
elif not album and not items:
raise ui.UserError("No matching items found.")
return items, albums

View file

@ -0,0 +1,23 @@
"""The 'version' command: show version information."""
from platform import python_version
import beets
from beets import plugins, ui
def show_version(*args):
ui.print_(f"beets version {beets.__version__}")
ui.print_(f"Python version {python_version()}")
# Show plugins.
names = sorted(p.name for p in plugins.find_plugins())
if names:
ui.print_("plugins:", ", ".join(names))
else:
ui.print_("no plugins loaded")
version_cmd = ui.Subcommand("version", help="output version information")
version_cmd.func = show_version
__all__ = ["version_cmd"]

View file

@ -0,0 +1,60 @@
"""The `write` command: write tag information to files."""
import os
from beets import library, logging, ui
from beets.util import syspath
from .utils import do_query
# Global logger.
log = logging.getLogger("beets")
def write_items(lib, query, pretend, force):
"""Write tag information from the database to the respective files
in the filesystem.
"""
items, albums = do_query(lib, query, False, False)
for item in items:
# Item deleted?
if not os.path.exists(syspath(item.path)):
log.info("missing file: {.filepath}", item)
continue
# Get an Item object reflecting the "clean" (on-disk) state.
try:
clean_item = library.Item.from_path(item.path)
except library.ReadError as exc:
log.error("error reading {.filepath}: {}", item, exc)
continue
# Check for and display changes.
changed = ui.show_model_changes(
item, clean_item, library.Item._media_tag_fields, force
)
if (changed or force) and not pretend:
# We use `try_sync` here to keep the mtime up to date in the
# database.
item.try_sync(True, False)
def write_func(lib, opts, args):
write_items(lib, args, opts.pretend, opts.force)
write_cmd = ui.Subcommand("write", help="write tag information to files")
write_cmd.parser.add_option(
"-p",
"--pretend",
action="store_true",
help="show all changes but do nothing",
)
write_cmd.parser.add_option(
"-f",
"--force",
action="store_true",
help="write tags even if the existing tags match the database",
)
write_cmd.func = write_func

View file

@ -28,8 +28,10 @@ import sys
import tempfile
import traceback
from collections import Counter
from collections.abc import Callable, Sequence
from contextlib import suppress
from enum import Enum
from functools import cache
from importlib import import_module
from multiprocessing.pool import ThreadPool
from pathlib import Path
@ -38,33 +40,36 @@ from typing import (
TYPE_CHECKING,
Any,
AnyStr,
Callable,
Iterable,
ClassVar,
Generic,
NamedTuple,
TypeVar,
Union,
cast,
)
from unidecode import unidecode
import beets
from beets.util import hidden
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence
from collections.abc import Iterable, Iterator
from logging import Logger
if sys.version_info >= (3, 10):
from typing import TypeAlias
else:
from typing_extensions import TypeAlias
from beets.library import Item
MAX_FILENAME_LENGTH = 200
WINDOWS_MAGIC_PREFIX = "\\\\?\\"
T = TypeVar("T")
BytesOrStr = Union[str, bytes]
PathLike = Union[BytesOrStr, Path]
Replacements: TypeAlias = "Sequence[tuple[Pattern[str], str]]"
PathLike = Union[str, bytes, Path]
StrPath = Union[str, Path]
Replacements = Sequence[tuple[Pattern[str], str]]
# Here for now to allow for a easy replace later on
# once we can move to a PathLike (mainly used in importer)
PathBytes = bytes
class HumanReadableError(Exception):
@ -106,7 +111,7 @@ class HumanReadableError(Exception):
elif hasattr(self.reason, "strerror"): # i.e., EnvironmentError
return self.reason.strerror
else:
return '"{}"'.format(str(self.reason))
return f'"{self.reason}"'
def get_message(self):
"""Create the human-readable description of the error, sans
@ -120,7 +125,7 @@ class HumanReadableError(Exception):
"""
if self.tb:
logger.debug(self.tb)
logger.error("{0}: {1}", self.error_kind, self.args[0])
logger.error("{0.error_kind}: {0.args[0]}", self)
class FilesystemError(HumanReadableError):
@ -136,18 +141,16 @@ class FilesystemError(HumanReadableError):
def get_message(self):
# Use a nicer English phrasing for some specific verbs.
if self.verb in ("move", "copy", "rename"):
clause = "while {} {} to {}".format(
self._gerund(),
displayable_path(self.paths[0]),
displayable_path(self.paths[1]),
clause = (
f"while {self._gerund()} {displayable_path(self.paths[0])} to"
f" {displayable_path(self.paths[1])}"
)
elif self.verb in ("delete", "write", "create", "read"):
clause = "while {} {}".format(
self._gerund(), displayable_path(self.paths[0])
)
clause = f"while {self._gerund()} {displayable_path(self.paths[0])}"
else:
clause = "during {} of paths {}".format(
self.verb, ", ".join(displayable_path(p) for p in self.paths)
clause = (
f"during {self.verb} of paths"
f" {', '.join(displayable_path(p) for p in self.paths)}"
)
return f"{self._reasonstr()} {clause}"
@ -164,6 +167,12 @@ class MoveOperation(Enum):
REFLINK_AUTO = 5
class PromptChoice(NamedTuple):
short: str
long: str
callback: Any
def normpath(path: PathLike) -> bytes:
"""Provide the canonical form of the path suitable for storing in
the database.
@ -217,12 +226,12 @@ def sorted_walk(
# Get all the directories and files at this level.
try:
contents = os.listdir(syspath(bytes_path))
except OSError as exc:
except OSError:
if logger:
logger.warning(
"could not list directory {}: {}".format(
displayable_path(bytes_path), exc.strerror
)
"could not list directory {}",
displayable_path(bytes_path),
exc_info=True,
)
return
dirs = []
@ -430,8 +439,8 @@ def syspath(path: PathLike, prefix: bool = True) -> str:
if prefix and not str_path.startswith(WINDOWS_MAGIC_PREFIX):
if str_path.startswith("\\\\"):
# UNC path. Final path should look like \\?\UNC\...
str_path = "UNC" + str_path[1:]
str_path = WINDOWS_MAGIC_PREFIX + str_path
str_path = f"UNC{str_path[1:]}"
str_path = f"{WINDOWS_MAGIC_PREFIX}{str_path}"
return str_path
@ -503,8 +512,8 @@ def move(path: bytes, dest: bytes, replace: bool = False):
basename = os.path.basename(bytestring_path(dest))
dirname = os.path.dirname(bytestring_path(dest))
tmp = tempfile.NamedTemporaryFile(
suffix=syspath(b".beets", prefix=False),
prefix=syspath(b"." + basename + b".", prefix=False),
suffix=".beets",
prefix=f".{os.fsdecode(basename)}.",
dir=syspath(dirname),
delete=False,
)
@ -557,7 +566,7 @@ def link(path: bytes, dest: bytes, replace: bool = False):
except NotImplementedError:
# raised on python >= 3.2 and Windows versions before Vista
raise FilesystemError(
"OS does not support symbolic links." "link",
"OS does not support symbolic links.link",
(path, dest),
traceback.format_exc(),
)
@ -573,20 +582,24 @@ def hardlink(path: bytes, dest: bytes, replace: bool = False):
if samefile(path, dest):
return
if os.path.exists(syspath(dest)) and not replace:
# Dereference symlinks, expand "~", and convert relative paths to absolute
origin_path = Path(os.fsdecode(path)).expanduser().resolve()
dest_path = Path(os.fsdecode(dest)).expanduser().resolve()
if dest_path.exists() and not replace:
raise FilesystemError("file exists", "rename", (path, dest))
try:
os.link(syspath(path), syspath(dest))
dest_path.hardlink_to(origin_path)
except NotImplementedError:
raise FilesystemError(
"OS does not support hard links." "link",
"OS does not support hard links.link",
(path, dest),
traceback.format_exc(),
)
except OSError as exc:
if exc.errno == errno.EXDEV:
raise FilesystemError(
"Cannot hard link across devices." "link",
"Cannot hard link across devices.link",
(path, dest),
traceback.format_exc(),
)
@ -694,105 +707,87 @@ def sanitize_path(path: str, replacements: Replacements | None = None) -> str:
return os.path.join(*comps)
def truncate_path(path: AnyStr, length: int = MAX_FILENAME_LENGTH) -> AnyStr:
"""Given a bytestring path or a Unicode path fragment, truncate the
components to a legal length. In the last component, the extension
is preserved.
def truncate_str(s: str, length: int) -> str:
"""Truncate the string to the given byte length.
If we end up truncating a unicode character in the middle (rendering it invalid),
it is removed:
>>> s = "🎹🎶" # 8 bytes
>>> truncate_str(s, 6)
'🎹'
"""
comps = components(path)
return os.fsencode(s)[:length].decode(sys.getfilesystemencoding(), "ignore")
out = [c[:length] for c in comps]
base, ext = os.path.splitext(comps[-1])
if ext:
# Last component has an extension.
base = base[: length - len(ext)]
out[-1] = base + ext
return os.path.join(*out)
def truncate_path(str_path: str) -> str:
"""Truncate each path part to a legal length preserving the extension."""
max_length = get_max_filename_length()
path = Path(str_path)
parent_parts = [truncate_str(p, max_length) for p in path.parts[:-1]]
stem = truncate_str(path.stem, max_length - len(path.suffix))
return f"{Path(*parent_parts, stem)}{path.suffix}"
def _legalize_stage(
path: str,
replacements: Replacements | None,
length: int,
extension: str,
fragment: bool,
) -> tuple[BytesOrStr, bool]:
path: str, replacements: Replacements | None, extension: str
) -> tuple[str, bool]:
"""Perform a single round of path legalization steps
(sanitation/replacement, encoding from Unicode to bytes,
extension-appending, and truncation). Return the path (Unicode if
`fragment` is set, `bytes` otherwise) and whether truncation was
required.
1. sanitation/replacement
2. appending the extension
3. truncation.
Return the path and whether truncation was required.
"""
# Perform an initial sanitization including user replacements.
path = sanitize_path(path, replacements)
# Encode for the filesystem.
if not fragment:
path = bytestring_path(path) # type: ignore
# Preserve extension.
path += extension.lower()
# Truncate too-long components.
pre_truncate_path = path
path = truncate_path(path, length)
path = truncate_path(path)
return path, path != pre_truncate_path
def legalize_path(
path: str,
replacements: Replacements | None,
length: int,
extension: bytes,
fragment: bool,
) -> tuple[BytesOrStr, bool]:
"""Given a path-like Unicode string, produce a legal path. Return
the path and a flag indicating whether some replacements had to be
ignored (see below).
path: str, replacements: Replacements | None, extension: str
) -> tuple[str, bool]:
"""Given a path-like Unicode string, produce a legal path. Return the path
and a flag indicating whether some replacements had to be ignored (see
below).
The legalization process (see `_legalize_stage`) consists of
applying the sanitation rules in `replacements`, encoding the string
to bytes (unless `fragment` is set), truncating components to
`length`, appending the `extension`.
This function uses `_legalize_stage` function to legalize the path, see its
documentation for the details of what this involves. It is called up to
three times in case truncation conflicts with replacements (as can happen
when truncation creates whitespace at the end of the string, for example).
This function performs up to three calls to `_legalize_stage` in
case truncation conflicts with replacements (as can happen when
truncation creates whitespace at the end of the string, for
example). The limited number of iterations iterations avoids the
possibility of an infinite loop of sanitation and truncation
operations, which could be caused by replacement rules that make the
string longer. The flag returned from this function indicates that
the path has to be truncated twice (indicating that replacements
made the string longer again after it was truncated); the
application should probably log some sort of warning.
The limited number of iterations avoids the possibility of an infinite loop
of sanitation and truncation operations, which could be caused by
replacement rules that make the string longer.
The flag returned from this function indicates that the path has to be
truncated twice (indicating that replacements made the string longer again
after it was truncated); the application should probably log some sort of
warning.
"""
suffix = as_string(extension)
if fragment:
# Outputting Unicode.
extension = extension.decode("utf-8", "ignore")
first_stage_path, _ = _legalize_stage(
path, replacements, length, extension, fragment
first_stage, _ = os.path.splitext(
_legalize_stage(path, replacements, suffix)[0]
)
# Convert back to Unicode with extension removed.
first_stage_path, _ = os.path.splitext(displayable_path(first_stage_path))
# Re-sanitize following truncation (including user replacements).
second_stage_path, retruncated = _legalize_stage(
first_stage_path, replacements, length, extension, fragment
)
second_stage, truncated = _legalize_stage(first_stage, replacements, suffix)
# If the path was once again truncated, discard user replacements
if not truncated:
return second_stage, False
# If the path was truncated, discard user replacements
# and run through one last legalization stage.
if retruncated:
second_stage_path, _ = _legalize_stage(
first_stage_path, None, length, extension, fragment
)
return second_stage_path, retruncated
return _legalize_stage(first_stage, None, suffix)[0], True
def str2bool(value: str) -> bool:
@ -814,7 +809,7 @@ def as_string(value: Any) -> str:
return str(value)
def plurality(objs: Sequence[T]) -> tuple[T, int]:
def plurality(objs: Iterable[T]) -> tuple[T, int]:
"""Given a sequence of hashble objects, returns the object that
is most common in the set and the its number of appearance. The
sequence must contain at least one object.
@ -825,13 +820,54 @@ def plurality(objs: Sequence[T]) -> tuple[T, int]:
return c.most_common(1)[0]
def get_most_common_tags(
items: Sequence[Item],
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Extract the likely current metadata for an album given a list of its
items. Return two dictionaries:
- The most common value for each field.
- Whether each field's value was unanimous (values are booleans).
"""
assert items # Must be nonempty.
likelies = {}
consensus = {}
fields = [
"artist",
"album",
"albumartist",
"year",
"disctotal",
"mb_albumid",
"label",
"barcode",
"catalognum",
"country",
"media",
"albumdisambig",
"data_source",
]
for field in fields:
values = [item.get(field) for item in items if item]
likelies[field], freq = plurality(values)
consensus[field] = freq == len(values)
# If there's an album artist consensus, use this for the artist.
if consensus["albumartist"] and likelies["albumartist"]:
likelies["artist"] = likelies["albumartist"]
return likelies, consensus
# stdout and stderr as bytes
class CommandOutput(NamedTuple):
stdout: bytes
stderr: bytes
def command_output(cmd: list[BytesOrStr], shell: bool = False) -> CommandOutput:
def command_output(
cmd: list[str] | list[bytes], shell: bool = False
) -> CommandOutput:
"""Runs the command and returns its output after it has exited.
Returns a CommandOutput. The attributes ``stdout`` and ``stderr`` contain
@ -849,8 +885,6 @@ def command_output(cmd: list[BytesOrStr], shell: bool = False) -> CommandOutput:
This replaces `subprocess.check_output` which can have problems if lots of
output is sent to stderr.
"""
converted_cmd = [os.fsdecode(a) for a in cmd]
devnull = subprocess.DEVNULL
proc = subprocess.Popen(
@ -865,22 +899,27 @@ def command_output(cmd: list[BytesOrStr], shell: bool = False) -> CommandOutput:
if proc.returncode:
raise subprocess.CalledProcessError(
returncode=proc.returncode,
cmd=" ".join(converted_cmd),
cmd=" ".join(map(os.fsdecode, cmd)),
output=stdout + stderr,
)
return CommandOutput(stdout, stderr)
def max_filename_length(path: BytesOrStr, limit=MAX_FILENAME_LENGTH) -> int:
@cache
def get_max_filename_length() -> int:
"""Attempt to determine the maximum filename length for the
filesystem containing `path`. If the value is greater than `limit`,
then `limit` is used instead (to prevent errors when a filesystem
misreports its capacity). If it cannot be determined (e.g., on
Windows), return `limit`.
"""
if length := beets.config["max_filename_length"].get(int):
return length
limit = MAX_FILENAME_LENGTH
if hasattr(os, "statvfs"):
try:
res = os.statvfs(path)
res = os.statvfs(beets.config["directory"].as_str())
except OSError:
return limit
return min(res[9], limit)
@ -985,19 +1024,6 @@ def case_sensitive(path: bytes) -> bool:
return not os.path.samefile(lower_sys, upper_sys)
def raw_seconds_short(string: str) -> float:
"""Formats a human-readable M:SS string as a float (number of seconds).
Raises ValueError if the conversion cannot take place due to `string` not
being in the right format.
"""
match = re.match(r"^(\d+):([0-5]\d)$", string)
if not match:
raise ValueError("String not in M:SS format")
minutes, seconds = map(int, match.groups())
return float(minutes * 60 + seconds)
def asciify_path(path: str, sep_replace: str) -> str:
"""Decodes all unicode characters in a path into ASCII equivalents.
@ -1035,21 +1061,94 @@ def par_map(transform: Callable[[T], Any], items: Sequence[T]) -> None:
pool.join()
class cached_classproperty:
"""A decorator implementing a read-only property that is *lazy* in
the sense that the getter is only invoked once. Subsequent accesses
through *any* instance use the cached result.
class cached_classproperty(Generic[T]):
"""Descriptor implementing cached class properties.
Provides class-level dynamic property behavior where the getter function is
called once per class and the result is cached for subsequent access. Unlike
instance properties, this operates on the class rather than instances.
"""
def __init__(self, getter):
self.getter = getter
self.cache = {}
cache: ClassVar[dict[tuple[type[object], str], object]] = {}
def __get__(self, instance, owner):
if owner not in self.cache:
self.cache[owner] = self.getter(owner)
name: str = ""
return self.cache[owner]
# Ideally, we would like to use `Callable[[type[T]], Any]` here,
# however, `mypy` is unable to see this as a **class** property, and thinks
# that this callable receives an **instance** of the object, failing the
# type check, for example:
# >>> class Album:
# >>> @cached_classproperty
# >>> def foo(cls):
# >>> reveal_type(cls) # mypy: revealed type is "Album"
# >>> return cls.bar
#
# Argument 1 to "cached_classproperty" has incompatible type
# "Callable[[Album], ...]"; expected "Callable[[type[Album]], ...]"
#
# Therefore, we just use `Any` here, which is not ideal, but works.
def __init__(self, getter: Callable[..., T]) -> None:
"""Initialize the descriptor with the property getter function."""
self.getter: Callable[..., T] = getter
def __set_name__(self, owner: object, name: str) -> None:
"""Capture the attribute name this descriptor is assigned to."""
self.name = name
def __get__(self, instance: object, owner: type[object]) -> T:
"""Compute and cache if needed, and return the property value."""
key: tuple[type[object], str] = owner, self.name
if key not in self.cache:
self.cache[key] = self.getter(owner)
return cast(T, self.cache[key])
class LazySharedInstance(Generic[T]):
"""A descriptor that provides access to a lazily-created shared instance of
the containing class, while calling the class constructor to construct a
new object works as usual.
```
ID: int = 0
class Foo:
def __init__():
global ID
self.id = ID
ID += 1
def func(self):
print(self.id)
shared: LazySharedInstance[Foo] = LazySharedInstance()
a0 = Foo()
a1 = Foo.shared
a2 = Foo()
a3 = Foo.shared
a0.func() # 0
a1.func() # 1
a2.func() # 2
a3.func() # 1
```
"""
_instance: T | None = None
def __get__(self, instance: T | None, owner: type[T]) -> T:
if instance is not None:
raise RuntimeError(
"shared instances must be obtained from the class property, "
"not an instance"
)
if self._instance is None:
self._instance = owner()
return self._instance
def get_module_tempdir(module: str) -> Path:

View file

@ -16,23 +16,36 @@
public resizing proxy if neither is available.
"""
from __future__ import annotations
import os
import os.path
import platform
import re
import subprocess
from abc import ABC, abstractmethod
from enum import Enum
from itertools import chain
from typing import TYPE_CHECKING, Any, ClassVar
from urllib.parse import urlencode
from beets import logging, util
from beets.util import displayable_path, get_temp_filename, syspath
from beets.util import (
LazySharedInstance,
displayable_path,
get_temp_filename,
syspath,
)
if TYPE_CHECKING:
from collections.abc import Mapping
PROXY_URL = "https://images.weserv.nl/"
log = logging.getLogger("beets")
def resize_url(url, maxwidth, quality=0):
def resize_url(url: str, maxwidth: int, quality: int = 0) -> str:
"""Return a proxied image URL that resizes the original image to
maxwidth (preserving aspect ratio).
"""
@ -44,25 +57,125 @@ def resize_url(url, maxwidth, quality=0):
if quality > 0:
params["q"] = quality
return "{}?{}".format(PROXY_URL, urlencode(params))
return f"{PROXY_URL}?{urlencode(params)}"
class LocalBackendNotAvailableError(Exception):
pass
_NOT_AVAILABLE = object()
# Singleton pattern that the typechecker understands:
# https://peps.python.org/pep-0484/#support-for-singleton-types-in-unions
class NotAvailable(Enum):
token = 0
class LocalBackend:
_NOT_AVAILABLE = NotAvailable.token
class LocalBackend(ABC):
NAME: ClassVar[str]
@classmethod
def available(cls):
@abstractmethod
def version(cls) -> Any:
"""Return the backend version if its dependencies are satisfied or
raise `LocalBackendNotAvailableError`.
"""
pass
@classmethod
def available(cls) -> bool:
"""Return `True` this backend's dependencies are satisfied and it can
be used, `False` otherwise."""
try:
cls.version()
return True
except LocalBackendNotAvailableError:
return False
@abstractmethod
def resize(
self,
maxwidth: int,
path_in: bytes,
path_out: bytes | None = None,
quality: int = 0,
max_filesize: int = 0,
) -> bytes:
"""Resize an image to the given width and return the output path.
On error, logs a warning and returns `path_in`.
"""
pass
@abstractmethod
def get_size(self, path_in: bytes) -> tuple[int, int] | None:
"""Return the (width, height) of the image or None if unavailable."""
pass
@abstractmethod
def deinterlace(
self,
path_in: bytes,
path_out: bytes | None = None,
) -> bytes:
"""Remove interlacing from an image and return the output path.
On error, logs a warning and returns `path_in`.
"""
pass
@abstractmethod
def get_format(self, path_in: bytes) -> str | None:
"""Return the image format (e.g., 'PNG') or None if undetectable."""
pass
@abstractmethod
def convert_format(
self,
source: bytes,
target: bytes,
deinterlaced: bool,
) -> bytes:
"""Convert an image to a new format and return the new file path.
On error, logs a warning and returns `source`.
"""
pass
@property
def can_compare(self) -> bool:
"""Indicate whether image comparison is supported by this backend."""
return False
def compare(
self,
im1: bytes,
im2: bytes,
compare_threshold: float,
) -> bool | None:
"""Compare two images and return `True` if they are similar enough, or
`None` if there is an error.
This must only be called if `self.can_compare()` returns `True`.
"""
# It is an error to call this when ArtResizer.can_compare is not True.
raise NotImplementedError()
@property
def can_write_metadata(self) -> bool:
"""Indicate whether writing metadata to images is supported."""
return False
def write_metadata(self, file: bytes, metadata: Mapping[str, str]) -> None:
"""Write key-value metadata into the image file.
This must only be called if `self.can_write_metadata()` returns `True`.
"""
# It is an error to call this when ArtResizer.can_write_metadata is not True.
raise NotImplementedError()
class IMBackend(LocalBackend):
NAME = "ImageMagick"
@ -70,11 +183,11 @@ class IMBackend(LocalBackend):
# These fields are used as a cache for `version()`. `_legacy` indicates
# whether the modern `magick` binary is available or whether to fall back
# to the old-style `convert`, `identify`, etc. commands.
_version = None
_legacy = None
_version: tuple[int, int, int] | NotAvailable | None = None
_legacy: bool | None = None
@classmethod
def version(cls):
def version(cls) -> tuple[int, int, int]:
"""Obtain and cache ImageMagick version.
Raises `LocalBackendNotAvailableError` if not available.
@ -98,12 +211,17 @@ class IMBackend(LocalBackend):
)
cls._legacy = legacy
if cls._version is _NOT_AVAILABLE:
# cls._version is never None here, but mypy doesn't get that
if cls._version is _NOT_AVAILABLE or cls._version is None:
raise LocalBackendNotAvailableError()
else:
return cls._version
def __init__(self):
convert_cmd: list[str]
identify_cmd: list[str]
compare_cmd: list[str]
def __init__(self) -> None:
"""Initialize a wrapper around ImageMagick for local image operations.
Stores the ImageMagick version and legacy flag. If ImageMagick is not
@ -124,8 +242,13 @@ class IMBackend(LocalBackend):
self.compare_cmd = ["magick", "compare"]
def resize(
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
):
self,
maxwidth: int,
path_in: bytes,
path_out: bytes | None = None,
quality: int = 0,
max_filesize: int = 0,
) -> bytes:
"""Resize using ImageMagick.
Use the ``magick`` program or ``convert`` on older versions. Return
@ -135,7 +258,7 @@ class IMBackend(LocalBackend):
path_out = get_temp_filename(__name__, "resize_IM_", path_in)
log.debug(
"artresizer: ImageMagick resizing {0} to {1}",
"artresizer: ImageMagick resizing {} to {}",
displayable_path(path_in),
displayable_path(path_out),
)
@ -145,7 +268,7 @@ class IMBackend(LocalBackend):
# with regards to the height.
# ImageMagick already seems to default to no interlace, but we include
# it here for the sake of explicitness.
cmd = self.convert_cmd + [
cmd: list[str] = self.convert_cmd + [
syspath(path_in, prefix=False),
"-resize",
f"{maxwidth}x>",
@ -167,15 +290,15 @@ class IMBackend(LocalBackend):
util.command_output(cmd)
except subprocess.CalledProcessError:
log.warning(
"artresizer: IM convert failed for {0}",
"artresizer: IM convert failed for {}",
displayable_path(path_in),
)
return path_in
return path_out
def get_size(self, path_in):
cmd = self.identify_cmd + [
def get_size(self, path_in: bytes) -> tuple[int, int] | None:
cmd: list[str] = self.identify_cmd + [
"-format",
"%w %h",
syspath(path_in, prefix=False),
@ -186,20 +309,30 @@ class IMBackend(LocalBackend):
except subprocess.CalledProcessError as exc:
log.warning("ImageMagick size query failed")
log.debug(
"`convert` exited with (status {}) when "
"`convert` exited with (status {.returncode}) when "
"getting size with command {}:\n{}",
exc.returncode,
exc,
cmd,
exc.output.strip(),
)
return None
try:
return tuple(map(int, out.split(b" ")))
size = tuple(map(int, out.split(b" ")))
except IndexError:
log.warning("Could not understand IM output: {0!r}", out)
return None
def deinterlace(self, path_in, path_out=None):
if len(size) != 2:
log.warning("Could not understand IM output: {0!r}", out)
return None
return size
def deinterlace(
self,
path_in: bytes,
path_out: bytes | None = None,
) -> bytes:
if not path_out:
path_out = get_temp_filename(__name__, "deinterlace_IM_", path_in)
@ -217,16 +350,24 @@ class IMBackend(LocalBackend):
# FIXME: Should probably issue a warning?
return path_in
def get_format(self, filepath):
cmd = self.identify_cmd + ["-format", "%[magick]", syspath(filepath)]
def get_format(self, path_in: bytes) -> str | None:
cmd = self.identify_cmd + ["-format", "%[magick]", syspath(path_in)]
try:
return util.command_output(cmd).stdout
except subprocess.CalledProcessError:
# Image formats should really only be ASCII strings such as "PNG",
# if anything else is returned, something is off and we return
# None for safety.
return util.command_output(cmd).stdout.decode("ascii", "strict")
except (subprocess.CalledProcessError, UnicodeError):
# FIXME: Should probably issue a warning?
return None
def convert_format(self, source, target, deinterlaced):
def convert_format(
self,
source: bytes,
target: bytes,
deinterlaced: bool,
) -> bytes:
cmd = self.convert_cmd + [
syspath(source),
*(["-interlace", "none"] if deinterlaced else []),
@ -243,10 +384,15 @@ class IMBackend(LocalBackend):
return source
@property
def can_compare(self):
def can_compare(self) -> bool:
return self.version() > (6, 8, 7)
def compare(self, im1, im2, compare_threshold):
def compare(
self,
im1: bytes,
im2: bytes,
compare_threshold: float,
) -> bool | None:
is_windows = platform.system() == "Windows"
# Converting images to grayscale tends to minimize the weight
@ -286,6 +432,10 @@ class IMBackend(LocalBackend):
close_fds=not is_windows,
)
# help out mypy
assert convert_proc.stdout is not None
assert convert_proc.stderr is not None
# Check the convert output. We're not interested in the
# standard output; that gets piped to the next stage.
convert_proc.stdout.close()
@ -294,8 +444,8 @@ class IMBackend(LocalBackend):
convert_proc.wait()
if convert_proc.returncode:
log.debug(
"ImageMagick convert failed with status {}: {!r}",
convert_proc.returncode,
"ImageMagick convert failed with status {.returncode}: {!r}",
convert_proc,
convert_stderr,
)
return None
@ -305,7 +455,7 @@ class IMBackend(LocalBackend):
if compare_proc.returncode:
if compare_proc.returncode != 1:
log.debug(
"ImageMagick compare failed: {0}, {1}",
"ImageMagick compare failed: {}, {}",
displayable_path(im2),
displayable_path(im1),
)
@ -325,18 +475,19 @@ class IMBackend(LocalBackend):
log.debug("IM output is not a number: {0!r}", out_str)
return None
log.debug("ImageMagick compare score: {0}", phash_diff)
log.debug("ImageMagick compare score: {}", phash_diff)
return phash_diff <= compare_threshold
@property
def can_write_metadata(self):
def can_write_metadata(self) -> bool:
return True
def write_metadata(self, file, metadata):
assignments = list(
chain.from_iterable(("-set", k, v) for k, v in metadata.items())
def write_metadata(self, file: bytes, metadata: Mapping[str, str]) -> None:
assignments = chain.from_iterable(
("-set", k, v) for k, v in metadata.items()
)
command = self.convert_cmd + [file, *assignments, file]
str_file = os.fsdecode(file)
command = self.convert_cmd + [str_file, *assignments, str_file]
util.command_output(command)
@ -345,13 +496,13 @@ class PILBackend(LocalBackend):
NAME = "PIL"
@classmethod
def version(cls):
def version(cls) -> None:
try:
__import__("PIL", fromlist=["Image"])
except ImportError:
raise LocalBackendNotAvailableError()
def __init__(self):
def __init__(self) -> None:
"""Initialize a wrapper around PIL for local image operations.
If PIL is not available, raise an Exception.
@ -359,8 +510,13 @@ class PILBackend(LocalBackend):
self.version()
def resize(
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
):
self,
maxwidth: int,
path_in: bytes,
path_out: bytes | None = None,
quality: int = 0,
max_filesize: int = 0,
) -> bytes:
"""Resize using Python Imaging Library (PIL). Return the output path
of resized image.
"""
@ -370,7 +526,7 @@ class PILBackend(LocalBackend):
from PIL import Image
log.debug(
"artresizer: PIL resizing {0} to {1}",
"artresizer: PIL resizing {} to {}",
displayable_path(path_in),
displayable_path(path_out),
)
@ -399,7 +555,7 @@ class PILBackend(LocalBackend):
for i in range(5):
# 5 attempts is an arbitrary choice
filesize = os.stat(syspath(path_out)).st_size
log.debug("PIL Pass {0} : Output size: {1}B", i, filesize)
log.debug("PIL Pass {} : Output size: {}B", i, filesize)
if filesize <= max_filesize:
return path_out
# The relationship between filesize & quality will be
@ -416,7 +572,7 @@ class PILBackend(LocalBackend):
progressive=False,
)
log.warning(
"PIL Failed to resize file to below {0}B", max_filesize
"PIL Failed to resize file to below {}B", max_filesize
)
return path_out
@ -424,12 +580,12 @@ class PILBackend(LocalBackend):
return path_out
except OSError:
log.error(
"PIL cannot create thumbnail for '{0}'",
"PIL cannot create thumbnail for '{}'",
displayable_path(path_in),
)
return path_in
def get_size(self, path_in):
def get_size(self, path_in: bytes) -> tuple[int, int] | None:
from PIL import Image
try:
@ -441,7 +597,11 @@ class PILBackend(LocalBackend):
)
return None
def deinterlace(self, path_in, path_out=None):
def deinterlace(
self,
path_in: bytes,
path_out: bytes | None = None,
) -> bytes:
if not path_out:
path_out = get_temp_filename(__name__, "deinterlace_PIL_", path_in)
@ -455,11 +615,11 @@ class PILBackend(LocalBackend):
# FIXME: Should probably issue a warning?
return path_in
def get_format(self, filepath):
def get_format(self, path_in: bytes) -> str | None:
from PIL import Image, UnidentifiedImageError
try:
with Image.open(syspath(filepath)) as im:
with Image.open(syspath(path_in)) as im:
return im.format
except (
ValueError,
@ -467,10 +627,15 @@ class PILBackend(LocalBackend):
UnidentifiedImageError,
FileNotFoundError,
):
log.exception("failed to detect image format for {}", filepath)
log.exception("failed to detect image format for {}", path_in)
return None
def convert_format(self, source, target, deinterlaced):
def convert_format(
self,
source: bytes,
target: bytes,
deinterlaced: bool,
) -> bytes:
from PIL import Image, UnidentifiedImageError
try:
@ -488,18 +653,23 @@ class PILBackend(LocalBackend):
return source
@property
def can_compare(self):
def can_compare(self) -> bool:
return False
def compare(self, im1, im2, compare_threshold):
def compare(
self,
im1: bytes,
im2: bytes,
compare_threshold: float,
) -> bool | None:
# It is an error to call this when ArtResizer.can_compare is not True.
raise NotImplementedError()
@property
def can_write_metadata(self):
def can_write_metadata(self) -> bool:
return True
def write_metadata(self, file, metadata):
def write_metadata(self, file: bytes, metadata: Mapping[str, str]) -> None:
from PIL import Image, PngImagePlugin
# FIXME: Detect and handle other file types (currently, the only user
@ -507,68 +677,68 @@ class PILBackend(LocalBackend):
im = Image.open(syspath(file))
meta = PngImagePlugin.PngInfo()
for k, v in metadata.items():
meta.add_text(k, v, 0)
meta.add_text(k, v, zip=False)
im.save(os.fsdecode(file), "PNG", pnginfo=meta)
class Shareable(type):
"""A pseudo-singleton metaclass that allows both shared and
non-shared instances. The ``MyClass.shared`` property holds a
lazily-created shared instance of ``MyClass`` while calling
``MyClass()`` to construct a new object works as usual.
"""
def __init__(cls, name, bases, dict):
super().__init__(name, bases, dict)
cls._instance = None
@property
def shared(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
BACKEND_CLASSES = [
BACKEND_CLASSES: list[type[LocalBackend]] = [
IMBackend,
PILBackend,
]
class ArtResizer(metaclass=Shareable):
"""A singleton class that performs image resizes."""
class ArtResizer:
"""A class that dispatches image operations to an available backend."""
def __init__(self):
local_method: LocalBackend | None
def __init__(self) -> None:
"""Create a resizer object with an inferred method."""
# Check if a local backend is available, and store an instance of the
# backend class. Otherwise, fallback to the web proxy.
for backend_cls in BACKEND_CLASSES:
try:
self.local_method = backend_cls()
log.debug(f"artresizer: method is {self.local_method.NAME}")
log.debug("artresizer: method is {.local_method.NAME}", self)
break
except LocalBackendNotAvailableError:
continue
else:
# FIXME: Turn WEBPROXY into a backend class as well to remove all
# the special casing. Then simply delegate all methods to the
# backends. (How does proxy_url fit in here, however?)
# Use an ABC (or maybe a typing Protocol?) for backend
# methods, such that both individual backends as well as
# ArtResizer implement it.
# It should probably be configurable which backends classes to
# consider, similar to fetchart or lyrics backends (i.e. a list
# of backends sorted by priority).
log.debug("artresizer: method is WEBPROXY")
self.local_method = None
shared: LazySharedInstance[ArtResizer] = LazySharedInstance()
@property
def method(self):
if self.local:
def method(self) -> str:
if self.local_method is not None:
return self.local_method.NAME
else:
return "WEBPROXY"
def resize(
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
):
self,
maxwidth: int,
path_in: bytes,
path_out: bytes | None = None,
quality: int = 0,
max_filesize: int = 0,
) -> bytes:
"""Manipulate an image file according to the method, returning a
new path. For PIL or IMAGEMAGIC methods, resizes the image to a
temporary file and encodes with the specified quality level.
For WEBPROXY, returns `path_in` unmodified.
"""
if self.local:
if self.local_method is not None:
return self.local_method.resize(
maxwidth,
path_in,
@ -580,18 +750,22 @@ class ArtResizer(metaclass=Shareable):
# Handled by `proxy_url` already.
return path_in
def deinterlace(self, path_in, path_out=None):
def deinterlace(
self,
path_in: bytes,
path_out: bytes | None = None,
) -> bytes:
"""Deinterlace an image.
Only available locally.
"""
if self.local:
if self.local_method is not None:
return self.local_method.deinterlace(path_in, path_out)
else:
# FIXME: Should probably issue a warning?
return path_in
def proxy_url(self, maxwidth, url, quality=0):
def proxy_url(self, maxwidth: int, url: str, quality: int = 0) -> str:
"""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.
@ -603,42 +777,48 @@ class ArtResizer(metaclass=Shareable):
return resize_url(url, maxwidth, quality)
@property
def local(self):
def local(self) -> bool:
"""A boolean indicating whether the resizing method is performed
locally (i.e., PIL or ImageMagick).
"""
return self.local_method is not None
def get_size(self, path_in):
def get_size(self, path_in: bytes) -> tuple[int, int] | None:
"""Return the size of an image file as an int couple (width, height)
in pixels.
Only available locally.
"""
if self.local:
if self.local_method is not None:
return self.local_method.get_size(path_in)
else:
# FIXME: Should probably issue a warning?
return path_in
raise RuntimeError(
"image cannot be obtained without artresizer backend"
)
def get_format(self, path_in):
def get_format(self, path_in: bytes) -> str | None:
"""Returns the format of the image as a string.
Only available locally.
"""
if self.local:
if self.local_method is not None:
return self.local_method.get_format(path_in)
else:
# FIXME: Should probably issue a warning?
return None
def reformat(self, path_in, new_format, deinterlaced=True):
def reformat(
self,
path_in: bytes,
new_format: str,
deinterlaced: bool = True,
) -> bytes:
"""Converts image to desired format, updating its extension, but
keeping the same filename.
Only available locally.
"""
if not self.local:
if self.local_method is None:
# FIXME: Should probably issue a warning?
return path_in
@ -664,40 +844,45 @@ class ArtResizer(metaclass=Shareable):
return result_path
@property
def can_compare(self):
def can_compare(self) -> bool:
"""A boolean indicating whether image comparison is available"""
if self.local:
if self.local_method is not None:
return self.local_method.can_compare
else:
return False
def compare(self, im1, im2, compare_threshold):
def compare(
self,
im1: bytes,
im2: bytes,
compare_threshold: float,
) -> bool | None:
"""Return a boolean indicating whether two images are similar.
Only available locally.
"""
if self.local:
if self.local_method is not None:
return self.local_method.compare(im1, im2, compare_threshold)
else:
# FIXME: Should probably issue a warning?
return None
@property
def can_write_metadata(self):
def can_write_metadata(self) -> bool:
"""A boolean indicating whether writing image metadata is supported."""
if self.local:
if self.local_method is not None:
return self.local_method.can_write_metadata
else:
return False
def write_metadata(self, file, metadata):
def write_metadata(self, file: bytes, metadata: Mapping[str, str]) -> None:
"""Write key-value metadata to the image file.
Only available locally. Currently, expects the image to be a PNG file.
"""
if self.local:
if self.local_method is not None:
self.local_method.write_metadata(file, metadata)
else:
# FIXME: Should probably issue a warning?

View file

@ -559,7 +559,7 @@ def spawn(coro):
and child coroutines run concurrently.
"""
if not isinstance(coro, types.GeneratorType):
raise ValueError("%s is not a coroutine" % coro)
raise ValueError(f"{coro} is not a coroutine")
return SpawnEvent(coro)
@ -569,7 +569,7 @@ def call(coro):
returns a value using end(), then this event returns that value.
"""
if not isinstance(coro, types.GeneratorType):
raise ValueError("%s is not a coroutine" % coro)
raise ValueError(f"{coro} is not a coroutine")
return DelegationEvent(coro)

66
beets/util/config.py Normal file
View file

@ -0,0 +1,66 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Collection, Sequence
def sanitize_choices(
choices: Sequence[str], choices_all: Collection[str]
) -> list[str]:
"""Clean up a stringlist configuration attribute: keep only choices
elements present in choices_all, remove duplicate elements, expand '*'
wildcard while keeping original stringlist order.
"""
seen: set[str] = set()
others = [x for x in choices_all if x not in choices]
res: list[str] = []
for s in choices:
if s not in seen:
if s in list(choices_all):
res.append(s)
elif s == "*":
res.extend(others)
seen.add(s)
return res
def sanitize_pairs(
pairs: Sequence[tuple[str, str]], pairs_all: Sequence[tuple[str, str]]
) -> list[tuple[str, str]]:
"""Clean up a single-element mapping configuration attribute as returned
by Confuse's `Pairs` template: keep only two-element tuples present in
pairs_all, remove duplicate elements, expand ('str', '*') and ('*', '*')
wildcards while keeping the original order. Note that ('*', '*') and
('*', 'whatever') have the same effect.
For example,
>>> sanitize_pairs(
... [('foo', 'baz bar'), ('key', '*'), ('*', '*')],
... [('foo', 'bar'), ('foo', 'baz'), ('foo', 'foobar'),
... ('key', 'value')]
... )
[('foo', 'baz'), ('foo', 'bar'), ('key', 'value'), ('foo', 'foobar')]
"""
pairs_all = list(pairs_all)
seen: set[tuple[str, str]] = set()
others = [x for x in pairs_all if x not in pairs]
res: list[tuple[str, str]] = []
for k, values in pairs:
for v in values.split():
x = (k, v)
if x in pairs_all:
if x not in seen:
seen.add(x)
res.append(x)
elif k == "*":
new = [o for o in others if o not in seen]
seen.update(new)
res.extend(new)
elif v == "*":
new = [o for o in others if o not in seen and o[0] == k]
seen.update(new)
res.extend(new)
return res

60
beets/util/deprecation.py Normal file
View file

@ -0,0 +1,60 @@
from __future__ import annotations
import warnings
from importlib import import_module
from typing import TYPE_CHECKING, Any
from packaging.version import Version
import beets
if TYPE_CHECKING:
from logging import Logger
def _format_message(old: str, new: str | None = None) -> str:
next_major = f"{Version(beets.__version__).major + 1}.0.0"
msg = f"{old} is deprecated and will be removed in version {next_major}."
if new:
msg += f" Use {new} instead."
return msg
def deprecate_for_user(
logger: Logger, old: str, new: str | None = None
) -> None:
logger.warning(_format_message(old, new))
def deprecate_for_maintainers(
old: str, new: str | None = None, stacklevel: int = 1
) -> None:
"""Issue a deprecation warning visible to maintainers during development.
Emits a DeprecationWarning that alerts developers about deprecated code
patterns. Unlike user-facing warnings, these are primarily for internal
code maintenance and appear during test runs or with warnings enabled.
"""
warnings.warn(
_format_message(old, new), DeprecationWarning, stacklevel=stacklevel + 1
)
def deprecate_imports(
old_module: str, new_module_by_name: dict[str, str], name: str
) -> Any:
"""Handle deprecated module imports by redirecting to new locations.
Facilitates gradual migration of module structure by intercepting import
attempts for relocated functionality. Issues deprecation warnings while
transparently providing access to the moved implementation, allowing
existing code to continue working during transition periods.
"""
if new_module := new_module_by_name.get(name):
deprecate_for_maintainers(
f"'{old_module}.{name}'", f"'{new_module}.{name}'", stacklevel=2
)
return getattr(import_module(new_module), name)
raise AttributeError(f"module '{old_module}' has no attribute '{name}'")

View file

@ -105,8 +105,6 @@ def compile_func(arg_names, statements, name="_the_func", debug=False):
decorator_list=[],
)
# The ast.Module signature changed in 3.8 to accept a list of types to
# ignore.
mod = ast.Module([func_def], [])
ast.fix_missing_locations(mod)
@ -136,7 +134,7 @@ class Symbol:
self.original = original
def __repr__(self):
return "Symbol(%s)" % repr(self.ident)
return f"Symbol({self.ident!r})"
def evaluate(self, env):
"""Evaluate the symbol in the environment, returning a Unicode
@ -152,7 +150,7 @@ class Symbol:
def translate(self):
"""Compile the variable lookup."""
ident = self.ident
expr = ex_rvalue(VARIABLE_PREFIX + ident)
expr = ex_rvalue(f"{VARIABLE_PREFIX}{ident}")
return [expr], {ident}, set()
@ -165,9 +163,7 @@ class Call:
self.original = original
def __repr__(self):
return "Call({}, {}, {})".format(
repr(self.ident), repr(self.args), repr(self.original)
)
return f"Call({self.ident!r}, {self.args!r}, {self.original!r})"
def evaluate(self, env):
"""Evaluate the function call in the environment, returning a
@ -180,7 +176,7 @@ class Call:
except Exception as exc:
# Function raised exception! Maybe inlining the name of
# the exception will help debug.
return "<%s>" % str(exc)
return f"<{exc}>"
return str(out)
else:
return self.original
@ -213,7 +209,7 @@ class Call:
)
)
subexpr_call = ex_call(FUNCTION_PREFIX + self.ident, arg_exprs)
subexpr_call = ex_call(f"{FUNCTION_PREFIX}{self.ident}", arg_exprs)
return [subexpr_call], varnames, funcnames
@ -226,7 +222,7 @@ class Expression:
self.parts = parts
def __repr__(self):
return "Expression(%s)" % (repr(self.parts))
return f"Expression({self.parts!r})"
def evaluate(self, env):
"""Evaluate the entire expression in the environment, returning
@ -298,9 +294,6 @@ class Parser:
GROUP_CLOSE,
ESCAPE_CHAR,
)
special_char_re = re.compile(
r"[%s]|\Z" % "".join(re.escape(c) for c in special_chars)
)
escapable_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_CLOSE, ARG_SEP)
terminator_chars = (GROUP_CLOSE,)
@ -312,24 +305,18 @@ class Parser:
"""
# Append comma (ARG_SEP) to the list of special characters only when
# parsing function arguments.
extra_special_chars = ()
special_char_re = self.special_char_re
if self.in_argument:
extra_special_chars = (ARG_SEP,)
special_char_re = re.compile(
r"[%s]|\Z"
% "".join(
re.escape(c)
for c in self.special_chars + extra_special_chars
)
)
extra_special_chars = (ARG_SEP,) if self.in_argument else ()
special_chars = (*self.special_chars, *extra_special_chars)
special_char_re = re.compile(
rf"[{''.join(map(re.escape, special_chars))}]|\Z"
)
text_parts = []
while self.pos < len(self.string):
char = self.string[self.pos]
if char not in self.special_chars + extra_special_chars:
if char not in special_chars:
# A non-special character. Skip to the next special
# character, treating the interstice as literal text.
next_pos = (
@ -566,9 +553,9 @@ class Template:
argnames = []
for varname in varnames:
argnames.append(VARIABLE_PREFIX + varname)
argnames.append(f"{VARIABLE_PREFIX}{varname}")
for funcname in funcnames:
argnames.append(FUNCTION_PREFIX + funcname)
argnames.append(f"{FUNCTION_PREFIX}{funcname}")
func = compile_func(
argnames,
@ -578,9 +565,9 @@ class Template:
def wrapper_func(values={}, functions={}):
args = {}
for varname in varnames:
args[VARIABLE_PREFIX + varname] = values[varname]
args[f"{VARIABLE_PREFIX}{varname}"] = values[varname]
for funcname in funcnames:
args[FUNCTION_PREFIX + funcname] = functions[funcname]
args[f"{FUNCTION_PREFIX}{funcname}"] = functions[funcname]
parts = func(**args)
return "".join(parts)

View file

@ -20,10 +20,9 @@ import os
import stat
import sys
from pathlib import Path
from typing import Union
def is_hidden(path: Union[bytes, Path]) -> bool:
def is_hidden(path: bytes | Path) -> bool:
"""
Determine whether the given path is treated as a 'hidden file' by the OS.
"""

View file

@ -14,36 +14,20 @@
"""Helpers around the extraction of album/track ID's from metadata sources."""
from __future__ import annotations
import re
# Spotify IDs consist of 22 alphanumeric characters
# (zero-left-padded base62 representation of randomly generated UUID4)
spotify_id_regex = {
"pattern": r"(^|open\.spotify\.com/{}/)([0-9A-Za-z]{{22}})",
"match_group": 2,
}
from beets import logging
deezer_id_regex = {
"pattern": r"(^|deezer\.com/)([a-z]*/)?({}/)?(\d+)",
"match_group": 4,
}
beatport_id_regex = {
"pattern": r"(^|beatport\.com/release/.+/)(\d+)$",
"match_group": 2,
}
# A note on Bandcamp: There is no such thing as a Bandcamp album or artist ID,
# the URL can be used as the identifier. The Bandcamp metadata source plugin
# works that way - https://github.com/snejus/beetcamp. Bandcamp album
# URLs usually look like: https://nameofartist.bandcamp.com/album/nameofalbum
log = logging.getLogger("beets")
def extract_discogs_id_regex(album_id):
"""Returns the Discogs_id or None."""
# Discogs-IDs are simple integers. In order to avoid confusion with
# other metadata plugins, we only look for very specific formats of the
# input string:
PATTERN_BY_SOURCE = {
"spotify": re.compile(r"(?:^|open\.spotify\.com/[^/]+/)([0-9A-Za-z]{22})"),
"deezer": re.compile(r"(?:^|deezer\.com/)(?:[a-z]*/)?(?:[^/]+/)?(\d+)"),
"beatport": re.compile(r"(?:^|beatport\.com/release/.+/)(\d+)$"),
"musicbrainz": re.compile(r"(\w{8}(?:-\w{4}){3}-\w{12})"),
# - plain integer, optionally wrapped in brackets and prefixed by an
# 'r', as this is how discogs displays the release ID on its webpage.
# - legacy url format: discogs.com/<name of release>/release/<id>
@ -51,15 +35,35 @@ def extract_discogs_id_regex(album_id):
# - current url format: discogs.com/release/<id>-<name of release>
# See #291, #4080 and #4085 for the discussions leading up to these
# patterns.
# Regex has been tested here https://regex101.com/r/TOu7kw/1
"discogs": re.compile(
r"(?:^|\[?r|discogs\.com/(?:[^/]+/)?release/)(\d+)\b"
),
# There is no such thing as a Bandcamp album or artist ID, the URL can be
# used as the identifier. The Bandcamp metadata source plugin works that way
# - https://github.com/snejus/beetcamp. Bandcamp album URLs usually look
# like: https://nameofartist.bandcamp.com/album/nameofalbum
"bandcamp": re.compile(r"(.+)"),
"tidal": re.compile(r"([^/]+)$"),
}
for pattern in [
r"^\[?r?(?P<id>\d+)\]?$",
r"discogs\.com/release/(?P<id>\d+)-?",
r"discogs\.com/[^/]+/release/(?P<id>\d+)",
]:
match = re.search(pattern, album_id)
if match:
return int(match.group("id"))
def extract_release_id(source: str, id_: str) -> str | None:
"""Extract the release ID from a given source and ID.
Normally, the `id_` is a url string which contains the ID of the
release. This function extracts the ID from the URL based on the
`source` provided.
"""
try:
source_pattern = PATTERN_BY_SOURCE[source.lower()]
except KeyError:
log.debug(
"Unknown source '{}' for ID extraction. Returning id/url as-is.",
source,
)
return id_
if m := source_pattern.search(str(id_)):
return m[1]
return None

View file

@ -36,18 +36,20 @@ from __future__ import annotations
import queue
import sys
from threading import Lock, Thread
from typing import Callable, Generator, TypeVar
from typing import TYPE_CHECKING, TypeVar
if sys.version_info >= (3, 11):
from typing import TypeVarTuple, Unpack
else:
from typing_extensions import TypeVarTuple, Unpack
from typing_extensions import TypeVarTuple, Unpack
if TYPE_CHECKING:
from collections.abc import Callable, Generator
BUBBLE = "__PIPELINE_BUBBLE__"
POISON = "__PIPELINE_POISON__"
DEFAULT_QUEUE_SIZE = 16
Tq = TypeVar("Tq")
def _invalidate_queue(q, val=None, sync=True):
"""Breaks a Queue such that it never blocks, always has size 1,
@ -91,7 +93,7 @@ def _invalidate_queue(q, val=None, sync=True):
q.mutex.release()
class CountedQueue(queue.Queue):
class CountedQueue(queue.Queue[Tq]):
"""A queue that keeps track of the number of threads that are
still feeding into it. The queue is poisoned when all threads are
finished with the queue.
@ -492,64 +494,3 @@ class Pipeline:
msgs = next_msgs
for msg in msgs:
yield msg
# Smoke test.
if __name__ == "__main__":
import time
# Test a normally-terminating pipeline both in sequence and
# in parallel.
def produce():
for i in range(5):
print("generating %i" % i)
time.sleep(1)
yield i
def work():
num = yield
while True:
print("processing %i" % num)
time.sleep(2)
num = yield num * 2
def consume():
while True:
num = yield
time.sleep(1)
print("received %i" % num)
ts_start = time.time()
Pipeline([produce(), work(), consume()]).run_sequential()
ts_seq = time.time()
Pipeline([produce(), work(), consume()]).run_parallel()
ts_par = time.time()
Pipeline([produce(), (work(), work()), consume()]).run_parallel()
ts_end = time.time()
print("Sequential time:", ts_seq - ts_start)
print("Parallel time:", ts_par - ts_seq)
print("Multiply-parallel time:", ts_end - ts_par)
print()
# Test a pipeline that raises an exception.
def exc_produce():
for i in range(10):
print("generating %i" % i)
time.sleep(1)
yield i
def exc_work():
num = yield
while True:
print("processing %i" % num)
time.sleep(3)
if num == 3:
raise Exception()
num = yield num * 2
def exc_consume():
while True:
num = yield
print("received %i" % num)
Pipeline([exc_produce(), exc_work(), exc_consume()]).run_parallel(1)

61
beets/util/units.py Normal file
View file

@ -0,0 +1,61 @@
import re
def raw_seconds_short(string: str) -> float:
"""Formats a human-readable M:SS string as a float (number of seconds).
Raises ValueError if the conversion cannot take place due to `string` not
being in the right format.
"""
match = re.match(r"^(\d+):([0-5]\d)$", string)
if not match:
raise ValueError("String not in M:SS format")
minutes, seconds = map(int, match.groups())
return float(minutes * 60 + seconds)
def human_seconds_short(interval):
"""Formats a number of seconds as a short human-readable M:SS
string.
"""
interval = int(interval)
return f"{interval // 60}:{interval % 60:02d}"
def human_bytes(size):
"""Formats size, a number of bytes, in a human-readable way."""
powers = ["", "K", "M", "G", "T", "P", "E", "Z", "Y", "H"]
unit = "B"
for power in powers:
if size < 1024:
return f"{size:3.1f} {power}{unit}"
size /= 1024.0
unit = "iB"
return "big"
def human_seconds(interval):
"""Formats interval, a number of seconds, as a human-readable time
interval using English words.
"""
units = [
(1, "second"),
(60, "minute"),
(60, "hour"),
(24, "day"),
(7, "week"),
(52, "year"),
(10, "decade"),
]
for i in range(len(units) - 1):
increment, suffix = units[i]
next_increment, _ = units[i + 1]
interval /= float(increment)
if interval < next_increment:
break
else:
# Last unit.
increment, suffix = units[-1]
interval /= float(increment)
return f"{interval:3.1f} {suffix}s"

View file

@ -0,0 +1,3 @@
from . import art, vfs
__all__ = ["art", "vfs"]

View file

@ -38,11 +38,7 @@ def get_art(log, item):
try:
mf = mediafile.MediaFile(syspath(item.path))
except mediafile.UnreadableFileError as exc:
log.warning(
"Could not extract art from {0}: {1}",
displayable_path(item.path),
exc,
)
log.warning("Could not extract art from {.filepath}: {}", item, exc)
return
return mf.art
@ -83,16 +79,16 @@ def embed_item(
# Get the `Image` object from the file.
try:
log.debug("embedding {0}", displayable_path(imagepath))
log.debug("embedding {}", displayable_path(imagepath))
image = mediafile_image(imagepath, maxwidth)
except OSError as exc:
log.warning("could not read image file: {0}", exc)
log.warning("could not read image file: {}", exc)
return
# Make sure the image kind is safe (some formats only support PNG
# and JPEG).
if image.mime_type not in ("image/jpeg", "image/png"):
log.info("not embedding image of unsupported type: {}", image.mime_type)
log.info("not embedding image of unsupported type: {.mime_type}", image)
return
item.try_write(path=itempath, tags={"images": [image]}, id3v23=id3v23)
@ -110,11 +106,11 @@ def embed_album(
"""Embed album art into all of the album's items."""
imagepath = album.artpath
if not imagepath:
log.info("No album art present for {0}", album)
log.info("No album art present for {}", album)
return
if not os.path.isfile(syspath(imagepath)):
log.info(
"Album art not found at {0} for {1}",
"Album art not found at {} for {}",
displayable_path(imagepath),
album,
)
@ -122,7 +118,7 @@ def embed_album(
if maxwidth:
imagepath = resize_image(log, imagepath, maxwidth, quality)
log.info("Embedding album art into {0}", album)
log.info("Embedding album art into {}", album)
for item in album.items():
embed_item(
@ -143,8 +139,7 @@ def resize_image(log, imagepath, maxwidth, quality):
specified quality level.
"""
log.debug(
"Resizing album art to {0} pixels wide and encoding at quality \
level {1}",
"Resizing album art to {} pixels wide and encoding at quality level {}",
maxwidth,
quality,
)
@ -184,18 +179,18 @@ def extract(log, outpath, item):
art = get_art(log, item)
outpath = bytestring_path(outpath)
if not art:
log.info("No album art present in {0}, skipping.", item)
log.info("No album art present in {}, skipping.", item)
return
# Add an extension to the filename.
ext = mediafile.image_extension(art)
if not ext:
log.warning("Unknown image type in {0}.", displayable_path(item.path))
log.warning("Unknown image type in {.filepath}.", item)
return
outpath += bytestring_path("." + ext)
outpath += bytestring_path(f".{ext}")
log.info(
"Extracting album art from: {0} to: {1}",
"Extracting album art from: {} to: {}",
item,
displayable_path(outpath),
)
@ -213,7 +208,7 @@ def extract_first(log, outpath, items):
def clear(log, lib, query):
items = lib.items(query)
log.info("Clearing album art from {0} items", len(items))
log.info("Clearing album art from {} items", len(items))
for item in items:
log.debug("Clearing art for {0}", item)
log.debug("Clearing art for {}", item)
item.try_write(tags={"images": None})

View file

@ -0,0 +1,290 @@
"""Helpers for communicating with the MusicBrainz webservice.
Provides rate-limited HTTP session and convenience methods to fetch and
normalize API responses.
This module centralizes request handling and response shaping so callers can
work with consistently structured data without embedding HTTP or rate-limit
logic throughout the codebase.
"""
from __future__ import annotations
import operator
from dataclasses import dataclass, field
from functools import cached_property, singledispatchmethod, wraps
from itertools import groupby
from typing import TYPE_CHECKING, Any, Literal, ParamSpec, TypedDict, TypeVar
from requests_ratelimiter import LimiterMixin
from typing_extensions import NotRequired, Unpack
from beets import config, logging
from .requests import RequestHandler, TimeoutAndRetrySession
if TYPE_CHECKING:
from collections.abc import Callable
from requests import Response
from .._typing import JSONDict
log = logging.getLogger(__name__)
class LimiterTimeoutSession(LimiterMixin, TimeoutAndRetrySession):
"""HTTP session that enforces rate limits."""
Entity = Literal[
"area",
"artist",
"collection",
"event",
"genre",
"instrument",
"label",
"place",
"recording",
"release",
"release-group",
"series",
"work",
"url",
]
class LookupKwargs(TypedDict, total=False):
includes: NotRequired[list[str]]
class PagingKwargs(TypedDict, total=False):
limit: NotRequired[int]
offset: NotRequired[int]
class SearchKwargs(PagingKwargs):
query: NotRequired[str]
class BrowseKwargs(LookupKwargs, PagingKwargs, total=False):
pass
class BrowseReleaseGroupsKwargs(BrowseKwargs, total=False):
artist: NotRequired[str]
collection: NotRequired[str]
release: NotRequired[str]
class BrowseRecordingsKwargs(BrowseReleaseGroupsKwargs, total=False):
work: NotRequired[str]
P = ParamSpec("P")
R = TypeVar("R")
def require_one_of(*keys: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
required = frozenset(keys)
def deco(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
# kwargs is a real dict at runtime; safe to inspect here
if not required & kwargs.keys():
required_str = ", ".join(sorted(required))
raise ValueError(
f"At least one of {required_str} filter is required"
)
return func(*args, **kwargs)
return wrapper
return deco
@dataclass
class MusicBrainzAPI(RequestHandler):
"""High-level interface to the MusicBrainz WS/2 API.
Responsibilities:
- Configure the API host and request rate from application configuration.
- Offer helpers to fetch common entity types and to run searches.
- Normalize MusicBrainz responses so relation lists are grouped by target
type for easier downstream consumption.
Documentation: https://musicbrainz.org/doc/MusicBrainz_API
"""
api_host: str = field(init=False)
rate_limit: float = field(init=False)
def __post_init__(self) -> None:
mb_config = config["musicbrainz"]
mb_config.add(
{
"host": "musicbrainz.org",
"https": False,
"ratelimit": 1,
"ratelimit_interval": 1,
}
)
hostname = mb_config["host"].as_str()
if hostname == "musicbrainz.org":
self.api_host, self.rate_limit = "https://musicbrainz.org", 1.0
else:
https = mb_config["https"].get(bool)
self.api_host = f"http{'s' if https else ''}://{hostname}"
self.rate_limit = (
mb_config["ratelimit"].get(int)
/ mb_config["ratelimit_interval"].as_number()
)
@cached_property
def api_root(self) -> str:
return f"{self.api_host}/ws/2"
def create_session(self) -> LimiterTimeoutSession:
return LimiterTimeoutSession(per_second=self.rate_limit)
def request(self, *args, **kwargs) -> Response:
"""Ensure all requests specify JSON response format by default."""
kwargs.setdefault("params", {})
kwargs["params"]["fmt"] = "json"
return super().request(*args, **kwargs)
def _get_resource(
self, resource: str, includes: list[str] | None = None, **kwargs
) -> JSONDict:
"""Retrieve and normalize data from the API resource endpoint.
If requested, includes are appended to the request. The response is
passed through a normalizer that groups relation entries by their
target type so that callers receive a consistently structured mapping.
"""
if includes:
kwargs["inc"] = "+".join(includes)
return self._group_relations(
self.get_json(f"{self.api_root}/{resource}", params=kwargs)
)
def _lookup(
self, entity: Entity, id_: str, **kwargs: Unpack[LookupKwargs]
) -> JSONDict:
return self._get_resource(f"{entity}/{id_}", **kwargs)
def _browse(self, entity: Entity, **kwargs) -> list[JSONDict]:
return self._get_resource(entity, **kwargs).get(f"{entity}s", [])
def search(
self,
entity: Entity,
filters: dict[str, str],
**kwargs: Unpack[SearchKwargs],
) -> list[JSONDict]:
"""Search for MusicBrainz entities matching the given filters.
* Query is constructed by combining the provided filters using AND logic
* Each filter key-value pair is formatted as 'key:"value"' unless
- 'key' is empty, in which case only the value is used, '"value"'
- 'value' is empty, in which case the filter is ignored
* Values are lowercased and stripped of whitespace.
"""
query = " AND ".join(
":".join(filter(None, (k, f'"{_v}"')))
for k, v in filters.items()
if (_v := v.lower().strip())
)
log.debug("Searching for MusicBrainz {}s with: {!r}", entity, query)
kwargs["query"] = query
return self._get_resource(entity, **kwargs)[f"{entity}s"]
def get_release(self, id_: str, **kwargs: Unpack[LookupKwargs]) -> JSONDict:
"""Retrieve a release by its MusicBrainz ID."""
return self._lookup("release", id_, **kwargs)
def get_recording(
self, id_: str, **kwargs: Unpack[LookupKwargs]
) -> JSONDict:
"""Retrieve a recording by its MusicBrainz ID."""
return self._lookup("recording", id_, **kwargs)
def get_work(self, id_: str, **kwargs: Unpack[LookupKwargs]) -> JSONDict:
"""Retrieve a work by its MusicBrainz ID."""
return self._lookup("work", id_, **kwargs)
@require_one_of("artist", "collection", "release", "work")
def browse_recordings(
self, **kwargs: Unpack[BrowseRecordingsKwargs]
) -> list[JSONDict]:
"""Browse recordings related to the given entities.
At least one of artist, collection, release, or work must be provided.
"""
return self._browse("recording", **kwargs)
@require_one_of("artist", "collection", "release")
def browse_release_groups(
self, **kwargs: Unpack[BrowseReleaseGroupsKwargs]
) -> list[JSONDict]:
"""Browse release groups related to the given entities.
At least one of artist, collection, or release must be provided.
"""
return self._get_resource("release-group", **kwargs)["release-groups"]
@singledispatchmethod
@classmethod
def _group_relations(cls, data: Any) -> Any:
"""Normalize MusicBrainz 'relations' into type-keyed fields recursively.
This helper rewrites payloads that use a generic 'relations' list into
a structure that is easier to consume downstream. When a mapping
contains 'relations', those entries are regrouped by their 'target-type'
and stored under keys like '<target-type>-relations'. The original
'relations' key is removed to avoid ambiguous access patterns.
The transformation is applied recursively so that nested objects and
sequences are normalized consistently, while non-container values are
left unchanged.
"""
return data
@_group_relations.register(list)
@classmethod
def _(cls, data: list[Any]) -> list[Any]:
return [cls._group_relations(i) for i in data]
@_group_relations.register(dict)
@classmethod
def _(cls, data: JSONDict) -> JSONDict:
for k, v in list(data.items()):
if k == "relations":
get_target_type = operator.methodcaller("get", "target-type")
for target_type, group in groupby(
sorted(v, key=get_target_type), get_target_type
):
relations = [
{k: v for k, v in item.items() if k != "target-type"}
for item in group
]
data[f"{target_type}-relations"] = cls._group_relations(
relations
)
data.pop("relations")
else:
data[k] = cls._group_relations(v)
return data
class MusicBrainzAPIMixin:
"""Mixin that provides a cached MusicBrainzAPI helper instance."""
@cached_property
def mb_api(self) -> MusicBrainzAPI:
return MusicBrainzAPI()

View file

@ -0,0 +1,196 @@
from __future__ import annotations
import atexit
import threading
from contextlib import contextmanager
from functools import cached_property
from http import HTTPStatus
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Protocol, TypeVar
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from beets import __version__
if TYPE_CHECKING:
from collections.abc import Iterator
class BeetsHTTPError(requests.exceptions.HTTPError):
STATUS: ClassVar[HTTPStatus]
def __init__(self, *args, **kwargs) -> None:
super().__init__(
f"HTTP Error: {self.STATUS.value} {self.STATUS.phrase}",
*args,
**kwargs,
)
class HTTPNotFoundError(BeetsHTTPError):
STATUS = HTTPStatus.NOT_FOUND
class Closeable(Protocol):
"""Protocol for objects that have a close method."""
def close(self) -> None: ...
C = TypeVar("C", bound=Closeable)
class SingletonMeta(type, Generic[C]):
"""Metaclass ensuring a single shared instance per class.
Creates one instance per class type on first instantiation, reusing it
for all subsequent calls. Automatically registers cleanup on program exit
for proper resource management.
"""
_instances: ClassVar[dict[type[Any], Any]] = {}
_lock: ClassVar[threading.Lock] = threading.Lock()
def __call__(cls, *args: Any, **kwargs: Any) -> C:
if cls not in cls._instances:
with cls._lock:
if cls not in SingletonMeta._instances:
instance = super().__call__(*args, **kwargs)
SingletonMeta._instances[cls] = instance
atexit.register(instance.close)
return SingletonMeta._instances[cls]
class TimeoutAndRetrySession(requests.Session, metaclass=SingletonMeta):
"""HTTP session with sensible defaults.
* default beets User-Agent header
* default request timeout
* automatic retries on transient connection or server errors
* raises exceptions for HTTP error status codes
"""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.headers["User-Agent"] = f"beets/{__version__} https://beets.io/"
retry = Retry(
connect=2,
total=2,
backoff_factor=1,
# Retry on server errors
status_forcelist=[
HTTPStatus.INTERNAL_SERVER_ERROR,
HTTPStatus.BAD_GATEWAY,
HTTPStatus.SERVICE_UNAVAILABLE,
HTTPStatus.GATEWAY_TIMEOUT,
],
)
adapter = HTTPAdapter(max_retries=retry)
self.mount("https://", adapter)
self.mount("http://", adapter)
def request(self, *args, **kwargs):
"""Execute HTTP request with automatic timeout and status validation.
Ensures all requests have a timeout (defaults to 10 seconds) and raises
an exception for HTTP error status codes.
"""
kwargs.setdefault("timeout", 10)
r = super().request(*args, **kwargs)
r.raise_for_status()
return r
class RequestHandler:
"""Manages HTTP requests with custom error handling and session management.
Provides a reusable interface for making HTTP requests with automatic
conversion of standard HTTP errors to beets-specific exceptions. Supports
custom session types and error mappings that can be overridden by
subclasses.
Usage:
Subclass and override :class:`RequestHandler.create_session`,
:class:`RequestHandler.explicit_http_errors` or
:class:`RequestHandler.status_to_error()` to customize behavior.
Use
- :class:`RequestHandler.get_json()` to get JSON response data
- :class:`RequestHandler.get()` to get HTTP response object
- :class:`RequestHandler.request()` to invoke arbitrary HTTP methods
Feel free to define common methods that are used in multiple plugins.
"""
#: List of custom exceptions to be raised for specific status codes.
explicit_http_errors: ClassVar[list[type[BeetsHTTPError]]] = [
HTTPNotFoundError
]
def create_session(self) -> TimeoutAndRetrySession:
"""Create a new HTTP session instance.
Can be overridden by subclasses to provide custom session types.
"""
return TimeoutAndRetrySession()
@cached_property
def session(self) -> TimeoutAndRetrySession:
return self.create_session()
def status_to_error(
self, code: int
) -> type[requests.exceptions.HTTPError] | None:
"""Map HTTP status codes to beets-specific exception types.
Searches the configured explicit HTTP errors for a matching status code.
Returns None if no specific error type is registered for the given code.
"""
return next(
(e for e in self.explicit_http_errors if e.STATUS == code), None
)
@contextmanager
def handle_http_error(self) -> Iterator[None]:
"""Convert standard HTTP errors to beets-specific exceptions.
Wraps operations that may raise HTTPError, automatically translating
recognized status codes into their corresponding beets exception types.
Unrecognized errors are re-raised unchanged.
"""
try:
yield
except requests.exceptions.HTTPError as e:
if beets_error := self.status_to_error(e.response.status_code):
raise beets_error(response=e.response) from e
raise
def request(self, *args, **kwargs) -> requests.Response:
"""Perform HTTP request using the session with automatic error handling.
Delegates to the underlying session method while converting recognized
HTTP errors to beets-specific exceptions through the error handler.
"""
with self.handle_http_error():
return self.session.request(*args, **kwargs)
def get(self, *args, **kwargs) -> requests.Response:
"""Perform HTTP GET request with automatic error handling."""
return self.request("get", *args, **kwargs)
def put(self, *args, **kwargs) -> requests.Response:
"""Perform HTTP PUT request with automatic error handling."""
return self.request("put", *args, **kwargs)
def delete(self, *args, **kwargs) -> requests.Response:
"""Perform HTTP DELETE request with automatic error handling."""
return self.request("delete", *args, **kwargs)
def get_json(self, *args, **kwargs):
"""Fetch and parse JSON data from an HTTP endpoint."""
return self.get(*args, **kwargs).json()

View file

@ -16,17 +16,25 @@
libraries.
"""
from typing import Any, NamedTuple
from __future__ import annotations
from typing import TYPE_CHECKING, NamedTuple
from beets import util
if TYPE_CHECKING:
from beets.library import Library
class Node(NamedTuple):
files: dict[str, Any]
dirs: dict[str, Any]
files: dict[str, int]
# Maps filenames to Item ids.
dirs: dict[str, Node]
# Maps directory names to child nodes.
def _insert(node, path, itemid):
def _insert(node: Node, path: list[str], itemid: int):
"""Insert an item into a virtual filesystem node."""
if len(path) == 1:
# Last component. Insert file.
@ -40,7 +48,7 @@ def _insert(node, path, itemid):
_insert(node.dirs[dirname], rest, itemid)
def libtree(lib):
def libtree(lib: Library) -> Node:
"""Generates a filesystem-like directory tree for the files
contained in `lib`. Filesystem nodes are (files, dirs) named
tuples in which both components are dictionaries. The first
@ -49,7 +57,7 @@ def libtree(lib):
"""
root = Node({}, {})
for item in lib.items():
dest = item.destination(fragment=True)
parts = util.components(dest)
dest = item.destination(relative_to_libdir=True)
parts = util.components(util.as_string(dest))
_insert(root, parts, item.id)
return root

View file

@ -18,9 +18,9 @@ import errno
import hashlib
import json
import os
import shutil
import subprocess
import tempfile
from distutils.spawn import find_executable
import requests
@ -42,9 +42,7 @@ def call(args):
try:
return util.command_output(args).stdout
except subprocess.CalledProcessError as e:
raise ABSubmitError(
"{} exited with status {}".format(args[0], e.returncode)
)
raise ABSubmitError(f"{args[0]} exited with status {e.returncode}")
class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
@ -63,9 +61,7 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
# Explicit path to extractor
if not os.path.isfile(self.extractor):
raise ui.UserError(
"Extractor command does not exist: {0}.".format(
self.extractor
)
f"Extractor command does not exist: {self.extractor}."
)
else:
# Implicit path to extractor, search for it in path
@ -84,7 +80,7 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
# Get the executable location on the system, which we need
# to calculate the SHA-1 hash.
self.extractor = find_executable(self.extractor)
self.extractor = shutil.which(self.extractor)
# Calculate extractor hash.
self.extractor_sha = hashlib.sha1()
@ -101,8 +97,8 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
"with an HTTP scheme"
)
elif base_url[-1] != "/":
base_url = base_url + "/"
self.url = base_url + "{mbid}/low-level"
base_url = f"{base_url}/"
self.url = f"{base_url}{{mbid}}/low-level"
def commands(self):
cmd = ui.Subcommand(
@ -122,8 +118,10 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
dest="pretend_fetch",
action="store_true",
default=False,
help="pretend to perform action, but show \
only files which would be processed",
help=(
"pretend to perform action, but show only files which would be"
" processed"
),
)
cmd.func = self.command
return [cmd]
@ -137,7 +135,7 @@ only files which would be processed",
)
else:
# Get items from arguments
items = lib.items(ui.decargs(args))
items = lib.items(args)
self.opts = opts
util.par_map(self.analyze_submit, items)
@ -157,7 +155,7 @@ only files which would be processed",
# If file has no MBID, skip it.
if not mbid:
self._log.info(
"Not analysing {}, missing " "musicbrainz track id.", item
"Not analysing {}, missing musicbrainz track id.", item
)
return None
@ -220,6 +218,6 @@ only files which would be processed",
)
else:
self._log.debug(
"Successfully submitted AcousticBrainz analysis " "for {}.",
"Successfully submitted AcousticBrainz analysis for {}.",
item,
)

View file

@ -97,7 +97,7 @@ class AcousticPlugin(plugins.BeetsPlugin):
"with an HTTP scheme"
)
elif self.base_url[-1] != "/":
self.base_url = self.base_url + "/"
self.base_url = f"{self.base_url}/"
if self.config["auto"]:
self.register_listener("import_task_files", self.import_task_files)
@ -116,7 +116,7 @@ class AcousticPlugin(plugins.BeetsPlugin):
)
def func(lib, opts, args):
items = lib.items(ui.decargs(args))
items = lib.items(args)
self._fetch_info(
items,
ui.should_write(),
@ -153,7 +153,7 @@ class AcousticPlugin(plugins.BeetsPlugin):
try:
data.update(res.json())
except ValueError:
self._log.debug("Invalid Response: {}", res.text)
self._log.debug("Invalid Response: {.text}", res)
return {}
return data
@ -286,7 +286,7 @@ class AcousticPlugin(plugins.BeetsPlugin):
yield v, subdata[k]
else:
self._log.warning(
"Acousticbrainz did not provide info " "about {}", k
"Acousticbrainz did not provide info about {}", k
)
self._log.debug(
"Data {} could not be mapped to scheme {} "
@ -300,4 +300,4 @@ class AcousticPlugin(plugins.BeetsPlugin):
def _generate_urls(base_url, mbid):
"""Generates AcousticBrainz end point urls for given `mbid`."""
for level in LEVELS:
yield base_url + mbid + level
yield f"{base_url}{mbid}{level}"

View file

@ -58,7 +58,9 @@ class AdvancedRewritePlugin(BeetsPlugin):
def __init__(self):
"""Parse configuration and register template fields for rewriting."""
super().__init__()
self.register_listener("pluginload", self.loaded)
def loaded(self):
template = confuse.Sequence(
confuse.OneOf(
[

View file

@ -14,10 +14,11 @@
"""Adds an album template field for formatted album types."""
from beets.autotag.mb import VARIOUS_ARTISTS_ID
from beets.library import Album
from beets.plugins import BeetsPlugin
from .musicbrainz import VARIOUS_ARTISTS_ID
class AlbumTypesPlugin(BeetsPlugin):
"""Adds an album template field for formatted album types."""

View file

@ -16,7 +16,6 @@
import os
import re
import sys
from collections.abc import Mapping
from dataclasses import dataclass
from mimetypes import guess_type
@ -30,11 +29,7 @@ from flask import (
request,
send_file,
)
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
from typing_extensions import Self
from beets import config
from beets.dbcore.query import (
@ -241,14 +236,14 @@ class AURADocument:
# Not the last page so work out links.next url
if not self.args:
# No existing arguments, so current page is 0
next_url = request.url + "?page=1"
next_url = f"{request.url}?page=1"
elif not self.args.get("page", None):
# No existing page argument, so add one to the end
next_url = request.url + "&page=1"
next_url = f"{request.url}&page=1"
else:
# Increment page token by 1
next_url = request.url.replace(
f"page={page}", "page={}".format(page + 1)
f"page={page}", f"page={page + 1}"
)
# Get only the items in the page range
data = [
@ -432,9 +427,7 @@ class TrackDocument(AURADocument):
return self.error(
"404 Not Found",
"No track with the requested id.",
"There is no track with an id of {} in the library.".format(
track_id
),
f"There is no track with an id of {track_id} in the library.",
)
return self.single_resource_document(
self.get_resource_object(self.lib, track)
@ -518,9 +511,7 @@ class AlbumDocument(AURADocument):
return self.error(
"404 Not Found",
"No album with the requested id.",
"There is no album with an id of {} in the library.".format(
album_id
),
f"There is no album with an id of {album_id} in the library.",
)
return self.single_resource_document(
self.get_resource_object(self.lib, album)
@ -605,9 +596,7 @@ class ArtistDocument(AURADocument):
return self.error(
"404 Not Found",
"No artist with the requested id.",
"There is no artist with an id of {} in the library.".format(
artist_id
),
f"There is no artist with an id of {artist_id} in the library.",
)
return self.single_resource_document(artist_resource)
@ -708,7 +697,7 @@ class ImageDocument(AURADocument):
relationships = {}
# Split id into [parent_type, parent_id, filename]
id_split = image_id.split("-")
relationships[id_split[0] + "s"] = {
relationships[f"{id_split[0]}s"] = {
"data": [{"type": id_split[0], "id": id_split[1]}]
}
@ -732,9 +721,7 @@ class ImageDocument(AURADocument):
return self.error(
"404 Not Found",
"No image with the requested id.",
"There is no image with an id of {} in the library.".format(
image_id
),
f"There is no image with an id of {image_id} in the library.",
)
return self.single_resource_document(image_resource)
@ -780,9 +767,7 @@ def audio_file(track_id):
return AURADocument.error(
"404 Not Found",
"No track with the requested id.",
"There is no track with an id of {} in the library.".format(
track_id
),
f"There is no track with an id of {track_id} in the library.",
)
path = os.fsdecode(track.path)
@ -790,9 +775,8 @@ def audio_file(track_id):
return AURADocument.error(
"404 Not Found",
"No audio file for the requested track.",
(
"There is no audio file for track {} at the expected location"
).format(track_id),
f"There is no audio file for track {track_id} at the expected"
" location",
)
file_mimetype = guess_type(path)[0]
@ -800,10 +784,8 @@ def audio_file(track_id):
return AURADocument.error(
"500 Internal Server Error",
"Requested audio file has an unknown mimetype.",
(
"The audio file for track {} has an unknown mimetype. "
"Its file extension is {}."
).format(track_id, path.split(".")[-1]),
f"The audio file for track {track_id} has an unknown mimetype. "
f"Its file extension is {path.split('.')[-1]}.",
)
# Check that the Accept header contains the file's mimetype
@ -815,10 +797,8 @@ def audio_file(track_id):
return AURADocument.error(
"406 Not Acceptable",
"Unsupported MIME type or bitrate parameter in Accept header.",
(
"The audio file for track {} is only available as {} and "
"bitrate parameters are not supported."
).format(track_id, file_mimetype),
f"The audio file for track {track_id} is only available as"
f" {file_mimetype} and bitrate parameters are not supported.",
)
return send_file(
@ -901,9 +881,7 @@ def image_file(image_id):
return AURADocument.error(
"404 Not Found",
"No image with the requested id.",
"There is no image with an id of {} in the library".format(
image_id
),
f"There is no image with an id of {image_id} in the library",
)
return send_file(img_path)

View file

@ -15,10 +15,10 @@
from __future__ import annotations
from collections.abc import Iterable
from typing import TYPE_CHECKING
import librosa
import numpy as np
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand, should_write
@ -76,7 +76,10 @@ class AutoBPMPlugin(BeetsPlugin):
self._log.error("Failed to measure BPM for {}: {}", path, exc)
continue
bpm = round(tempo[0] if isinstance(tempo, Iterable) else tempo)
bpm = round(
float(tempo[0] if isinstance(tempo, np.ndarray) else tempo)
)
item["bpm"] = bpm
self._log.info("Computed BPM for {}: {}", path, bpm)

View file

@ -110,9 +110,7 @@ class BadFiles(BeetsPlugin):
self._log.debug("checking path: {}", dpath)
if not os.path.exists(item.path):
ui.print_(
"{}: file does not exist".format(
ui.colorize("text_error", dpath)
)
f"{ui.colorize('text_error', dpath)}: file does not exist"
)
# Run the checker against the file if one is found
@ -129,37 +127,32 @@ class BadFiles(BeetsPlugin):
except CheckerCommandError as e:
if e.errno == errno.ENOENT:
self._log.error(
"command not found: {} when validating file: {}",
e.checker,
e.path,
"command not found: {0.checker} when validating file: {0.path}",
e,
)
else:
self._log.error("error invoking {}: {}", e.checker, e.msg)
self._log.error("error invoking {0.checker}: {0.msg}", e)
return []
error_lines = []
if status > 0:
error_lines.append(
"{}: checker exited with status {}".format(
ui.colorize("text_error", dpath), status
)
f"{ui.colorize('text_error', dpath)}: checker exited with"
f" status {status}"
)
for line in output:
error_lines.append(f" {line}")
elif errors > 0:
error_lines.append(
"{}: checker found {} errors or warnings".format(
ui.colorize("text_warning", dpath), errors
)
f"{ui.colorize('text_warning', dpath)}: checker found"
f" {status} errors or warnings"
)
for line in output:
error_lines.append(f" {line}")
elif self.verbose:
error_lines.append(
"{}: ok".format(ui.colorize("text_success", dpath))
)
error_lines.append(f"{ui.colorize('text_success', dpath)}: ok")
return error_lines
@ -180,9 +173,8 @@ class BadFiles(BeetsPlugin):
def on_import_task_before_choice(self, task, session):
if hasattr(task, "_badfiles_checks_failed"):
ui.print_(
"{} one or more files failed checks:".format(
ui.colorize("text_warning", "BAD")
)
f"{ui.colorize('text_warning', 'BAD')} one or more files failed"
" checks:"
)
for error in task._badfiles_checks_failed:
for error_line in error:
@ -194,7 +186,7 @@ class BadFiles(BeetsPlugin):
sel = ui.input_options(["aBort", "skip", "continue"])
if sel == "s":
return importer.action.SKIP
return importer.Action.SKIP
elif sel == "c":
return None
elif sel == "b":
@ -204,7 +196,7 @@ class BadFiles(BeetsPlugin):
def command(self, lib, opts, args):
# Get items from arguments
items = lib.items(ui.decargs(args))
items = lib.items(args)
self.verbose = opts.verbose
def check_and_print(item):

View file

@ -23,7 +23,7 @@ from unidecode import unidecode
from beets import ui
from beets.dbcore.query import StringFieldQuery
from beets.plugins import BeetsPlugin
from beets.ui import decargs, print_
from beets.ui import print_
class BareascQuery(StringFieldQuery[str]):
@ -75,7 +75,7 @@ class BareascPlugin(BeetsPlugin):
"bareasc", help="unidecode version of beet list command"
)
cmd.parser.usage += (
"\n" "Example: %prog -f '$album: $title' artist:beatles"
"\nExample: %prog -f '$album: $title' artist:beatles"
)
cmd.parser.add_all_common_options()
cmd.func = self.unidecode_list
@ -83,14 +83,13 @@ class BareascPlugin(BeetsPlugin):
def unidecode_list(self, lib, opts, args):
"""Emulate normal 'list' command but with unidecode output."""
query = decargs(args)
album = opts.album
# Copied from commands.py - list_items
if album:
for album in lib.albums(query):
for album in lib.albums(args):
bare = unidecode(str(album))
print_(bare)
else:
for item in lib.items(query):
for item in lib.items(args):
bare = unidecode(str(item))
print_(bare)

View file

@ -14,9 +14,12 @@
"""Adds Beatport release and track search support to the autotagger"""
from __future__ import annotations
import json
import re
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Literal, overload
import confuse
from requests_oauthlib import OAuth1Session
@ -29,8 +32,15 @@ from requests_oauthlib.oauth1_session import (
import beets
import beets.ui
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.plugins import BeetsPlugin, MetadataSourcePlugin, get_distance
from beets.util.id_extractors import beatport_id_regex
from beets.metadata_plugins import MetadataSourcePlugin
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence
from beets.importer import ImportSession
from beets.library import Item
from ._typing import JSONDict
AUTH_ERRORS = (TokenRequestDenied, TokenMissing, VerifierMissing)
USER_AGENT = f"beets/{beets.__version__} +https://beets.io/"
@ -40,20 +50,6 @@ class BeatportAPIError(Exception):
pass
class BeatportObject:
def __init__(self, data):
self.beatport_id = data["id"]
self.name = str(data["name"])
if "releaseDate" in data:
self.release_date = datetime.strptime(
data["releaseDate"], "%Y-%m-%d"
)
if "artists" in data:
self.artists = [(x["id"], str(x["name"])) for x in data["artists"]]
if "genres" in data:
self.genres = [str(x["name"]) for x in data["genres"]]
class BeatportClient:
_api_base = "https://oauth-api.beatport.com"
@ -78,7 +74,7 @@ class BeatportClient:
)
self.api.headers = {"User-Agent": USER_AGENT}
def get_authorize_url(self):
def get_authorize_url(self) -> str:
"""Generate the URL for the user to authorize the application.
Retrieves a request token from the Beatport API and returns the
@ -100,38 +96,53 @@ class BeatportClient:
self._make_url("/identity/1/oauth/authorize")
)
def get_access_token(self, auth_data):
def get_access_token(self, auth_data: str) -> tuple[str, str]:
"""Obtain the final access token and secret for the API.
:param auth_data: URL-encoded authorization data as displayed at
the authorization url (obtained via
:py:meth:`get_authorize_url`) after signing in
:type auth_data: unicode
:returns: OAuth resource owner key and secret
:rtype: (unicode, unicode) tuple
:returns: OAuth resource owner key and secret as unicode
"""
self.api.parse_authorization_response(
"https://beets.io/auth?" + auth_data
f"https://beets.io/auth?{auth_data}"
)
access_data = self.api.fetch_access_token(
self._make_url("/identity/1/oauth/access-token")
)
return access_data["oauth_token"], access_data["oauth_token_secret"]
def search(self, query, release_type="release", details=True):
@overload
def search(
self,
query: str,
release_type: Literal["release"],
details: bool = True,
) -> Iterator[BeatportRelease]: ...
@overload
def search(
self,
query: str,
release_type: Literal["track"],
details: bool = True,
) -> Iterator[BeatportTrack]: ...
def search(
self,
query: str,
release_type: Literal["release", "track"],
details=True,
) -> Iterator[BeatportRelease | BeatportTrack]:
"""Perform a search of the Beatport catalogue.
:param query: Query string
:param release_type: Type of releases to search for, can be
'release' or 'track'
:param release_type: Type of releases to search for.
:param details: Retrieve additional information about the
search results. Currently this will fetch
the tracklist for releases and do nothing for
tracks
:returns: Search results
:rtype: generator that yields
py:class:`BeatportRelease` or
:py:class:`BeatportTrack`
"""
response = self._get(
"catalog/3/search",
@ -141,20 +152,18 @@ class BeatportClient:
)
for item in response:
if release_type == "release":
release = BeatportRelease(item)
if details:
release = self.get_release(item["id"])
else:
release = BeatportRelease(item)
release.tracks = self.get_release_tracks(item["id"])
yield release
elif release_type == "track":
yield BeatportTrack(item)
def get_release(self, beatport_id):
def get_release(self, beatport_id: str) -> BeatportRelease | None:
"""Get information about a single release.
:param beatport_id: Beatport ID of the release
:returns: The matching release
:rtype: :py:class:`BeatportRelease`
"""
response = self._get("/catalog/3/releases", id=beatport_id)
if response:
@ -163,35 +172,33 @@ class BeatportClient:
return release
return None
def get_release_tracks(self, beatport_id):
def get_release_tracks(self, beatport_id: str) -> list[BeatportTrack]:
"""Get all tracks for a given release.
:param beatport_id: Beatport ID of the release
:returns: Tracks in the matching release
:rtype: list of :py:class:`BeatportTrack`
"""
response = self._get(
"/catalog/3/tracks", releaseId=beatport_id, perPage=100
)
return [BeatportTrack(t) for t in response]
def get_track(self, beatport_id):
def get_track(self, beatport_id: str) -> BeatportTrack:
"""Get information about a single track.
:param beatport_id: Beatport ID of the track
:returns: The matching track
:rtype: :py:class:`BeatportTrack`
"""
response = self._get("/catalog/3/tracks", id=beatport_id)
return BeatportTrack(response[0])
def _make_url(self, endpoint):
def _make_url(self, endpoint: str) -> str:
"""Get complete URL for a given API endpoint."""
if not endpoint.startswith("/"):
endpoint = "/" + endpoint
return self._api_base + endpoint
endpoint = f"/{endpoint}"
return f"{self._api_base}{endpoint}"
def _get(self, endpoint, **kwargs):
def _get(self, endpoint: str, **kwargs) -> list[JSONDict]:
"""Perform a GET request on a given API endpoint.
Automatically extracts result data from the response and converts HTTP
@ -200,60 +207,88 @@ class BeatportClient:
try:
response = self.api.get(self._make_url(endpoint), params=kwargs)
except Exception as e:
raise BeatportAPIError(
"Error connecting to Beatport API: {}".format(e)
)
raise BeatportAPIError(f"Error connecting to Beatport API: {e}")
if not response:
raise BeatportAPIError(
"Error {0.status_code} for '{0.request.path_url}".format(
response
)
f"Error {response.status_code} for '{response.request.path_url}"
)
return response.json()["results"]
class BeatportRelease(BeatportObject):
def __str__(self):
if len(self.artists) < 4:
artist_str = ", ".join(x[1] for x in self.artists)
else:
artist_str = "Various Artists"
return "<BeatportRelease: {} - {} ({})>".format(
artist_str,
self.name,
self.catalog_number,
)
class BeatportObject:
beatport_id: str
name: str
def __repr__(self):
return str(self).encode("utf-8")
release_date: datetime | None = None
def __init__(self, data):
BeatportObject.__init__(self, data)
if "catalogNumber" in data:
self.catalog_number = data["catalogNumber"]
if "label" in data:
self.label_name = data["label"]["name"]
if "category" in data:
self.category = data["category"]
if "slug" in data:
self.url = "https://beatport.com/release/{}/{}".format(
data["slug"], data["id"]
artists: list[tuple[str, str]] | None = None
# tuple of artist id and artist name
def __init__(self, data: JSONDict):
self.beatport_id = str(data["id"]) # given as int in the response
self.name = str(data["name"])
if "releaseDate" in data:
self.release_date = datetime.strptime(
data["releaseDate"], "%Y-%m-%d"
)
if "artists" in data:
self.artists = [(x["id"], str(x["name"])) for x in data["artists"]]
if "genres" in data:
self.genres = [str(x["name"]) for x in data["genres"]]
def artists_str(self) -> str | None:
if self.artists is not None:
if len(self.artists) < 4:
artist_str = ", ".join(x[1] for x in self.artists)
else:
artist_str = "Various Artists"
else:
artist_str = None
return artist_str
class BeatportRelease(BeatportObject):
catalog_number: str | None
label_name: str | None
category: str | None
url: str | None
genre: str | None
tracks: list[BeatportTrack] | None = None
def __init__(self, data: JSONDict):
super().__init__(data)
self.catalog_number = data.get("catalogNumber")
self.label_name = data.get("label", {}).get("name")
self.category = data.get("category")
self.genre = data.get("genre")
if "slug" in data:
self.url = (
f"https://beatport.com/release/{data['slug']}/{data['id']}"
)
def __str__(self) -> str:
return (
"<BeatportRelease: "
f"{self.artists_str()} - {self.name} ({self.catalog_number})>"
)
class BeatportTrack(BeatportObject):
def __str__(self):
artist_str = ", ".join(x[1] for x in self.artists)
return "<BeatportTrack: {} - {} ({})>".format(
artist_str, self.name, self.mix_name
)
title: str | None
mix_name: str | None
length: timedelta
url: str | None
track_number: int | None
bpm: str | None
initial_key: str | None
genre: str | None
def __repr__(self):
return str(self).encode("utf-8")
def __init__(self, data):
BeatportObject.__init__(self, data)
def __init__(self, data: JSONDict):
super().__init__(data)
if "title" in data:
self.title = str(data["title"])
if "mixName" in data:
@ -266,9 +301,7 @@ class BeatportTrack(BeatportObject):
except ValueError:
pass
if "slug" in data:
self.url = "https://beatport.com/track/{}/{}".format(
data["slug"], data["id"]
)
self.url = f"https://beatport.com/track/{data['slug']}/{data['id']}"
self.track_number = data.get("trackNumber")
self.bpm = data.get("bpm")
self.initial_key = str((data.get("key") or {}).get("shortName"))
@ -280,9 +313,8 @@ class BeatportTrack(BeatportObject):
self.genre = str(data["genres"][0].get("name"))
class BeatportPlugin(BeetsPlugin):
data_source = "Beatport"
id_regex = beatport_id_regex
class BeatportPlugin(MetadataSourcePlugin):
_client: BeatportClient | None = None
def __init__(self):
super().__init__()
@ -291,17 +323,23 @@ class BeatportPlugin(BeetsPlugin):
"apikey": "57713c3906af6f5def151b33601389176b37b429",
"apisecret": "b3fe08c93c80aefd749fe871a16cd2bb32e2b954",
"tokenfile": "beatport_token.json",
"source_weight": 0.5,
}
)
self.config["apikey"].redact = True
self.config["apisecret"].redact = True
self.client = None
self.register_listener("import_begin", self.setup)
def setup(self, session=None):
c_key = self.config["apikey"].as_str()
c_secret = self.config["apisecret"].as_str()
@property
def client(self) -> BeatportClient:
if self._client is None:
raise ValueError(
"Beatport client not initialized. Call setup() first."
)
return self._client
def setup(self, session: ImportSession):
c_key: str = self.config["apikey"].as_str()
c_secret: str = self.config["apisecret"].as_str()
# Get the OAuth token from a file or log in.
try:
@ -314,15 +352,15 @@ class BeatportPlugin(BeetsPlugin):
token = tokendata["token"]
secret = tokendata["secret"]
self.client = BeatportClient(c_key, c_secret, token, secret)
self._client = BeatportClient(c_key, c_secret, token, secret)
def authenticate(self, c_key, c_secret):
def authenticate(self, c_key: str, c_secret: str) -> tuple[str, str]:
# Get the link for the OAuth page.
auth_client = BeatportClient(c_key, c_secret)
try:
url = auth_client.get_authorize_url()
except AUTH_ERRORS as e:
self._log.debug("authentication error: {0}", e)
self._log.debug("authentication error: {}", e)
raise beets.ui.UserError("communication with Beatport failed")
beets.ui.print_("To authenticate with Beatport, visit:")
@ -333,69 +371,54 @@ class BeatportPlugin(BeetsPlugin):
try:
token, secret = auth_client.get_access_token(data)
except AUTH_ERRORS as e:
self._log.debug("authentication error: {0}", e)
self._log.debug("authentication error: {}", e)
raise beets.ui.UserError("Beatport token request failed")
# Save the token for later use.
self._log.debug("Beatport token {0}, secret {1}", token, secret)
self._log.debug("Beatport token {}, secret {}", token, secret)
with open(self._tokenfile(), "w") as f:
json.dump({"token": token, "secret": secret}, f)
return token, secret
def _tokenfile(self):
def _tokenfile(self) -> str:
"""Get the path to the JSON file for storing the OAuth token."""
return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
def album_distance(self, items, album_info, mapping):
"""Returns the Beatport source weight and the maximum source weight
for albums.
"""
return get_distance(
data_source=self.data_source, info=album_info, config=self.config
)
def track_distance(self, item, track_info):
"""Returns the Beatport source weight and the maximum source weight
for individual tracks.
"""
return get_distance(
data_source=self.data_source, info=track_info, config=self.config
)
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).
"""
def candidates(
self,
items: Sequence[Item],
artist: str,
album: str,
va_likely: bool,
) -> Iterator[AlbumInfo]:
if va_likely:
query = release
query = album
else:
query = f"{artist} {release}"
query = f"{artist} {album}"
try:
return self._get_releases(query)
yield from self._get_releases(query)
except BeatportAPIError as e:
self._log.debug("API Error: {0} (query: {1})", e, query)
return []
self._log.debug("API Error: {} (query: {})", e, query)
return
def item_candidates(self, item, artist, title):
"""Returns a list of TrackInfo objects for beatport search results
matching title and artist.
"""
def item_candidates(
self, item: Item, artist: str, title: str
) -> Iterable[TrackInfo]:
query = f"{artist} {title}"
try:
return self._get_tracks(query)
except BeatportAPIError as e:
self._log.debug("API Error: {0} (query: {1})", e, query)
self._log.debug("API Error: {} (query: {})", e, query)
return []
def album_for_id(self, release_id):
def album_for_id(self, album_id: str):
"""Fetches a release by its Beatport ID and returns an AlbumInfo object
or None if the query is not a valid ID or release is not found.
"""
self._log.debug("Searching for release {0}", release_id)
self._log.debug("Searching for release {}", album_id)
release_id = self._get_id("album", release_id, self.id_regex)
if release_id is None:
if not (release_id := self._extract_id(album_id)):
self._log.debug("Not a valid Beatport release ID.")
return None
@ -404,11 +427,12 @@ class BeatportPlugin(BeetsPlugin):
return self._get_album_info(release)
return None
def track_for_id(self, track_id):
def track_for_id(self, track_id: str):
"""Fetches a track by its Beatport ID and returns a TrackInfo object
or None if the track is not a valid Beatport ID or track is not found.
"""
self._log.debug("Searching for track {0}", track_id)
self._log.debug("Searching for track {}", track_id)
# TODO: move to extractor
match = re.search(r"(^|beatport\.com/track/.+/)(\d+)$", track_id)
if not match:
self._log.debug("Not a valid Beatport track ID.")
@ -418,7 +442,7 @@ class BeatportPlugin(BeetsPlugin):
return self._get_track_info(bp_track)
return None
def _get_releases(self, query):
def _get_releases(self, query: str) -> Iterator[AlbumInfo]:
"""Returns a list of AlbumInfo objects for a beatport search query."""
# Strip non-word characters from query. Things like "!" and "-" can
# cause a query to return no results, even if they match the artist or
@ -428,16 +452,22 @@ class BeatportPlugin(BeetsPlugin):
# Strip medium information from query, Things like "CD1" and "disk 1"
# can also negate an otherwise positive result.
query = re.sub(r"\b(CD|disc)\s*\d+", "", query, flags=re.I)
albums = [self._get_album_info(x) for x in self.client.search(query)]
return albums
for beatport_release in self.client.search(query, "release"):
if beatport_release is None:
continue
yield self._get_album_info(beatport_release)
def _get_album_info(self, release):
def _get_album_info(self, release: BeatportRelease) -> AlbumInfo:
"""Returns an AlbumInfo object for a Beatport Release object."""
va = len(release.artists) > 3
va = release.artists is not None and len(release.artists) > 3
artist, artist_id = self._get_artist(release.artists)
if va:
artist = "Various Artists"
tracks = [self._get_track_info(x) for x in release.tracks]
tracks: list[TrackInfo] = []
if release.tracks is not None:
tracks = [self._get_track_info(x) for x in release.tracks]
release_date = release.release_date
return AlbumInfo(
album=release.name,
@ -448,18 +478,18 @@ class BeatportPlugin(BeetsPlugin):
tracks=tracks,
albumtype=release.category,
va=va,
year=release.release_date.year,
month=release.release_date.month,
day=release.release_date.day,
label=release.label_name,
catalognum=release.catalog_number,
media="Digital",
data_source=self.data_source,
data_url=release.url,
genre=release.genre,
year=release_date.year if release_date else None,
month=release_date.month if release_date else None,
day=release_date.day if release_date else None,
)
def _get_track_info(self, track):
def _get_track_info(self, track: BeatportTrack) -> TrackInfo:
"""Returns a TrackInfo object for a Beatport Track object."""
title = track.name
if track.mix_name != "Original Mix":
@ -485,9 +515,7 @@ class BeatportPlugin(BeetsPlugin):
"""Returns an artist string (all artists) and an artist_id (the main
artist) for a list of Beatport release or track artists.
"""
return MetadataSourcePlugin.get_artist(
artists=artists, id_key=0, name_key=1
)
return self.get_artist(artists=artists, id_key=0, name_key=1)
def _get_tracks(self, query):
"""Returns a list of TrackInfo objects for a Beatport query."""

View file

@ -17,10 +17,11 @@
import cProfile
import timeit
from beets import importer, library, plugins, ui, vfs
from beets import importer, library, plugins, ui
from beets.autotag import match
from beets.plugins import BeetsPlugin
from beets.util.functemplate import Template
from beetsplug._utils import vfs
def aunique_benchmark(lib, prof):
@ -125,7 +126,7 @@ class BenchmarkPlugin(BeetsPlugin):
"-i", "--id", default=None, help="album ID to match against"
)
match_bench_cmd.func = lambda lib, opts, args: match_benchmark(
lib, opts.profile, ui.decargs(args), opts.id
lib, opts.profile, args, opts.id
)
return [aunique_bench_cmd, match_bench_cmd]

View file

@ -30,18 +30,30 @@ from typing import TYPE_CHECKING
import beets
import beets.ui
from beets import dbcore, vfs
from beets import dbcore, logging
from beets.library import Item
from beets.plugins import BeetsPlugin
from beets.util import bluelet
from beets.util import as_string, bluelet
from beetsplug._utils import vfs
if TYPE_CHECKING:
from beets.dbcore.query import Query
log = logging.getLogger(__name__)
try:
from . import gstplayer
except ImportError as e:
raise ImportError(
"Gstreamer Python bindings not found."
' Install "gstreamer1.0" and "python-gi" or similar package to use BPD.'
) from e
PROTOCOL_VERSION = "0.16.0"
BUFSIZE = 1024
HELLO = "OK MPD %s" % PROTOCOL_VERSION
HELLO = f"OK MPD {PROTOCOL_VERSION}"
CLIST_BEGIN = "command_list_begin"
CLIST_VERBOSE_BEGIN = "command_list_ok_begin"
CLIST_END = "command_list_end"
@ -94,11 +106,6 @@ SUBSYSTEMS = [
]
# Gstreamer import error.
class NoGstreamerError(Exception):
pass
# Error-handling, exceptions, parameter parsing.
@ -276,7 +283,7 @@ class BaseServer:
if not self.ctrl_sock:
self.ctrl_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.ctrl_sock.connect((self.ctrl_host, self.ctrl_port))
self.ctrl_sock.sendall((message + "\n").encode("utf-8"))
self.ctrl_sock.sendall((f"{message}\n").encode())
def _send_event(self, event):
"""Notify subscribed connections of an event."""
@ -370,13 +377,13 @@ class BaseServer:
if self.password and not conn.authenticated:
# Not authenticated. Show limited list of commands.
for cmd in SAFE_COMMANDS:
yield "command: " + cmd
yield f"command: {cmd}"
else:
# Authenticated. Show all commands.
for func in dir(self):
if func.startswith("cmd_"):
yield "command: " + func[4:]
yield f"command: {func[4:]}"
def cmd_notcommands(self, conn):
"""Lists all unavailable commands."""
@ -386,7 +393,7 @@ class BaseServer:
if func.startswith("cmd_"):
cmd = func[4:]
if cmd not in SAFE_COMMANDS:
yield "command: " + cmd
yield f"command: {cmd}"
else:
# Authenticated. No commands are unavailable.
@ -400,22 +407,22 @@ class BaseServer:
playlist, playlistlength, and xfade.
"""
yield (
"repeat: " + str(int(self.repeat)),
"random: " + str(int(self.random)),
"consume: " + str(int(self.consume)),
"single: " + str(int(self.single)),
"playlist: " + str(self.playlist_version),
"playlistlength: " + str(len(self.playlist)),
"mixrampdb: " + str(self.mixrampdb),
f"repeat: {int(self.repeat)}",
f"random: {int(self.random)}",
f"consume: {int(self.consume)}",
f"single: {int(self.single)}",
f"playlist: {self.playlist_version}",
f"playlistlength: {len(self.playlist)}",
f"mixrampdb: {self.mixrampdb}",
)
if self.volume > 0:
yield "volume: " + str(self.volume)
yield f"volume: {self.volume}"
if not math.isnan(self.mixrampdelay):
yield "mixrampdelay: " + str(self.mixrampdelay)
yield f"mixrampdelay: {self.mixrampdelay}"
if self.crossfade > 0:
yield "xfade: " + str(self.crossfade)
yield f"xfade: {self.crossfade}"
if self.current_index == -1:
state = "stop"
@ -423,20 +430,20 @@ class BaseServer:
state = "pause"
else:
state = "play"
yield "state: " + state
yield f"state: {state}"
if self.current_index != -1: # i.e., paused or playing
current_id = self._item_id(self.playlist[self.current_index])
yield "song: " + str(self.current_index)
yield "songid: " + str(current_id)
yield f"song: {self.current_index}"
yield f"songid: {current_id}"
if len(self.playlist) > self.current_index + 1:
# If there's a next song, report its index too.
next_id = self._item_id(self.playlist[self.current_index + 1])
yield "nextsong: " + str(self.current_index + 1)
yield "nextsongid: " + str(next_id)
yield f"nextsong: {self.current_index + 1}"
yield f"nextsongid: {next_id}"
if self.error:
yield "error: " + self.error
yield f"error: {self.error}"
def cmd_clearerror(self, conn):
"""Removes the persistent error state of the server. This
@ -516,7 +523,7 @@ class BaseServer:
def cmd_replay_gain_status(self, conn):
"""Get the replaygain mode."""
yield "replay_gain_mode: " + str(self.replay_gain_mode)
yield f"replay_gain_mode: {self.replay_gain_mode}"
def cmd_clear(self, conn):
"""Clear the playlist."""
@ -637,8 +644,8 @@ class BaseServer:
Also a dummy implementation.
"""
for idx, track in enumerate(self.playlist):
yield "cpos: " + str(idx)
yield "Id: " + str(track.id)
yield f"cpos: {idx}"
yield f"Id: {track.id}"
def cmd_currentsong(self, conn):
"""Sends information about the currently-playing song."""
@ -753,11 +760,11 @@ class Connection:
"""Create a new connection for the accepted socket `client`."""
self.server = server
self.sock = sock
self.address = "{}:{}".format(*sock.sock.getpeername())
self.address = ":".join(map(str, sock.sock.getpeername()))
def debug(self, message, kind=" "):
"""Log a debug message about this connection."""
self.server._log.debug("{}[{}]: {}", kind, self.address, message)
self.server._log.debug("{}[{.address}]: {}", kind, self, message)
def run(self):
pass
@ -893,9 +900,7 @@ class MPDConnection(Connection):
return
except BPDIdleError as e:
self.idle_subscriptions = e.subsystems
self.debug(
"awaiting: {}".format(" ".join(e.subsystems)), kind="z"
)
self.debug(f"awaiting: {' '.join(e.subsystems)}", kind="z")
yield bluelet.call(self.server.dispatch_events())
@ -907,7 +912,7 @@ class ControlConnection(Connection):
super().__init__(server, sock)
def debug(self, message, kind=" "):
self.server._log.debug("CTRL {}[{}]: {}", kind, self.address, message)
self.server._log.debug("CTRL {}[{.address}]: {}", kind, self, message)
def run(self):
"""Listen for control commands and delegate to `ctrl_*` methods."""
@ -927,7 +932,7 @@ class ControlConnection(Connection):
func = command.delegate("ctrl_", self)
yield bluelet.call(func(*command.args))
except (AttributeError, TypeError) as e:
yield self.send("ERROR: {}".format(e.args[0]))
yield self.send(f"ERROR: {e.args[0]}")
except Exception:
yield self.send(
["ERROR: server error", traceback.format_exc().rstrip()]
@ -986,7 +991,7 @@ class Command:
of arguments.
"""
# Attempt to get correct command function.
func_name = prefix + self.name
func_name = f"{prefix}{self.name}"
if not hasattr(target, func_name):
raise AttributeError(f'unknown command "{self.name}"')
func = getattr(target, func_name)
@ -1005,7 +1010,7 @@ class Command:
# If the command accepts a variable number of arguments skip the check.
if wrong_num and not argspec.varargs:
raise TypeError(
'wrong number of arguments for "{}"'.format(self.name),
f'wrong number of arguments for "{self.name}"',
self.name,
)
@ -1099,23 +1104,13 @@ class Server(BaseServer):
"""
def __init__(self, library, host, port, password, ctrl_port, log):
try:
from beetsplug.bpd import gstplayer
except ImportError as e:
# This is a little hacky, but it's the best I know for now.
if e.args[0].endswith(" gst"):
raise NoGstreamerError()
else:
raise
log.info("Starting server...")
super().__init__(host, port, password, ctrl_port, log)
self.lib = library
self.player = gstplayer.GstPlayer(self.play_finished)
self.cmd_update(None)
log.info("Server ready and listening on {}:{}".format(host, port))
log.debug(
"Listening for control signals on {}:{}".format(host, ctrl_port)
)
log.info("Server ready and listening on {}:{}", host, port)
log.debug("Listening for control signals on {}:{}", host, ctrl_port)
def run(self):
self.player.run()
@ -1130,23 +1125,21 @@ class Server(BaseServer):
def _item_info(self, item):
info_lines = [
"file: " + item.destination(fragment=True),
"Time: " + str(int(item.length)),
"duration: " + f"{item.length:.3f}",
"Id: " + str(item.id),
f"file: {as_string(item.destination(relative_to_libdir=True))}",
f"Time: {int(item.length)}",
"duration: {item.length:.3f}",
f"Id: {item.id}",
]
try:
pos = self._id_to_index(item.id)
info_lines.append("Pos: " + str(pos))
info_lines.append(f"Pos: {pos}")
except ArgumentNotFoundError:
# Don't include position if not in playlist.
pass
for tagtype, field in self.tagtype_map.items():
info_lines.append(
"{}: {}".format(tagtype, str(getattr(item, field)))
)
info_lines.append(f"{tagtype}: {getattr(item, field)}")
return info_lines
@ -1209,7 +1202,7 @@ class Server(BaseServer):
def _path_join(self, p1, p2):
"""Smashes together two BPD paths."""
out = p1 + "/" + p2
out = f"{p1}/{p2}"
return out.replace("//", "/").replace("//", "/")
def cmd_lsinfo(self, conn, path="/"):
@ -1227,7 +1220,7 @@ class Server(BaseServer):
if dirpath.startswith("/"):
# Strip leading slash (libmpc rejects this).
dirpath = dirpath[1:]
yield "directory: %s" % dirpath
yield f"directory: {dirpath}"
def _listall(self, basepath, node, info=False):
"""Helper function for recursive listing. If info, show
@ -1239,7 +1232,7 @@ class Server(BaseServer):
item = self.lib.get_item(node)
yield self._item_info(item)
else:
yield "file: " + basepath
yield f"file: {basepath}"
else:
# List a directory. Recurse into both directories and files.
for name, itemid in sorted(node.files.items()):
@ -1248,7 +1241,7 @@ class Server(BaseServer):
yield from self._listall(newpath, itemid, info)
for name, subdir in sorted(node.dirs.items()):
newpath = self._path_join(basepath, name)
yield "directory: " + newpath
yield f"directory: {newpath}"
yield from self._listall(newpath, subdir, info)
def cmd_listall(self, conn, path="/"):
@ -1282,7 +1275,7 @@ class Server(BaseServer):
for item in self._all_items(self._resolve_path(path)):
self.playlist.append(item)
if send_id:
yield "Id: " + str(item.id)
yield f"Id: {item.id}"
self.playlist_version += 1
self._send_event("playlist")
@ -1304,20 +1297,13 @@ class Server(BaseServer):
item = self.playlist[self.current_index]
yield (
"bitrate: " + str(item.bitrate / 1000),
"audio: {}:{}:{}".format(
str(item.samplerate),
str(item.bitdepth),
str(item.channels),
),
f"bitrate: {item.bitrate / 1000}",
f"audio: {item.samplerate}:{item.bitdepth}:{item.channels}",
)
(pos, total) = self.player.time()
yield (
"time: {}:{}".format(
str(int(pos)),
str(int(total)),
),
f"time: {int(pos)}:{int(total)}",
"elapsed: " + f"{pos:.3f}",
"duration: " + f"{total:.3f}",
)
@ -1337,13 +1323,13 @@ class Server(BaseServer):
artists, albums, songs, totaltime = tx.query(statement)[0]
yield (
"artists: " + str(artists),
"albums: " + str(albums),
"songs: " + str(songs),
"uptime: " + str(int(time.time() - self.startup_time)),
"playtime: " + "0", # Missing.
"db_playtime: " + str(int(totaltime)),
"db_update: " + str(int(self.updated_time)),
f"artists: {artists}",
f"albums: {albums}",
f"songs: {songs}",
f"uptime: {int(time.time() - self.startup_time)}",
"playtime: 0", # Missing.
f"db_playtime: {int(totaltime)}",
f"db_update: {int(self.updated_time)}",
)
def cmd_decoders(self, conn):
@ -1385,7 +1371,7 @@ class Server(BaseServer):
searching.
"""
for tag in self.tagtype_map:
yield "tagtype: " + tag
yield f"tagtype: {tag}"
def _tagtype_lookup(self, tag):
"""Uses `tagtype_map` to look up the beets column name for an
@ -1460,12 +1446,9 @@ class Server(BaseServer):
clause, subvals = query.clause()
statement = (
"SELECT DISTINCT "
+ show_key
+ " FROM items WHERE "
+ clause
+ " ORDER BY "
+ show_key
f"SELECT DISTINCT {show_key}"
f" FROM items WHERE {clause}"
f" ORDER BY {show_key}"
)
self._log.debug(statement)
with self.lib.transaction() as tx:
@ -1475,7 +1458,7 @@ class Server(BaseServer):
if not row[0]:
# Skip any empty values of the field.
continue
yield show_tag_canon + ": " + str(row[0])
yield f"{show_tag_canon}: {row[0]}"
def cmd_count(self, conn, tag, value):
"""Returns the number and total time of songs matching the
@ -1489,8 +1472,8 @@ class Server(BaseServer):
):
songs += 1
playtime += item.length
yield "songs: " + str(songs)
yield "playtime: " + str(int(playtime))
yield f"songs: {songs}"
yield f"playtime: {int(playtime)}"
# Persistent playlist manipulation. In MPD this is an optional feature so
# these dummy implementations match MPD's behaviour with the feature off.
@ -1616,16 +1599,9 @@ class BPDPlugin(BeetsPlugin):
def start_bpd(self, lib, host, port, password, volume, ctrl_port):
"""Starts a BPD server."""
try:
server = Server(lib, host, port, password, ctrl_port, self._log)
server.cmd_setvol(None, volume)
server.run()
except NoGstreamerError:
self._log.error("Gstreamer Python bindings not found.")
self._log.error(
'Install "gstreamer1.0" and "python-gi"'
"or similar package to use BPD."
)
server = Server(lib, host, port, password, ctrl_port, self._log)
server.cmd_setvol(None, volume)
server.run()
def commands(self):
cmd = beets.ui.Subcommand(

View file

@ -27,7 +27,16 @@ import gi
from beets import ui
gi.require_version("Gst", "1.0")
try:
gi.require_version("Gst", "1.0")
except ValueError as e:
# on some scenarios, gi may be importable, but we get a ValueError when
# trying to specify the required version. This is problematic in the test
# suite where test_bpd.py has a call to
# pytest.importorskip("beetsplug.bpd"). Re-raising as an ImportError
# makes it so the test collector functions as inteded.
raise ImportError from e
from gi.repository import GLib, Gst # noqa: E402
Gst.init(None)
@ -129,7 +138,7 @@ class GstPlayer:
self.player.set_state(Gst.State.NULL)
if isinstance(path, str):
path = path.encode("utf-8")
uri = "file://" + urllib.parse.quote(path)
uri = f"file://{urllib.parse.quote(path)}"
self.player.set_property("uri", uri)
self.player.set_state(Gst.State.PLAYING)
self.playing = True

View file

@ -57,15 +57,14 @@ class BPMPlugin(BeetsPlugin):
def commands(self):
cmd = ui.Subcommand(
"bpm",
help="determine bpm of a song by pressing " "a key to the rhythm",
help="determine bpm of a song by pressing a key to the rhythm",
)
cmd.func = self.command
return [cmd]
def command(self, lib, opts, args):
items = lib.items(ui.decargs(args))
write = ui.should_write()
self.get_bpm(items, write)
self.get_bpm(lib.items(args), write)
def get_bpm(self, items, write=False):
overwrite = self.config["overwrite"].get(bool)
@ -74,12 +73,12 @@ class BPMPlugin(BeetsPlugin):
item = items[0]
if item["bpm"]:
self._log.info("Found bpm {0}", item["bpm"])
self._log.info("Found bpm {}", item["bpm"])
if not overwrite:
return
self._log.info(
"Press Enter {0} times to the rhythm or Ctrl-D " "to exit",
"Press Enter {} times to the rhythm or Ctrl-D to exit",
self.config["max_strokes"].get(int),
)
new_bpm = bpm(self.config["max_strokes"].get(int))
@ -87,4 +86,4 @@ class BPMPlugin(BeetsPlugin):
if write:
item.try_write()
item.store()
self._log.info("Added new bpm {0}", item["bpm"])
self._log.info("Added new bpm {}", item["bpm"])

View file

@ -65,10 +65,9 @@ class BPSyncPlugin(BeetsPlugin):
move = ui.should_move(opts.move)
pretend = opts.pretend
write = ui.should_write(opts.write)
query = ui.decargs(args)
self.singletons(lib, query, move, pretend, write)
self.albums(lib, query, move, pretend, write)
self.singletons(lib, args, move, pretend, write)
self.albums(lib, args, move, pretend, write)
def singletons(self, lib, query, move, pretend, write):
"""Retrieve and apply info from the autotagger for items matched by
@ -83,8 +82,8 @@ class BPSyncPlugin(BeetsPlugin):
if not self.is_beatport_track(item):
self._log.info(
"Skipping non-{} singleton: {}",
self.beatport_plugin.data_source,
"Skipping non-{.beatport_plugin.data_source} singleton: {}",
self,
item,
)
continue
@ -108,8 +107,8 @@ class BPSyncPlugin(BeetsPlugin):
return False
if not album.mb_albumid.isnumeric():
self._log.info(
"Skipping album with invalid {} ID: {}",
self.beatport_plugin.data_source,
"Skipping album with invalid {.beatport_plugin.data_source} ID: {}",
self,
album,
)
return False
@ -118,8 +117,8 @@ class BPSyncPlugin(BeetsPlugin):
return items
if not all(self.is_beatport_track(item) for item in items):
self._log.info(
"Skipping non-{} release: {}",
self.beatport_plugin.data_source,
"Skipping non-{.beatport_plugin.data_source} release: {}",
self,
album,
)
return False
@ -140,9 +139,7 @@ class BPSyncPlugin(BeetsPlugin):
albuminfo = self.beatport_plugin.album_for_id(album.mb_albumid)
if not albuminfo:
self._log.info(
"Release ID {} not found for album {}",
album.mb_albumid,
album,
"Release ID {0.mb_albumid} not found for album {0}", album
)
continue
@ -152,14 +149,14 @@ class BPSyncPlugin(BeetsPlugin):
library_trackid_to_item = {
int(item.mb_trackid): item for item in items
}
item_to_trackinfo = {
item: beatport_trackid_to_trackinfo[track_id]
item_info_pairs = [
(item, beatport_trackid_to_trackinfo[track_id])
for track_id, item in library_trackid_to_item.items()
}
]
self._log.info("applying changes to {}", album)
with lib.transaction():
autotag.apply_metadata(albuminfo, item_to_trackinfo)
autotag.apply_metadata(albuminfo, item_info_pairs)
changed = False
# Find any changed item to apply Beatport changes to album.
any_changed_item = items[0]

View file

@ -41,7 +41,7 @@ def span_from_str(span_str):
def normalize_year(d, yearfrom):
"""Convert string to a 4 digits year"""
if yearfrom < 100:
raise BucketError("%d must be expressed on 4 digits" % yearfrom)
raise BucketError(f"{yearfrom} must be expressed on 4 digits")
# if two digits only, pick closest year that ends by these two
# digits starting from yearfrom
@ -55,14 +55,13 @@ def span_from_str(span_str):
years = [int(x) for x in re.findall(r"\d+", span_str)]
if not years:
raise ui.UserError(
"invalid range defined for year bucket '%s': no "
"year found" % span_str
f"invalid range defined for year bucket {span_str!r}: no year found"
)
try:
years = [normalize_year(x, years[0]) for x in years]
except BucketError as exc:
raise ui.UserError(
"invalid range defined for year bucket '%s': %s" % (span_str, exc)
f"invalid range defined for year bucket {span_str!r}: {exc}"
)
res = {"from": years[0], "str": span_str}
@ -125,22 +124,19 @@ def str2fmt(s):
"fromnchars": len(m.group("fromyear")),
"tonchars": len(m.group("toyear")),
}
res["fmt"] = "{}%s{}{}{}".format(
m.group("bef"),
m.group("sep"),
"%s" if res["tonchars"] else "",
m.group("after"),
res["fmt"] = (
f"{m['bef']}{{}}{m['sep']}{'{}' if res['tonchars'] else ''}{m['after']}"
)
return res
def format_span(fmt, yearfrom, yearto, fromnchars, tonchars):
"""Return a span string representation."""
args = str(yearfrom)[-fromnchars:]
args = [str(yearfrom)[-fromnchars:]]
if tonchars:
args = (str(yearfrom)[-fromnchars:], str(yearto)[-tonchars:])
args.append(str(yearto)[-tonchars:])
return fmt % args
return fmt.format(*args)
def extract_modes(spans):
@ -169,14 +165,12 @@ def build_alpha_spans(alpha_spans_str, alpha_regexs):
else:
raise ui.UserError(
"invalid range defined for alpha bucket "
"'%s': no alphanumeric character found" % elem
f"'{elem}': no alphanumeric character found"
)
spans.append(
re.compile(
"^["
+ ASCII_DIGITS[begin_index : end_index + 1]
+ ASCII_DIGITS[begin_index : end_index + 1].upper()
+ "]"
rf"^[{ASCII_DIGITS[begin_index : end_index + 1]}]",
re.IGNORECASE,
)
)
return spans

View file

@ -18,13 +18,17 @@ autotagger. Requires the pyacoustid library.
import re
from collections import defaultdict
from functools import partial
from collections.abc import Iterable
from functools import cached_property, partial
import acoustid
import confuse
from beets import config, plugins, ui, util
from beets.autotag import hooks
from beets import config, ui, util
from beets.autotag.distance import Distance
from beets.autotag.hooks import TrackInfo
from beets.metadata_plugins import MetadataSourcePlugin
from beetsplug.musicbrainz import MusicBrainzPlugin
API_KEY = "1vOwZtEn"
SCORE_THRESH = 0.5
@ -86,7 +90,7 @@ def acoustid_match(log, path):
duration, fp = acoustid.fingerprint_file(util.syspath(path))
except acoustid.FingerprintGenerationError as exc:
log.error(
"fingerprinting of {0} failed: {1}",
"fingerprinting of {} failed: {}",
util.displayable_path(repr(path)),
exc,
)
@ -94,15 +98,17 @@ def acoustid_match(log, path):
fp = fp.decode()
_fingerprints[path] = fp
try:
res = acoustid.lookup(API_KEY, fp, duration, meta="recordings releases")
res = acoustid.lookup(
API_KEY, fp, duration, meta="recordings releases", timeout=10
)
except acoustid.AcoustidError as exc:
log.debug(
"fingerprint matching {0} failed: {1}",
"fingerprint matching {} failed: {}",
util.displayable_path(repr(path)),
exc,
)
return None
log.debug("chroma: fingerprinted {0}", util.displayable_path(repr(path)))
log.debug("chroma: fingerprinted {}", util.displayable_path(repr(path)))
# Ensure the response is usable and parse it.
if res["status"] != "ok" or not res.get("results"):
@ -140,7 +146,7 @@ def acoustid_match(log, path):
release_ids = [rel["id"] for rel in releases]
log.debug(
"matched recordings {0} on releases {1}", recording_ids, release_ids
"matched recordings {} on releases {}", recording_ids, release_ids
)
_matches[path] = recording_ids, release_ids
@ -167,10 +173,9 @@ def _all_releases(items):
yield release_id
class AcoustidPlugin(plugins.BeetsPlugin):
class AcoustidPlugin(MetadataSourcePlugin):
def __init__(self):
super().__init__()
self.config.add(
{
"auto": True,
@ -182,11 +187,15 @@ class AcoustidPlugin(plugins.BeetsPlugin):
self.register_listener("import_task_start", self.fingerprint_task)
self.register_listener("import_task_apply", apply_acoustid_metadata)
@cached_property
def mb(self) -> MusicBrainzPlugin:
return MusicBrainzPlugin()
def fingerprint_task(self, task, session):
return fingerprint_task(self._log, task, session)
def track_distance(self, item, info):
dist = hooks.Distance()
dist = Distance()
if item.path not in _matches or not info.track_id:
# Match failed or no track ID.
return dist
@ -195,29 +204,37 @@ 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, extra_tags=None):
def candidates(self, items, artist, album, va_likely):
albums = []
for relid in prefix(_all_releases(items), MAX_RELEASES):
album = hooks.album_for_mbid(relid)
album = self.mb.album_for_id(relid)
if album:
albums.append(album)
self._log.debug("acoustid album candidates: {0}", len(albums))
self._log.debug("acoustid album candidates: {}", len(albums))
return albums
def item_candidates(self, item, artist, title):
def item_candidates(self, item, artist, title) -> Iterable[TrackInfo]:
if item.path not in _matches:
return []
recording_ids, _ = _matches[item.path]
tracks = []
for recording_id in prefix(recording_ids, MAX_RECORDINGS):
track = hooks.track_for_mbid(recording_id)
track = self.mb.track_for_id(recording_id)
if track:
tracks.append(track)
self._log.debug("acoustid item candidates: {0}", len(tracks))
self._log.debug("acoustid item candidates: {}", len(tracks))
return tracks
def album_for_id(self, *args, **kwargs):
# Lookup by fingerprint ID does not make too much sense.
return None
def track_for_id(self, *args, **kwargs):
# Lookup by fingerprint ID does not make too much sense.
return None
def commands(self):
submit_cmd = ui.Subcommand(
"submit", help="submit Acoustid fingerprints"
@ -228,7 +245,7 @@ class AcoustidPlugin(plugins.BeetsPlugin):
apikey = config["acoustid"]["apikey"].as_str()
except confuse.NotFoundError:
raise ui.UserError("no Acoustid user API key provided")
submit_items(self._log, apikey, lib.items(ui.decargs(args)))
submit_items(self._log, apikey, lib.items(args))
submit_cmd.func = submit_cmd_func
@ -237,7 +254,7 @@ class AcoustidPlugin(plugins.BeetsPlugin):
)
def fingerprint_cmd_func(lib, opts, args):
for item in lib.items(ui.decargs(args)):
for item in lib.items(args):
fingerprint_item(self._log, item, write=ui.should_write())
fingerprint_cmd.func = fingerprint_cmd_func
@ -275,11 +292,11 @@ def submit_items(log, userkey, items, chunksize=64):
def submit_chunk():
"""Submit the current accumulated fingerprint data."""
log.info("submitting {0} fingerprints", len(data))
log.info("submitting {} fingerprints", len(data))
try:
acoustid.submit(API_KEY, userkey, data)
acoustid.submit(API_KEY, userkey, data, timeout=10)
except acoustid.AcoustidError as exc:
log.warning("acoustid submission error: {0}", exc)
log.warning("acoustid submission error: {}", exc)
del data[:]
for item in items:
@ -326,31 +343,23 @@ def fingerprint_item(log, item, write=False):
"""
# Get a fingerprint and length for this track.
if not item.length:
log.info("{0}: no duration available", util.displayable_path(item.path))
log.info("{.filepath}: no duration available", item)
elif item.acoustid_fingerprint:
if write:
log.info(
"{0}: fingerprint exists, skipping",
util.displayable_path(item.path),
)
log.info("{.filepath}: fingerprint exists, skipping", item)
else:
log.info(
"{0}: using existing fingerprint",
util.displayable_path(item.path),
)
log.info("{.filepath}: using existing fingerprint", item)
return item.acoustid_fingerprint
else:
log.info("{0}: fingerprinting", util.displayable_path(item.path))
log.info("{.filepath}: fingerprinting", item)
try:
_, fp = acoustid.fingerprint_file(util.syspath(item.path))
item.acoustid_fingerprint = fp.decode()
if write:
log.info(
"{0}: writing fingerprint", util.displayable_path(item.path)
)
log.info("{.filepath}: writing fingerprint", item)
item.try_write()
if item._db:
item.store()
return item.acoustid_fingerprint
except acoustid.FingerprintGenerationError as exc:
log.info("fingerprint generation failed: {0}", exc)
log.info("fingerprint generation failed: {}", exc)

View file

@ -25,12 +25,13 @@ from string import Template
import mediafile
from confuse import ConfigTypeError, Optional
from beets import art, config, plugins, ui, util
from beets import config, plugins, ui, util
from beets.library import Item, parse_query_string
from beets.plugins import BeetsPlugin
from beets.util import par_map
from beets.util.artresizer import ArtResizer
from beets.util.m3u import M3UFile
from beetsplug._utils import art
_fs_lock = threading.Lock()
_temp_files = [] # Keep track of temporary transcoded files for deletion.
@ -64,9 +65,7 @@ def get_format(fmt=None):
command = format_info["command"]
extension = format_info.get("extension", fmt)
except KeyError:
raise ui.UserError(
'convert: format {} needs the "command" field'.format(fmt)
)
raise ui.UserError(f'convert: format {fmt} needs the "command" field')
except ConfigTypeError:
command = config["convert"]["formats"][fmt].get(str)
extension = fmt
@ -77,8 +76,8 @@ def get_format(fmt=None):
command = config["convert"]["command"].as_str()
elif "opts" in keys:
# Undocumented option for backwards compatibility with < 1.3.1.
command = "ffmpeg -i $source -y {} $dest".format(
config["convert"]["opts"].as_str()
command = (
f"ffmpeg -i $source -y {config['convert']['opts'].as_str()} $dest"
)
if "extension" in keys:
extension = config["convert"]["extension"].as_str()
@ -96,12 +95,18 @@ def in_no_convert(item: Item) -> bool:
return False
def should_transcode(item, fmt):
def should_transcode(item, fmt, force: bool = False):
"""Determine whether the item should be transcoded as part of
conversion (i.e., its bitrate is high or it has the wrong format).
If ``force`` is True, safety checks like ``no_convert`` and
``never_convert_lossy_files`` are ignored and the item is always
transcoded.
"""
if force:
return True
if in_no_convert(item) or (
config["convert"]["never_convert_lossy_files"]
config["convert"]["never_convert_lossy_files"].get(bool)
and item.format.lower() not in LOSSLESS_FORMATS
):
return False
@ -123,20 +128,28 @@ class ConvertPlugin(BeetsPlugin):
"threads": os.cpu_count(),
"format": "mp3",
"id3v23": "inherit",
"write_metadata": True,
"formats": {
"aac": {
"command": "ffmpeg -i $source -y -vn -acodec aac "
"-aq 1 $dest",
"command": (
"ffmpeg -i $source -y -vn -acodec aac -aq 1 $dest"
),
"extension": "m4a",
},
"alac": {
"command": "ffmpeg -i $source -y -vn -acodec alac $dest",
"command": (
"ffmpeg -i $source -y -vn -acodec alac $dest"
),
"extension": "m4a",
},
"flac": "ffmpeg -i $source -y -vn -acodec flac $dest",
"mp3": "ffmpeg -i $source -y -vn -aq 2 $dest",
"opus": "ffmpeg -i $source -y -vn -acodec libopus -ab 96k $dest",
"ogg": "ffmpeg -i $source -y -vn -acodec libvorbis -aq 3 $dest",
"opus": (
"ffmpeg -i $source -y -vn -acodec libopus -ab 96k $dest"
),
"ogg": (
"ffmpeg -i $source -y -vn -acodec libvorbis -aq 3 $dest"
),
"wma": "ffmpeg -i $source -y -vn -acodec wmav2 -vn $dest",
},
"max_bitrate": None,
@ -171,16 +184,17 @@ class ConvertPlugin(BeetsPlugin):
"--threads",
action="store",
type="int",
help="change the number of threads, \
defaults to maximum available processors",
help=(
"change the number of threads, defaults to maximum available"
" processors"
),
)
cmd.parser.add_option(
"-k",
"--keep-new",
action="store_true",
dest="keep_new",
help="keep only the converted \
and move the old files",
help="keep only the converted and move the old files",
)
cmd.parser.add_option(
"-d", "--dest", action="store", help="set the destination directory"
@ -204,16 +218,16 @@ class ConvertPlugin(BeetsPlugin):
"--link",
action="store_true",
dest="link",
help="symlink files that do not \
need transcoding.",
help="symlink files that do not need transcoding.",
)
cmd.parser.add_option(
"-H",
"--hardlink",
action="store_true",
dest="hardlink",
help="hardlink files that do not \
need transcoding. Overrides --link.",
help=(
"hardlink files that do not need transcoding. Overrides --link."
),
)
cmd.parser.add_option(
"-m",
@ -228,6 +242,16 @@ class ConvertPlugin(BeetsPlugin):
drive, relative paths pointing to media files
will be used.""",
)
cmd.parser.add_option(
"-F",
"--force",
action="store_true",
dest="force",
help=(
"force transcoding. Ignores no_convert, "
"never_convert_lossy_files, and max_bitrate"
),
)
cmd.parser.add_album_option()
cmd.func = self.convert_func
return [cmd]
@ -251,6 +275,7 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
link,
playlist,
force,
) = self._get_opts_and_config(empty_opts)
items = task.imported_items()
@ -264,6 +289,7 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
threads,
items,
force,
)
# Utilities converted from functions to methods on logging overhaul
@ -282,7 +308,7 @@ class ConvertPlugin(BeetsPlugin):
quiet = self.config["quiet"].get(bool)
if not quiet and not pretend:
self._log.info("Encoding {0}", util.displayable_path(source))
self._log.info("Encoding {}", util.displayable_path(source))
command = os.fsdecode(command)
source = os.fsdecode(source)
@ -301,7 +327,7 @@ class ConvertPlugin(BeetsPlugin):
encode_cmd.append(os.fsdecode(args[i]))
if pretend:
self._log.info("{0}", " ".join(ui.decargs(args)))
self._log.info("{}", " ".join(args))
return
try:
@ -309,28 +335,25 @@ class ConvertPlugin(BeetsPlugin):
except subprocess.CalledProcessError as exc:
# Something went wrong (probably Ctrl+C), remove temporary files
self._log.info(
"Encoding {0} failed. Cleaning up...",
"Encoding {} failed. Cleaning up...",
util.displayable_path(source),
)
self._log.debug(
"Command {0} exited with status {1}: {2}",
"Command {0} exited with status {1.returncode}: {1.output}",
args,
exc.returncode,
exc.output,
exc,
)
util.remove(dest)
util.prune_dirs(os.path.dirname(dest))
raise
except OSError as exc:
raise ui.UserError(
"convert: couldn't invoke '{}': {}".format(
" ".join(ui.decargs(args)), exc
)
f"convert: couldn't invoke {' '.join(args)!r}: {exc}"
)
if not quiet and not pretend:
self._log.info(
"Finished encoding {0}", util.displayable_path(source)
"Finished encoding {}", util.displayable_path(source)
)
def convert_item(
@ -342,6 +365,7 @@ class ConvertPlugin(BeetsPlugin):
pretend=False,
link=False,
hardlink=False,
force=False,
):
"""A pipeline thread that converts `Item` objects from a
library.
@ -358,7 +382,7 @@ class ConvertPlugin(BeetsPlugin):
try:
mediafile.MediaFile(util.syspath(item.path))
except mediafile.UnreadableFileError as exc:
self._log.error("Could not open file to convert: {0}", exc)
self._log.error("Could not open file to convert: {}", exc)
continue
# When keeping the new file in the library, we first move the
@ -367,11 +391,11 @@ class ConvertPlugin(BeetsPlugin):
if keep_new:
original = dest
converted = item.path
if should_transcode(item, fmt):
if should_transcode(item, fmt, force):
converted = replace_ext(converted, ext)
else:
original = item.path
if should_transcode(item, fmt):
if should_transcode(item, fmt, force):
dest = replace_ext(dest, ext)
converted = dest
@ -384,25 +408,24 @@ class ConvertPlugin(BeetsPlugin):
if os.path.exists(util.syspath(dest)):
self._log.info(
"Skipping {0} (target file exists)",
util.displayable_path(item.path),
"Skipping {.filepath} (target file exists)", item
)
continue
if keep_new:
if pretend:
self._log.info(
"mv {0} {1}",
util.displayable_path(item.path),
"mv {.filepath} {}",
item,
util.displayable_path(original),
)
else:
self._log.info(
"Moving to {0}", util.displayable_path(original)
"Moving to {}", util.displayable_path(original)
)
util.move(item.path, original)
if should_transcode(item, fmt):
if should_transcode(item, fmt, force):
linked = False
try:
self.encode(command, original, converted, pretend)
@ -414,10 +437,10 @@ class ConvertPlugin(BeetsPlugin):
msg = "ln" if hardlink else ("ln -s" if link else "cp")
self._log.info(
"{2} {0} {1}",
"{} {} {}",
msg,
util.displayable_path(original),
util.displayable_path(converted),
msg,
)
else:
# No transcoding necessary.
@ -427,9 +450,7 @@ class ConvertPlugin(BeetsPlugin):
else ("Linking" if link else "Copying")
)
self._log.info(
"{1} {0}", util.displayable_path(item.path), msg
)
self._log.info("{} {.filepath}", msg, item)
if hardlink:
util.hardlink(original, converted)
@ -445,8 +466,9 @@ class ConvertPlugin(BeetsPlugin):
if id3v23 == "inherit":
id3v23 = None
# Write tags from the database to the converted file.
item.try_write(path=converted, id3v23=id3v23)
# Write tags from the database to the file if requested
if self.config["write_metadata"].get(bool):
item.try_write(path=converted, id3v23=id3v23)
if keep_new:
# If we're keeping the transcoded file, read it again (after
@ -460,8 +482,7 @@ class ConvertPlugin(BeetsPlugin):
if album and album.artpath:
maxwidth = self._get_art_resize(album.artpath)
self._log.debug(
"embedding album art from {}",
util.displayable_path(album.artpath),
"embedding album art from {.art_filepath}", album
)
art.embed_item(
self._log,
@ -519,8 +540,7 @@ class ConvertPlugin(BeetsPlugin):
if os.path.exists(util.syspath(dest)):
self._log.info(
"Skipping {0} (target file exists)",
util.displayable_path(album.artpath),
"Skipping {.art_filepath} (target file exists)", album
)
return
@ -530,8 +550,8 @@ class ConvertPlugin(BeetsPlugin):
# Either copy or resize (while copying) the image.
if maxwidth is not None:
self._log.info(
"Resizing cover art from {0} to {1}",
util.displayable_path(album.artpath),
"Resizing cover art from {.art_filepath} to {}",
album,
util.displayable_path(dest),
)
if not pretend:
@ -541,10 +561,10 @@ class ConvertPlugin(BeetsPlugin):
msg = "ln" if hardlink else ("ln -s" if link else "cp")
self._log.info(
"{2} {0} {1}",
util.displayable_path(album.artpath),
util.displayable_path(dest),
"{} {.art_filepath} {}",
msg,
album,
util.displayable_path(dest),
)
else:
msg = (
@ -554,10 +574,10 @@ class ConvertPlugin(BeetsPlugin):
)
self._log.info(
"{2} cover art from {0} to {1}",
util.displayable_path(album.artpath),
util.displayable_path(dest),
"{} cover art from {.art_filepath} to {}",
msg,
album,
util.displayable_path(dest),
)
if hardlink:
util.hardlink(album.artpath, dest)
@ -576,16 +596,17 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
link,
playlist,
force,
) = self._get_opts_and_config(opts)
if opts.album:
albums = lib.albums(ui.decargs(args))
albums = lib.albums(args)
items = [i for a in albums for i in a.items()]
if not pretend:
for a in albums:
ui.print_(format(a, ""))
else:
items = list(lib.items(ui.decargs(args)))
items = list(lib.items(args))
if not pretend:
for i in items:
ui.print_(format(i, ""))
@ -612,25 +633,20 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
threads,
items,
force,
)
if playlist:
# Playlist paths are understood as relative to the dest directory.
pl_normpath = util.normpath(playlist)
pl_dir = os.path.dirname(pl_normpath)
self._log.info("Creating playlist file {0}", pl_normpath)
self._log.info("Creating playlist file {}", pl_normpath)
# Generates a list of paths to media files, ensures the paths are
# relative to the playlist's location and translates the unicode
# strings we get from item.destination to bytes.
items_paths = [
os.path.relpath(
util.bytestring_path(
item.destination(
basedir=dest,
path_formats=path_formats,
fragment=False,
)
),
item.destination(basedir=dest, path_formats=path_formats),
pl_dir,
)
for item in items
@ -652,7 +668,7 @@ class ConvertPlugin(BeetsPlugin):
tmpdir = self.config["tmpdir"].get()
if tmpdir:
tmpdir = os.fsdecode(util.bytestring_path(tmpdir))
fd, dest = tempfile.mkstemp(os.fsdecode(b"." + ext), dir=tmpdir)
fd, dest = tempfile.mkstemp(f".{os.fsdecode(ext)}", dir=tmpdir)
os.close(fd)
dest = util.bytestring_path(dest)
_temp_files.append(dest) # Delete the transcode later.
@ -674,7 +690,7 @@ class ConvertPlugin(BeetsPlugin):
if self.config["delete_originals"]:
self._log.log(
logging.DEBUG if self.config["quiet"] else logging.INFO,
"Removing original file {0}",
"Removing original file {}",
source_path,
)
util.remove(source_path, False)
@ -740,7 +756,7 @@ class ConvertPlugin(BeetsPlugin):
else:
hardlink = self.config["hardlink"].get(bool)
link = self.config["link"].get(bool)
force = getattr(opts, "force", False)
return (
dest,
threads,
@ -750,6 +766,7 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
link,
playlist,
force,
)
def _parallel_convert(
@ -763,13 +780,21 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
threads,
items,
force,
):
"""Run the convert_item function for every items on as many thread as
defined in threads
"""
convert = [
self.convert_item(
dest, keep_new, path_formats, fmt, pretend, link, hardlink
dest,
keep_new,
path_formats,
fmt,
pretend,
link,
hardlink,
force,
)
for _ in range(threads)
]

View file

@ -14,38 +14,44 @@
"""Adds Deezer release and track search support to the autotagger"""
from __future__ import annotations
import collections
import time
from typing import TYPE_CHECKING, Literal
import requests
import unidecode
from beets import ui
from beets.autotag import AlbumInfo, TrackInfo
from beets.dbcore import types
from beets.library import DateType
from beets.plugins import BeetsPlugin, MetadataSourcePlugin
from beets.util.id_extractors import deezer_id_regex
from beets.metadata_plugins import (
IDResponse,
SearchApiMetadataSourcePlugin,
SearchFilter,
)
if TYPE_CHECKING:
from collections.abc import Sequence
from beets.library import Item, Library
from ._typing import JSONDict
class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
data_source = "Deezer"
class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]):
item_types = {
"deezer_track_rank": types.INTEGER,
"deezer_track_id": types.INTEGER,
"deezer_updated": DateType(),
"deezer_updated": types.DATE,
}
# Base URLs for the Deezer API
# Documentation: https://developers.deezer.com/api/
search_url = "https://api.deezer.com/search/"
album_url = "https://api.deezer.com/album/"
track_url = "https://api.deezer.com/track/"
id_regex = deezer_id_regex
def __init__(self):
def __init__(self) -> None:
super().__init__()
def commands(self):
@ -54,42 +60,23 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
"deezerupdate", help=f"Update {self.data_source} rank"
)
def func(lib, opts, args):
items = lib.items(ui.decargs(args))
self.deezerupdate(items, ui.should_write())
def func(lib: Library, opts, args):
items = lib.items(args)
self.deezerupdate(list(items), ui.should_write())
deezer_update_cmd.func = func
return [deezer_update_cmd]
def fetch_data(self, url):
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
data = response.json()
except requests.exceptions.RequestException as e:
self._log.error("Error fetching data from {}\n Error: {}", url, e)
def album_for_id(self, album_id: str) -> AlbumInfo | None:
"""Fetch an album by its Deezer ID or URL."""
if not (deezer_id := self._extract_id(album_id)):
return None
if "error" in data:
self._log.debug("Deezer API error: {}", data["error"]["message"])
return None
return data
def album_for_id(self, album_id):
"""Fetch an album by its Deezer ID or URL and return an
AlbumInfo object or None if the album is not found.
album_url = f"{self.album_url}{deezer_id}"
if not (album_data := self.fetch_data(album_url)):
return None
:param album_id: Deezer ID or URL for the album.
:type album_id: str
:return: AlbumInfo object for album.
:rtype: beets.autotag.hooks.AlbumInfo or None
"""
deezer_id = self._get_id("album", album_id, self.id_regex)
if deezer_id is None:
return None
album_data = self.fetch_data(self.album_url + deezer_id)
if album_data is None:
return None
contributors = album_data.get("contributors")
if contributors is not None:
artist, artist_id = self.get_artist(contributors)
@ -114,7 +101,7 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
f"Invalid `release_date` returned by {self.data_source} API: "
f"{release_date!r}"
)
tracks_obj = self.fetch_data(self.album_url + deezer_id + "/tracks")
tracks_obj = self.fetch_data(f"{self.album_url}{deezer_id}/tracks")
if tracks_obj is None:
return None
try:
@ -132,7 +119,7 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
tracks_data.extend(tracks_obj["data"])
tracks = []
medium_totals = collections.defaultdict(int)
medium_totals: dict[int | None, int] = collections.defaultdict(int)
for i, track_data in enumerate(tracks_data, start=1):
track = self._get_track(track_data)
track.index = i
@ -150,25 +137,68 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
artist_id=artist_id,
tracks=tracks,
albumtype=album_data["record_type"],
va=len(album_data["contributors"]) == 1
and artist.lower() == "various artists",
va=(
len(album_data["contributors"]) == 1
and (artist or "").lower() == "various artists"
),
year=year,
month=month,
day=day,
label=album_data["label"],
mediums=max(medium_totals.keys()),
mediums=max(filter(None, medium_totals.keys())),
data_source=self.data_source,
data_url=album_data["link"],
cover_art_url=album_data.get("cover_xl"),
)
def _get_track(self, track_data):
def track_for_id(self, track_id: str) -> None | TrackInfo:
"""Fetch a track by its Deezer ID or URL and return a
TrackInfo object or None if the track is not found.
:param track_id: (Optional) Deezer ID or URL for the track. Either
``track_id`` or ``track_data`` must be provided.
"""
if not (deezer_id := self._extract_id(track_id)):
self._log.debug("Invalid Deezer track_id: {}", track_id)
return None
if not (track_data := self.fetch_data(f"{self.track_url}{deezer_id}")):
self._log.debug("Track not found: {}", track_id)
return None
track = self._get_track(track_data)
# Get album's tracks to set `track.index` (position on the entire
# release) and `track.medium_total` (total number of tracks on
# the track's disc).
if not (
album_tracks_obj := self.fetch_data(
f"{self.album_url}{track_data['album']['id']}/tracks"
)
):
return None
try:
album_tracks_data = album_tracks_obj["data"]
except KeyError:
self._log.debug(
"Error fetching album tracks for {}", track_data["album"]["id"]
)
return None
medium_total = 0
for i, track_data in enumerate(album_tracks_data, start=1):
if track_data["disk_number"] == track.medium:
medium_total += 1
if track_data["id"] == track.track_id:
track.index = i
track.medium_total = medium_total
return track
def _get_track(self, track_data: JSONDict) -> TrackInfo:
"""Convert a Deezer track object dict to a TrackInfo object.
:param track_data: Deezer Track object dict
:type track_data: dict
:return: TrackInfo object for track
:rtype: beets.autotag.hooks.TrackInfo
"""
artist, artist_id = self.get_artist(
track_data.get("contributors", [track_data["artist"]])
@ -190,118 +220,60 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
deezer_updated=time.time(),
)
def track_for_id(self, track_id=None, track_data=None):
"""Fetch a track by its Deezer ID or URL and return a
TrackInfo object or None if the track is not found.
:param track_id: (Optional) Deezer ID or URL for the track. Either
``track_id`` or ``track_data`` must be provided.
:type track_id: str
:param track_data: (Optional) Simplified track object dict. May be
provided instead of ``track_id`` to avoid unnecessary API calls.
:type track_data: dict
:return: TrackInfo object for track
:rtype: beets.autotag.hooks.TrackInfo or None
"""
if track_data is None:
deezer_id = self._get_id("track", track_id, self.id_regex)
if deezer_id is None:
return None
track_data = self.fetch_data(self.track_url + deezer_id)
if track_data is None:
return None
track = self._get_track(track_data)
# Get album's tracks to set `track.index` (position on the entire
# release) and `track.medium_total` (total number of tracks on
# the track's disc).
album_tracks_obj = self.fetch_data(
self.album_url + str(track_data["album"]["id"]) + "/tracks"
)
if album_tracks_obj is None:
return None
try:
album_tracks_data = album_tracks_obj["data"]
except KeyError:
self._log.debug(
"Error fetching album tracks for {}", track_data["album"]["id"]
)
return None
medium_total = 0
for i, track_data in enumerate(album_tracks_data, start=1):
if track_data["disk_number"] == track.medium:
medium_total += 1
if track_data["id"] == track.track_id:
track.index = i
track.medium_total = medium_total
return track
@staticmethod
def _construct_search_query(filters=None, keywords=""):
"""Construct a query string with the specified filters and keywords to
be provided to the Deezer Search API
(https://developers.deezer.com/api/search).
:param filters: (Optional) Field filters to apply.
:type filters: dict
:param keywords: (Optional) Query keywords to use.
:type keywords: str
:return: Query string to be provided to the Search API.
:rtype: str
"""
query_components = [
keywords,
" ".join(f'{k}:"{v}"' for k, v in filters.items()),
]
query = " ".join([q for q in query_components if q])
if not isinstance(query, str):
query = query.decode("utf8")
return unidecode.unidecode(query)
def _search_api(self, query_type, filters=None, keywords=""):
"""Query the Deezer Search API for the specified ``keywords``, applying
def _search_api(
self,
query_type: Literal[
"album",
"track",
"artist",
"history",
"playlist",
"podcast",
"radio",
"user",
],
filters: SearchFilter,
query_string: str = "",
) -> Sequence[IDResponse]:
"""Query the Deezer Search API for the specified ``query_string``, applying
the provided ``filters``.
:param query_type: The Deezer Search API method to use. Valid types
are: 'album', 'artist', 'history', 'playlist', 'podcast',
'radio', 'track', 'user', and 'track'.
:type query_type: str
:param filters: (Optional) Field filters to apply.
:type filters: dict
:param keywords: (Optional) Query keywords to use.
:type keywords: str
:param filters: Field filters to apply.
:param query_string: Additional query to include in the search.
:return: JSON data for the class:`Response <Response>` object or None
if no search results are returned.
:rtype: dict or None
"""
query = self._construct_search_query(keywords=keywords, filters=filters)
if not query:
return None
self._log.debug(f"Searching {self.data_source} for '{query}'")
query = self._construct_search_query(
query_string=query_string, filters=filters
)
self._log.debug("Searching {.data_source} for '{}'", self, query)
try:
response = requests.get(
self.search_url + query_type,
params={"q": query},
f"{self.search_url}{query_type}",
params={
"q": query,
"limit": self.config["search_limit"].get(),
},
timeout=10,
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
self._log.error(
"Error fetching data from {} API\n Error: {}",
self.data_source,
"Error fetching data from {.data_source} API\n Error: {}",
self,
e,
)
return None
response_data = response.json().get("data", [])
return ()
response_data: Sequence[IDResponse] = response.json().get("data", [])
self._log.debug(
"Found {} result(s) from {} for '{}'",
"Found {} result(s) from {.data_source} for '{}'",
len(response_data),
self.data_source,
self,
query,
)
return response_data
def deezerupdate(self, items, write):
def deezerupdate(self, items: Sequence[Item], write: bool):
"""Obtain rank information from Deezer."""
for index, item in enumerate(items, start=1):
self._log.info(
@ -327,3 +299,16 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
item.deezer_updated = time.time()
if write:
item.try_write()
def fetch_data(self, url: str):
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
data = response.json()
except requests.exceptions.RequestException as e:
self._log.error("Error fetching data from {}\n Error: {}", url, e)
return None
if "error" in data:
self._log.debug("Deezer API error: {}", data["error"]["message"])
return None
return data

View file

@ -25,20 +25,27 @@ import re
import socket
import time
import traceback
from functools import cache
from string import ascii_lowercase
from typing import TYPE_CHECKING, cast
import confuse
from discogs_client import Client, Master, Release
from discogs_client.exceptions import DiscogsAPIError
from requests.exceptions import ConnectionError
from typing_extensions import TypedDict
from typing_extensions import NotRequired, TypedDict
import beets
import beets.ui
from beets import config
from beets.autotag.hooks import AlbumInfo, TrackInfo, string_dist
from beets.plugins import BeetsPlugin, MetadataSourcePlugin, get_distance
from beets.util.id_extractors import extract_discogs_id_regex
from beets.autotag.distance import string_dist
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.metadata_plugins import MetadataSourcePlugin
if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Sequence
from beets.library import Item
USER_AGENT = f"beets/{beets.__version__} +https://beets.io/"
API_KEY = "rAzVUQYRaoFjeBjyWuWZ"
@ -54,13 +61,67 @@ CONNECTION_ERRORS = (
)
TRACK_INDEX_RE = re.compile(
r"""
(.*?) # medium: everything before medium_index.
(\d*?) # medium_index: a number at the end of
# `position`, except if followed by a subtrack index.
# subtrack_index: can only be matched if medium
# or medium_index have been matched, and can be
(
(?<=\w)\.[\w]+ # a dot followed by a string (A.1, 2.A)
| (?<=\d)[A-Z]+ # a string that follows a number (1A, B2a)
)?
""",
re.VERBOSE,
)
DISAMBIGUATION_RE = re.compile(r" \(\d+\)")
class ReleaseFormat(TypedDict):
name: str
qty: int
descriptions: list[str] | None
class DiscogsPlugin(BeetsPlugin):
class Artist(TypedDict):
name: str
anv: str
join: str
role: str
tracks: str
id: str
resource_url: str
class Track(TypedDict):
position: str
type_: str
title: str
duration: str
artists: list[Artist]
extraartists: NotRequired[list[Artist]]
class TrackWithSubtracks(Track):
sub_tracks: list[TrackWithSubtracks]
class IntermediateTrackInfo(TrackInfo):
"""Allows work with string mediums from
get_track_info"""
def __init__(
self,
medium_str: str | None,
**kwargs,
) -> None:
self.medium_str = medium_str
super().__init__(**kwargs)
class DiscogsPlugin(MetadataSourcePlugin):
def __init__(self):
super().__init__()
self.config.add(
@ -68,11 +129,17 @@ class DiscogsPlugin(BeetsPlugin):
"apikey": API_KEY,
"apisecret": API_SECRET,
"tokenfile": "discogs_token.json",
"source_weight": 0.5,
"user_token": "",
"separator": ", ",
"index_tracks": False,
"append_style_genre": False,
"strip_disambiguation": True,
"featured_string": "Feat.",
"anv": {
"artist_credit": True,
"artist": False,
"album_artist": False,
},
}
)
self.config["apikey"].redact = True
@ -80,7 +147,7 @@ class DiscogsPlugin(BeetsPlugin):
self.config["user_token"].redact = True
self.setup()
def setup(self, session=None):
def setup(self, session=None) -> None:
"""Create the `discogs_client` field. Authenticate if necessary."""
c_key = self.config["apikey"].as_str()
c_secret = self.config["apisecret"].as_str()
@ -106,22 +173,22 @@ class DiscogsPlugin(BeetsPlugin):
self.discogs_client = Client(USER_AGENT, c_key, c_secret, token, secret)
def reset_auth(self):
def reset_auth(self) -> None:
"""Delete token file & redo the auth steps."""
os.remove(self._tokenfile())
self.setup()
def _tokenfile(self):
def _tokenfile(self) -> str:
"""Get the path to the JSON file for storing the OAuth token."""
return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
def authenticate(self, c_key, c_secret):
def authenticate(self, c_key: str, c_secret: str) -> tuple[str, str]:
# Get the link for the OAuth page.
auth_client = Client(USER_AGENT, c_key, c_secret)
try:
_, _, url = auth_client.get_authorize_url()
except CONNECTION_ERRORS as e:
self._log.debug("connection error: {0}", e)
self._log.debug("connection error: {}", e)
raise beets.ui.UserError("communication with Discogs failed")
beets.ui.print_("To authenticate with Discogs, visit:")
@ -134,139 +201,53 @@ class DiscogsPlugin(BeetsPlugin):
except DiscogsAPIError:
raise beets.ui.UserError("Discogs authorization failed")
except CONNECTION_ERRORS as e:
self._log.debug("connection error: {0}", e)
self._log.debug("connection error: {}", e)
raise beets.ui.UserError("Discogs token request failed")
# Save the token for later use.
self._log.debug("Discogs token {0}, secret {1}", token, secret)
self._log.debug("Discogs token {}, secret {}", token, secret)
with open(self._tokenfile(), "w") as f:
json.dump({"token": token, "secret": secret}, f)
return token, secret
def album_distance(self, items, album_info, mapping):
"""Returns the album distance."""
return get_distance(
data_source="Discogs", info=album_info, config=self.config
)
def candidates(
self, items: Sequence[Item], artist: str, album: str, va_likely: bool
) -> Iterable[AlbumInfo]:
return self.get_albums(f"{artist} {album}" if va_likely else album)
def track_distance(self, item, track_info):
"""Returns the track distance."""
return get_distance(
data_source="Discogs", info=track_info, config=self.config
)
def candidates(self, items, artist, album, va_likely, extra_tags=None):
"""Returns a list of AlbumInfo objects for discogs search results
matching an album and artist (if not various).
"""
if not album and not artist:
self._log.debug(
"Skipping Discogs query. Files missing album and artist tags."
)
return []
if va_likely:
query = album
else:
query = f"{artist} {album}"
try:
return self.get_albums(query)
except DiscogsAPIError as e:
self._log.debug("API Error: {0} (query: {1})", e, query)
if e.status_code == 401:
self.reset_auth()
return self.candidates(items, artist, album, va_likely)
else:
return []
except CONNECTION_ERRORS:
self._log.debug("Connection error in album search", exc_info=True)
return []
def get_track_from_album_by_title(
self, album_info, title, dist_threshold=0.3
):
def compare_func(track_info):
track_title = getattr(track_info, "title", None)
dist = string_dist(track_title, title)
return track_title and dist < dist_threshold
return self.get_track_from_album(album_info, compare_func)
def get_track_from_album(self, album_info, compare_func):
"""Return the first track of the release where `compare_func` returns
true.
:return: TrackInfo object.
:rtype: beets.autotag.hooks.TrackInfo
"""
if not album_info:
def get_track_from_album(
self, album_info: AlbumInfo, compare: Callable[[TrackInfo], float]
) -> TrackInfo | None:
"""Return the best matching track of the release."""
scores_and_tracks = [(compare(t), t) for t in album_info.tracks]
score, track_info = min(scores_and_tracks, key=lambda x: x[0])
if score > 0.3:
return None
for track_info in album_info.tracks:
# check for matching position
if not compare_func(track_info):
continue
track_info["artist"] = album_info.artist
track_info["artist_id"] = album_info.artist_id
track_info["album"] = album_info.album
return track_info
# attach artist info if not provided
if not track_info["artist"]:
track_info["artist"] = album_info.artist
track_info["artist_id"] = album_info.artist_id
# attach album info
track_info["album"] = album_info.album
def item_candidates(
self, item: Item, artist: str, title: str
) -> Iterable[TrackInfo]:
albums = self.candidates([item], artist, title, False)
return track_info
def compare_func(track_info: TrackInfo) -> float:
return string_dist(track_info.title, title)
return None
tracks = (self.get_track_from_album(a, compare_func) for a in albums)
return list(filter(None, tracks))
def item_candidates(self, item, artist, title):
"""Returns a list of TrackInfo objects for Search API results
matching ``title`` and ``artist``.
:param item: Singleton item to be matched.
:type item: beets.library.Item
:param artist: The artist of the track to be matched.
:type artist: str
:param title: The title of the track to be matched.
:type title: str
:return: Candidate TrackInfo objects.
:rtype: list[beets.autotag.hooks.TrackInfo]
"""
if not artist and not title:
self._log.debug(
"Skipping Discogs query. File missing artist and title tags."
)
return []
query = f"{artist} {title}"
try:
albums = self.get_albums(query)
except DiscogsAPIError as e:
self._log.debug("API Error: {0} (query: {1})", e, query)
if e.status_code == 401:
self.reset_auth()
return self.item_candidates(item, artist, title)
else:
return []
except CONNECTION_ERRORS:
self._log.debug("Connection error in track search", exc_info=True)
candidates = []
for album_cur in albums:
self._log.debug("searching within album {0}", album_cur.album)
track_result = self.get_track_from_album_by_title(
album_cur, item["title"]
)
if track_result:
candidates.append(track_result)
# first 10 results, don't overwhelm with options
return candidates[:10]
def album_for_id(self, album_id):
def album_for_id(self, album_id: str) -> AlbumInfo | None:
"""Fetches an album by its Discogs ID and returns an AlbumInfo object
or None if the album is not found.
"""
self._log.debug("Searching for release {0}", album_id)
self._log.debug("Searching for release {}", album_id)
discogs_id = extract_discogs_id_regex(album_id)
discogs_id = self._extract_id(album_id)
if not discogs_id:
return None
@ -278,7 +259,7 @@ class DiscogsPlugin(BeetsPlugin):
except DiscogsAPIError as e:
if e.status_code != 404:
self._log.debug(
"API Error: {0} (query: {1})",
"API Error: {} (query: {})",
e,
result.data["resource_url"],
)
@ -291,7 +272,15 @@ class DiscogsPlugin(BeetsPlugin):
return None
return self.get_album_info(result)
def get_albums(self, query):
def track_for_id(self, track_id: str) -> TrackInfo | None:
if album := self.album_for_id(track_id):
for track in album.tracks:
if track.track_id == track_id:
return track
return None
def get_albums(self, query: str) -> Iterable[AlbumInfo]:
"""Returns a list of AlbumInfo objects for a discogs search query."""
# Strip non-word characters from query. Things like "!" and "-" can
# cause a query to return no results, even if they match the artist or
@ -303,8 +292,9 @@ class DiscogsPlugin(BeetsPlugin):
query = re.sub(r"(?i)\b(CD|disc|vinyl)\s*\d+", "", query)
try:
releases = self.discogs_client.search(query, type="release").page(1)
results = self.discogs_client.search(query, type="release")
results.per_page = self.config["search_limit"].get()
releases = results.page(1)
except CONNECTION_ERRORS:
self._log.debug(
"Communication error while searching for {0!r}",
@ -312,24 +302,22 @@ class DiscogsPlugin(BeetsPlugin):
exc_info=True,
)
return []
return [
album for album in map(self.get_album_info, releases[:5]) if album
]
return filter(None, map(self.get_album_info, releases))
def get_master_year(self, master_id):
@cache
def get_master_year(self, master_id: str) -> int | None:
"""Fetches a master release given its Discogs ID and returns its year
or None if the master release is not found.
"""
self._log.debug("Searching for master release {0}", master_id)
self._log.debug("Getting master release {}", master_id)
result = Master(self.discogs_client, {"id": master_id})
try:
year = result.fetch("year")
return year
return result.fetch("year")
except DiscogsAPIError as e:
if e.status_code != 404:
self._log.debug(
"API Error: {0} (query: {1})",
"API Error: {} (query: {})",
e,
result.data["resource_url"],
)
@ -355,12 +343,38 @@ class DiscogsPlugin(BeetsPlugin):
return media, albumtype
def get_album_info(self, result):
def get_artist_with_anv(
self, artists: list[Artist], use_anv: bool = False
) -> tuple[str, str | None]:
"""Iterates through a discogs result, fetching data
if the artist anv is to be used, maps that to the name.
Calls the parent class get_artist method."""
artist_list: list[dict[str | int, str]] = []
for artist_data in artists:
a: dict[str | int, str] = {
"name": artist_data["name"],
"id": artist_data["id"],
"join": artist_data.get("join", ""),
}
if use_anv and (anv := artist_data.get("anv", "")):
a["name"] = anv
artist_list.append(a)
artist, artist_id = self.get_artist(artist_list, join_key="join")
return self.strip_disambiguation(artist), artist_id
def get_album_info(self, result: Release) -> AlbumInfo | None:
"""Returns an AlbumInfo object for a discogs Release object."""
# Explicitly reload the `Release` fields, as they might not be yet
# present if the result is from a `discogs_client.search()`.
if not result.data.get("artists"):
result.refresh()
try:
result.refresh()
except CONNECTION_ERRORS:
self._log.debug(
"Connection error in release lookup: {0}",
result,
)
return None
# Sanity check for required fields. The list of required fields is
# defined at Guideline 1.3.1.a, but in practice some releases might be
@ -376,16 +390,29 @@ class DiscogsPlugin(BeetsPlugin):
self._log.warning("Release does not contain the required fields")
return None
artist, artist_id = MetadataSourcePlugin.get_artist(
[a.data for a in result.artists], join_key="join"
artist_data = [a.data for a in result.artists]
album_artist, album_artist_id = self.get_artist_with_anv(artist_data)
album_artist_anv, _ = self.get_artist_with_anv(
artist_data, use_anv=True
)
artist_credit = album_artist_anv
album = re.sub(r" +", " ", result.title)
album_id = result.data["id"]
# Use `.data` to access the tracklist directly instead of the
# convenient `.tracklist` property, which will strip out useful artist
# information and leave us with skeleton `Artist` objects that will
# each make an API call just to get the same data back.
tracks = self.get_tracks(result.data["tracklist"])
tracks = self.get_tracks(
result.data["tracklist"],
(album_artist, album_artist_anv, album_artist_id),
)
# Assign ANV to the proper fields for tagging
if not self.config["anv"]["artist_credit"]:
artist_credit = album_artist
if self.config["anv"]["album_artist"]:
album_artist = album_artist_anv
# Extract information for the optional AlbumInfo fields, if possible.
va = result.data["artists"][0].get("name", "").lower() == "various"
@ -401,7 +428,7 @@ class DiscogsPlugin(BeetsPlugin):
else:
genre = base_genre
discogs_albumid = extract_discogs_id_regex(result.data.get("uri"))
discogs_albumid = self._extract_id(result.data.get("uri"))
# Extract information for the optional AlbumInfo fields that are
# contained on nested discogs fields.
@ -411,15 +438,20 @@ class DiscogsPlugin(BeetsPlugin):
label = catalogno = labelid = None
if result.data.get("labels"):
label = result.data["labels"][0].get("name")
label = self.strip_disambiguation(
result.data["labels"][0].get("name")
)
catalogno = result.data["labels"][0].get("catno")
labelid = result.data["labels"][0].get("id")
cover_art_url = self.select_cover_art(result)
# Additional cleanups (various artists name, catalog number, media).
# Additional cleanups
# (various artists name, catalog number, media, disambiguation).
if va:
artist = config["va_name"].as_str()
va_name = config["va_name"].as_str()
album_artist = va_name
artist_credit = va_name
if catalogno == "none":
catalogno = None
# Explicitly set the `media` for the tracks, since it is expected by
@ -427,13 +459,9 @@ class DiscogsPlugin(BeetsPlugin):
for track in tracks:
track.media = media
track.medium_total = mediums.count(track.medium)
if not track.artist: # get_track_info often fails to find artist
track.artist = artist
if not track.artist_id:
track.artist_id = artist_id
# Discogs does not have track IDs. Invent our own IDs as proposed
# in #2336.
track.track_id = str(album_id) + "-" + track.track_alt
track.track_id = f"{album_id}-{track.track_alt}"
track.data_url = data_url
track.data_source = "Discogs"
@ -446,8 +474,9 @@ class DiscogsPlugin(BeetsPlugin):
return AlbumInfo(
album=album,
album_id=album_id,
artist=artist,
artist_id=artist_id,
artist=album_artist,
artist_credit=artist_credit,
artist_id=album_artist_id,
tracks=tracks,
albumtype=albumtype,
va=va,
@ -461,15 +490,15 @@ class DiscogsPlugin(BeetsPlugin):
genre=genre,
media=media,
original_year=original_year,
data_source="Discogs",
data_source=self.data_source,
data_url=data_url,
discogs_albumid=discogs_albumid,
discogs_labelid=labelid,
discogs_artistid=artist_id,
discogs_artistid=album_artist_id,
cover_art_url=cover_art_url,
)
def select_cover_art(self, result):
def select_cover_art(self, result: Release) -> str | None:
"""Returns the best candidate image, if any, from a Discogs `Release` object."""
if result.data.get("images") and len(result.data.get("images")) > 0:
# The first image in this list appears to be the one displayed first
@ -479,7 +508,7 @@ class DiscogsPlugin(BeetsPlugin):
return None
def format(self, classification):
def format(self, classification: Iterable[str]) -> str | None:
if classification:
return (
self.config["separator"].as_str().join(sorted(classification))
@ -487,22 +516,17 @@ class DiscogsPlugin(BeetsPlugin):
else:
return None
def get_tracks(self, tracklist):
"""Returns a list of TrackInfo objects for a discogs tracklist."""
try:
clean_tracklist = self.coalesce_tracks(tracklist)
except Exception as exc:
# FIXME: this is an extra precaution for making sure there are no
# side effects after #2222. It should be removed after further
# testing.
self._log.debug("{}", traceback.format_exc())
self._log.error("uncaught exception in coalesce_tracks: {}", exc)
clean_tracklist = tracklist
tracks = []
def _process_clean_tracklist(
self,
clean_tracklist: list[Track],
album_artist_data: tuple[str, str, str | None],
) -> tuple[list[TrackInfo], dict[int, str], int, list[str], list[str]]:
# Distinct works and intra-work divisions, as defined by index tracks.
tracks: list[TrackInfo] = []
index_tracks = {}
index = 0
# Distinct works and intra-work divisions, as defined by index tracks.
divisions, next_divisions = [], []
divisions: list[str] = []
next_divisions: list[str] = []
for track in clean_tracklist:
# Only real tracks have `position`. Otherwise, it's an index track.
if track["position"]:
@ -512,7 +536,9 @@ class DiscogsPlugin(BeetsPlugin):
# divisions.
divisions += next_divisions
del next_divisions[:]
track_info = self.get_track_info(track, index, divisions)
track_info = self.get_track_info(
track, index, divisions, album_artist_data
)
track_info.track_alt = track["position"]
tracks.append(track_info)
else:
@ -524,7 +550,29 @@ class DiscogsPlugin(BeetsPlugin):
except IndexError:
pass
index_tracks[index + 1] = track["title"]
return tracks, index_tracks, index, divisions, next_divisions
def get_tracks(
self,
tracklist: list[Track],
album_artist_data: tuple[str, str, str | None],
) -> list[TrackInfo]:
"""Returns a list of TrackInfo objects for a discogs tracklist."""
try:
clean_tracklist: list[Track] = self.coalesce_tracks(
cast(list[TrackWithSubtracks], tracklist)
)
except Exception as exc:
# FIXME: this is an extra precaution for making sure there are no
# side effects after #2222. It should be removed after further
# testing.
self._log.debug("{}", traceback.format_exc())
self._log.error("uncaught exception in coalesce_tracks: {}", exc)
clean_tracklist = tracklist
processed = self._process_clean_tracklist(
clean_tracklist, album_artist_data
)
tracks, index_tracks, index, divisions, next_divisions = processed
# Fix up medium and medium_index for each track. Discogs position is
# unreliable, but tracks are in order.
medium = None
@ -533,8 +581,8 @@ class DiscogsPlugin(BeetsPlugin):
# If a medium has two sides (ie. vinyl or cassette), each pair of
# consecutive sides should belong to the same medium.
if all([track.medium is not None for track in tracks]):
m = sorted({track.medium.lower() for track in tracks})
if all([track.medium_str is not None for track in tracks]):
m = sorted({track.medium_str.lower() for track in tracks})
# If all track.medium are single consecutive letters, assume it is
# a 2-sided medium.
if "".join(m) in ascii_lowercase:
@ -548,17 +596,17 @@ class DiscogsPlugin(BeetsPlugin):
# side_count is the number of mediums or medium sides (in the case
# of two-sided mediums) that were seen before.
medium_is_index = (
track.medium
track.medium_str
and not track.medium_index
and (
len(track.medium) != 1
len(track.medium_str) != 1
or
# Not within standard incremental medium values (A, B, C, ...).
ord(track.medium) - 64 != side_count + 1
ord(track.medium_str) - 64 != side_count + 1
)
)
if not medium_is_index and medium != track.medium:
if not medium_is_index and medium != track.medium_str:
side_count += 1
if sides_per_medium == 2:
if side_count % sides_per_medium:
@ -569,7 +617,7 @@ class DiscogsPlugin(BeetsPlugin):
# Medium changed. Reset index_count.
medium_count += 1
index_count = 0
medium = track.medium
medium = track.medium_str
index_count += 1
medium_count = 1 if medium_count == 0 else medium_count
@ -585,22 +633,27 @@ class DiscogsPlugin(BeetsPlugin):
disctitle = None
track.disctitle = disctitle
return tracks
return cast(list[TrackInfo], tracks)
def coalesce_tracks(self, raw_tracklist):
def coalesce_tracks(
self, raw_tracklist: list[TrackWithSubtracks]
) -> list[Track]:
"""Pre-process a tracklist, merging subtracks into a single track. The
title for the merged track is the one from the previous index track,
if present; otherwise it is a combination of the subtracks titles.
"""
def add_merged_subtracks(tracklist, subtracks):
def add_merged_subtracks(
tracklist: list[TrackWithSubtracks],
subtracks: list[TrackWithSubtracks],
) -> None:
"""Modify `tracklist` in place, merging a list of `subtracks` into
a single track into `tracklist`."""
# Calculate position based on first subtrack, without subindex.
idx, medium_idx, sub_idx = self.get_track_index(
subtracks[0]["position"]
)
position = "{}{}".format(idx or "", medium_idx or "")
position = f"{idx or ''}{medium_idx or ''}"
if tracklist and not tracklist[-1]["position"]:
# Assume the previous index track contains the track title.
@ -622,8 +675,8 @@ class DiscogsPlugin(BeetsPlugin):
# option is set
if self.config["index_tracks"]:
for subtrack in subtracks:
subtrack["title"] = "{}: {}".format(
index_track["title"], subtrack["title"]
subtrack["title"] = (
f"{index_track['title']}: {subtrack['title']}"
)
tracklist.extend(subtracks)
else:
@ -633,8 +686,8 @@ class DiscogsPlugin(BeetsPlugin):
tracklist.append(track)
# Pre-process the tracklist, trying to identify subtracks.
subtracks = []
tracklist = []
subtracks: list[TrackWithSubtracks] = []
tracklist: list[TrackWithSubtracks] = []
prev_subindex = ""
for track in raw_tracklist:
# Regular subtrack (track with subindex).
@ -669,10 +722,32 @@ class DiscogsPlugin(BeetsPlugin):
if subtracks:
add_merged_subtracks(tracklist, subtracks)
return tracklist
return cast(list[Track], tracklist)
def get_track_info(self, track, index, divisions):
def strip_disambiguation(self, text: str) -> str:
"""Removes discogs specific disambiguations from a string.
Turns 'Label Name (5)' to 'Label Name' or 'Artist (1) & Another Artist (2)'
to 'Artist & Another Artist'. Does nothing if strip_disambiguation is False."""
if not self.config["strip_disambiguation"]:
return text
return DISAMBIGUATION_RE.sub("", text)
def get_track_info(
self,
track: Track,
index: int,
divisions: list[str],
album_artist_data: tuple[str, str, str | None],
) -> IntermediateTrackInfo:
"""Returns a TrackInfo object for a discogs track."""
artist, artist_anv, artist_id = album_artist_data
artist_credit = artist_anv
if not self.config["anv"]["artist_credit"]:
artist_credit = artist
if self.config["anv"]["artist"]:
artist = artist_anv
title = track["title"]
if self.config["index_tracks"]:
prefix = ", ".join(divisions)
@ -680,51 +755,65 @@ class DiscogsPlugin(BeetsPlugin):
title = f"{prefix}: {title}"
track_id = None
medium, medium_index, _ = self.get_track_index(track["position"])
artist, artist_id = MetadataSourcePlugin.get_artist(
track.get("artists", []), join_key="join"
)
# If artists are found on the track, we will use those instead
if artists := track.get("artists", []):
artist, artist_id = self.get_artist_with_anv(
artists, self.config["anv"]["artist"]
)
artist_credit, _ = self.get_artist_with_anv(
artists, self.config["anv"]["artist_credit"]
)
length = self.get_track_length(track["duration"])
return TrackInfo(
# Add featured artists
if extraartists := track.get("extraartists", []):
featured_list = [
artist
for artist in extraartists
if "Featuring" in artist["role"]
]
featured, _ = self.get_artist_with_anv(
featured_list, self.config["anv"]["artist"]
)
featured_credit, _ = self.get_artist_with_anv(
featured_list, self.config["anv"]["artist_credit"]
)
if featured:
artist += f" {self.config['featured_string']} {featured}"
artist_credit += (
f" {self.config['featured_string']} {featured_credit}"
)
return IntermediateTrackInfo(
title=title,
track_id=track_id,
artist_credit=artist_credit,
artist=artist,
artist_id=artist_id,
length=length,
index=index,
medium=medium,
medium_str=medium,
medium_index=medium_index,
)
def get_track_index(self, position):
@staticmethod
def get_track_index(
position: str,
) -> tuple[str | None, str | None, str | None]:
"""Returns the medium, medium index and subtrack index for a discogs
track position."""
# Match the standard Discogs positions (12.2.9), which can have several
# forms (1, 1-1, A1, A1.1, A1a, ...).
match = re.match(
r"^(.*?)" # medium: everything before medium_index.
r"(\d*?)" # medium_index: a number at the end of
# `position`, except if followed by a subtrack
# index.
# subtrack_index: can only be matched if medium
# or medium_index have been matched, and can be
r"((?<=\w)\.[\w]+" # - a dot followed by a string (A.1, 2.A)
r"|(?<=\d)[A-Z]+" # - a string that follows a number (1A, B2a)
r")?"
r"$",
position.upper(),
)
if match:
medium = index = subindex = None
if match := TRACK_INDEX_RE.fullmatch(position.upper()):
medium, index, subindex = match.groups()
if subindex and subindex.startswith("."):
subindex = subindex[1:]
else:
self._log.debug("Invalid position: {0}", position)
medium = index = subindex = None
return medium or None, index or None, subindex or None
def get_track_length(self, duration):
def get_track_length(self, duration: str) -> int | None:
"""Returns the track length in seconds for a discogs duration."""
try:
length = time.strptime(duration, "%M:%S")

View file

@ -19,7 +19,7 @@ import shlex
from beets.library import Album, Item
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand, UserError, decargs, print_
from beets.ui import Subcommand, UserError, print_
from beets.util import (
MoveOperation,
bytestring_path,
@ -53,6 +53,7 @@ class DuplicatesPlugin(BeetsPlugin):
"tiebreak": {},
"strict": False,
"tag": "",
"remove": False,
}
)
@ -131,6 +132,13 @@ class DuplicatesPlugin(BeetsPlugin):
action="store",
help="tag matched items with 'k=v' attribute",
)
self._command.parser.add_option(
"-r",
"--remove",
dest="remove",
action="store_true",
help="remove items from library",
)
self._command.parser.add_all_common_options()
def commands(self):
@ -141,7 +149,8 @@ class DuplicatesPlugin(BeetsPlugin):
copy = bytestring_path(self.config["copy"].as_str())
count = self.config["count"].get(bool)
delete = self.config["delete"].get(bool)
fmt = self.config["format"].get(str)
remove = self.config["remove"].get(bool)
fmt_tmpl = self.config["format"].get(str)
full = self.config["full"].get(bool)
keys = self.config["keys"].as_str_seq()
merge = self.config["merge"].get(bool)
@ -154,11 +163,11 @@ class DuplicatesPlugin(BeetsPlugin):
if album:
if not keys:
keys = ["mb_albumid"]
items = lib.albums(decargs(args))
items = lib.albums(args)
else:
if not keys:
keys = ["mb_trackid", "mb_albumid"]
items = lib.items(decargs(args))
items = lib.items(args)
# If there's nothing to do, return early. The code below assumes
# `items` to be non-empty.
@ -166,15 +175,14 @@ class DuplicatesPlugin(BeetsPlugin):
return
if path:
fmt = "$path"
fmt_tmpl = "$path"
# Default format string for count mode.
if count and not fmt:
if count and not fmt_tmpl:
if album:
fmt = "$albumartist - $album"
fmt_tmpl = "$albumartist - $album"
else:
fmt = "$albumartist - $album - $title"
fmt += ": {0}"
fmt_tmpl = "$albumartist - $album - $title"
if checksum:
for i in items:
@ -196,15 +204,23 @@ class DuplicatesPlugin(BeetsPlugin):
copy=copy,
move=move,
delete=delete,
remove=remove,
tag=tag,
fmt=fmt.format(obj_count),
fmt=f"{fmt_tmpl}: {obj_count}",
)
self._command.func = _dup
return [self._command]
def _process_item(
self, item, copy=False, move=False, delete=False, tag=False, fmt=""
self,
item,
copy=False,
move=False,
delete=False,
tag=False,
fmt="",
remove=False,
):
"""Process Item `item`."""
print_(format(item, fmt))
@ -216,6 +232,8 @@ class DuplicatesPlugin(BeetsPlugin):
item.store()
if delete:
item.remove(delete=True)
elif remove:
item.remove(delete=False)
if tag:
try:
k, v = tag.split("=")
@ -236,28 +254,24 @@ class DuplicatesPlugin(BeetsPlugin):
checksum = getattr(item, key, False)
if not checksum:
self._log.debug(
"key {0} on item {1} not cached:" "computing checksum",
"key {} on item {.filepath} not cached:computing checksum",
key,
displayable_path(item.path),
item,
)
try:
checksum = command_output(args).stdout
setattr(item, key, checksum)
item.store()
self._log.debug(
"computed checksum for {0} using {1}", item.title, key
"computed checksum for {.title} using {}", item, key
)
except subprocess.CalledProcessError as e:
self._log.debug(
"failed to checksum {0}: {1}",
displayable_path(item.path),
e,
)
self._log.debug("failed to checksum {.filepath}: {}", item, e)
else:
self._log.debug(
"key {0} on item {1} cached:" "not computing checksum",
"key {} on item {.filepath} cached:not computing checksum",
key,
displayable_path(item.path),
item,
)
return key, checksum
@ -275,15 +289,15 @@ class DuplicatesPlugin(BeetsPlugin):
values = [v for v in values if v not in (None, "")]
if strict and len(values) < len(keys):
self._log.debug(
"some keys {0} on item {1} are null or empty:" " skipping",
"some keys {} on item {.filepath} are null or empty: skipping",
keys,
displayable_path(obj.path),
obj,
)
elif not strict and not len(values):
self._log.debug(
"all keys {0} on item {1} are null or empty:" " skipping",
"all keys {} on item {.filepath} are null or empty: skipping",
keys,
displayable_path(obj.path),
obj,
)
else:
key = tuple(values)
@ -341,11 +355,11 @@ class DuplicatesPlugin(BeetsPlugin):
value = getattr(o, f, None)
if value:
self._log.debug(
"key {0} on item {1} is null "
"or empty: setting from item {2}",
"key {} on item {} is null "
"or empty: setting from item {.filepath}",
f,
displayable_path(objs[0].path),
displayable_path(o.path),
o,
)
setattr(objs[0], f, value)
objs[0].store()
@ -365,11 +379,11 @@ class DuplicatesPlugin(BeetsPlugin):
missing.album_id = objs[0].id
missing.add(i._db)
self._log.debug(
"item {0} missing from album {1}:"
" merging from {2} into {3}",
"item {} missing from album {}:"
" merging from {.filepath} into {}",
missing,
objs[0],
displayable_path(o.path),
o,
displayable_path(missing.destination()),
)
missing.move(operation=MoveOperation.COPY)

View file

@ -24,8 +24,9 @@ import yaml
from beets import plugins, ui, util
from beets.dbcore import types
from beets.importer import action
from beets.ui.commands import PromptChoice, _do_query
from beets.importer import Action
from beets.ui.commands.utils import do_query
from beets.util import PromptChoice
# These "safe" types can avoid the format/parse cycle that most fields go
# through: they are safe to edit with native YAML types.
@ -46,9 +47,7 @@ def edit(filename, log):
try:
subprocess.call(cmd)
except OSError as exc:
raise ui.UserError(
"could not run editor command {!r}: {}".format(cmd[0], exc)
)
raise ui.UserError(f"could not run editor command {cmd[0]!r}: {exc}")
def dump(arg):
@ -71,9 +70,7 @@ def load(s):
for d in yaml.safe_load_all(s):
if not isinstance(d, dict):
raise ParseError(
"each entry must be a dictionary; found {}".format(
type(d).__name__
)
f"each entry must be a dictionary; found {type(d).__name__}"
)
# Convert all keys to strings. They started out as strings,
@ -180,8 +177,7 @@ class EditPlugin(plugins.BeetsPlugin):
def _edit_command(self, lib, opts, args):
"""The CLI command function for the `beet edit` command."""
# Get the objects to edit.
query = ui.decargs(args)
items, albums = _do_query(lib, query, opts.album, False)
items, albums = do_query(lib, args, opts.album, False)
objs = albums if opts.album else items
if not objs:
ui.print_("Nothing to edit.")
@ -279,23 +275,18 @@ class EditPlugin(plugins.BeetsPlugin):
ui.print_("No changes to apply.")
return False
# Confirm the changes.
# For cancel/keep-editing, restore objects to their original
# in-memory state so temp edits don't leak into the session
choice = ui.input_options(
("continue Editing", "apply", "cancel")
)
if choice == "a": # Apply.
return True
elif choice == "c": # Cancel.
self.apply_data(objs, new_data, old_data)
return False
elif choice == "e": # Keep editing.
# Reset the temporary changes to the objects. I we have a
# copy from above, use that, else reload from the database.
objs = [
(old_obj or obj) for old_obj, obj in zip(objs_old, objs)
]
for obj in objs:
if not obj.id < 0:
obj.load()
self.apply_data(objs, new_data, old_data)
continue
# Remove the temporary file before returning.
@ -380,13 +371,11 @@ class EditPlugin(plugins.BeetsPlugin):
# Save the new data.
if success:
# Return action.RETAG, which makes the importer write the tags
# Return Action.RETAG, which makes the importer write the tags
# to the files if needed without re-applying metadata.
return action.RETAG
return Action.RETAG
else:
# Edit cancelled / no edits made. Revert changes.
for obj in task.items:
obj.read()
return None
def importer_edit_candidate(self, session, task):
"""Callback for invoking the functionality during an interactive

View file

@ -20,11 +20,12 @@ from mimetypes import guess_extension
import requests
from beets import art, config, ui
from beets import config, ui
from beets.plugins import BeetsPlugin
from beets.ui import decargs, print_
from beets.ui import print_
from beets.util import bytestring_path, displayable_path, normpath, syspath
from beets.util.artresizer import ArtResizer
from beetsplug._utils import art
def _confirm(objs, album):
@ -35,8 +36,9 @@ def _confirm(objs, album):
to items).
"""
noun = "album" if album else "file"
prompt = "Modify artwork for {} {}{} (Y/n)?".format(
len(objs), noun, "s" if len(objs) > 1 else ""
prompt = (
"Modify artwork for"
f" {len(objs)} {noun}{'s' if len(objs) > 1 else ''} (Y/n)?"
)
# Show all the items or albums.
@ -66,7 +68,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
if self.config["maxwidth"].get(int) and not ArtResizer.shared.local:
self.config["maxwidth"] = 0
self._log.warning(
"ImageMagick or PIL not found; " "'maxwidth' option ignored"
"ImageMagick or PIL not found; 'maxwidth' option ignored"
)
if (
self.config["compare_threshold"].get(int)
@ -110,12 +112,10 @@ class EmbedCoverArtPlugin(BeetsPlugin):
imagepath = normpath(opts.file)
if not os.path.isfile(syspath(imagepath)):
raise ui.UserError(
"image file {} not found".format(
displayable_path(imagepath)
)
f"image file {displayable_path(imagepath)} not found"
)
items = lib.items(decargs(args))
items = lib.items(args)
# Confirm with user.
if not opts.yes and not _confirm(items, not opts.file):
@ -137,7 +137,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
response = requests.get(opts.url, timeout=5)
response.raise_for_status()
except requests.exceptions.RequestException as e:
self._log.error("{}".format(e))
self._log.error("{}", e)
return
extension = guess_extension(response.headers["Content-Type"])
if extension is None:
@ -149,9 +149,9 @@ class EmbedCoverArtPlugin(BeetsPlugin):
with open(tempimg, "wb") as f:
f.write(response.content)
except Exception as e:
self._log.error("Unable to save image: {}".format(e))
self._log.error("Unable to save image: {}", e)
return
items = lib.items(decargs(args))
items = lib.items(args)
# Confirm with user.
if not opts.yes and not _confirm(items, not opts.url):
os.remove(tempimg)
@ -169,7 +169,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
)
os.remove(tempimg)
else:
albums = lib.albums(decargs(args))
albums = lib.albums(args)
# Confirm with user.
if not opts.yes and not _confirm(albums, not opts.file):
return
@ -212,7 +212,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
def extract_func(lib, opts, args):
if opts.outpath:
art.extract_first(
self._log, normpath(opts.outpath), lib.items(decargs(args))
self._log, normpath(opts.outpath), lib.items(args)
)
else:
filename = bytestring_path(
@ -223,7 +223,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
"Only specify a name rather than a path for -n"
)
return
for album in lib.albums(decargs(args)):
for album in lib.albums(args):
artpath = normpath(os.path.join(album.path, filename))
artpath = art.extract_first(
self._log, artpath, album.items()
@ -244,11 +244,11 @@ class EmbedCoverArtPlugin(BeetsPlugin):
)
def clear_func(lib, opts, args):
items = lib.items(decargs(args))
items = lib.items(args)
# Confirm with user.
if not opts.yes and not _confirm(items, False):
return
art.clear(self._log, lib, decargs(args))
art.clear(self._log, lib, args)
clear_cmd.func = clear_func
@ -274,7 +274,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
"""
if self.config["remove_art_file"] and album.artpath:
if os.path.isfile(syspath(album.artpath)):
self._log.debug("Removing album art file for {0}", album)
self._log.debug("Removing album art file for {}", album)
os.remove(syspath(album.artpath))
album.artpath = None
album.store()

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