Compare commits

..

No commits in common. "master" and "v2.5.0" have entirely different histories.

152 changed files with 6806 additions and 11954 deletions

View file

@ -73,11 +73,3 @@ d93ddf8dd43e4f9ed072a03829e287c78d2570a2
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

2
.github/CODEOWNERS vendored
View file

@ -3,5 +3,3 @@
# Specific ownerships:
/beets/metadata_plugins.py @semohr
/beetsplug/titlecase.py @henry-oberholtzer
/beetsplug/mbpseudo.py @asardaes

View file

@ -10,7 +10,7 @@ jobs:
check_changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Get all updated Python files
id: changed-python-files

View file

@ -20,17 +20,17 @@ jobs:
fail-fast: false
matrix:
platform: [ubuntu-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.9", "3.10", "3.11", "3.12"]
runs-on: ${{ matrix.platform }}
env:
IS_MAIN_PYTHON: ${{ matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest' }}
IS_MAIN_PYTHON: ${{ matrix.python-version == '3.9' && matrix.platform == 'ubuntu-latest' }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Install Python tools
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@v6
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: poetry
@ -39,15 +39,7 @@ jobs:
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt update
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
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
@ -98,10 +90,10 @@ jobs:
permissions:
id-token: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Get the coverage report
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: coverage-report

View file

@ -3,20 +3,16 @@ 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@v5
- uses: actions/checkout@v4
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v6
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
python-version: 3.9
cache: poetry
- name: Install dependencies

View file

@ -12,7 +12,7 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
PYTHON_VERSION: "3.10"
PYTHON_VERSION: 3.9
jobs:
changed-files:
@ -24,7 +24,7 @@ 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@v5
- uses: actions/checkout@v4
- name: Get changed docs files
id: changed-doc-files
uses: tj-actions/changed-files@v46
@ -56,10 +56,10 @@ jobs:
name: Check formatting
needs: changed-files
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v6
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -77,10 +77,10 @@ jobs:
name: Check linting
needs: changed-files
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v6
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -97,10 +97,10 @@ jobs:
name: Check types with mypy
needs: changed-files
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v6
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -120,10 +120,10 @@ jobs:
name: Check docs
needs: changed-files
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v6
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry

View file

@ -8,7 +8,7 @@ on:
required: true
env:
PYTHON_VERSION: "3.10"
PYTHON_VERSION: 3.9
NEW_VERSION: ${{ inputs.version }}
NEW_TAG: v${{ inputs.version }}
@ -17,10 +17,10 @@ jobs:
name: Bump version, commit and create tag
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v6
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -45,13 +45,13 @@ jobs:
outputs:
changelog: ${{ steps.generate_changelog.outputs.changelog }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
ref: ${{ env.NEW_TAG }}
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v6
- uses: actions/setup-python@v5
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@v5
uses: actions/download-artifact@v4
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@v5
uses: actions/download-artifact@v4
with:
name: python-package-distributions
path: dist/

4
.gitignore vendored
View file

@ -95,5 +95,5 @@ ENV/
# pyright
pyrightconfig.json
# Pyrefly
pyrefly.toml
# Versioning
beets/_version.py

View file

@ -124,12 +124,12 @@ command. Instead, you can activate the virtual environment in your shell with:
$ poetry shell
You should see ``(beets-py3.10)`` prefix in your shell prompt. Now you can run
You should see ``(beets-py3.9)`` prefix in your shell prompt. Now you can run
commands directly, for example:
::
$ (beets-py3.10) pytest
$ (beets-py3.9) pytest
Additionally, poethepoet_ task runner assists us with the most common
operations. Formatting, linting, testing are defined as ``poe`` tasks in
@ -286,6 +286,31 @@ according to the specifications required by the project.
Similarly, run ``poe format-docs`` and ``poe lint-docs`` to ensure consistent
documentation formatting and check for any issues.
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.
Editor Settings
~~~~~~~~~~~~~~~

View file

@ -85,7 +85,7 @@ simple if you know a little Python.
.. _transcode audio: https://beets.readthedocs.org/page/plugins/convert.html
.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins/index.html
.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins.html
Install
-------

View file

@ -79,7 +79,7 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들
.. _transcode audio: https://beets.readthedocs.org/page/plugins/convert.html
.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins/index.html
.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins.html
설치
-------

View file

@ -17,18 +17,23 @@ from sys import stderr
import confuse
from .util.deprecation import deprecate_imports
# Version management using poetry-dynamic-versioning
from ._version import __version__, __version_tuple__
from .util 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,
old_module=__name__,
new_module_by_name={
"art": "beetsplug._utils",
"vfs": "beetsplug._utils",
},
name=name,
version="3.0.0",
)
@ -50,3 +55,6 @@ class IncludeLazyConfig(confuse.LazyConfig):
config = IncludeLazyConfig("beets", __name__)
__all__ = ["__version__", "__version_tuple__", "config"]

7
beets/_version.py Normal file
View file

@ -0,0 +1,7 @@
# This file is auto-generated during the build process.
# Do not edit this file directly.
# Placeholders are replaced during substitution.
# Run `git update-index --assume-unchanged beets/_version.py`
# to ignore local changes to this file.
__version__ = "0.0.0"
__version_tuple__ = (0, 0, 0)

View file

@ -16,15 +16,16 @@
from __future__ import annotations
import warnings
from importlib import import_module
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Union
from beets import config, logging
# Parts of external interface.
from beets.util import unique_list
from beets.util.deprecation import deprecate_for_maintainers, deprecate_imports
from ..util import deprecate_imports
from .hooks import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch
from .match import Proposal, Recommendation, tag_album, tag_item
@ -36,13 +37,18 @@ if TYPE_CHECKING:
def __getattr__(name: str):
if name == "current_metadata":
deprecate_for_maintainers(
f"'beets.autotag.{name}'", "'beets.util.get_most_common_tags'"
warnings.warn(
(
f"'beets.autotag.{name}' is deprecated and will be removed in"
" 3.0.0. Use 'beets.util.get_most_common_tags' instead."
),
DeprecationWarning,
stacklevel=2,
)
return import_module("beets.util").get_most_common_tags
return deprecate_imports(
__name__, {"Distance": "beets.autotag.distance"}, name
__name__, {"Distance": "beets.autotag.distance"}, name, "3.0.0"
)
@ -111,8 +117,8 @@ SPECIAL_FIELDS = {
def _apply_metadata(
info: AlbumInfo | TrackInfo,
db_obj: Album | Item,
info: Union[AlbumInfo, TrackInfo],
db_obj: Union[Album, Item],
nullable_fields: Sequence[str] = [],
):
"""Set the db_obj's metadata to match the info."""

View file

@ -24,7 +24,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
import lap
import numpy as np
from beets import config, logging, metadata_plugins, plugins
from beets import config, logging, metadata_plugins
from beets.autotag import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch, hooks
from beets.util import get_most_common_tags
@ -274,17 +274,12 @@ def tag_album(
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.
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 {}", rec)
if candidates and not config["import"]["timid"]:
@ -318,8 +313,6 @@ def tag_album(
items, search_artist, search_album, 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 {} candidates.", len(candidates))
# Sort and get the recommendation.

View file

@ -26,16 +26,9 @@ import threading
import time
from abc import ABC
from collections import defaultdict
from collections.abc import (
Callable,
Generator,
Iterable,
Iterator,
Mapping,
Sequence,
)
from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence
from sqlite3 import Connection, sqlite_version_info
from typing import TYPE_CHECKING, Any, AnyStr, Generic
from typing import TYPE_CHECKING, Any, AnyStr, Callable, Generic
from typing_extensions import TypeVar # default value support
from unidecode import unidecode
@ -947,10 +940,10 @@ class Transaction:
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> bool | None:
exc_type: type[Exception],
exc_value: Exception,
traceback: TracebackType,
):
"""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.
@ -972,8 +965,6 @@ class Transaction:
):
raise DBCustomFunctionError()
return None
def query(
self, statement: str, subvals: Sequence[SQLiteType] = ()
) -> list[sqlite3.Row]:

View file

@ -15,7 +15,7 @@ from __future__ import annotations
import os
import time
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Sequence
from beets import config, dbcore, library, logging, plugins, util
from beets.importer.tasks import Action
@ -25,8 +25,6 @@ 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

View file

@ -16,7 +16,7 @@ from __future__ import annotations
import itertools
import logging
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Callable
from beets import config, plugins
from beets.util import MoveOperation, displayable_path, pipeline
@ -30,8 +30,6 @@ from .tasks import (
)
if TYPE_CHECKING:
from collections.abc import Callable
from beets import library
from .session import ImportSession

View file

@ -20,10 +20,9 @@ import re
import shutil
import time
from collections import defaultdict
from collections.abc import Callable, Iterable, Sequence
from enum import Enum
from tempfile import mkdtemp
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Callable, Iterable, Sequence
import mediafile

View file

@ -1,4 +1,4 @@
from beets.util.deprecation import deprecate_imports
from beets.util import deprecate_imports
from .exceptions import FileOperationError, ReadError, WriteError
from .library import Library
@ -13,7 +13,7 @@ NEW_MODULE_BY_NAME = dict.fromkeys(
def __getattr__(name: str):
return deprecate_imports(__name__, NEW_MODULE_BY_NAME, name)
return deprecate_imports(__name__, NEW_MODULE_BY_NAME, name, "3.0.0")
__all__ = [

View file

@ -22,7 +22,6 @@ calls (`debug`, `info`, etc).
from __future__ import annotations
import re
import threading
from copy import copy
from logging import (
@ -38,7 +37,7 @@ from logging import (
RootLogger,
StreamHandler,
)
from typing import TYPE_CHECKING, Any, TypeVar, Union, overload
from typing import TYPE_CHECKING, Any, Mapping, TypeVar, Union, overload
__all__ = [
"DEBUG",
@ -55,8 +54,6 @@ __all__ = [
]
if TYPE_CHECKING:
from collections.abc import Mapping
T = TypeVar("T")
from types import TracebackType
@ -69,15 +66,6 @@ if TYPE_CHECKING:
_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.
@ -92,10 +80,6 @@ def _logsafe(val: T) -> str | T:
# 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.

View file

@ -13,11 +13,17 @@
# included in all copies or substantial portions of the Software.
import warnings
import mediafile
from .util.deprecation import deprecate_for_maintainers
deprecate_for_maintainers("'beets.mediafile'", "'mediafile'", stacklevel=2)
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,
)
# Import everything from the mediafile module into this module.
for key, value in mediafile.__dict__.items():
@ -25,4 +31,4 @@ for key, value in mediafile.__dict__.items():
globals()[key] = value
# Cleanup namespace.
del key, value, mediafile
del key, value, warnings, mediafile

View file

@ -10,7 +10,7 @@ from __future__ import annotations
import abc
import re
from functools import cache, cached_property
from typing import TYPE_CHECKING, Generic, Literal, TypedDict, TypeVar
from typing import TYPE_CHECKING, Generic, Literal, Sequence, TypedDict, TypeVar
import unidecode
from confuse import NotFoundError
@ -22,7 +22,7 @@ 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 collections.abc import Iterable
from .autotag.hooks import AlbumInfo, Item, TrackInfo

View file

@ -20,10 +20,12 @@ import abc
import inspect
import re
import sys
import warnings
from collections import defaultdict
from functools import cached_property, wraps
from functools import wraps
from importlib import import_module
from pathlib import Path
from types import GenericAlias
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar
import mediafile
@ -32,7 +34,6 @@ from typing_extensions import ParamSpec
import beets
from beets import logging
from beets.util import unique_list
from beets.util.deprecation import deprecate_for_maintainers, deprecate_for_user
if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Sequence
@ -71,7 +72,6 @@ EventType = Literal[
"album_imported",
"album_removed",
"albuminfo_received",
"album_matched",
"before_choose_candidate",
"before_item_moved",
"cli_exit",
@ -151,9 +151,9 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
list
)
listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list)
template_funcs: ClassVar[TFuncMap[str]] | TFuncMap[str] = {} # type: ignore[valid-type]
template_fields: ClassVar[TFuncMap[Item]] | TFuncMap[Item] = {} # type: ignore[valid-type]
album_template_fields: ClassVar[TFuncMap[Album]] | TFuncMap[Album] = {} # type: ignore[valid-type]
template_funcs: TFuncMap[str] | None = None
template_fields: TFuncMap[Item] | None = None
album_template_fields: TFuncMap[Album] | None = None
name: str
config: ConfigView
@ -184,32 +184,20 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
):
return
deprecate_for_maintainers(
(
f"'{cls.__name__}' is used as a legacy metadata source since it"
" inherits 'beets.plugins.BeetsPlugin'. Support for this"
),
"'beets.metadata_plugins.MetadataSourcePlugin'",
warnings.warn(
f"{cls.__name__} is used as a legacy metadata source. "
"It should extend MetadataSourcePlugin instead of BeetsPlugin. "
"Support for this will be removed in the v3.0.0 release!",
DeprecationWarning,
stacklevel=3,
)
method: property | cached_property[Any] | Callable[..., Any]
for name, method in inspect.getmembers(
MetadataSourcePlugin,
predicate=lambda f: ( # type: ignore[arg-type]
(
isinstance(f, (property, cached_property))
and not hasattr(
BeetsPlugin,
getattr(f, "attrname", None) or f.fget.__name__, # type: ignore[union-attr]
)
)
or (
inspect.isfunction(f)
and f.__name__
and not getattr(f, "__isabstractmethod__", False)
and not hasattr(BeetsPlugin, f.__name__)
)
predicate=lambda f: (
inspect.isfunction(f)
and f.__name__ not in MetadataSourcePlugin.__abstractmethods__
and not hasattr(cls, f.__name__)
),
):
setattr(cls, name, method)
@ -220,8 +208,8 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
self.name = name or self.__module__.split(".")[-1]
self.config = beets.config[self.name]
# If the class attributes are not set, initialize as instance attributes.
# TODO: Revise with v3.0.0, see also type: ignore[valid-type] above
# Set class attributes if they are not already set
# for the type of plugin.
if not self.template_funcs:
self.template_funcs = {}
if not self.template_fields:
@ -240,9 +228,9 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
# In order to verify the config we need to make sure the plugin is fully
# configured (plugins usually add the default configuration *after*
# calling super().__init__()).
self.register_listener("pluginload", self._verify_config)
self.register_listener("pluginload", self.verify_config)
def _verify_config(self, *_, **__) -> None:
def verify_config(self, *_, **__) -> None:
"""Verify plugin configuration.
If deprecated 'source_weight' option is explicitly set by the user, they
@ -257,19 +245,16 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
):
return
message = (
"'source_weight' configuration option is deprecated and will be"
" removed in v3.0.0. Use 'data_source_mismatch_penalty' instead"
)
for source in self.config.root().sources:
if "source_weight" in (source.get(self.name) or {}):
if source.filename: # user config
deprecate_for_user(
self._log,
f"'{self.name}.source_weight' configuration option",
f"'{self.name}.data_source_mismatch_penalty'",
)
self._log.warning(message)
else: # 3rd-party plugin config
deprecate_for_maintainers(
"'source_weight' configuration option",
"'data_source_mismatch_penalty'",
)
warnings.warn(message, DeprecationWarning, stacklevel=0)
def commands(self) -> Sequence[Subcommand]:
"""Should return a list of beets.ui.Subcommand objects for
@ -372,6 +357,8 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
"""
def helper(func: TFunc[str]) -> TFunc[str]:
if cls.template_funcs is None:
cls.template_funcs = {}
cls.template_funcs[name] = func
return func
@ -386,6 +373,8 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
"""
def helper(func: TFunc[Item]) -> TFunc[Item]:
if cls.template_fields is None:
cls.template_fields = {}
cls.template_fields[name] = func
return func
@ -414,22 +403,16 @@ def get_plugin_names() -> list[str]:
# *contain* a `beetsplug` package.
sys.path += paths
plugins = unique_list(beets.config["plugins"].as_str_seq())
# TODO: Remove in v3.0.0
if (
"musicbrainz" not in plugins
and "musicbrainz" in beets.config
and beets.config["musicbrainz"].get().get("enabled")
):
plugins.append("musicbrainz")
beets.config.add({"disabled_plugins": []})
disabled_plugins = set(beets.config["disabled_plugins"].as_str_seq())
# TODO: Remove in v3.0.0
mb_enabled = beets.config["musicbrainz"].flatten().get("enabled")
if mb_enabled:
deprecate_for_user(
log,
"'musicbrainz.enabled' configuration option",
"'plugins' configuration to explicitly add 'musicbrainz'",
)
if "musicbrainz" not in plugins:
plugins.append("musicbrainz")
elif mb_enabled is False:
deprecate_for_user(log, "'musicbrainz.enabled' configuration option")
disabled_plugins.add("musicbrainz")
return [p for p in plugins if p not in disabled_plugins]
@ -439,12 +422,6 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
Attempts to import the plugin module, locate the appropriate plugin class
within it, and return an instance. Handles import failures gracefully and
logs warnings for missing plugins or loading errors.
Note we load the *last* plugin class found in the plugin namespace. This
allows plugins to define helper classes that inherit from BeetsPlugin
without those being loaded as the main plugin class.
Returns None if the plugin could not be loaded for any reason.
"""
try:
try:
@ -452,9 +429,12 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
except Exception as exc:
raise PluginImportError(name) from exc
for obj in reversed(namespace.__dict__.values()):
for obj in namespace.__dict__.values():
if (
inspect.isclass(obj)
and not isinstance(
obj, GenericAlias
) # seems to be needed for python <= 3.9 only
and issubclass(obj, BeetsPlugin)
and obj != BeetsPlugin
and not inspect.isabstract(obj)
@ -571,7 +551,8 @@ def template_funcs() -> TFuncMap[str]:
"""
funcs: TFuncMap[str] = {}
for plugin in find_plugins():
funcs.update(plugin.template_funcs)
if plugin.template_funcs:
funcs.update(plugin.template_funcs)
return funcs
@ -597,20 +578,21 @@ F = TypeVar("F")
def _check_conflicts_and_merge(
plugin: BeetsPlugin, plugin_funcs: dict[str, F], funcs: dict[str, F]
plugin: BeetsPlugin, plugin_funcs: dict[str, F] | None, funcs: dict[str, F]
) -> None:
"""Check the provided template functions for conflicts and merge into funcs.
Raises a `PluginConflictError` if a plugin defines template functions
for fields that another plugin has already defined template functions for.
"""
if not plugin_funcs.keys().isdisjoint(funcs.keys()):
conflicted_fields = ", ".join(plugin_funcs.keys() & funcs.keys())
raise PluginConflictError(
f"Plugin {plugin.name} defines template functions for "
f"{conflicted_fields} that conflict with another plugin."
)
funcs.update(plugin_funcs)
if plugin_funcs:
if not plugin_funcs.keys().isdisjoint(funcs.keys()):
conflicted_fields = ", ".join(plugin_funcs.keys() & funcs.keys())
raise PluginConflictError(
f"Plugin {plugin.name} defines template functions for "
f"{conflicted_fields} that conflict with another plugin."
)
funcs.update(plugin_funcs)
def item_field_getters() -> TFuncMap[Item]:
@ -650,17 +632,13 @@ def send(event: EventType, **arguments: Any) -> list[Any]:
]
def feat_tokens(
for_artist: bool = True, custom_words: list[str] | None = None
) -> str:
def feat_tokens(for_artist: bool = True) -> str:
"""Return a regular expression that matches phrases like "featuring"
that separate a main artist or a song title from secondary artists.
The `for_artist` option determines whether the regex should be
suitable for matching artist fields (the default) or title fields.
"""
feat_words = ["ft", "featuring", "feat", "feat.", "ft."]
if isinstance(custom_words, list):
feat_words += custom_words
if for_artist:
feat_words += ["with", "vs", "and", "con", "&"]
return (

View file

@ -107,11 +107,7 @@ def item(lib=None, **kwargs):
# Dummy import session.
def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False):
cls = (
commands.import_.session.TerminalImportSession
if cli
else importer.ImportSession
)
cls = commands.TerminalImportSession if cli else importer.ImportSession
return cls(lib, loghandler, paths, query)

View file

@ -54,7 +54,7 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.importer import ImportSession
from beets.library import Item, Library
from beets.test import _common
from beets.ui.commands.import_.session import TerminalImportSession
from beets.ui.commands import TerminalImportSession
from beets.util import (
MoveOperation,
bytestring_path,

View file

@ -23,15 +23,16 @@ import errno
import optparse
import os.path
import re
import shutil
import sqlite3
import struct
import sys
import textwrap
import traceback
import warnings
from difflib import SequenceMatcher
from functools import cache
from itertools import chain
from typing import TYPE_CHECKING, Any, Literal
from typing import Any, Callable, Literal
import confuse
@ -39,12 +40,8 @@ from beets import config, library, logging, plugins, util
from beets.dbcore import db
from beets.dbcore import query as db_query
from beets.util import as_string
from beets.util.deprecation import deprecate_for_maintainers
from beets.util.functemplate import template
if TYPE_CHECKING:
from collections.abc import Callable
# On Windows platforms, use colorama to support "ANSI" terminal colors.
if sys.platform == "win32":
try:
@ -114,7 +111,11 @@ def decargs(arglist):
.. deprecated:: 2.4.0
This function will be removed in 3.0.0.
"""
deprecate_for_maintainers("'beets.ui.decargs'")
warnings.warn(
"decargs() is deprecated and will be removed in version 3.0.0.",
DeprecationWarning,
stacklevel=2,
)
return arglist
@ -698,11 +699,27 @@ def get_replacements():
return replacements
@cache
def term_width() -> int:
def term_width():
"""Get the width (columns) of the terminal."""
columns, _ = shutil.get_terminal_size(fallback=(0, 0))
return columns if columns else config["ui"]["terminal_width"].get(int)
fallback = config["ui"]["terminal_width"].get(int)
# The fcntl and termios modules are not available on non-Unix
# platforms, so we fall back to a constant.
try:
import fcntl
import termios
except ImportError:
return fallback
try:
buf = fcntl.ioctl(0, termios.TIOCGWINSZ, " " * 4)
except OSError:
return fallback
try:
height, width = struct.unpack("hh", buf)
except struct.error:
return fallback
return width
def split_into_lines(string, width_tuple):
@ -1061,9 +1078,7 @@ def _field_diff(field, old, old_fmt, new, new_fmt):
return f"{oldstr} -> {newstr}"
def show_model_changes(
new, old=None, fields=None, always=False, print_obj: bool = True
):
def show_model_changes(new, old=None, fields=None, always=False):
"""Given a Model object, print a list of changes from its pristine
version stored in the database. Return a boolean indicating whether
any changes were found.
@ -1102,7 +1117,7 @@ def show_model_changes(
)
# Print changes.
if print_obj and (changes or always):
if changes or always:
print_(format(old))
if changes:
print_("\n".join(changes))
@ -1110,9 +1125,76 @@ def show_model_changes(
return bool(changes)
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(util.displayable_path, sources))
destinations = list(map(util.displayable_path, destinations))
# Calculate widths for terminal split
col_width = (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 = colordiff(source, dest)
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(" -> ")
print_(f"Source {' ' * title_pad} Destination")
for source, dest in zip(sources, destinations):
pad = max_width - len(source)
color_source, color_dest = colordiff(source, dest)
print_(f"{color_source} {' ' * pad} -> {color_dest}")
# Helper functions for option parsing.
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 UserError(
f"supplied argument `{value}' is not of the form `key=value'"
)
option_values[key] = value
class CommonOptionsParser(optparse.OptionParser):
"""Offers a simple way to add common formatting options.
@ -1598,9 +1680,9 @@ def _raw_main(args: list[str], lib=None) -> None:
and subargs[0] == "config"
and ("-e" in subargs or "--edit" in subargs)
):
from beets.ui.commands.config import config_edit
from beets.ui.commands import config_edit
return config_edit(options)
return config_edit()
test_lib = bool(lib)
subcommands, lib = _setup(options, lib)

2490
beets/ui/commands.py Executable file

File diff suppressed because it is too large Load diff

View file

@ -1,67 +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.
"""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

@ -1,117 +0,0 @@
"""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

@ -1,93 +0,0 @@
"""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

@ -1,41 +0,0 @@
"""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

View file

@ -1,22 +0,0 @@
"""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

@ -1,341 +0,0 @@
"""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

@ -1,570 +0,0 @@
import os
from collections.abc import Sequence
from functools import cached_property
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
VARIOUS_ARTISTS = "Various Artists"
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.
"""
@cached_property
def changed_prefix(self) -> str:
return ui.colorize("changed", "\u2260")
cur_artist = None
# cur_album set if album, cur_title set if singleton
cur_album = None
cur_title = None
match = None
indent_header = ""
indent_detail = ""
def __init__(self):
# Read match header indentation width from config.
match_header_indent_width = config["ui"]["import"]["indentation"][
"match_header"
].as_number()
self.indent_header = ui.indent(match_header_indent_width)
# Read match detail indentation width from config.
match_detail_indent_width = config["ui"]["import"]["indentation"][
"match_details"
].as_number()
self.indent_detail = ui.indent(match_detail_indent_width)
# Read match tracklist indentation width from config
match_tracklist_indent_width = config["ui"]["import"]["indentation"][
"match_tracklist"
].as_number()
self.indent_tracklist = ui.indent(match_tracklist_indent_width)
self.layout = config["ui"]["import"]["layout"].as_choice(
{
"column": 0,
"newline": 1,
}
)
def print_layout(
self, indent, left, right, separator=" -> ", max_width=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):
"""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)}):"
)
if isinstance(self.match.info, autotag.hooks.AlbumInfo):
# Matching an album - print that
artist_album_str = (
f"{self.match.info.artist} - {self.match.info.album}"
)
else:
# Matching a single track
artist_album_str = (
f"{self.match.info.artist} - {self.match.info.title}"
)
ui.print_(
self.indent_header
+ dist_colorize(artist_album_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):
"""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 = "", ""
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_album:
# Album
album_l, album_r = self.cur_album or "", self.match.info.album
if (
self.cur_album != self.match.info.album
and self.match.info.album != VARIOUS_ARTISTS
):
album_l, album_r = ui.colordiff(album_l, album_r)
left = {
"prefix": f"{self.changed_prefix} Album: ",
"contents": album_l,
"suffix": "",
}
right = {"prefix": "", "contents": album_r, "suffix": ""}
self.print_layout(self.indent_detail, left, right)
else:
ui.print_(f"{self.indent_detail}*", "Album:", album_r)
elif self.cur_title:
# Title - for singletons
title_l, title_r = self.cur_title or "", self.match.info.title
if self.cur_title != self.match.info.title:
title_l, title_r = ui.colordiff(title_l, title_r)
left = {
"prefix": f"{self.changed_prefix} Title: ",
"contents": title_l,
"suffix": "",
}
right = {"prefix": "", "contents": title_r, "suffix": ""}
self.print_layout(self.indent_detail, left, right)
else:
ui.print_(f"{self.indent_detail}*", "Title:", title_r)
def make_medium_info_line(self, track_info):
"""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):
"""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, track_info):
"""Format colored track indices."""
cur_track = self.format_index(item)
new_track = self.format_index(track_info)
changed = False
# Choose color based on change.
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, track_info):
"""Format colored track titles."""
new_title = track_info.title
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, track_info):
"""Format colored track lengths."""
changed = False
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, track_info):
"""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 = {
"prefix": f"{self.changed_prefix if changed else '*'} {lhs_track} ",
"contents": lhs_title,
"suffix": f" {lhs_length}",
}
rhs = {"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):
"""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):
"""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):
"""Album change representation, setting cur_album"""
def __init__(self, cur_artist, cur_album, match):
super().__init__()
self.cur_artist = cur_artist
self.cur_album = cur_album
self.match = match
def show_match_tracks(self):
"""Print out the tracks of the match, summarizing changes the match
suggests for them.
"""
# Tracks.
# match is an AlbumMatch NamedTuple, mapping is a dict
# Sort the pairs by the track_info index (at index 1 of the NamedTuple)
pairs = list(self.match.mapping.items())
pairs.sort(key=lambda item_and_track_info: item_and_track_info[1].index)
# Build up LHS and RHS for track difference display. The `lines` list
# contains `(left, right)` tuples.
lines = []
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."""
def __init__(self, cur_artist, cur_title, match):
super().__init__()
self.cur_artist = cur_artist
self.cur_title = cur_title
self.match = match
def show_change(cur_artist, cur_album, match):
"""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_artist, cur_album=cur_album, match=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, match):
"""Print out the change that would occur by tagging `item` with the
metadata from `match`, a TrackMatch object.
"""
change = TrackChange(
cur_artist=item.artist, cur_title=item.title, match=match
)
# Print the match header.
change.show_match_header()
# Print the match details.
change.show_match_details()
def disambig_string(info):
"""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, dist):
"""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):
"""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, limit=None):
"""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)

View file

@ -1,550 +0,0 @@
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} -"
f" {match.info.title if singleton else match.info.album}"
)
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()

View file

@ -1,25 +0,0 @@
"""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

View file

@ -1,162 +0,0 @@
"""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

View file

@ -1,200 +0,0 @@
"""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

@ -1,84 +0,0 @@
"""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

@ -1,62 +0,0 @@
"""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

View file

@ -1,196 +0,0 @@
"""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

@ -1,29 +0,0 @@
"""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

@ -1,23 +0,0 @@
"""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

@ -1,60 +0,0 @@
"""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

@ -27,8 +27,9 @@ import subprocess
import sys
import tempfile
import traceback
import warnings
from collections import Counter
from collections.abc import Callable, Sequence
from collections.abc import Sequence
from contextlib import suppress
from enum import Enum
from functools import cache
@ -40,12 +41,12 @@ from typing import (
TYPE_CHECKING,
Any,
AnyStr,
Callable,
ClassVar,
Generic,
NamedTuple,
TypeVar,
Union,
cast,
)
from unidecode import unidecode
@ -167,12 +168,6 @@ 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.
@ -582,14 +577,10 @@ def hardlink(path: bytes, dest: bytes, replace: bool = False):
if samefile(path, dest):
return
# 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:
if os.path.exists(syspath(dest)) and not replace:
raise FilesystemError("file exists", "rename", (path, dest))
try:
dest_path.hardlink_to(origin_path)
os.link(syspath(path), syspath(dest))
except NotImplementedError:
raise FilesystemError(
"OS does not support hard links.link",
@ -1061,7 +1052,7 @@ def par_map(transform: Callable[[T], Any], items: Sequence[T]) -> None:
pool.join()
class cached_classproperty(Generic[T]):
class cached_classproperty:
"""Descriptor implementing cached class properties.
Provides class-level dynamic property behavior where the getter function is
@ -1069,9 +1060,9 @@ class cached_classproperty(Generic[T]):
instance properties, this operates on the class rather than instances.
"""
cache: ClassVar[dict[tuple[type[object], str], object]] = {}
cache: ClassVar[dict[tuple[Any, str], Any]] = {}
name: str = ""
name: str
# Ideally, we would like to use `Callable[[type[T]], Any]` here,
# however, `mypy` is unable to see this as a **class** property, and thinks
@ -1087,21 +1078,21 @@ class cached_classproperty(Generic[T]):
# "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:
def __init__(self, getter: Callable[[Any], Any]) -> None:
"""Initialize the descriptor with the property getter function."""
self.getter: Callable[..., T] = getter
self.getter = getter
def __set_name__(self, owner: object, name: str) -> None:
def __set_name__(self, owner: Any, name: str) -> None:
"""Capture the attribute name this descriptor is assigned to."""
self.name = name
def __get__(self, instance: object, owner: type[object]) -> T:
def __get__(self, instance: Any, owner: type[Any]) -> Any:
"""Compute and cache if needed, and return the property value."""
key: tuple[type[object], str] = owner, self.name
key = owner, self.name
if key not in self.cache:
self.cache[key] = self.getter(owner)
return cast(T, self.cache[key])
return self.cache[key]
class LazySharedInstance(Generic[T]):
@ -1200,3 +1191,26 @@ def get_temp_filename(
def unique_list(elements: Iterable[T]) -> list[T]:
"""Return a list with unique elements in the original order."""
return list(dict.fromkeys(elements))
def deprecate_imports(
old_module: str, new_module_by_name: dict[str, str], name: str, version: 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):
warnings.warn(
(
f"'{old_module}.{name}' is deprecated and will be removed"
f" in {version}. Use '{new_module}.{name}' instead."
),
DeprecationWarning,
stacklevel=2,
)
return getattr(import_module(new_module), name)
raise AttributeError(f"module '{old_module}' has no attribute '{name}'")

View file

@ -26,7 +26,7 @@ import subprocess
from abc import ABC, abstractmethod
from enum import Enum
from itertools import chain
from typing import TYPE_CHECKING, Any, ClassVar
from typing import Any, ClassVar, Mapping
from urllib.parse import urlencode
from beets import logging, util
@ -37,9 +37,6 @@ from beets.util import (
syspath,
)
if TYPE_CHECKING:
from collections.abc import Mapping
PROXY_URL = "https://images.weserv.nl/"
log = logging.getLogger("beets")

View file

@ -1,60 +0,0 @@
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,6 +105,8 @@ 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)

View file

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

View file

@ -36,13 +36,10 @@ from __future__ import annotations
import queue
import sys
from threading import Lock, Thread
from typing import TYPE_CHECKING, TypeVar
from typing import Callable, Generator, TypeVar
from typing_extensions import TypeVarTuple, Unpack
if TYPE_CHECKING:
from collections.abc import Callable, Generator
BUBBLE = "__PIPELINE_BUBBLE__"
POISON = "__PIPELINE_POISON__"

View file

@ -19,7 +19,14 @@ from __future__ import annotations
import json
import re
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Literal, overload
from typing import (
TYPE_CHECKING,
Iterable,
Iterator,
Literal,
Sequence,
overload,
)
import confuse
from requests_oauthlib import OAuth1Session
@ -35,8 +42,6 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo
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

View file

@ -283,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((f"{message}\n").encode())
self.ctrl_sock.sendall((f"{message}\n").encode("utf-8"))
def _send_event(self, event):
"""Notify subscribed connections of an event."""

View file

@ -27,16 +27,7 @@ import gi
from beets import ui
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
gi.require_version("Gst", "1.0")
from gi.repository import GLib, Gst # noqa: E402
Gst.init(None)

View file

@ -18,8 +18,8 @@ autotagger. Requires the pyacoustid library.
import re
from collections import defaultdict
from collections.abc import Iterable
from functools import cached_property, partial
from typing import Iterable
import acoustid
import confuse

View file

@ -95,18 +95,12 @@ def in_no_convert(item: Item) -> bool:
return False
def should_transcode(item, fmt, force: bool = False):
def should_transcode(item, fmt):
"""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"].get(bool)
config["convert"]["never_convert_lossy_files"]
and item.format.lower() not in LOSSLESS_FORMATS
):
return False
@ -242,16 +236,6 @@ 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]
@ -275,7 +259,6 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
link,
playlist,
force,
) = self._get_opts_and_config(empty_opts)
items = task.imported_items()
@ -289,7 +272,6 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
threads,
items,
force,
)
# Utilities converted from functions to methods on logging overhaul
@ -365,7 +347,6 @@ class ConvertPlugin(BeetsPlugin):
pretend=False,
link=False,
hardlink=False,
force=False,
):
"""A pipeline thread that converts `Item` objects from a
library.
@ -391,11 +372,11 @@ class ConvertPlugin(BeetsPlugin):
if keep_new:
original = dest
converted = item.path
if should_transcode(item, fmt, force):
if should_transcode(item, fmt):
converted = replace_ext(converted, ext)
else:
original = item.path
if should_transcode(item, fmt, force):
if should_transcode(item, fmt):
dest = replace_ext(dest, ext)
converted = dest
@ -425,7 +406,7 @@ class ConvertPlugin(BeetsPlugin):
)
util.move(item.path, original)
if should_transcode(item, fmt, force):
if should_transcode(item, fmt):
linked = False
try:
self.encode(command, original, converted, pretend)
@ -596,7 +577,6 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
link,
playlist,
force,
) = self._get_opts_and_config(opts)
if opts.album:
@ -633,7 +613,6 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
threads,
items,
force,
)
if playlist:
@ -756,7 +735,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,
@ -766,7 +745,6 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
link,
playlist,
force,
)
def _parallel_convert(
@ -780,21 +758,13 @@ 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,
force,
dest, keep_new, path_formats, fmt, pretend, link, hardlink
)
for _ in range(threads)
]

View file

@ -18,7 +18,7 @@ from __future__ import annotations
import collections
import time
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING, Literal, Sequence
import requests
@ -32,8 +32,6 @@ from beets.metadata_plugins import (
)
if TYPE_CHECKING:
from collections.abc import Sequence
from beets.library import Item, Library
from ._typing import JSONDict

View file

@ -27,7 +27,7 @@ import time
import traceback
from functools import cache
from string import ascii_lowercase
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING, Sequence, cast
import confuse
from discogs_client import Client, Master, Release
@ -43,7 +43,7 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.metadata_plugins import MetadataSourcePlugin
if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Sequence
from collections.abc import Callable, Iterable
from beets.library import Item
@ -132,9 +132,9 @@ class DiscogsPlugin(MetadataSourcePlugin):
"user_token": "",
"separator": ", ",
"index_tracks": False,
"featured_string": "Feat.",
"append_style_genre": False,
"strip_disambiguation": True,
"featured_string": "Feat.",
"anv": {
"artist_credit": True,
"artist": False,

View file

@ -25,8 +25,7 @@ import yaml
from beets import plugins, ui, util
from beets.dbcore import types
from beets.importer import Action
from beets.ui.commands.utils import do_query
from beets.util import PromptChoice
from beets.ui.commands import PromptChoice, _do_query
# These "safe" types can avoid the format/parse cycle that most fields go
# through: they are safe to edit with native YAML types.
@ -177,7 +176,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.
items, albums = do_query(lib, args, 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.")

View file

@ -23,7 +23,7 @@ from collections import OrderedDict
from contextlib import closing
from enum import Enum
from functools import cached_property
from typing import TYPE_CHECKING, AnyStr, ClassVar, Literal
from typing import TYPE_CHECKING, AnyStr, ClassVar, Literal, Tuple, Type
import confuse
import requests
@ -86,7 +86,7 @@ class Candidate:
path: None | bytes = None,
url: None | str = None,
match: None | MetadataMatch = None,
size: None | tuple[int, int] = None,
size: None | Tuple[int, int] = None,
):
self._log = log
self.path = path
@ -682,7 +682,7 @@ class GoogleImages(RemoteArtSource):
"""
if not (album.albumartist and album.album):
return
search_string = f"{album.albumartist},{album.album}".encode()
search_string = f"{album.albumartist},{album.album}".encode("utf-8")
try:
response = self.request(
@ -1293,7 +1293,7 @@ class CoverArtUrl(RemoteArtSource):
# All art sources. The order they will be tried in is specified by the config.
ART_SOURCES: set[type[ArtSource]] = {
ART_SOURCES: set[Type[ArtSource]] = {
FileSystem,
CoverArtArchive,
ITunesStore,

View file

@ -19,17 +19,15 @@ from __future__ import annotations
import re
from typing import TYPE_CHECKING
from beets import config, plugins, ui
from beets import plugins, ui
if TYPE_CHECKING:
from beets.importer import ImportSession, ImportTask
from beets.library import Album, Item
from beets.library import Item
def split_on_feat(
artist: str,
for_artist: bool = True,
custom_words: list[str] | None = None,
artist: str, for_artist: bool = True
) -> tuple[str, str | None]:
"""Given an artist string, split the "main" artist from any artist
on the right-hand side of a string like "feat". Return the main
@ -37,9 +35,7 @@ def split_on_feat(
may be a string or None if none is present.
"""
# split on the first "feat".
regex = re.compile(
plugins.feat_tokens(for_artist, custom_words), re.IGNORECASE
)
regex = re.compile(plugins.feat_tokens(for_artist), re.IGNORECASE)
parts = tuple(s.strip() for s in regex.split(artist, 1))
if len(parts) == 1:
return parts[0], None
@ -48,22 +44,18 @@ def split_on_feat(
return parts
def contains_feat(title: str, custom_words: list[str] | None = None) -> bool:
def contains_feat(title: str) -> bool:
"""Determine whether the title contains a "featured" marker."""
return bool(
re.search(
plugins.feat_tokens(for_artist=False, custom_words=custom_words),
plugins.feat_tokens(for_artist=False),
title,
flags=re.IGNORECASE,
)
)
def find_feat_part(
artist: str,
albumartist: str | None,
custom_words: list[str] | None = None,
) -> str | None:
def find_feat_part(artist: str, albumartist: str | None) -> str | None:
"""Attempt to find featured artists in the item's artist fields and
return the results. Returns None if no featured artist found.
"""
@ -77,32 +69,23 @@ def find_feat_part(
# featured artist.
if albumartist_split[1] != "":
# Extract the featured artist from the right-hand side.
_, feat_part = split_on_feat(
albumartist_split[1], custom_words=custom_words
)
_, feat_part = split_on_feat(albumartist_split[1])
return feat_part
# Otherwise, if there's nothing on the right-hand side,
# look for a featuring artist on the left-hand side.
else:
lhs, _ = split_on_feat(
albumartist_split[0], custom_words=custom_words
)
lhs, _ = split_on_feat(albumartist_split[0])
if lhs:
return lhs
# Fall back to conservative handling of the track artist without relying
# on albumartist, which covers compilations using a 'Various Artists'
# albumartist and album tracks by a guest artist featuring a third artist.
_, feat_part = split_on_feat(artist, False, custom_words)
_, feat_part = split_on_feat(artist, False)
return feat_part
def _album_artist_no_feat(album: Album) -> str:
custom_words = config["ftintitle"]["custom_words"].as_str_seq()
return split_on_feat(album["albumartist"], False, list(custom_words))[0]
class FtInTitlePlugin(plugins.BeetsPlugin):
def __init__(self) -> None:
super().__init__()
@ -113,8 +96,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
"drop": False,
"format": "feat. {}",
"keep_in_artist": False,
"preserve_album_artist": True,
"custom_words": [],
}
)
@ -134,29 +115,15 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
if self.config["auto"]:
self.import_stages = [self.imported]
self.album_template_fields["album_artist_no_feat"] = (
_album_artist_no_feat
)
def commands(self) -> list[ui.Subcommand]:
def func(lib, opts, args):
self.config.set_args(opts)
drop_feat = self.config["drop"].get(bool)
keep_in_artist_field = self.config["keep_in_artist"].get(bool)
preserve_album_artist = self.config["preserve_album_artist"].get(
bool
)
custom_words = self.config["custom_words"].get(list)
write = ui.should_write()
for item in lib.items(args):
if self.ft_in_title(
item,
drop_feat,
keep_in_artist_field,
preserve_album_artist,
custom_words,
):
if self.ft_in_title(item, drop_feat, keep_in_artist_field):
item.store()
if write:
item.try_write()
@ -168,17 +135,9 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
"""Import hook for moving featuring artist automatically."""
drop_feat = self.config["drop"].get(bool)
keep_in_artist_field = self.config["keep_in_artist"].get(bool)
preserve_album_artist = self.config["preserve_album_artist"].get(bool)
custom_words = self.config["custom_words"].get(list)
for item in task.imported_items():
if self.ft_in_title(
item,
drop_feat,
keep_in_artist_field,
preserve_album_artist,
custom_words,
):
if self.ft_in_title(item, drop_feat, keep_in_artist_field):
item.store()
def update_metadata(
@ -187,7 +146,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
feat_part: str,
drop_feat: bool,
keep_in_artist_field: bool,
custom_words: list[str],
) -> None:
"""Choose how to add new artists to the title and set the new
metadata. Also, print out messages about any changes that are made.
@ -200,21 +158,17 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
"artist: {.artist} (Not changing due to keep_in_artist)", item
)
else:
track_artist, _ = split_on_feat(
item.artist, custom_words=custom_words
)
track_artist, _ = split_on_feat(item.artist)
self._log.info("artist: {0.artist} -> {1}", item, track_artist)
item.artist = track_artist
if item.artist_sort:
# Just strip the featured artist from the sort name.
item.artist_sort, _ = split_on_feat(
item.artist_sort, custom_words=custom_words
)
item.artist_sort, _ = split_on_feat(item.artist_sort)
# Only update the title if it does not already contain a featured
# artist and if we do not drop featuring information.
if not drop_feat and not contains_feat(item.title, custom_words):
if not drop_feat and not contains_feat(item.title):
feat_format = self.config["format"].as_str()
new_format = feat_format.format(feat_part)
new_title = f"{item.title} {new_format}"
@ -226,8 +180,6 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
item: Item,
drop_feat: bool,
keep_in_artist_field: bool,
preserve_album_artist: bool,
custom_words: list[str],
) -> bool:
"""Look for featured artists in the item's artist fields and move
them to the title.
@ -241,24 +193,22 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
# Check whether there is a featured artist on this track and the
# artist field does not exactly match the album artist field. In
# that case, we attempt to move the featured artist to the title.
if preserve_album_artist and albumartist and artist == albumartist:
if albumartist and artist == albumartist:
return False
_, featured = split_on_feat(artist, custom_words=custom_words)
_, featured = split_on_feat(artist)
if not featured:
return False
self._log.info("{.filepath}", item)
# Attempt to find the featured artist.
feat_part = find_feat_part(artist, albumartist, custom_words)
feat_part = find_feat_part(artist, albumartist)
if not feat_part:
self._log.info("no featuring artists found")
return False
# If we have a featuring artist, move it to the title.
self.update_metadata(
item, feat_part, drop_feat, keep_in_artist_field, custom_words
)
self.update_metadata(item, feat_part, drop_feat, keep_in_artist_field)
return True

27
beetsplug/gmusic.py Normal file
View file

@ -0,0 +1,27 @@
# This file is part of beets.
#
# 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.
"""Deprecation warning for the removed gmusic plugin."""
from beets.plugins import BeetsPlugin
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."
)

View file

@ -1,167 +0,0 @@
"""Adds a `source_path` attribute to imported albums indicating from what path
the album was imported from. Also suggests removing that source path in case
you've removed the album from the library.
"""
import os
from pathlib import Path
from shutil import rmtree
from beets.dbcore.query import PathQuery
from beets.plugins import BeetsPlugin
from beets.ui import colorize as colorize_text
from beets.ui import input_options
class ImportSourcePlugin(BeetsPlugin):
"""Main plugin class."""
def __init__(self):
"""Initialize the plugin and read configuration."""
super().__init__()
self.config.add(
{
"suggest_removal": False,
}
)
self.import_stages = [self.import_stage]
self.register_listener("item_removed", self.suggest_removal)
# In order to stop future removal suggestions for an album we keep
# track of `mb_albumid`s in this set.
self.stop_suggestions_for_albums = set()
# During reimports (import --library) both the import_task_choice and
# the item_removed event are triggered. The item_removed event is
# triggered first. For the import_task_choice event we prevent removal
# suggestions using the existing stop_suggestions_for_album mechanism.
self.register_listener(
"import_task_choice", self.prevent_suggest_removal
)
def prevent_suggest_removal(self, session, task):
for item in task.imported_items():
if "mb_albumid" in item:
self.stop_suggestions_for_albums.add(item.mb_albumid)
def import_stage(self, _, task):
"""Event handler for albums import finished."""
for item in task.imported_items():
# During reimports (import --library), we prevent overwriting the
# source_path attribute with the path from the music library
if "source_path" in item:
self._log.info(
"Preserving source_path of reimported item {}", item.id
)
continue
item["source_path"] = item.path
item.try_sync(write=False, move=False)
def suggest_removal(self, item):
"""Prompts the user to delete the original path the item was imported from."""
if (
not self.config["suggest_removal"]
or item.mb_albumid in self.stop_suggestions_for_albums
):
return
if "source_path" not in item:
self._log.warning(
"Item without source_path (probably imported before plugin "
"usage): {}",
item.filepath,
)
return
srcpath = Path(os.fsdecode(item.source_path))
if not srcpath.is_file():
self._log.warning(
"Original source file no longer exists or is not accessible: {}",
srcpath,
)
return
if not (
os.access(srcpath, os.W_OK)
and os.access(srcpath.parent, os.W_OK | os.X_OK)
):
self._log.warning(
"Original source file cannot be deleted (insufficient permissions): {}",
srcpath,
)
return
# We ask the user whether they'd like to delete the item's source
# directory
item_path = colorize_text("text_warning", item.filepath)
source_path = colorize_text("text_warning", srcpath)
print(
f"The item:\n{item_path}\nis originated from:\n{source_path}\n"
"What would you like to do?"
)
resp = input_options(
[
"Delete the item's source",
"Recursively delete the source's directory",
"do Nothing",
"do nothing and Stop suggesting to delete items from this album",
],
require=True,
)
# Handle user response
if resp == "d":
self._log.info(
"Deleting the item's source file: {}",
srcpath,
)
srcpath.unlink()
elif resp == "r":
self._log.info(
"Searching for other items with a source_path attr containing: {}",
srcpath.parent,
)
source_dir_query = PathQuery(
"source_path",
srcpath.parent,
# The "source_path" attribute may not be present in all
# items of the library, so we avoid errors with this:
fast=False,
)
print("Doing so will delete the following items' sources as well:")
for searched_item in item._db.items(source_dir_query):
print(colorize_text("text_warning", searched_item.filepath))
print("Would you like to continue?")
continue_resp = input_options(
["Yes", "delete None", "delete just the File"],
require=False, # Yes is the a default
)
if continue_resp == "y":
self._log.info(
"Deleting the item's source directory: {}",
srcpath.parent,
)
rmtree(srcpath.parent)
elif continue_resp == "n":
self._log.info("doing nothing - aborting hook function")
return
elif continue_resp == "f":
self._log.info(
"removing just the item's original source: {}",
srcpath,
)
srcpath.unlink()
elif resp == "s":
self.stop_suggestions_for_albums.add(item.mb_albumid)
else:
self._log.info("Doing nothing")

View file

@ -61,18 +61,18 @@ class InlinePlugin(BeetsPlugin):
config["item_fields"].items(), config["pathfields"].items()
):
self._log.debug("adding item field {}", key)
func = self.compile_inline(view.as_str(), False, key)
func = self.compile_inline(view.as_str(), False)
if func is not None:
self.template_fields[key] = func
# Album fields.
for key, view in config["album_fields"].items():
self._log.debug("adding album field {}", key)
func = self.compile_inline(view.as_str(), True, key)
func = self.compile_inline(view.as_str(), True)
if func is not None:
self.album_template_fields[key] = func
def compile_inline(self, python_code, album, field_name):
def compile_inline(self, python_code, album):
"""Given a Python expression or function body, compile it as a path
field function. The returned function takes a single argument, an
Item, and returns a Unicode string. If the expression cannot be
@ -97,12 +97,7 @@ class InlinePlugin(BeetsPlugin):
is_expr = True
def _dict_for(obj):
out = {}
for key in obj.keys(computed=False):
if key == field_name:
continue
out[key] = obj._get(key)
out = dict(obj)
if album:
out["items"] = list(obj.items())
return out

View file

@ -22,13 +22,10 @@ The scraper script used is available here:
https://gist.github.com/1241307
"""
from __future__ import annotations
import os
import traceback
from functools import singledispatchmethod
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Union
import pylast
import yaml
@ -37,9 +34,6 @@ from beets import config, library, plugins, ui
from beets.library import Album, Item
from beets.util import plurality, unique_list
if TYPE_CHECKING:
from beets.library import LibModel
LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY)
PYLAST_EXCEPTIONS = (
@ -106,7 +100,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
"separator": ", ",
"prefer_specific": False,
"title_case": True,
"pretend": False,
"extended_debug": False,
}
)
self.setup()
@ -161,11 +155,6 @@ class LastGenrePlugin(plugins.BeetsPlugin):
flatten_tree(genres_tree, [], c14n_branches)
return c14n_branches, canonicalize
def _tunelog(self, msg, *args, **kwargs):
"""Log tuning messages at DEBUG level when verbosity level is high enough."""
if config["verbose"].as_number() >= 3:
self._log.debug(msg, *args, **kwargs)
@property
def sources(self) -> tuple[str, ...]:
"""A tuple of allowed genre sources. May contain 'track',
@ -297,7 +286,8 @@ class LastGenrePlugin(plugins.BeetsPlugin):
self._genre_cache[key] = self.fetch_genre(method(*args))
genre = self._genre_cache[key]
self._tunelog("last.fm (unfiltered) {} tags: {}", entity, genre)
if self.config["extended_debug"]:
self._log.debug("last.fm (unfiltered) {} tags: {}", entity, genre)
return genre
def fetch_album_genre(self, obj):
@ -331,7 +321,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
return self.config["separator"].as_str().join(formatted)
def _get_existing_genres(self, obj: LibModel) -> list[str]:
def _get_existing_genres(self, obj: Union[Album, Item]) -> list[str]:
"""Return a list of genres for this Item or Album. Empty string genres
are removed."""
separator = self.config["separator"].get()
@ -352,7 +342,9 @@ class LastGenrePlugin(plugins.BeetsPlugin):
combined = old + new
return self._resolve_genres(combined)
def _get_genre(self, obj: LibModel) -> tuple[str | None, ...]:
def _get_genre(
self, obj: Union[Album, Item]
) -> tuple[Union[str, None], ...]:
"""Get the final genre string for an Album or Item object.
`self.sources` specifies allowed genre sources. Starting with the first
@ -467,39 +459,6 @@ class LastGenrePlugin(plugins.BeetsPlugin):
# Beets plugin hooks and CLI.
def _fetch_and_log_genre(self, obj: LibModel) -> None:
"""Fetch genre and log it."""
self._log.info(str(obj))
obj.genre, label = self._get_genre(obj)
self._log.debug("Resolved ({}): {}", label, obj.genre)
ui.show_model_changes(obj, fields=["genre"], print_obj=False)
@singledispatchmethod
def _process(self, obj: LibModel, write: bool) -> None:
"""Process an object, dispatching to the appropriate method."""
raise NotImplementedError
@_process.register
def _process_track(self, obj: Item, write: bool) -> None:
"""Process a single track/item."""
self._fetch_and_log_genre(obj)
if not self.config["pretend"]:
obj.try_sync(write=write, move=False)
@_process.register
def _process_album(self, obj: Album, write: bool) -> None:
"""Process an entire album."""
self._fetch_and_log_genre(obj)
if "track" in self.sources:
for item in obj.items():
self._process(item, write)
if not self.config["pretend"]:
obj.try_sync(
write=write, move=False, inherit="track" not in self.sources
)
def commands(self):
lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres")
lastgenre_cmd.parser.add_option(
@ -557,20 +516,111 @@ class LastGenrePlugin(plugins.BeetsPlugin):
dest="album",
help="match albums instead of items (default)",
)
lastgenre_cmd.parser.add_option(
"-d",
"--debug",
action="store_true",
dest="extended_debug",
help="extended last.fm debug logging",
)
lastgenre_cmd.parser.set_defaults(album=True)
def lastgenre_func(lib, opts, args):
write = ui.should_write()
pretend = getattr(opts, "pretend", False)
self.config.set_args(opts)
method = lib.albums if opts.album else lib.items
for obj in method(args):
self._process(obj, write=ui.should_write())
if opts.album:
# Fetch genres for whole albums
for album in lib.albums(args):
album_genre, src = self._get_genre(album)
prefix = "Pretend: " if pretend else ""
self._log.info(
'{}genre for album "{.album}" ({}): {}',
prefix,
album,
src,
album_genre,
)
if not pretend:
album.genre = album_genre
if "track" in self.sources:
album.store(inherit=False)
else:
album.store()
for item in album.items():
# If we're using track-level sources, also look up each
# track on the album.
if "track" in self.sources:
item_genre, src = self._get_genre(item)
self._log.info(
'{}genre for track "{.title}" ({}): {}',
prefix,
item,
src,
item_genre,
)
if not pretend:
item.genre = item_genre
item.store()
if write and not pretend:
item.try_write()
else:
# Just query singletons, i.e. items that are not part of
# an album
for item in lib.items(args):
item_genre, src = self._get_genre(item)
prefix = "Pretend: " if pretend else ""
self._log.info(
'{}genre for track "{0.title}" ({1}): {}',
prefix,
item,
src,
item_genre,
)
if not pretend:
item.genre = item_genre
item.store()
if write and not pretend:
item.try_write()
lastgenre_cmd.func = lastgenre_func
return [lastgenre_cmd]
def imported(self, session, task):
self._process(task.album if task.is_album else task.item, write=False)
"""Event hook called when an import task finishes."""
if task.is_album:
album = task.album
album.genre, src = self._get_genre(album)
self._log.debug(
'genre for album "{0.album}" ({1}): {0.genre}', album, src
)
# If we're using track-level sources, store the album genre only,
# then also look up individual track genres.
if "track" in self.sources:
album.store(inherit=False)
for item in album.items():
item.genre, src = self._get_genre(item)
self._log.debug(
'genre for track "{0.title}" ({1}): {0.genre}',
item,
src,
)
item.store()
# Store the album genre and inherit to tracks.
else:
album.store()
else:
item = task.item
item.genre, src = self._get_genre(item)
self._log.debug(
'genre for track "{0.title}" ({1}): {0.genre}', item, src
)
item.store()
def _tags_for(self, obj, min_weight=None):
"""Core genre identification routine.

View file

@ -28,7 +28,7 @@ from html import unescape
from http import HTTPStatus
from itertools import groupby
from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple
from typing import TYPE_CHECKING, Iterable, Iterator, NamedTuple
from urllib.parse import quote, quote_plus, urlencode, urlparse
import langdetect
@ -42,8 +42,6 @@ from beets.autotag.distance import string_dist
from beets.util.config import sanitize_choices
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from beets.importer import ImportTask
from beets.library import Item, Library
from beets.logging import BeetsLogger as Logger
@ -747,9 +745,7 @@ class Translator(RequestHandler):
TRANSLATE_URL = "https://api.cognitive.microsofttranslator.com/translate"
LINE_PARTS_RE = re.compile(r"^(\[\d\d:\d\d.\d\d\]|) *(.*)$")
SEPARATOR = " | "
remove_translations = staticmethod(
partial(re.compile(r" / [^\n]+").sub, "")
)
remove_translations = partial(re.compile(r" / [^\n]+").sub, "")
_log: Logger
api_key: str
@ -960,7 +956,7 @@ class LyricsPlugin(RequestHandler, plugins.BeetsPlugin):
@cached_property
def backends(self) -> list[Backend]:
user_sources = self.config["sources"].as_str_seq()
user_sources = self.config["sources"].get()
chosen = sanitize_choices(user_sources, self.BACKEND_BY_NAME)
if "google" in chosen and not self.config["google_API_key"].get():

View file

@ -1,366 +0,0 @@
# This file is part of beets.
# Copyright 2025, Alexis Sarda-Espinosa.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Adds pseudo-releases from MusicBrainz as candidates during import."""
from __future__ import annotations
import itertools
import traceback
from copy import deepcopy
from typing import TYPE_CHECKING, Any
import mediafile
import musicbrainzngs
from typing_extensions import override
from beets import config
from beets.autotag.distance import Distance, distance
from beets.autotag.hooks import AlbumInfo
from beets.autotag.match import assign_items
from beets.plugins import find_plugins
from beets.util.id_extractors import extract_release_id
from beetsplug.musicbrainz import (
RELEASE_INCLUDES,
MusicBrainzAPIError,
MusicBrainzPlugin,
_merge_pseudo_and_actual_album,
_preferred_alias,
)
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from beets.autotag import AlbumMatch
from beets.library import Item
from beetsplug._typing import JSONDict
_STATUS_PSEUDO = "Pseudo-Release"
class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin):
def __init__(self) -> None:
super().__init__()
self._release_getter = musicbrainzngs.get_release_by_id
self.config.add(
{
"scripts": [],
"custom_tags_only": False,
"album_custom_tags": {
"album_transl": "album",
"album_artist_transl": "artist",
},
"track_custom_tags": {
"title_transl": "title",
"artist_transl": "artist",
},
}
)
self._scripts = self.config["scripts"].as_str_seq()
self._log.debug("Desired scripts: {0}", self._scripts)
album_custom_tags = self.config["album_custom_tags"].get().keys()
track_custom_tags = self.config["track_custom_tags"].get().keys()
self._log.debug(
"Custom tags for albums and tracks: {0} + {1}",
album_custom_tags,
track_custom_tags,
)
for custom_tag in album_custom_tags | track_custom_tags:
if not isinstance(custom_tag, str):
continue
media_field = mediafile.MediaField(
mediafile.MP3DescStorageStyle(custom_tag),
mediafile.MP4StorageStyle(
f"----:com.apple.iTunes:{custom_tag}"
),
mediafile.StorageStyle(custom_tag),
mediafile.ASFStorageStyle(custom_tag),
)
try:
self.add_media_field(custom_tag, media_field)
except ValueError:
# ignore errors due to duplicates
pass
self.register_listener("pluginload", self._on_plugins_loaded)
self.register_listener("album_matched", self._adjust_final_album_match)
# noinspection PyMethodMayBeStatic
def _on_plugins_loaded(self):
for plugin in find_plugins():
if isinstance(plugin, MusicBrainzPlugin) and not isinstance(
plugin, MusicBrainzPseudoReleasePlugin
):
raise RuntimeError(
"The musicbrainz plugin should not be enabled together with"
" the mbpseudo plugin"
)
@override
def candidates(
self,
items: Sequence[Item],
artist: str,
album: str,
va_likely: bool,
) -> Iterable[AlbumInfo]:
if len(self._scripts) == 0:
yield from super().candidates(items, artist, album, va_likely)
else:
for album_info in super().candidates(
items, artist, album, va_likely
):
if isinstance(album_info, PseudoAlbumInfo):
self._log.debug(
"Using {0} release for distance calculations for album {1}",
album_info.determine_best_ref(items),
album_info.album_id,
)
yield album_info # first yield pseudo to give it priority
yield album_info.get_official_release()
else:
yield album_info
@override
def album_info(self, release: JSONDict) -> AlbumInfo:
official_release = super().album_info(release)
if release.get("status") == _STATUS_PSEUDO:
return official_release
elif pseudo_release_ids := self._intercept_mb_release(release):
album_id = self._extract_id(pseudo_release_ids[0])
try:
raw_pseudo_release = self._release_getter(
album_id, RELEASE_INCLUDES
)["release"]
pseudo_release = super().album_info(raw_pseudo_release)
if self.config["custom_tags_only"].get(bool):
self._replace_artist_with_alias(
raw_pseudo_release, pseudo_release
)
self._add_custom_tags(official_release, pseudo_release)
return official_release
else:
return PseudoAlbumInfo(
pseudo_release=_merge_pseudo_and_actual_album(
pseudo_release, official_release
),
official_release=official_release,
)
except musicbrainzngs.MusicBrainzError as exc:
raise MusicBrainzAPIError(
exc,
"get pseudo-release by ID",
album_id,
traceback.format_exc(),
)
else:
return official_release
def _intercept_mb_release(self, data: JSONDict) -> list[str]:
album_id = data["id"] if "id" in data else None
if self._has_desired_script(data) or not isinstance(album_id, str):
return []
return [
pr_id
for rel in data.get("release-relation-list", [])
if (pr_id := self._wanted_pseudo_release_id(album_id, rel))
is not None
]
def _has_desired_script(self, release: JSONDict) -> bool:
if len(self._scripts) == 0:
return False
elif script := release.get("text-representation", {}).get("script"):
return script in self._scripts
else:
return False
def _wanted_pseudo_release_id(
self,
album_id: str,
relation: JSONDict,
) -> str | None:
if (
len(self._scripts) == 0
or relation.get("type", "") != "transl-tracklisting"
or relation.get("direction", "") != "forward"
or "release" not in relation
):
return None
release = relation["release"]
if "id" in release and self._has_desired_script(release):
self._log.debug(
"Adding pseudo-release {0} for main release {1}",
release["id"],
album_id,
)
return release["id"]
else:
return None
def _replace_artist_with_alias(
self,
raw_pseudo_release: JSONDict,
pseudo_release: AlbumInfo,
):
"""Use the pseudo-release's language to search for artist
alias if the user hasn't configured import languages."""
if len(config["import"]["languages"].as_str_seq()) > 0:
return
lang = raw_pseudo_release.get("text-representation", {}).get("language")
artist_credits = raw_pseudo_release.get("release-group", {}).get(
"artist-credit", []
)
aliases = [
artist_credit.get("artist", {}).get("alias-list", [])
for artist_credit in artist_credits
]
if lang and len(lang) >= 2 and len(aliases) > 0:
locale = lang[0:2]
aliases_flattened = list(itertools.chain.from_iterable(aliases))
self._log.debug(
"Using locale '{0}' to search aliases {1}",
locale,
aliases_flattened,
)
if alias_dict := _preferred_alias(aliases_flattened, [locale]):
if alias := alias_dict.get("alias"):
self._log.debug("Got alias '{0}'", alias)
pseudo_release.artist = alias
for track in pseudo_release.tracks:
track.artist = alias
def _add_custom_tags(
self,
official_release: AlbumInfo,
pseudo_release: AlbumInfo,
):
for tag_key, pseudo_key in (
self.config["album_custom_tags"].get().items()
):
official_release[tag_key] = pseudo_release[pseudo_key]
track_custom_tags = self.config["track_custom_tags"].get().items()
for track, pseudo_track in zip(
official_release.tracks, pseudo_release.tracks
):
for tag_key, pseudo_key in track_custom_tags:
track[tag_key] = pseudo_track[pseudo_key]
def _adjust_final_album_match(self, match: AlbumMatch):
album_info = match.info
if isinstance(album_info, PseudoAlbumInfo):
self._log.debug(
"Switching {0} to pseudo-release source for final proposal",
album_info.album_id,
)
album_info.use_pseudo_as_ref()
mapping = match.mapping
new_mappings, _, _ = assign_items(
list(mapping.keys()), album_info.tracks
)
mapping.update(new_mappings)
if album_info.data_source == self.data_source:
album_info.data_source = "MusicBrainz"
@override
def _extract_id(self, url: str) -> str | None:
return extract_release_id("MusicBrainz", url)
class PseudoAlbumInfo(AlbumInfo):
"""This is a not-so-ugly hack.
We want the pseudo-release to result in a distance that is lower or equal to that of
the official release, otherwise it won't qualify as a good candidate. However, if
the input is in a script that's different from the pseudo-release (and we want to
translate/transliterate it in the library), it will receive unwanted penalties.
This class is essentially a view of the ``AlbumInfo`` of both official and
pseudo-releases, where it's possible to change the details that are exposed to other
parts of the auto-tagger, enabling a "fair" distance calculation based on the
current input's script but still preferring the translation/transliteration in the
final proposal.
"""
def __init__(
self,
pseudo_release: AlbumInfo,
official_release: AlbumInfo,
**kwargs,
):
super().__init__(pseudo_release.tracks, **kwargs)
self.__dict__["_pseudo_source"] = True
self.__dict__["_official_release"] = official_release
for k, v in pseudo_release.items():
if k not in kwargs:
self[k] = v
def get_official_release(self) -> AlbumInfo:
return self.__dict__["_official_release"]
def determine_best_ref(self, items: Sequence[Item]) -> str:
self.use_pseudo_as_ref()
pseudo_dist = self._compute_distance(items)
self.use_official_as_ref()
official_dist = self._compute_distance(items)
if official_dist < pseudo_dist:
self.use_official_as_ref()
return "official"
else:
self.use_pseudo_as_ref()
return "pseudo"
def _compute_distance(self, items: Sequence[Item]) -> Distance:
mapping, _, _ = assign_items(items, self.tracks)
return distance(items, self, mapping)
def use_pseudo_as_ref(self):
self.__dict__["_pseudo_source"] = True
def use_official_as_ref(self):
self.__dict__["_pseudo_source"] = False
def __getattr__(self, attr: str) -> Any:
# ensure we don't duplicate an official release's id, always return pseudo's
if self.__dict__["_pseudo_source"] or attr == "album_id":
return super().__getattr__(attr)
else:
return self.__dict__["_official_release"].__getattr__(attr)
def __deepcopy__(self, memo):
cls = self.__class__
result = cls.__new__(cls)
memo[id(self)] = result
result.__dict__.update(self.__dict__)
for k, v in self.items():
result[k] = deepcopy(v, memo)
return result

View file

@ -26,7 +26,8 @@ import subprocess
from beets import ui
from beets.autotag import Recommendation
from beets.plugins import BeetsPlugin
from beets.util import PromptChoice, displayable_path
from beets.ui.commands import PromptChoice
from beets.util import displayable_path
from beetsplug.info import print_data

View file

@ -21,7 +21,7 @@ from collections import Counter
from contextlib import suppress
from functools import cached_property
from itertools import product
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Iterable, Sequence
from urllib.parse import urljoin
import musicbrainzngs
@ -31,11 +31,9 @@ import beets
import beets.autotag.hooks
from beets import config, plugins, util
from beets.metadata_plugins import MetadataSourcePlugin
from beets.util.deprecation import deprecate_for_user
from beets.util.id_extractors import extract_release_id
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from typing import Literal
from beets.library import Item
@ -91,7 +89,6 @@ RELEASE_INCLUDES = list(
"isrcs",
"url-rels",
"release-rels",
"genres",
"tags",
}
& set(musicbrainzngs.VALID_INCLUDES["release"])
@ -121,15 +118,13 @@ BROWSE_CHUNKSIZE = 100
BROWSE_MAXTRACKS = 500
def _preferred_alias(
aliases: list[JSONDict], languages: list[str] | None = None
) -> JSONDict | None:
"""Given a list of alias structures for an artist credit, select
and return the user's preferred alias or None if no matching
def _preferred_alias(aliases: list[JSONDict]):
"""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 None
return
# Only consider aliases that have locales set.
valid_aliases = [a for a in aliases if "locale" in a]
@ -139,10 +134,7 @@ def _preferred_alias(
ignored_alias_types = [a.lower() for a in ignored_alias_types]
# Search configured locales in order.
if languages is None:
languages = config["import"]["languages"].as_str_seq()
for locale in languages:
for locale in config["import"]["languages"].as_str_seq():
# Find matching primary aliases for this locale that are not
# being ignored
matches = []
@ -160,8 +152,6 @@ def _preferred_alias(
return matches[0]
return None
def _multi_artist_credit(
credit: list[JSONDict], include_join_phrase: bool
@ -333,7 +323,7 @@ def _find_actual_release_from_pseudo_release(
def _merge_pseudo_and_actual_album(
pseudo: beets.autotag.hooks.AlbumInfo, actual: beets.autotag.hooks.AlbumInfo
) -> beets.autotag.hooks.AlbumInfo:
) -> beets.autotag.hooks.AlbumInfo | None:
"""
Merges a pseudo release with its actual release.
@ -372,10 +362,6 @@ def _merge_pseudo_and_actual_album(
class MusicBrainzPlugin(MetadataSourcePlugin):
@cached_property
def genres_field(self) -> str:
return f"{self.config['genres_tag'].as_choice(['genre', 'tag'])}-list"
def __init__(self):
"""Set up the python-musicbrainz-ngs module according to settings
from the beets configuration. This should be called at startup.
@ -388,7 +374,6 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
"ratelimit": 1,
"ratelimit_interval": 1,
"genres": False,
"genres_tag": "genre",
"external_ids": {
"discogs": False,
"bandcamp": False,
@ -404,10 +389,9 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
self.config["search_limit"] = self.config["match"][
"searchlimit"
].get()
deprecate_for_user(
self._log,
"'musicbrainz.searchlimit' configuration option",
"'musicbrainz.search_limit'",
self._log.warning(
"'musicbrainz.searchlimit' option is deprecated and will be "
"removed in 3.0.0. Use 'musicbrainz.search_limit' instead."
)
hostname = self.config["host"].as_str()
https = self.config["https"].get(bool)
@ -731,8 +715,8 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
if self.config["genres"]:
sources = [
release["release-group"].get(self.genres_field, []),
release.get(self.genres_field, []),
release["release-group"].get("tag-list", []),
release.get("tag-list", []),
]
genres: Counter[str] = Counter()
for source in sources:

View file

@ -21,17 +21,13 @@ from os.path import relpath
from beets import config, ui, util
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
from beets.util import PromptChoice, get_temp_filename
from beets.ui.commands import PromptChoice
from beets.util import get_temp_filename
# Indicate where arguments should be inserted into the command string.
# If this is missing, they're placed at the end.
ARGS_MARKER = "$args"
# Indicate where the playlist file (with absolute path) should be inserted into
# the command string. If this is missing, its placed at the end, but before
# arguments.
PLS_MARKER = "$playlist"
def play(
command_str,
@ -136,23 +132,8 @@ class PlayPlugin(BeetsPlugin):
return
open_args = self._playlist_or_paths(paths)
open_args_str = [
p.decode("utf-8") for p in self._playlist_or_paths(paths)
]
command_str = self._command_str(opts.args)
if PLS_MARKER in command_str:
if not config["play"]["raw"]:
command_str = command_str.replace(
PLS_MARKER, "".join(open_args_str)
)
self._log.debug(
"command altered by PLS_MARKER to: {}", command_str
)
open_args = []
else:
command_str = command_str.replace(PLS_MARKER, " ")
# Check if the selection exceeds configured threshold. If True,
# cancel, otherwise proceed with play command.
if opts.yes or not self._exceeds_threshold(
@ -181,7 +162,6 @@ class PlayPlugin(BeetsPlugin):
return paths
else:
return [self._create_tmp_playlist(paths)]
return [shlex.quote(self._create_tmp_playlist(paths))]
def _exceeds_threshold(
self, selection, command_str, open_args, item_type="track"

View file

@ -28,7 +28,7 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass
from multiprocessing.pool import ThreadPool
from threading import Event, Thread
from typing import TYPE_CHECKING, Any, TypeVar
from typing import TYPE_CHECKING, Any, Callable, TypeVar
from beets import ui
from beets.plugins import BeetsPlugin
@ -36,7 +36,7 @@ from beets.util import command_output, displayable_path, syspath
if TYPE_CHECKING:
import optparse
from collections.abc import Callable, Sequence
from collections.abc import Sequence
from logging import Logger
from confuse import ConfigView

View file

@ -13,9 +13,8 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Adds Spotify release and track search support to the autotagger.
Also includes Spotify playlist construction.
"""Adds Spotify release and track search support to the autotagger, along with
Spotify playlist construction.
"""
from __future__ import annotations
@ -24,10 +23,9 @@ import base64
import collections
import json
import re
import threading
import time
import webbrowser
from typing import TYPE_CHECKING, Any, Literal, Union
from typing import TYPE_CHECKING, Any, Literal, Sequence, Union
import confuse
import requests
@ -43,8 +41,6 @@ from beets.metadata_plugins import (
)
if TYPE_CHECKING:
from collections.abc import Sequence
from beets.library import Library
from beetsplug._typing import JSONDict
@ -54,14 +50,13 @@ DEFAULT_WAITING_TIME = 5
class SearchResponseAlbums(IDResponse):
"""A response returned by the Spotify API.
We only use items and disregard the pagination information. i.e.
res["albums"]["items"][0].
We only use items and disregard the pagination information.
i.e. res["albums"]["items"][0].
There are more fields in the response, but we only type the ones we
currently use.
There are more fields in the response, but we only type
the ones we currently use.
see https://developer.spotify.com/documentation/web-api/reference/search
"""
album_type: str
@ -82,12 +77,6 @@ class APIError(Exception):
pass
class AudioFeaturesUnavailableError(Exception):
"""Raised when audio features API returns 403 (deprecated)."""
pass
class SpotifyPlugin(
SearchApiMetadataSourcePlugin[
Union[SearchResponseAlbums, SearchResponseTracks]
@ -151,12 +140,6 @@ class SpotifyPlugin(
self.config["client_id"].redact = True
self.config["client_secret"].redact = True
self.audio_features_available = (
True # Track if audio features API is available
)
self._audio_features_lock = (
threading.Lock()
) # Protects audio_features_available
self.setup()
def setup(self):
@ -175,7 +158,9 @@ class SpotifyPlugin(
return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
def _authenticate(self) -> None:
"""Request an access token via the Client Credentials Flow: https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow"""
"""Request an access token via the Client Credentials Flow:
https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow
"""
c_id: str = self.config["client_id"].as_str()
c_secret: str = self.config["client_secret"].as_str()
@ -216,9 +201,9 @@ class SpotifyPlugin(
:param method: HTTP method to use for the request.
:param url: URL for the new :class:`Request` object.
:param dict params: (optional) list of tuples or bytes to send
:param params: (optional) list of tuples or bytes to send
in the query string for the :class:`Request`.
:type params: dict
"""
if retry_count > max_retries:
@ -261,17 +246,6 @@ class SpotifyPlugin(
f"API Error: {e.response.status_code}\n"
f"URL: {url}\nparams: {params}"
)
elif e.response.status_code == 403:
# Check if this is the audio features endpoint
if url.startswith(self.audio_features_url):
raise AudioFeaturesUnavailableError(
"Audio features API returned 403 "
"(deprecated or unavailable)"
)
raise APIError(
f"API Error: {e.response.status_code}\n"
f"URL: {url}\nparams: {params}"
)
elif e.response.status_code == 429:
seconds = e.response.headers.get(
"Retry-After", DEFAULT_WAITING_TIME
@ -294,8 +268,7 @@ class SpotifyPlugin(
raise APIError("Bad Gateway.")
elif e.response is not None:
raise APIError(
f"{self.data_source} API error:\n"
f"{e.response.text}\n"
f"{self.data_source} API error:\n{e.response.text}\n"
f"URL:\n{url}\nparams:\n{params}"
)
else:
@ -306,11 +279,10 @@ class SpotifyPlugin(
"""Fetch an album by its Spotify ID or URL and return an
AlbumInfo object or None if the album is not found.
:param str album_id: Spotify ID or URL for the album
:returns: AlbumInfo object for album
:param album_id: Spotify ID or URL for the album
:type album_id: str
:return: AlbumInfo object for album
:rtype: beets.autotag.hooks.AlbumInfo or None
"""
if not (spotify_id := self._extract_id(album_id)):
return None
@ -384,9 +356,7 @@ class SpotifyPlugin(
:param track_data: Simplified track object
(https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified)
:returns: TrackInfo object for track
:return: TrackInfo object for track
"""
artist, artist_id = self.get_artist(track_data["artists"])
@ -415,7 +385,6 @@ class SpotifyPlugin(
"""Fetch a track by its Spotify ID or URL.
Returns a TrackInfo object or None if the track is not found.
"""
if not (spotify_id := self._extract_id(track_id)):
@ -456,11 +425,10 @@ class SpotifyPlugin(
"""Query the Spotify Search API for the specified ``query_string``,
applying the provided ``filters``.
:param query_type: Item type to search across. Valid types are: 'album',
'artist', 'playlist', and 'track'.
:param query_type: Item type to search across. Valid types are:
'album', 'artist', 'playlist', and 'track'.
:param filters: Field filters to apply.
:param query_string: Additional query to include in the search.
"""
query = self._construct_search_query(
filters=filters, query_string=query_string
@ -555,16 +523,13 @@ class SpotifyPlugin(
return True
def _match_library_tracks(self, library: Library, keywords: str):
"""Get simplified track object dicts for library tracks.
Matches tracks based on the specified ``keywords``.
"""Get a list of simplified track object dicts for library tracks
matching the specified ``keywords``.
:param library: beets library object to query.
:param keywords: Query to match library items against.
:returns: List of simplified track object dicts for library
items matching the specified query.
:return: List of simplified track object dicts for library items
matching the specified query.
"""
results = []
failures = []
@ -675,14 +640,12 @@ class SpotifyPlugin(
return results
def _output_match_results(self, results):
"""Open a playlist or print Spotify URLs.
Uses the provided track object dicts.
:param list[dict] results: List of simplified track object dicts
(https://developer.spotify.com/documentation/web-api/
reference/object-model/#track-object-simplified)
"""Open a playlist or print Spotify URLs for the provided track
object dicts.
:param results: List of simplified track object dicts
(https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified)
:type results: list[dict]
"""
if results:
spotify_ids = [track_data["id"] for track_data in results]
@ -728,18 +691,13 @@ class SpotifyPlugin(
item["isrc"] = isrc
item["ean"] = ean
item["upc"] = upc
if self.audio_features_available:
audio_features = self.track_audio_features(spotify_track_id)
if audio_features is None:
self._log.info("No audio features found for: {}", item)
else:
for feature, value in audio_features.items():
if feature in self.spotify_audio_features:
item[self.spotify_audio_features[feature]] = value
audio_features = self.track_audio_features(spotify_track_id)
if audio_features is None:
self._log.info("No audio features found for: {}", item)
else:
self._log.debug("Audio features API unavailable, skipping")
for feature, value in audio_features.items():
if feature in self.spotify_audio_features:
item[self.spotify_audio_features[feature]] = value
item["spotify_updated"] = time.time()
item.store()
if write:
@ -763,34 +721,11 @@ class SpotifyPlugin(
)
def track_audio_features(self, track_id: str):
"""Fetch track audio features by its Spotify ID.
Thread-safe: avoids redundant API calls and logs the 403 warning only
once.
"""
# Fast path: if we've already detected unavailability, skip the call.
with self._audio_features_lock:
if not self.audio_features_available:
return None
"""Fetch track audio features by its Spotify ID."""
try:
return self._handle_response(
"get", f"{self.audio_features_url}{track_id}"
)
except AudioFeaturesUnavailableError:
# Disable globally in a thread-safe manner and warn once.
should_log = False
with self._audio_features_lock:
if self.audio_features_available:
self.audio_features_available = False
should_log = True
if should_log:
self._log.warning(
"Audio features API is unavailable (403 error). "
"Skipping audio features for remaining tracks."
)
return None
except APIError as e:
self._log.debug("Spotify API error: {}", e)
return None

View file

@ -1,236 +0,0 @@
# This file is part of beets.
# Copyright 2025, Henry Oberholtzer
#
# 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.
"""Apply NYT manual of style title case rules, to text.
Title case logic is derived from the python-titlecase library.
Provides a template function and a tag modification function."""
import re
from functools import cached_property
from typing import TypedDict
from titlecase import titlecase
from beets import ui
from beets.autotag.hooks import AlbumInfo, Info
from beets.importer import ImportSession, ImportTask
from beets.library import Item
from beets.plugins import BeetsPlugin
__author__ = "henryoberholtzer@gmail.com"
__version__ = "1.0"
class PreservedText(TypedDict):
words: dict[str, str]
phrases: dict[str, re.Pattern[str]]
class TitlecasePlugin(BeetsPlugin):
def __init__(self) -> None:
super().__init__()
self.config.add(
{
"auto": True,
"preserve": [],
"fields": [],
"replace": [],
"seperators": [],
"force_lowercase": False,
"small_first_last": True,
"the_artist": True,
"after_choice": False,
}
)
"""
auto - Automatically apply titlecase to new import metadata.
preserve - Provide a list of strings with specific case requirements.
fields - Fields to apply titlecase to.
replace - List of pairs, first is the target, second is the replacement
seperators - Other characters to treat like periods.
force_lowercase - Lowercases the string before titlecasing.
small_first_last - If small characters should be cased at the start of strings.
the_artist - If the plugin infers the field to be an artist field
(e.g. the field contains "artist")
It will capitalize a lowercase The, helpful for the artist names
that start with 'The', like 'The Who' or 'The Talking Heads' when
they are not at the start of a string. Superceded by preserved phrases.
"""
# Register template function
self.template_funcs["titlecase"] = self.titlecase
# Register UI subcommands
self._command = ui.Subcommand(
"titlecase",
help="Apply titlecasing to metadata specified in config.",
)
if self.config["auto"].get(bool):
if self.config["after_choice"].get(bool):
self.import_stages = [self.imported]
else:
self.register_listener(
"trackinfo_received", self.received_info_handler
)
self.register_listener(
"albuminfo_received", self.received_info_handler
)
@cached_property
def force_lowercase(self) -> bool:
return self.config["force_lowercase"].get(bool)
@cached_property
def replace(self) -> list[tuple[str, str]]:
return self.config["replace"].as_pairs()
@cached_property
def the_artist(self) -> bool:
return self.config["the_artist"].get(bool)
@cached_property
def fields_to_process(self) -> set[str]:
fields = set(self.config["fields"].as_str_seq())
self._log.debug(f"fields: {', '.join(fields)}")
return fields
@cached_property
def preserve(self) -> PreservedText:
strings = self.config["preserve"].as_str_seq()
preserved: PreservedText = {"words": {}, "phrases": {}}
for s in strings:
if " " in s:
preserved["phrases"][s] = re.compile(
rf"\b{re.escape(s)}\b", re.IGNORECASE
)
else:
preserved["words"][s.upper()] = s
return preserved
@cached_property
def seperators(self) -> re.Pattern[str] | None:
if seperators := "".join(
dict.fromkeys(self.config["seperators"].as_str_seq())
):
return re.compile(rf"(.*?[{re.escape(seperators)}]+)(\s*)(?=.)")
return None
@cached_property
def small_first_last(self) -> bool:
return self.config["small_first_last"].get(bool)
@cached_property
def the_artist_regexp(self) -> re.Pattern[str]:
return re.compile(r"\bthe\b")
def titlecase_callback(self, word, **kwargs) -> str | None:
"""Callback function for words to preserve case of."""
if preserved_word := self.preserve["words"].get(word.upper(), ""):
return preserved_word
return None
def received_info_handler(self, info: Info):
"""Calls titlecase fields for AlbumInfo or TrackInfo
Processes the tracks field for AlbumInfo
"""
self.titlecase_fields(info)
if isinstance(info, AlbumInfo):
for track in info.tracks:
self.titlecase_fields(track)
def commands(self) -> list[ui.Subcommand]:
def func(lib, opts, args):
write = ui.should_write()
for item in lib.items(args):
self._log.info(f"titlecasing {item.title}:")
self.titlecase_fields(item)
item.store()
if write:
item.try_write()
self._command.func = func
return [self._command]
def titlecase_fields(self, item: Item | Info) -> None:
"""Applies titlecase to fields, except
those excluded by the default exclusions and the
set exclude lists.
"""
for field in self.fields_to_process:
init_field = getattr(item, field, "")
if init_field:
if isinstance(init_field, list) and isinstance(
init_field[0], str
):
cased_list: list[str] = [
self.titlecase(i, field) for i in init_field
]
if cased_list != init_field:
setattr(item, field, cased_list)
self._log.info(
f"{field}: {', '.join(init_field)} ->",
f"{', '.join(cased_list)}",
)
elif isinstance(init_field, str):
cased: str = self.titlecase(init_field, field)
if cased != init_field:
setattr(item, field, cased)
self._log.info(f"{field}: {init_field} -> {cased}")
else:
self._log.debug(f"{field}: no string present")
else:
self._log.debug(f"{field}: does not exist on {type(item)}")
def titlecase(self, text: str, field: str = "") -> str:
"""Titlecase the given text."""
# Check we should split this into two substrings.
if self.seperators:
if len(splits := self.seperators.findall(text)):
split_cased = "".join(
[self.titlecase(s[0], field) + s[1] for s in splits]
)
# Add on the remaining portion
return split_cased + self.titlecase(
text[len(split_cased) :], field
)
# Any necessary replacements go first, mainly punctuation.
titlecased = text.lower() if self.force_lowercase else text
for pair in self.replace:
target, replacement = pair
titlecased = titlecased.replace(target, replacement)
# General titlecase operation
titlecased = titlecase(
titlecased,
small_first_last=self.small_first_last,
callback=self.titlecase_callback,
)
# Apply "The Artist" feature
if self.the_artist and "artist" in field:
titlecased = self.the_artist_regexp.sub("The", titlecased)
# More complicated phrase replacements.
for phrase, regexp in self.preserve["phrases"].items():
titlecased = regexp.sub(phrase, titlecased)
return titlecased
def imported(self, session: ImportSession, task: ImportTask) -> None:
"""Import hook for titlecasing on import."""
for item in task.imported_items():
try:
self._log.debug(f"titlecasing {item.title}:")
self.titlecase_fields(item)
item.store()
except Exception as e:
self._log.debug(f"titlecasing exception {e}")

View file

@ -17,10 +17,9 @@
import base64
import json
import os
import typing as t
import flask
from flask import jsonify
from flask import g, jsonify
from unidecode import unidecode
from werkzeug.routing import BaseConverter, PathConverter
@ -29,17 +28,6 @@ from beets import ui, util
from beets.dbcore.query import PathQuery
from beets.plugins import BeetsPlugin
# Type checking hacks
if t.TYPE_CHECKING:
class LibraryCtx(flask.ctx._AppCtxGlobals):
lib: beets.library.Library
g = LibraryCtx()
else:
from flask import g
# Utilities.
@ -244,7 +232,7 @@ def _get_unique_table_field_values(model, field, sort_field):
raise KeyError
with g.lib.transaction() as tx:
rows = tx.query(
f"SELECT DISTINCT {field} FROM {model._table} ORDER BY {sort_field}"
f"SELECT DISTINCT '{field}' FROM '{model._table}' ORDER BY '{sort_field}'"
)
return [row[0] for row in rows]

View file

@ -241,11 +241,6 @@ var AppView = Backbone.View.extend({
'pause': _.bind(this.audioPause, this),
'ended': _.bind(this.audioEnded, this)
});
if ("mediaSession" in navigator) {
navigator.mediaSession.setActionHandler("nexttrack", () => {
this.playNext();
});
}
},
showItems: function(items) {
this.shownItems = items;
@ -311,9 +306,7 @@ var AppView = Backbone.View.extend({
},
audioEnded: function() {
this.playingItem.entryView.setPlaying(false);
this.playNext();
},
playNext: function(){
// Try to play the next track.
var idx = this.shownItems.indexOf(this.playingItem);
if (idx == -1) {

View file

@ -41,7 +41,6 @@ class ZeroPlugin(BeetsPlugin):
"fields": [],
"keep_fields": [],
"update_database": False,
"omit_single_disc": False,
}
)
@ -124,14 +123,9 @@ class ZeroPlugin(BeetsPlugin):
"""
fields_set = False
if "disc" in tags and self.config["omit_single_disc"].get(bool):
if item.disctotal == 1:
fields_set = True
self._log.debug("disc: {.disc} -> None", item)
tags["disc"] = None
if not self.fields_to_progs:
self._log.warning("no fields list to remove")
self._log.warning("no fields, nothing to do")
return False
for field, progs in self.fields_to_progs.items():
if field in tags:

View file

@ -7,115 +7,14 @@ below!
Unreleased
----------
Beets now requires Python 3.10 or later since support for EOL Python 3.9 has
been dropped.
New features:
- :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle.
- :doc:`plugins/ftintitle`: Added album template value ``album_artist_no_feat``.
- :doc:`plugins/musicbrainz`: Allow selecting tags or genres to populate the
genres tag.
- :doc:`plugins/ftintitle`: Added argument to skip the processing of artist and
album artist are the same in ftintitle.
- :doc:`plugins/play`: Added `$playlist` marker to precisely edit the playlist
filepath into the command calling the player program.
- :doc:`plugins/lastgenre`: For tuning plugin settings ``-vvv`` can be passed
to receive extra verbose logging around last.fm results and how they are
resolved. The ``extended_debug`` config setting and ``--debug`` option
have been removed.
- :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive
MusicBrainz pseudo-releases as recommendations during import.
- Added support for Python 3.13.
- :doc:`/plugins/convert`: ``force`` can be passed to override checks like
no_convert, never_convert_lossy_files, same format, and max_bitrate
- :doc:`plugins/titlecase`: Add the `titlecase` plugin to allow users to
resolve differences in metadata source styles.
Bug fixes:
- :doc:`plugins/inline`: Fix recursion error when an inline field definition
shadows a built-in item field (e.g., redefining ``track_no``). Inline
expressions now skip self-references during evaluation to avoid infinite
recursion. :bug:`6115`
- When hardlinking from a symlink (e.g. importing a symlink with hardlinking
enabled), dereference the symlink then hardlink, rather than creating a new
(potentially broken) symlink :bug:`5676`
- :doc:`/plugins/spotify`: The plugin now gracefully handles audio-features API
deprecation (HTTP 403 errors). When a 403 error is encountered from the
audio-features endpoint, the plugin logs a warning once and skips audio
features for all remaining tracks in the session, avoiding unnecessary API
calls and rate limit exhaustion.
- Running `beet --config <mypath> config -e` now edits `<mypath>` rather than
the default config path. :bug:`5652`
- :doc:`plugins/lyrics`: Accepts strings for lyrics sources (previously only
accepted a list of strings). :bug:`5962`
- Fix a bug introduced in release 2.4.0 where import from any valid
import-log-file always threw a "none of the paths are importable" error.
- :doc:`/plugins/web`: repair broken `/item/values/…` and `/albums/values/…`
endpoints. Previously, due to single-quotes (ie. string literal) in the SQL
query, the query eg. `GET /item/values/albumartist` would return the literal
"albumartist" instead of a list of unique album artists.
- Sanitize log messages by removing control characters preventing terminal
rendering issues.
For plugin developers:
- A new plugin event, ``album_matched``, is sent when an album that is being
imported has been matched to its metadata and the corresponding distance has
been calculated.
For packagers:
- The minimum supported Python version is now 3.10.
Other changes:
- The documentation chapter :doc:`dev/paths` has been moved to the "For
Developers" section and revised to reflect current best practices (pathlib
usage).
- Refactored the ``beets/ui/commands.py`` monolithic file (2000+ lines) into
multiple modules within the ``beets/ui/commands`` directory for better
maintainability.
- :doc:`plugins/bpd`: Raise ImportError instead of ValueError when GStreamer is
unavailable, enabling ``importorskip`` usage in pytest setup.
- Finally removed gmusic plugin and all related code/docs as the Google Play
Music service was shut down in 2020.
2.5.1 (October 14, 2025)
------------------------
New features:
- :doc:`plugins/zero`: Add new configuration option, ``omit_single_disc``, to
allow zeroing the disc number on write for single-disc albums. Defaults to
False.
Bug fixes:
- |BeetsPlugin|: load the last plugin class defined in the plugin namespace.
:bug:`6093`
For packagers:
- Fixed issue with legacy metadata plugins not copying properties from the base
class.
- Reverted the following: When installing ``beets`` via git or locally the
version string now reflects the current git branch and commit hash.
:bug:`6089`
Other changes:
- Removed outdated mailing list contact information from the documentation
:bug:`5462`.
- :doc:`guides/main`: Modernized the *Getting Started* guide with tabbed
sections and dropdown menus. Installation instructions have been streamlined,
and a new subpage now provides additional setup details.
- Documentation: introduced a new role ``conf`` for documenting configuration
options. This role provides consistent formatting and creates references
automatically. Applied it to :doc:`plugins/deezer`, :doc:`plugins/discogs`,
:doc:`plugins/musicbrainz` and :doc:`plugins/spotify` plugins documentation.
2.5.0 (October 11, 2025)
------------------------
@ -125,18 +24,16 @@ New features:
without storing or writing them.
- :doc:`plugins/convert`: Add a config option to disable writing metadata to
converted files.
- :doc:`plugins/discogs`: New config option
:conf:`plugins.discogs:strip_disambiguation` to toggle stripping discogs
numeric disambiguation on artist and label fields.
- :doc:`plugins/discogs`: New config option `strip_disambiguation` to toggle
stripping discogs numeric disambiguation on artist and label fields.
- :doc:`plugins/discogs` Added support for featured artists. :bug:`6038`
- :doc:`plugins/discogs` New configuration option
:conf:`plugins.discogs:featured_string` to change the default string used to
join featured artists. The default string is `Feat.`.
- :doc:`plugins/discogs` New configuration option `featured_string` to change
the default string used to join featured artists. The default string is
`Feat.`.
- :doc:`plugins/discogs` Support for `artist_credit` in Discogs tags.
:bug:`3354`
- :doc:`plugins/discogs` Support for name variations and config options to
specify where the variations are written. :bug:`3354`
- :doc:`plugins/web` Support for `nexttrack` keyboard press
Bug fixes:
@ -156,14 +53,15 @@ Bug fixes:
- :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from
artists but not labels. :bug:`5366`
- :doc:`plugins/chroma` :doc:`plugins/bpsync` Fix plugin loading issue caused by
an import of another |BeetsPlugin| class. :bug:`6033`
an import of another :class:`beets.plugins.BeetsPlugin` class. :bug:`6033`
- :doc:`/plugins/fromfilename`: Fix :bug:`5218`, improve the code (refactor
regexps, allow for more cases, add some logging), add tests.
- Metadata source plugins: Fixed data source penalty calculation that was
incorrectly applied during import matching. The
:conf:`plugins.index:source_weight` configuration option has been renamed to
:conf:`plugins.index:data_source_mismatch_penalty` to better reflect its
purpose. :bug:`6066`
incorrectly applied during import matching. The ``source_weight``
configuration option has been renamed to ``data_source_mismatch_penalty`` to
better reflect its purpose. :bug:`6066`
For packagers:
Other changes:
@ -209,13 +107,12 @@ New features:
separate plugin. The default :ref:`plugins-config` includes ``musicbrainz``,
but if you've customized your ``plugins`` list in your configuration, you'll
need to explicitly add ``musicbrainz`` to continue using this functionality.
Configuration option :conf:`plugins.musicbrainz:enabled` has thus been
deprecated. :bug:`2686` :bug:`4605`
Configuration option ``musicbrainz.enabled`` has thus been deprecated.
:bug:`2686` :bug:`4605`
- :doc:`plugins/web`: Show notifications when a track plays. This uses the Media
Session API to customize media notifications.
- :doc:`plugins/discogs`: Add configurable :conf:`plugins.discogs:search_limit`
option to limit the number of results returned by the Discogs metadata search
queries.
- :doc:`plugins/discogs`: Add configurable ``search_limit`` option to limit the
number of results returned by the Discogs metadata search queries.
- :doc:`plugins/discogs`: Implement ``track_for_id`` method to allow retrieving
singletons by their Discogs ID. :bug:`4661`
- :doc:`plugins/replace`: Add new plugin.
@ -230,13 +127,12 @@ New features:
be played for it to be counted as played instead of skipped.
- :doc:`plugins/web`: Display artist and album as part of the search results.
- :doc:`plugins/spotify` :doc:`plugins/deezer`: Add new configuration option
:conf:`plugins.index:search_limit` to limit the number of results returned by
search queries.
``search_limit`` to limit the number of results returned by search queries.
Bug fixes:
- :doc:`plugins/musicbrainz`: fix regression where user configured
:conf:`plugins.musicbrainz:extra_tags` have been read incorrectly. :bug:`5788`
``extra_tags`` have been read incorrectly. :bug:`5788`
- tests: Fix library tests failing on Windows when run from outside ``D:/``.
:bug:`5802`
- Fix an issue where calling ``Library.add`` would cause the ``database_change``
@ -268,10 +164,9 @@ Bug fixes:
For packagers:
- Optional :conf:`plugins.musicbrainz:extra_tags` parameter has been removed
from ``BeetsPlugin.candidates`` method signature since it is never passed in.
If you override this method in your plugin, feel free to remove this
parameter.
- Optional ``extra_tags`` parameter has been removed from
``BeetsPlugin.candidates`` method signature since it is never passed in. If
you override this method in your plugin, feel free to remove this parameter.
- Loosened ``typing_extensions`` dependency in pyproject.toml to apply to every
python version.
@ -282,8 +177,8 @@ For plugin developers:
art sources might need to be adapted.
- We split the responsibilities of plugins into two base classes
1. |BeetsPlugin| is the base class for all plugins, any plugin needs to
inherit from this class.
1. :class:`beets.plugins.BeetsPlugin` is the base class for all plugins, any
plugin needs to inherit from this class.
2. :class:`beets.metadata_plugin.MetadataSourcePlugin` allows plugins to act
like metadata sources. E.g. used by the MusicBrainz plugin. All plugins in
the beets repo are opted into this class where applicable. If you are
@ -485,7 +380,6 @@ New features:
``beet list -a title:something`` or ``beet list artpath:cover``. Consequently
album queries involving ``path`` field have been sped up, like ``beet list -a
path:/path/``.
- :doc:`plugins/importsource`: Added plugin
- :doc:`plugins/ftintitle`: New ``keep_in_artist`` option for the plugin, which
allows keeping the "feat." part in the artist metadata while still changing
the title.
@ -628,9 +522,8 @@ New features:
:bug:`4348`
- Create the parental directories for database if they do not exist. :bug:`3808`
:bug:`4327`
- :ref:`musicbrainz-config`: a new :conf:`plugins.musicbrainz:enabled` option
allows disabling the MusicBrainz metadata source during the autotagging
process
- :ref:`musicbrainz-config`: a new :ref:`musicbrainz.enabled` option allows
disabling the MusicBrainz metadata source during the autotagging process
- :doc:`/plugins/kodiupdate`: Now supports multiple kodi instances :bug:`4101`
- Add the item fields ``bitrate_mode``, ``encoder_info`` and
``encoder_settings``.
@ -663,8 +556,8 @@ New features:
:bug:`4561` :bug:`4600`
- :ref:`musicbrainz-config`: MusicBrainz release pages often link to related
metadata sources like Discogs, Bandcamp, Spotify, Deezer and Beatport. When
enabled via the :conf:`plugins.musicbrainz:external_ids` options, release ID's
will be extracted from those URL's and imported to the library. :bug:`4220`
enabled via the :ref:`musicbrainz.external_ids` options, release ID's will be
extracted from those URL's and imported to the library. :bug:`4220`
- :doc:`/plugins/convert`: Add support for generating m3u8 playlists together
with converted media files. :bug:`4373`
- Fetch the ``release_group_title`` field from MusicBrainz. :bug:`4809`
@ -1018,9 +911,8 @@ Other new things:
- ``beet remove`` now also allows interactive selection of items from the query,
similar to ``beet modify``.
- Enable HTTPS for MusicBrainz by default and add configuration option
:conf:`plugins.musicbrainz:https` for custom servers. See
:ref:`musicbrainz-config` for more details.
- Enable HTTPS for MusicBrainz by default and add configuration option ``https``
for custom servers. See :ref:`musicbrainz-config` for more details.
- :doc:`/plugins/mpdstats`: Add a new ``strip_path`` option to help build the
right local path from MPD information.
- :doc:`/plugins/convert`: Conversion can now parallelize conversion jobs on
@ -1040,8 +932,8 @@ Other new things:
server.
- :doc:`/plugins/subsonicupdate`: The plugin now automatically chooses between
token- and password-based authentication based on the server version.
- A new :conf:`plugins.musicbrainz:extra_tags` configuration option lets you use
more metadata in MusicBrainz queries to further narrow the search.
- A new :ref:`extra_tags` configuration option lets you use more metadata in
MusicBrainz queries to further narrow the search.
- A new :doc:`/plugins/fish` adds `Fish shell`_ tab autocompletion to beets.
- :doc:`plugins/fetchart` and :doc:`plugins/embedart`: Added a new ``quality``
option that controls the quality of the image output when the image is
@ -1095,9 +987,9 @@ Other new things:
(and now deprecated) separate ``host``, ``port``, and ``contextpath`` config
options. As a consequence, the plugin can now talk to Subsonic over HTTPS.
Thanks to :user:`jef`. :bug:`3449`
- :doc:`/plugins/discogs`: The new :conf:`plugins.discogs:index_tracks` option
enables incorporation of work names and intra-work divisions into imported
track titles. Thanks to :user:`cole-miller`. :bug:`3459`
- :doc:`/plugins/discogs`: The new ``index_tracks`` option enables incorporation
of work names and intra-work divisions into imported track titles. Thanks to
:user:`cole-miller`. :bug:`3459`
- :doc:`/plugins/web`: The query API now interprets backslashes as path
separators to support path queries. Thanks to :user:`nmeum`. :bug:`3567`
- ``beet import`` now handles tar archives with bzip2 or gzip compression.
@ -1111,9 +1003,9 @@ Other new things:
:user:`logan-arens`. :bug:`2947`
- There is a new ``--plugins`` (or ``-p``) CLI flag to specify a list of plugins
to load.
- A new :conf:`plugins.musicbrainz:genres` option fetches genre information from
MusicBrainz. This functionality depends on functionality that is currently
unreleased in the python-musicbrainzngs_ library: see PR `#266
- A new :ref:`genres` option fetches genre information from MusicBrainz. This
functionality depends on functionality that is currently unreleased in the
python-musicbrainzngs_ library: see PR `#266
<https://github.com/alastair/python-musicbrainzngs/pull/266>`_. Thanks to
:user:`aereaux`.
- :doc:`/plugins/replaygain`: Analysis now happens in parallel using the
@ -1153,10 +1045,9 @@ Fixes:
:bug:`3867`
- :doc:`/plugins/web`: Fixed a small bug that caused the album art path to be
redacted even when ``include_paths`` option is set. :bug:`3866`
- :doc:`/plugins/discogs`: Fixed a bug with the
:conf:`plugins.discogs:index_tracks` option that sometimes caused the index to
be discarded. Also, remove the extra semicolon that was added when there is no
index track.
- :doc:`/plugins/discogs`: Fixed a bug with the ``index_tracks`` option that
sometimes caused the index to be discarded. Also, remove the extra semicolon
that was added when there is no index track.
- :doc:`/plugins/subsonicupdate`: The API client was using the ``POST`` method
rather the ``GET`` method. Also includes better exception handling, response
parsing, and tests.
@ -1365,9 +1256,9 @@ There are some fixes in this release:
- Fix a regression in the last release that made the image resizer fail to
detect older versions of ImageMagick. :bug:`3269`
- ``/plugins/gmusic``: The ``oauth_file`` config option now supports more
- :doc:`/plugins/gmusic`: The ``oauth_file`` config option now supports more
flexible path values, including ``~`` for the home directory. :bug:`3270`
- ``/plugins/gmusic``: Fix a crash when using version 12.0.0 or later of the
- :doc:`/plugins/gmusic`: Fix a crash when using version 12.0.0 or later of the
``gmusicapi`` module. :bug:`3270`
- Fix an incompatibility with Python 3.8's AST changes. :bug:`3278`
@ -1418,7 +1309,7 @@ And many improvements to existing plugins:
singletons. :bug:`3220` :bug:`3219`
- :doc:`/plugins/play`: The plugin can now emit a UTF-8 BOM, fixing some issues
with foobar2000 and Winamp. Thanks to :user:`mz2212`. :bug:`2944`
- ``/plugins/gmusic``:
- :doc:`/plugins/gmusic`:
- Add a new option to automatically upload to Google Play Music library on
track import. Thanks to :user:`shuaiscott`.
@ -1857,7 +1748,7 @@ Here are the new features:
- :ref:`Date queries <datequery>` can also be *relative*. You can say
``added:-1w..`` to match music added in the last week, for example. Thanks to
:user:`euri10`. :bug:`2598`
- A new ``/plugins/gmusic`` lets you interact with your Google Play Music
- A new :doc:`/plugins/gmusic` lets you interact with your Google Play Music
library. Thanks to :user:`tigranl`. :bug:`2553` :bug:`2586`
- :doc:`/plugins/replaygain`: We now keep R128 data in separate tags from
classic ReplayGain data for formats that need it (namely, Ogg Opus). A new
@ -2772,9 +2663,9 @@ Major new features and bigger changes:
analysis tool. Thanks to :user:`jmwatte`. :bug:`1343`
- A new ``filesize`` field on items indicates the number of bytes in the file.
:bug:`1291`
- A new :conf:`plugins.index:search_limit` configuration option allows you to
specify how many search results you wish to see when looking up releases at
MusicBrainz during import. :bug:`1245`
- A new :ref:`search_limit` configuration option allows you to specify how many
search results you wish to see when looking up releases at MusicBrainz during
import. :bug:`1245`
- The importer now records the data source for a match in a new flexible
attribute ``data_source`` on items and albums. :bug:`1311`
- The colors used in the terminal interface are now configurable via the new
@ -5170,7 +5061,7 @@ BPD). To "upgrade" an old database, you can use the included ``albumify`` plugin
list of plugin names) and ``pluginpath`` (a colon-separated list of
directories to search beyond ``sys.path``). Plugins are just Python modules
under the ``beetsplug`` namespace package containing subclasses of
|BeetsPlugin|. See `the beetsplug directory`_ for examples or
``beets.plugins.BeetsPlugin``. See `the beetsplug directory`_ for examples or
:doc:`/plugins/index` for instructions.
- As a consequence of adding album art, the database was significantly
refactored to keep track of some information at an album (rather than item)

View file

@ -6,11 +6,6 @@
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
import sys
from pathlib import Path
# Add custom extensions directory to path
sys.path.insert(0, str(Path(__file__).parent / "extensions"))
project = "beets"
AUTHOR = "Adrian Sampson"
@ -19,7 +14,7 @@ copyright = "2016, Adrian Sampson"
master_doc = "index"
language = "en"
version = "2.5"
release = "2.5.1"
release = "2.5.0"
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
@ -28,17 +23,13 @@ extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.autosummary",
"sphinx.ext.extlinks",
"sphinx.ext.viewcode",
"sphinx_design",
"sphinx_copybutton",
"conf",
]
autosummary_generate = True
exclude_patterns = ["_build"]
templates_path = ["_templates"]
source_suffix = {".rst": "restructuredtext", ".md": "markdown"}
pygments_style = "sphinx"
# External links to the bug tracker and other sites.
@ -88,7 +79,6 @@ man_pages = [
rst_epilog = """
.. |Album| replace:: :class:`~beets.library.models.Album`
.. |AlbumInfo| replace:: :class:`beets.autotag.hooks.AlbumInfo`
.. |BeetsPlugin| replace:: :class:`beets.plugins.BeetsPlugin`
.. |ImportSession| replace:: :class:`~beets.importer.session.ImportSession`
.. |ImportTask| replace:: :class:`~beets.importer.tasks.ImportTask`
.. |Item| replace:: :class:`~beets.library.models.Item`

View file

@ -18,7 +18,6 @@ configuration files, respectively.
plugins/index
library
paths
importer
cli
../api/index

View file

@ -1,64 +0,0 @@
Handling Paths
==============
``pathlib`` provides a clean, cross-platform API for working with filesystem
paths.
Use the ``.filepath`` property on ``Item`` and ``Album`` library objects to
access paths as ``pathlib.Path`` objects. This produces a readable, native
representation suitable for printing, logging, or further processing.
Normalize paths using ``Path(...).expanduser().resolve()``, which expands ``~``
and resolves symlinks.
Cross-platform differences—such as path separators, Unicode handling, and
long-path support (Windows) are automatically managed by ``pathlib``.
When storing paths in the database, however, convert them to bytes with
``bytestring_path()``. Paths in Beets are currently stored as bytes, although
there are plans to eventually store ``pathlib.Path`` objects directly. To access
media file paths in their stored form, use the ``.path`` property on ``Item``
and ``Album``.
Legacy utilities
----------------
Historically, Beets used custom utilities to ensure consistent behavior across
Linux, macOS, and Windows before ``pathlib`` became reliable:
- ``syspath()``: worked around Windows Unicode and long-path limitations by
converting to a system-safe string (adding the ``\\?\`` prefix where needed).
- ``normpath()``: normalized slashes and removed ``./`` or ``..`` parts but did
not expand ``~``.
- ``bytestring_path()``: converted paths to bytes for database storage (still
used for that purpose today).
- ``displayable_path()``: converted byte paths to Unicode for display or
logging.
These functions remain safe to use in legacy code, but new code should rely
solely on ``pathlib.Path``.
Examples
--------
Old style
.. code-block:: python
displayable_path(item.path)
normpath("~/Music/../Artist")
syspath(path)
New style
.. code-block:: python
item.filepath
Path("~/Music/../Artist").expanduser().resolve()
Path(path)
When storing paths in the database
.. code-block:: python
path_bytes = bytestring_path(Path("/some/path/to/file.mp3"))

View file

@ -95,9 +95,9 @@ starting points include:
Migration guidance
------------------
Older metadata plugins that extend |BeetsPlugin| should be migrated to
:py:class:`MetadataSourcePlugin`. Legacy support will be removed in **beets
v3.0.0**.
Older metadata plugins that extend :py:class:`beets.plugins.BeetsPlugin` should
be migrated to :py:class:`MetadataSourcePlugin`. Legacy support will be removed
in **beets v3.0.0**.
.. seealso::

View file

@ -178,13 +178,6 @@ registration process in this case:
:Parameters: ``info`` (|AlbumInfo|)
:Description: Like ``trackinfo_received`` but for album-level metadata.
``album_matched``
:Parameters: ``match`` (``AlbumMatch``)
:Description: Called after ``Item`` objects from a folder that's being
imported have been matched to an ``AlbumInfo`` and the corresponding
distance has been calculated. Missing and extra tracks, if any, are
included in the match.
``before_choose_candidate``
:Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|)
:Description: Called before prompting the user during interactive import.

View file

@ -40,8 +40,8 @@ or your plugin subpackage
anymore.
The meat of your plugin goes in ``myawesomeplugin.py``. Every plugin has to
extend the |BeetsPlugin| abstract base class [2]_ . For instance, a minimal
plugin without any functionality would look like this:
extend the :class:`beets.plugins.BeetsPlugin` abstract base class [2]_ . For
instance, a minimal plugin without any functionality would look like this:
.. code-block:: python
@ -52,12 +52,6 @@ plugin without any functionality would look like this:
class MyAwesomePlugin(BeetsPlugin):
pass
.. attention::
If your plugin is composed of intermediate |BeetsPlugin| subclasses, make
sure that your plugin is defined *last* in the namespace. We only load the
last subclass of |BeetsPlugin| we find in your plugin namespace.
To use your new plugin, you need to package [3]_ your plugin and install it into
your ``beets`` (virtual) environment. To enable your plugin, add it it to the
beets configuration

View file

@ -13,7 +13,7 @@ str.format-style string formatting. So you can write logging calls like this:
.. _pep 3101: https://www.python.org/dev/peps/pep-3101/
.. _standard python logging module: https://docs.python.org/3/library/logging.html
.. _standard python logging module: https://docs.python.org/2/library/logging.html
When beets is in verbose mode, plugin messages are prefixed with the plugin name
to make them easier to see.

View file

@ -13,7 +13,7 @@ shall expose to the user:
.. code-block:: python
from beets.plugins import BeetsPlugin
from beets.util import PromptChoice
from beets.ui.commands import PromptChoice
class ExamplePlugin(BeetsPlugin):

View file

@ -1,142 +0,0 @@
"""Sphinx extension for simple configuration value documentation."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar
from docutils import nodes
from docutils.parsers.rst import directives
from sphinx import addnodes
from sphinx.directives import ObjectDescription
from sphinx.domains import Domain, ObjType
from sphinx.roles import XRefRole
from sphinx.util.nodes import make_refnode
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from docutils.nodes import Element
from docutils.parsers.rst.states import Inliner
from sphinx.addnodes import desc_signature, pending_xref
from sphinx.application import Sphinx
from sphinx.builders import Builder
from sphinx.environment import BuildEnvironment
from sphinx.util.typing import ExtensionMetadata, OptionSpec
class Conf(ObjectDescription[str]):
"""Directive for documenting a single configuration value."""
option_spec: ClassVar[OptionSpec] = {
"default": directives.unchanged,
}
def handle_signature(self, sig: str, signode: desc_signature) -> str:
"""Process the directive signature (the config name)."""
signode += addnodes.desc_name(sig, sig)
# Add default value if provided
if "default" in self.options:
signode += nodes.Text(" ")
default_container = nodes.inline("", "")
default_container += nodes.Text("(default: ")
default_container += nodes.literal("", self.options["default"])
default_container += nodes.Text(")")
signode += default_container
return sig
def add_target_and_index(
self, name: str, sig: str, signode: desc_signature
) -> None:
"""Add cross-reference target and index entry."""
target = f"conf-{name}"
if target not in self.state.document.ids:
signode["ids"].append(target)
self.state.document.note_explicit_target(signode)
# A unique full name which includes the document name
index_name = f"{self.env.docname.replace('/', '.')}:{name}"
# Register with the conf domain
domain = self.env.get_domain("conf")
domain.data["objects"][index_name] = (self.env.docname, target)
# Add to index
self.indexnode["entries"].append(
("single", f"{name} (configuration value)", target, "", None)
)
class ConfDomain(Domain):
"""Domain for simple configuration values."""
name = "conf"
label = "Simple Configuration"
object_types = {"conf": ObjType("conf", "conf")}
directives = {"conf": Conf}
roles = {"conf": XRefRole()}
initial_data: dict[str, Any] = {"objects": {}}
def get_objects(self) -> Iterable[tuple[str, str, str, str, str, int]]:
"""Return an iterable of object tuples for the inventory."""
for name, (docname, targetname) in self.data["objects"].items():
# Remove the document name prefix for display
display_name = name.split(":")[-1]
yield (name, display_name, "conf", docname, targetname, 1)
def resolve_xref(
self,
env: BuildEnvironment,
fromdocname: str,
builder: Builder,
typ: str,
target: str,
node: pending_xref,
contnode: Element,
) -> Element | None:
if entry := self.data["objects"].get(target):
docname, targetid = entry
return make_refnode(
builder, fromdocname, docname, targetid, contnode
)
return None
# sphinx.util.typing.RoleFunction
def conf_role(
name: str,
rawtext: str,
text: str,
lineno: int,
inliner: Inliner,
/,
options: dict[str, Any] | None = None,
content: Sequence[str] = (),
) -> tuple[list[nodes.Node], list[nodes.system_message]]:
"""Role for referencing configuration values."""
node = addnodes.pending_xref(
"",
refdomain="conf",
reftype="conf",
reftarget=text,
refwarn=True,
**(options or {}),
)
node += nodes.literal(text, text.split(":")[-1])
return [node], []
def setup(app: Sphinx) -> ExtensionMetadata:
app.add_domain(ConfDomain)
# register a top-level directive so users can use ".. conf:: ..."
app.add_directive("conf", Conf)
# Register role with short name
app.add_role("conf", conf_role)
return {
"version": "0.1",
"parallel_read_safe": True,
"parallel_write_safe": True,
}

View file

@ -163,7 +163,7 @@ documentation </dev/index>` pages.
.. _bugs:
…report a bug in beets?
-----------------------
~~~~~~~~~~~~~~~~~~~~~~~
We use the `issue tracker`_ on GitHub where you can `open a new ticket`_. Please
follow these guidelines when reporting an issue:
@ -171,7 +171,7 @@ follow these guidelines when reporting an issue:
- Most importantly: if beets is crashing, please `include the traceback
<https://imgur.com/jacoj>`__. Tracebacks can be more readable if you put them
in a pastebin (e.g., `Gist <https://gist.github.com/>`__ or `Hastebin
<https://hastebin.com/>`__), especially when communicating over IRC.
<https://hastebin.com/>`__), especially when communicating over IRC or email.
- Turn on beets' debug output (using the -v option: for example, ``beet -v
import ...``) and include that with your bug report. Look through this verbose
output for any red flags that might point to the problem.

View file

@ -9,6 +9,5 @@ guide.
:maxdepth: 1
main
installation
tagger
advanced

View file

@ -1,179 +0,0 @@
Installation
============
Beets requires `Python 3.10 or later`_. You can install it using package
managers, pipx_, pip_ or by using package managers.
.. _python 3.10 or later: https://python.org/download/
Using ``pipx`` or ``pip``
-------------------------
We recommend installing with pipx_ as it isolates beets and its dependencies
from your system Python and other Python packages. This helps avoid dependency
conflicts and keeps your system clean.
.. <!-- start-quick-install -->
.. tab-set::
.. tab-item:: pipx
.. code-block:: console
pipx install beets
.. tab-item:: pip
.. code-block:: console
pip install beets
.. tab-item:: pip (user install)
.. code-block:: console
pip install --user beets
.. <!-- end-quick-install -->
If you don't have pipx_ installed, you can follow the instructions on the `pipx
installation page`_ to get it set up.
.. _pip: https://pip.pypa.io/en/
.. _pipx: https://pipx.pypa.io/stable
.. _pipx installation page: https://pipx.pypa.io/stable/installation/
Using a Package Manager
-----------------------
Depending on your operating system, you may be able to install beets using a
package manager. Here are some common options:
.. attention::
Package manager installations may not provide the latest version of beets.
Release cycles for package managers vary, and they may not always have the
most recent version of beets. If you want the latest features and fixes,
consider using pipx_ or pip_ as described above.
Additionally, installing external beets plugins may be surprisingly
difficult when using a package manager.
- On **Debian or Ubuntu**, depending on the version, beets is available as an
official package (`Debian details`_, `Ubuntu details`_), so try typing:
``apt-get install beets``. But the version in the repositories might lag
behind, so make sure you read the right version of these docs. If you want the
latest version, you can get everything you need to install with pip as
described below by running: ``apt-get install python-dev python-pip``
- On **Arch Linux**, `beets is in [extra] <arch extra_>`_, so just run ``pacman
-S beets``. (There's also a bleeding-edge `dev package <aur_>`_ in the AUR,
which will probably set your computer on fire.)
- On **Alpine Linux**, `beets is in the community repository <alpine package_>`_
and can be installed with ``apk add beets``.
- On **Void Linux**, `beets is in the official repository <void package_>`_ and
can be installed with ``xbps-install -S beets``.
- For **Gentoo Linux**, beets is in Portage as ``media-sound/beets``. Just run
``emerge beets`` to install. There are several USE flags available for
optional plugin dependencies.
- On **FreeBSD**, there's a `beets port <freebsd_>`_ at ``audio/beets``.
- On **OpenBSD**, there's a `beets port <openbsd_>`_ can be installed with
``pkg_add beets``.
- On **Fedora** 22 or later, there's a `DNF package`_ you can install with
``sudo dnf install beets beets-plugins beets-doc``.
- On **Solus**, run ``eopkg install beets``.
- On **NixOS**, there's a `package <nixos_>`_ you can install with ``nix-env -i
beets``.
- Using **MacPorts**, run ``port install beets`` or ``port install beets-full``
to include many third-party plugins.
.. _alpine package: https://pkgs.alpinelinux.org/package/edge/community/x86_64/beets
.. _arch extra: https://archlinux.org/packages/extra/any/beets/
.. _aur: https://aur.archlinux.org/packages/beets-git/
.. _debian details: https://tracker.debian.org/pkg/beets
.. _dnf package: https://packages.fedoraproject.org/pkgs/beets/
.. _freebsd: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets
.. _nixos: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets
.. _openbsd: http://openports.se/audio/beets
.. _ubuntu details: https://launchpad.net/ubuntu/+source/beets
.. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets
Installation FAQ
----------------
MacOS Installation
~~~~~~~~~~~~~~~~~~
**Q: I'm getting permission errors on macOS. What should I do?**
Due to System Integrity Protection on macOS 10.11+, you may need to install for
your user only:
.. code-block:: console
pip install --user beets
You might need to also add ``~/Library/Python/3.x/bin`` to your ``$PATH``.
Windows Installation
~~~~~~~~~~~~~~~~~~~~
**Q: What's the process for installing on Windows?**
Installing beets on Windows can be tricky. Following these steps might help you
get it right:
1. `Install Python`_ (check "Add Python to PATH" skip to 3)
2. Ensure Python is in your ``PATH`` (add if needed):
- Settings → System → About → Advanced system settings → Environment
Variables
- Edit "PATH" and add: `;C:\Python39;C:\Python39\Scripts`
- *Guide: [Adding Python to
PATH](https://realpython.com/add-python-to-path/)*
3. Now install beets by running: ``pip install beets``
4. You're all set! Type ``beet version`` in a new command prompt to verify the
installation.
**Bonus: Windows Context Menu Integration**
Windows users may also want to install a context menu item for importing files
into beets. Download the beets.reg_ file and open it in a text file to make sure
the paths to Python match your system. Then double-click the file add the
necessary keys to your registry. You can then right-click a directory and choose
"Import with beets".
.. _beets.reg: https://github.com/beetbox/beets/blob/master/extra/beets.reg
.. _install pip: https://pip.pypa.io/en/stable/installing/
.. _install python: https://python.org/download/
ARM Installation
~~~~~~~~~~~~~~~~
**Q: Can I run beets on a Raspberry Pi or other ARM device?**
Yes, but with some considerations: Beets on ARM devices is not recommended for
Linux novices. If you are comfortable with troubleshooting tools like ``pip``,
``make``, and binary dependencies (e.g. ``ffmpeg`` and ``ImageMagick``), you
will be fine. We have `notes for ARM`_ and an `older ARM reference`_. Beets is
generally developed on x86-64 based devices, and most plugins target that
platform as well.
.. _notes for arm: https://github.com/beetbox/beets/discussions/4910
.. _older arm reference: https://discourse.beets.io/t/diary-of-beets-on-arm-odroid-hc4-armbian/1993

View file

@ -1,310 +1,322 @@
Getting Started
===============
Welcome to beets_! This guide will help get started with improving and
organizing your music collection.
Welcome to beets_! This guide will help you begin using it to make your music
collection better.
.. _beets: https://beets.io/
Quick Installation
------------------
Installing
----------
Beets is distributed via PyPI_ and can be installed by most users with a single
command:
You will need Python. Beets works on Python 3.8 or later.
.. include:: installation.rst
:start-after: <!-- start-quick-install -->
:end-before: <!-- end-quick-install -->
- **macOS** 11 (Big Sur) includes Python 3.8 out of the box. You can opt for a
more recent Python installing it via Homebrew_ (``brew install python3``).
There's also a MacPorts_ port. Run ``port install beets`` or ``port install
beets-full`` to include many third-party plugins.
- On **Debian or Ubuntu**, depending on the version, beets is available as an
official package (`Debian details`_, `Ubuntu details`_), so try typing:
``apt-get install beets``. But the version in the repositories might lag
behind, so make sure you read the right version of these docs. If you want the
latest version, you can get everything you need to install with pip as
described below by running: ``apt-get install python-dev python-pip``
- On **Arch Linux**, `beets is in [extra] <arch extra_>`_, so just run ``pacman
-S beets``. (There's also a bleeding-edge `dev package <aur_>`_ in the AUR,
which will probably set your computer on fire.)
- On **Alpine Linux**, `beets is in the community repository <alpine package_>`_
and can be installed with ``apk add beets``.
- On **Void Linux**, `beets is in the official repository <void package_>`_ and
can be installed with ``xbps-install -S beets``.
- For **Gentoo Linux**, beets is in Portage as ``media-sound/beets``. Just run
``emerge beets`` to install. There are several USE flags available for
optional plugin dependencies.
- On **FreeBSD**, there's a `beets port <freebsd_>`_ at ``audio/beets``.
- On **OpenBSD**, there's a `beets port <openbsd_>`_ can be installed with
``pkg_add beets``.
- For **Slackware**, there's a SlackBuild_ available.
- On **Fedora** 22 or later, there's a `DNF package`_ you can install with
``sudo dnf install beets beets-plugins beets-doc``.
- On **Solus**, run ``eopkg install beets``.
- On **NixOS**, there's a `package <nixos_>`_ you can install with ``nix-env -i
beets``.
.. admonition:: Need more installation options?
.. _alpine package: https://pkgs.alpinelinux.org/package/edge/community/x86_64/beets
Having trouble with the commands above? Looking for package manager
instructions? See the :doc:`complete installation guide
</guides/installation>` for:
.. _arch extra: https://archlinux.org/packages/extra/any/beets/
- Operating system specific instructions
- Package manager options
- Troubleshooting help
.. _aur: https://aur.archlinux.org/packages/beets-git/
.. _pypi: https://pypi.org/project/beets/
.. _debian details: https://tracker.debian.org/pkg/beets
Basic Configuration
-------------------
.. _dnf package: https://packages.fedoraproject.org/pkgs/beets/
Before using beets, you'll need a configuration file. This YAML file tells beets
where to store your music and how to organize it.
.. _freebsd: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets
While beets is highly configurable, you only need a few basic settings to get
started.
.. _macports: https://www.macports.org
1. **Open the config file:**
.. code-block:: console
.. _nixos: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets
beet config -e
.. _openbsd: http://openports.se/audio/beets
This creates the file (if needed) and opens it in your default editor.
You can also find its location with ``beet config -p``.
2. **Add required settings:**
In the config file, set the ``directory`` option to the path where you
want beets to store your music files. Set the ``library`` option to the
path where you want beets to store its database file.
.. _slackbuild: https://slackbuilds.org/repository/14.2/multimedia/beets/
.. code-block:: yaml
.. _ubuntu details: https://launchpad.net/ubuntu/+source/beets
directory: ~/music
library: ~/data/musiclibrary.db
3. **Choose your import style** (pick one):
Beets offers flexible import strategies to match your workflow. Choose
one of the following approaches and put one of the following in your
config file:
.. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets
.. tab-set::
If you have pip_, just say ``pip install beets`` (or ``pip install --user
beets`` if you run into permissions problems).
.. tab-item:: Copy Files (Default)
To install without pip, download beets from `its PyPI page`_ and run ``python
setup.py install`` in the directory therein.
This is the default configuration and assumes you want to start a new organized music folder (inside ``directory`` above). During import we will *copy* cleaned-up music into that empty folder.
.. _its pypi page: https://pypi.org/project/beets/#files
.. code-block:: yaml
.. _pip: https://pip.pypa.io
import:
copy: yes # Copy files to new location
The best way to upgrade beets to a new version is by running ``pip install -U
beets``. You may want to follow `@b33ts`_ on Twitter to hear about progress on
new versions.
.. _@b33ts: https://twitter.com/b33ts
.. tab-item:: Move Files
Installing by Hand on macOS 10.11 and Higher
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Start with a new empty directory, but *move* new music in instead of copying it (saving disk space).
Starting with version 10.11 (El Capitan), macOS has a new security feature
called `System Integrity Protection`_ (SIP) that prevents you from modifying
some parts of the system. This means that some ``pip`` commands may fail with a
permissions error. (You probably *won't* run into this if you've installed
Python yourself with Homebrew_ or otherwise. You can also try MacPorts_.)
.. code-block:: yaml
If this happens, you can install beets for the current user only by typing ``pip
install --user beets``. If you do that, you might want to add
``~/Library/Python/3.6/bin`` to your ``$PATH``.
import:
move: yes # Move files to new location
.. _homebrew: https://brew.sh
.. tab-item:: Use Existing Structure
.. _system integrity protection: https://support.apple.com/en-us/HT204899
Keep your current directory structure; importing should never move or copy files but instead just correct the tags on music. Make sure to point ``directory`` at the place where your music is currently stored.
Installing on Windows
~~~~~~~~~~~~~~~~~~~~~
.. code-block:: yaml
Installing beets on Windows can be tricky. Following these steps might help you
get it right:
import:
copy: no # Use files in place
1. If you don't have it, `install Python`_ (you want at least Python 3.8). The
installer should give you the option to "add Python to PATH." Check this box.
If you do that, you can skip the next step.
2. If you haven't done so already, set your ``PATH`` environment variable to
include Python and its scripts. To do so, open the "Settings" application,
then access the "System" screen, then access the "About" tab, and then hit
"Advanced system settings" located on the right side of the screen. This
should open the "System Properties" screen, then select the "Advanced" tab,
then hit the "Environmental Variables..." button, and then look for the PATH
variable in the table. Add the following to the end of the variable's value:
``;C:\Python38;C:\Python38\Scripts``. You may need to adjust these paths to
point to your Python installation.
3. Now install beets by running: ``pip install beets``
4. You're all set! Type ``beet`` at the command prompt to make sure everything's
in order.
.. tab-item:: Read-Only Mode
Windows users may also want to install a context menu item for importing files
into beets. Download the beets.reg_ file and open it in a text file to make sure
the paths to Python match your system. Then double-click the file add the
necessary keys to your registry. You can then right-click a directory and choose
"Import with beets".
Keep everything exactly as-is; only track metadata in database. (Corrected tags will still be stored in beets' database, and you can use them to do renaming or tag changes later.)
Because I don't use Windows myself, I may have missed something. If you have
trouble or you have more detail to contribute here, please direct it to `the
mailing list`_.
.. code-block:: yaml
.. _beets.reg: https://github.com/beetbox/beets/blob/master/extra/beets.reg
import:
copy: no # Use files in place
write: no # Don't modify tags
4. **Add customization via plugins (optional):**
Beets comes with many plugins that extend its functionality. You can
enable plugins by adding a `plugins` section to your config file.
.. _get-pip.py: https://bootstrap.pypa.io/get-pip.py
We recommend adding at least one :ref:`Autotagger Plugin
<autotagger_extensions>` to help with fetching metadata during import.
For getting started, :doc:`MusicBrainz </plugins/musicbrainz>` is a good
choice.
.. _install pip: https://pip.pypa.io/en/stable/installing/
.. code-block:: yaml
.. _install python: https://python.org/download/
plugins:
- musicbrainz # Example plugin for fetching metadata
- ... other plugins you want ...
Installing on ARM (Raspberry Pi and similar)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can find a list of available plugins in the :doc:`plugins index
</plugins/index>`.
Beets on ARM devices is not recommended for Linux novices. If you are
comfortable with light troubleshooting in tools like ``pip``, ``make``, and
beets' command-line binary dependencies (e.g. ``ffmpeg`` and ``ImageMagick``),
you will probably be okay on ARM devices like the Raspberry Pi. We have `notes
for ARM`_ and an `older ARM reference`_. Beets is generally developed on x86-64
based devices, and most plugins target that platform as well.
.. _notes for arm: https://github.com/beetbox/beets/discussions/4910
.. _older arm reference: https://discourse.beets.io/t/diary-of-beets-on-arm-odroid-hc4-armbian/1993
Configuring
-----------
You'll want to set a few basic options before you start using beets. The
:doc:`configuration </reference/config>` is stored in a text file. You can show
its location by running ``beet config -p``, though it may not exist yet. Run
``beet config -e`` to edit the configuration in your favorite text editor. The
file will start out empty, but here's good place to start:
::
directory: ~/music
library: ~/data/musiclibrary.db
Change that first path to a directory where you'd like to keep your music. Then,
for ``library``, choose a good place to keep a database file that keeps an index
of your music. (The config's format is YAML_. You'll want to configure your text
editor to use spaces, not real tabs, for indentation. Also, ``~`` means your
home directory in these paths, even on Windows.)
The default configuration assumes you want to start a new organized music folder
(that ``directory`` above) and that you'll *copy* cleaned-up music into that
empty folder using beets' ``import`` command (see below). But you can configure
beets to behave many other ways:
- Start with a new empty directory, but *move* new music in instead of copying
it (saving disk space). Put this in your config file:
::
import:
move: yes
- Keep your current directory structure; importing should never move or copy
files but instead just correct the tags on music. Put the line ``copy: no``
under the ``import:`` heading in your config file to disable any copying or
renaming. Make sure to point ``directory`` at the place where your music is
currently stored.
- Keep your current directory structure and *do not* correct files' tags: leave
files completely unmodified on your disk. (Corrected tags will still be stored
in beets' database, and you can use them to do renaming or tag changes later.)
Put this in your config file:
::
import:
copy: no
write: no
to disable renaming and tag-writing.
There are other configuration options you can set here, including the directory
and file naming scheme. See :doc:`/reference/config` for a full reference.
.. _yaml: https://yaml.org/
To validate that you've set up your configuration and it is valid YAML, you can
type ``beet version`` to see a list of enabled plugins or ``beet config`` to get
a complete listing of your current configuration.
To check that you've set up your configuration how you want it, you can type
``beet version`` to see a list of enabled plugins or ``beet config`` to get a
complete listing of your current configuration.
.. dropdown:: Minimal configuration
Importing Your Library
----------------------
Here's a sample configuration file that includes the settings mentioned above:
The next step is to import your music files into the beets library database.
Because this can involve modifying files and moving them around, data loss is
always a possibility, so now would be a good time to make sure you have a recent
backup of all your music. We'll wait.
.. code-block:: yaml
There are two good ways to bring your existing library into beets. You can
either: (a) quickly bring all your files with all their current metadata into
beets' database, or (b) use beets' highly-refined autotagger to find canonical
metadata for every album you import. Option (a) is really fast, but option (b)
makes sure all your songs' tags are exactly right from the get-go. The point
about speed bears repeating: using the autotagger on a large library can take a
very long time, and it's an interactive process. So set aside a good chunk of
time if you're going to go that route. For more on the interactive tagging
process, see :doc:`tagger`.
directory: ~/music
library: ~/data/musiclibrary.db
If you've got time and want to tag all your music right once and for all, do
this:
import:
move: yes # Move files to new location
# copy: no # Use files in place
# write: no # Don't modify tags
::
plugins:
- musicbrainz # Example plugin for fetching metadata
# - ... other plugins you want ...
$ beet import /path/to/my/music
You can copy and paste this into your config file and modify it as needed.
(Note that by default, this command will *copy music into the directory you
specified above*. If you want to use your current directory structure, set the
``import.copy`` config option.) To take the fast, un-autotagged path, just say:
.. admonition:: Ready for more?
::
For a complete reference of all configuration options, see the
:doc:`configuration reference </reference/config>`.
$ beet import -A /my/huge/mp3/library
Importing Your Music
--------------------
Note that you just need to add ``-A`` for "don't autotag".
Now you're ready to import your music into beets!
Adding More Music
-----------------
.. important::
If you've ripped or... otherwise obtained some new music, you can add it with
the ``beet import`` command, the same way you imported your library. Like so:
Importing can modify and move your music files. **Make sure you have a
recent backup** before proceeding.
::
Choose Your Import Method
~~~~~~~~~~~~~~~~~~~~~~~~~
$ beet import ~/some_great_album
There are two good ways to bring your *existing* library into beets database.
.. tab-set::
.. tab-item:: Autotag (Recommended)
This method uses beets' autotagger to find canonical metadata for every album you import. It may take a while, especially for large libraries, and it's an interactive process. But it ensures all your songs' tags are exactly right from the get-go.
.. code-block:: console
beet import /a/chunk/of/my/library
.. warning::
The point about speed bears repeating: using the autotagger on a large library can take a
very long time, and it's an interactive process. So set aside a good chunk of
time if you're going to go that route.
We also recommend importing smaller batches of music at a time (e.g., a few albums) to make the process more manageable. For more on the interactive tagging
process, see :doc:`tagger`.
.. tab-item:: Quick Import
This method quickly brings all your files with all their current metadata into beets' database without any changes. It's really fast, but it doesn't clean up or correct any tags.
To use this method, run:
.. code-block:: console
beet import --noautotag /my/huge/mp3/library
The ``--noautotag`` / ``-A`` flag skips autotagging and uses your files' current metadata.
.. admonition:: More Import Options
The ``beet import`` command has many options to customize its behavior. For
a full list, type ``beet help import`` or see the :ref:`import command
reference <import-cmd>`.
Adding More Music Later
~~~~~~~~~~~~~~~~~~~~~~~
When you acquire new music, use the same ``beet import`` command to add it to
your library:
.. code-block:: console
beet import ~/new_totally_not_ripped_album
This will apply the same autotagging process to your new additions. For
alternative import behaviors, consult the options mentioned above.
This will attempt to autotag the new album (interactively) and add it to your
library. There are, of course, more options for this command---just type ``beet
help import`` to see what's available.
Seeing Your Music
-----------------
Once you've imported music into beets, you'll want to explore and query your
library. Beets provides several commands for searching, browsing, and getting
statistics about your collection.
If you want to query your music library, the ``beet list`` (shortened to ``beet
ls``) command is for you. You give it a :doc:`query string </reference/query>`,
which is formatted something like a Google search, and it gives you a list of
songs. Thus:
Basic Searching
~~~~~~~~~~~~~~~
The ``beet list`` command (shortened to ``beet ls``) lets you search your music
library using :doc:`query string </reference/query>` similar to web searches:
.. code-block:: console
::
$ beet ls the magnetic fields
The Magnetic Fields - Distortion - Three-Way
The Magnetic Fields - Dist
The Magnetic Fields - Distortion - California Girls
The Magnetic Fields - Distortion - Old Fools
.. code-block:: console
$ beet ls hissing gronlandic
of Montreal - Hissing Fauna, Are You the Destroyer? - Gronlandic Edit
.. code-block:: console
$ beet ls bird
The Knife - The Knife - Bird
The Mae Shi - Terrorbird - Revelation Six
By default, search terms match against :ref:`common attributes <keywordquery>`
of songs, and multiple terms are combined with AND logic (a track must match
*all* criteria).
Searching Specific Fields
~~~~~~~~~~~~~~~~~~~~~~~~~
To narrow a search term to a particular metadata field, prefix the term with the
field name followed by a colon. For example, ``album:bird`` searches for "bird"
only in the "album" field of your songs. For more details, see
:doc:`/reference/query/`.
.. code-block:: console
$ beet ls album:bird
The Mae Shi - Terrorbird - Revelation Six
This searches only the ``album`` field for the term ``bird``.
Searching for Albums
~~~~~~~~~~~~~~~~~~~~
By default, a search term will match any of a handful of :ref:`common attributes
<keywordquery>` of songs. (They're also implicitly joined by ANDs: a track must
match *all* criteria in order to match the query.) To narrow a search term to a
particular metadata field, just put the field before the term, separated by a :
character. So ``album:bird`` only looks for ``bird`` in the "album" field of
your songs. (Need to know more? :doc:`/reference/query/` will answer all your
questions.)
The ``beet list`` command also has an ``-a`` option, which searches for albums
instead of songs:
.. code-block:: console
::
$ beet ls -a forever
Bon Iver - For Emma, Forever Ago
Freezepop - Freezepop Forever
Custom Output Formatting
~~~~~~~~~~~~~~~~~~~~~~~~
There's also an ``-f`` option (for *format*) that lets you specify what gets
displayed in the results of a search:
.. code-block:: console
::
$ beet ls -a forever -f "[$format] $album ($year) - $artist - $title"
[MP3] For Emma, Forever Ago (2009) - Bon Iver - Flume
[AAC] Freezepop Forever (2011) - Freezepop - Harebrained Scheme
In the format string, field references like ``$format``, ``$year``, ``$album``,
etc., are replaced with data from each result.
In the format option, field references like ``$format`` and ``$year`` are filled
in with data from each result. You can see a full list of available fields by
running ``beet fields``.
.. dropdown:: Available fields for formatting
Beets also has a ``stats`` command, just in case you want to see how much music
you have:
To see all available fields you can use in custom formats, run:
.. code-block:: console
beet fields
This will display a comprehensive list of metadata fields available for your music.
Library Statistics
~~~~~~~~~~~~~~~~~~
Beets can also show you statistics about your music collection:
.. code-block:: console
::
$ beet stats
Tracks: 13019
@ -313,107 +325,31 @@ Beets can also show you statistics about your music collection:
Artists: 548
Albums: 1094
.. admonition:: Ready for more advanced queries?
The ``beet list`` command has many additional options for sorting, limiting
results, and more complex queries. For a complete reference, run:
.. code-block:: console
beet help list
Or see the :ref:`list command reference <list-cmd>`.
Keep Playing
------------
Congratulations! You've now mastered the basics of beets. But this is only the
beginning, beets has many more powerful features to explore.
This is only the beginning of your long and prosperous journey with beets. To
keep learning, take a look at :doc:`advanced` for a sampling of what else is
possible. You'll also want to glance over the :doc:`/reference/cli` page for a
more detailed description of all of beets' functionality. (Like deleting music!
That's important.)
Continue Your Learning Journey
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Also, check out :doc:`beets' plugins </plugins/index>`. The real power of beets
is in its extensibility---with plugins, beets can do almost anything for your
music collection.
*I was there to push people beyond what's expected of them.*
You can always get help using the ``beet help`` command. The plain ``beet help``
command lists all the available commands; then, for example, ``beet help
import`` gives more specific help about the ``import`` command.
.. grid:: 2
:gutter: 3
If you need more of a walkthrough, you can read an illustrated one `on the beets
blog <https://beets.io/blog/walkthrough.html>`_.
.. grid-item-card:: :octicon:`zap` Advanced Techniques
:link: advanced
:link-type: doc
Please let us know what you think of beets via `the discussion board`_ or
Mastodon_.
Explore sophisticated beets workflows including:
.. _mastodon: https://fosstodon.org/@beets
- Advanced tagging strategies
- Complex import scenarios
- Custom metadata management
- Workflow automation
.. _the discussion board: https://github.com/beetbox/beets/discussions
.. grid-item-card:: :octicon:`terminal` Command Reference
:link: /reference/cli
:link-type: doc
Comprehensive guide to all beets commands:
- Complete command syntax
- All available options
- Usage examples
- **Important operations like deleting music**
.. grid-item-card:: :octicon:`plug` Plugin Ecosystem
:link: /plugins/index
:link-type: doc
Discover beets' true power through plugins:
- Metadata fetching from multiple sources
- Audio analysis and processing
- Streaming service integration
- Custom export formats
.. grid-item-card:: :octicon:`question` Illustrated Walkthrough
:link: https://beets.io/blog/walkthrough.html
:link-type: url
Visual, step-by-step guide covering:
- Real-world import examples
- Screenshots of interactive tagging
- Common workflow patterns
- Troubleshooting tips
.. admonition:: Need Help?
Remember you can always use ``beet help`` to see all available commands, or
``beet help [command]`` for detailed help on specific commands.
Join the Community
~~~~~~~~~~~~~~~~~~
We'd love to hear about your experience with beets!
.. grid:: 2
:gutter: 2
.. grid-item-card:: :octicon:`comment-discussion` Discussion Board
:link: https://github.com/beetbox/beets/discussions
:link-type: url
- Ask questions
- Share tips and tricks
- Discuss feature ideas
- Get help from other users
.. grid-item-card:: :octicon:`git-pull-request` Developer Resources
:link: /dev/index
:link-type: doc
- Contribute code
- Report issues
- Review pull requests
- Join development discussions
.. admonition:: Found a Bug?
If you encounter any issues, please report them on our `GitHub Issues page
<https://github.com/beetbox/beets/issues>`_.
.. _the mailing list: https://groups.google.com/group/beets-users

View file

@ -311,3 +311,5 @@ If we haven't made the process clear, please post on `the discussion board`_ and
we'll try to improve this guide.
.. _the discussion board: https://github.com/beetbox/beets/discussions/
.. _the mailing list: https://groups.google.com/group/beets-users

View file

@ -13,8 +13,9 @@ Then you can get a more detailed look at beets' features in the
be interested in exploring the :doc:`plugins </plugins/index>`.
If you still need help, you can drop by the ``#beets`` IRC channel on
Libera.Chat, drop by `the discussion board`_ or `file a bug`_ in the issue
tracker. Please let us know where you think this documentation can be improved.
Libera.Chat, drop by `the discussion board`_, send email to `the mailing list`_,
or `file a bug`_ in the issue tracker. Please let us know where you think this
documentation can be improved.
.. _beets: https://beets.io/
@ -22,6 +23,8 @@ tracker. Please let us know where you think this documentation can be improved.
.. _the discussion board: https://github.com/beetbox/beets/discussions/
.. _the mailing list: https://groups.google.com/group/beets-users
Contents
--------

View file

@ -51,11 +51,6 @@ instead, passing ``-H`` (``--hardlink``) creates hard links. Note that album art
embedding is disabled for files that are linked. Refer to the ``link`` and
``hardlink`` options below.
The ``-F`` (or ``--force``) option forces transcoding even when safety options
such as ``no_convert``, ``never_convert_lossy_files``, or ``max_bitrate`` would
normally cause a file to be copied or skipped instead. This can be combined with
``--format`` to explicitly transcode lossy inputs to a chosen target format.
The ``-m`` (or ``--playlist``) option enables the plugin to create an m3u8
playlist file in the destination folder given by the ``-d`` (``--dest``) option
or the ``dest`` configuration. The path to the playlist file can either be
@ -109,21 +104,15 @@ The available options are:
with high bitrates, even if they are already in the same format as the output.
Note that this does not guarantee that all converted files will have a lower
bitrate---that depends on the encoder and its configuration. Default: none.
This option will be overridden by the ``--force`` flag
- **no_convert**: Does not transcode items matching the query string provided
(see :doc:`/reference/query`). For example, to not convert AAC or WMA formats,
you can use ``format:AAC, format:WMA`` or ``path::\.(m4a|wma)$``. If you only
want to transcode WMA format, you can use a negative query, e.g.,
``^path::\.(wma)$``, to not convert any other format except WMA. This option
will be overridden by the ``--force`` flag
``^path::\.(wma)$``, to not convert any other format except WMA.
- **never_convert_lossy_files**: Cross-conversions between lossy codecs---such
as mp3, ogg vorbis, etc.---makes little sense as they will decrease quality
even further. If set to ``yes``, lossy files are always copied. Default:
``no``. When ``never_convert_lossy_files`` is enabled, lossy source files (for
example MP3 or Ogg Vorbis) are normally not transcoded and are instead copied
or linked as-is. To explicitly transcode lossy files in spite of this, use the
``--force`` option with the ``convert`` command (optionally together with
``--format`` to choose a target format)
``no``.
- **paths**: The directory structure and naming scheme for the converted files.
Uses the same format as the top-level ``paths`` section (see
:ref:`path-format-config`). Default: Reuse your top-level path format

View file

@ -35,23 +35,15 @@ Default
.. code-block:: yaml
deezer:
search_query_ascii: no
data_source_mismatch_penalty: 0.5
search_limit: 5
search_query_ascii: no
.. conf:: search_query_ascii
:default: no
If enabled, the search query will be converted to ASCII before being sent to
Deezer. Converting searches to ASCII can enhance search results in some cases,
but in general, it is not recommended. For instance, ``artist:deadmau5
album:4×4`` will be converted to ``artist:deadmau5 album:4x4`` (notice
``×!=x``).
.. include:: ./shared_metadata_source_config.rst
Commands
--------
- **search_query_ascii**: If set to ``yes``, the search query will be converted
to ASCII before being sent to Deezer. Converting searches to ASCII can enhance
search results in some cases, but in general, it is not recommended. For
instance ``artist:deadmau5 album:4×4`` will be converted to ``artist:deadmau5
album:4x4`` (notice ``×!=x``). Default: ``no``.
The ``deezer`` plugin provides an additional command ``deezerupdate`` to update
the ``rank`` information from Deezer. The ``rank`` (ranges from 0 to 1M) is a

View file

@ -71,93 +71,67 @@ Default
.. code-block:: yaml
discogs:
data_source_mismatch_penalty: 0.5
search_limit: 5
apikey: REDACTED
apisecret: REDACTED
tokenfile: discogs_token.json
user_token:
user_token: REDACTED
index_tracks: no
append_style_genre: no
separator: ', '
strip_disambiguation: yes
featured_string: Feat.
- **index_tracks**: Index tracks (see the `Discogs guidelines`_) along with
headers, mark divisions between distinct works on the same release or within
works. When enabled, beets will incorporate the names of the divisions
containing each track into the imported track's title. Default: ``no``.
For example, importing `divisions album`_ would result in track names like:
.. code-block:: text
Messiah, Part I: No.1: Sinfony
Messiah, Part II: No.22: Chorus- Behold The Lamb Of God
Athalia, Act I, Scene I: Sinfonia
whereas with ``index_tracks`` disabled you'd get:
.. code-block:: text
No.1: Sinfony
No.22: Chorus- Behold The Lamb Of God
Sinfonia
This option is useful when importing classical music.
- **append_style_genre**: Appends the Discogs style (if found) to the genre tag.
This can be useful if you want more granular genres to categorize your music.
For example, a release in Discogs might have a genre of "Electronic" and a
style of "Techno": enabling this setting would set the genre to be
"Electronic, Techno" (assuming default separator of ``", "``) instead of just
"Electronic". Default: ``False``
- **separator**: How to join multiple genre and style values from Discogs into a
string. Default: ``", "``
- **strip_disambiguation**: Discogs uses strings like ``"(4)"`` to mark distinct
artists and labels with the same name. If you'd like to use the discogs
disambiguation in your tags, you can disable it. Default: ``True``
- **featured_string**: Configure the string used for noting featured artists.
Useful if you prefer ``Featuring`` or ``ft.``. Default: ``Feat.``
- **anv**: These configuration option are dedicated to handling Artist Name
Variations (ANVs). Sometimes a release credits artists differently compared to
the majority of their work. For example, "Basement Jaxx" may be credited as
"Tha Jaxx" or "The Basement Jaxx".You can select any combination of these
config options to control where beets writes and stores the variation credit.
The default, shown below, writes variations to the artist_credit field.
.. code-block:: yaml
discogs:
anv:
artist_credit: yes
artist: no
album_artist: no
data_source_mismatch_penalty: 0.5
search_limit: 5
.. conf:: index_tracks
:default: no
Index tracks (see the `Discogs guidelines`_) along with headers, mark divisions
between distinct works on the same release or within works. When enabled,
beets will incorporate the names of the divisions containing each track into the
imported track's title.
For example, importing `divisions album`_ would result in track names like:
.. code-block:: text
Messiah, Part I: No.1: Sinfony
Messiah, Part II: No.22: Chorus- Behold The Lamb Of God
Athalia, Act I, Scene I: Sinfonia
whereas with ``index_tracks`` disabled you'd get:
.. code-block:: text
No.1: Sinfony
No.22: Chorus- Behold The Lamb Of God
Sinfonia
This option is useful when importing classical music.
.. conf:: append_style_genre
:default: no
Appends the Discogs style (if found) to the genre tag. This can be useful if
you want more granular genres to categorize your music. For example,
a release in Discogs might have a genre of "Electronic" and a style of
"Techno": enabling this setting would set the genre to be "Electronic,
Techno" (assuming default separator of ``", "``) instead of just
"Electronic".
.. conf:: separator
:default: ", "
How to join multiple genre and style values from Discogs into a string.
.. conf:: strip_disambiguation
:default: yes
Discogs uses strings like ``"(4)"`` to mark distinct artists and labels with
the same name. If you'd like to use the Discogs disambiguation in your tags,
you can disable this option.
.. conf:: featured_string
:default: Feat.
Configure the string used for noting featured artists. Useful if you prefer ``Featuring`` or ``ft.``.
.. conf:: anv
This configuration option is dedicated to handling Artist Name
Variations (ANVs). Sometimes a release credits artists differently compared to
the majority of their work. For example, "Basement Jaxx" may be credited as
"Tha Jaxx" or "The Basement Jaxx". You can select any combination of these
config options to control where beets writes and stores the variation credit.
The default, shown below, writes variations to the artist_credit field.
.. code-block:: yaml
discogs:
anv:
artist_credit: yes
artist: no
album_artist: no
.. include:: ./shared_metadata_source_config.rst
artist_credit: True
artist: False
album_artist: False
.. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005055373-Database-Guidelines-12-Tracklisting#Index_Tracks_And_Headings

View file

@ -70,7 +70,7 @@ These options match the options from the `Python csv module`_.
.. _python csv module: https://docs.python.org/3/library/csv.html#csv-fmt-params
.. _python json module: https://docs.python.org/3/library/json.html#basic-usage
.. _python json module: https://docs.python.org/2/library/json.html#basic-usage
The default options look like this:

View file

@ -28,18 +28,6 @@ file. The available options are:
- **keep_in_artist**: Keep the featuring X part in the artist field. This can be
useful if you still want to be able to search for features in the artist
field. Default: ``no``.
- **preserve_album_artist**: If the artist and the album artist are the same,
skip the ftintitle processing. Default: ``yes``.
- **custom_words**: List of additional words that will be treated as a marker
for artist features. Default: ``[]``.
Path Template Values
--------------------
This plugin provides the ``album_artist_no_feat`` :ref:`template value
<templ_plugins>` that you can use in your :ref:`path-format-config` in
``paths.default``. Any ``custom_words`` in the configuration are taken into
account.
Running Manually
----------------

5
docs/plugins/gmusic.rst Normal file
View file

@ -0,0 +1,5 @@
Gmusic Plugin
=============
The ``gmusic`` plugin interfaced beets to Google Play Music. It has been removed
after the shutdown of this service.

View file

@ -1,80 +0,0 @@
ImportSource Plugin
===================
The ``importsource`` plugin adds a ``source_path`` field to every item imported
to the library which stores the original media files' paths. Using this plugin
makes most sense when the general importing workflow is using ``beet import
--copy``. Additionally the plugin interactively suggests deletion of original
source files whenever items are removed from the Beets library.
To enable it, add ``importsource`` to the list of plugins in your configuration
(see :ref:`using-plugins`).
Tracking Source Paths
---------------------
The primary use case for the plugin is tracking the original location of
imported files using the ``source_path`` field. Consider this scenario: you've
imported all directories in your current working directory using:
.. code-block:: bash
beet import --flat --copy */
Later, for instance if the import didn't complete successfully, you'll need to
rerun the import but don't want Beets to re-process the already successfully
imported directories. You can view which files were successfully imported using:
.. code-block:: bash
beet ls source_path:$PWD --format='$source_path'
To extract just the directory names, pipe the output to standard UNIX utilities:
.. code-block:: bash
beet ls source_path:$PWD --format='$source_path' | awk -F / '{print $(NF-1)}' | sort -u
This might help to find out what's left to be imported.
Removal Suggestion
------------------
Another feature of the plugin is suggesting removal of original source files
when items are deleted from your library. Consider this scenario: you imported
an album using:
.. code-block:: bash
beet import --copy --flat ~/Desktop/interesting-album-to-check/
After listening to that album and deciding it wasn't good, you want to delete it
from your library as well as from your ``~/Desktop``, so you run:
.. code-block:: bash
beet remove --delete source_path:$HOME/Desktop/interesting-album-to-check
After approving the deletion, the plugin will prompt:
.. code-block:: text
The item:
<music-library>/Interesting Album/01 Interesting Song.flac
is originated from:
<HOME>/Desktop/interesting-album-to-check/01-interesting-song.flac
What would you like to do?
Delete the item's source, Recursively delete the source's directory,
do Nothing,
do nothing and Stop suggesting to delete items from this album?
Configuration
-------------
To configure the plugin, make an ``importsource:`` section in your configuration
file. There is one option available:
- **suggest_removal**: By default ``importsource`` suggests to remove the
original directories / files from which the items were imported whenever
library items (and files) are removed. To disable these prompts set this
option to ``no``. Default: ``yes``.

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