Merge branch 'master' into embedart-clear-improvements

This commit is contained in:
Serene 2025-12-06 13:08:51 +10:00 committed by GitHub
commit 23670b3307
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
79 changed files with 3090 additions and 1690 deletions

View file

@ -78,4 +78,6 @@ d93ddf8dd43e4f9ed072a03829e287c78d2570a2
# Moved ui.commands._utils into ui.commands.utils
25ae330044abf04045e3f378f72bbaed739fb30d
# Refactor test_ui_command.py into multiple modules
a59e41a88365e414db3282658d2aa456e0b3468a
a59e41a88365e414db3282658d2aa456e0b3468a
# pyupgrade Python 3.10
301637a1609831947cb5dd90270ed46c24b1ab1b

3
.github/CODEOWNERS vendored
View file

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

View file

@ -20,10 +20,10 @@ jobs:
fail-fast: false
matrix:
platform: [ubuntu-latest, windows-latest]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13"]
runs-on: ${{ matrix.platform }}
env:
IS_MAIN_PYTHON: ${{ matrix.python-version == '3.9' && matrix.platform == 'ubuntu-latest' }}
IS_MAIN_PYTHON: ${{ matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest' }}
steps:
- uses: actions/checkout@v5
- name: Install Python tools

View file

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

View file

@ -12,7 +12,7 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
PYTHON_VERSION: 3.9
PYTHON_VERSION: "3.10"
jobs:
changed-files:

View file

@ -8,7 +8,7 @@ on:
required: true
env:
PYTHON_VERSION: 3.9
PYTHON_VERSION: "3.10"
NEW_VERSION: ${{ inputs.version }}
NEW_TAG: v${{ inputs.version }}

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.9)`` prefix in your shell prompt. Now you can run
You should see ``(beets-py3.10)`` prefix in your shell prompt. Now you can run
commands directly, for example:
::
$ (beets-py3.9) pytest
$ (beets-py3.10) pytest
Additionally, poethepoet_ task runner assists us with the most common
operations. Formatting, linting, testing are defined as ``poe`` tasks in

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.html
.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins/index.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.html
.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins/index.html
설치
-------

View file

@ -17,7 +17,7 @@ from sys import stderr
import confuse
from .util import deprecate_imports
from .util.deprecation import deprecate_imports
__version__ = "2.5.1"
__author__ = "Adrian Sampson <adrian@radbox.org>"
@ -26,13 +26,9 @@ __author__ = "Adrian Sampson <adrian@radbox.org>"
def __getattr__(name: str):
"""Handle deprecated imports."""
return deprecate_imports(
old_module=__name__,
new_module_by_name={
"art": "beetsplug._utils",
"vfs": "beetsplug._utils",
},
name=name,
version="3.0.0",
__name__,
{"art": "beetsplug._utils", "vfs": "beetsplug._utils"},
name,
)

View file

@ -16,16 +16,15 @@
from __future__ import annotations
import warnings
from importlib import import_module
from typing import TYPE_CHECKING, Union
from typing import TYPE_CHECKING
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
@ -37,18 +36,13 @@ if TYPE_CHECKING:
def __getattr__(name: str):
if name == "current_metadata":
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,
deprecate_for_maintainers(
f"'beets.autotag.{name}'", "'beets.util.get_most_common_tags'"
)
return import_module("beets.util").get_most_common_tags
return deprecate_imports(
__name__, {"Distance": "beets.autotag.distance"}, name, "3.0.0"
__name__, {"Distance": "beets.autotag.distance"}, name
)
@ -117,8 +111,8 @@ SPECIAL_FIELDS = {
def _apply_metadata(
info: Union[AlbumInfo, TrackInfo],
db_obj: Union[Album, Item],
info: AlbumInfo | TrackInfo,
db_obj: Album | Item,
nullable_fields: Sequence[str] = [],
):
"""Set the db_obj's metadata to match the info."""

View file

@ -26,9 +26,16 @@ import threading
import time
from abc import ABC
from collections import defaultdict
from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence
from collections.abc import (
Callable,
Generator,
Iterable,
Iterator,
Mapping,
Sequence,
)
from sqlite3 import Connection, sqlite_version_info
from typing import TYPE_CHECKING, Any, AnyStr, Callable, Generic
from typing import TYPE_CHECKING, Any, AnyStr, Generic
from typing_extensions import TypeVar # default value support
from unidecode import unidecode

View file

@ -15,7 +15,7 @@ from __future__ import annotations
import os
import time
from typing import TYPE_CHECKING, Sequence
from typing import TYPE_CHECKING
from beets import config, dbcore, library, logging, plugins, util
from beets.importer.tasks import Action
@ -25,6 +25,8 @@ 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, Callable
from typing import TYPE_CHECKING
from beets import config, plugins
from beets.util import MoveOperation, displayable_path, pipeline
@ -30,6 +30,8 @@ from .tasks import (
)
if TYPE_CHECKING:
from collections.abc import Callable
from beets import library
from .session import ImportSession

View file

@ -20,9 +20,10 @@ 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, Callable, Iterable, Sequence
from typing import TYPE_CHECKING, Any
import mediafile

View file

@ -1,4 +1,4 @@
from beets.util import deprecate_imports
from beets.util.deprecation 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, "3.0.0")
return deprecate_imports(__name__, NEW_MODULE_BY_NAME, name)
__all__ = [

View file

@ -22,6 +22,7 @@ calls (`debug`, `info`, etc).
from __future__ import annotations
import re
import threading
from copy import copy
from logging import (
@ -37,7 +38,7 @@ from logging import (
RootLogger,
StreamHandler,
)
from typing import TYPE_CHECKING, Any, Mapping, TypeVar, Union, overload
from typing import TYPE_CHECKING, Any, TypeVar, Union, overload
__all__ = [
"DEBUG",
@ -54,6 +55,8 @@ __all__ = [
]
if TYPE_CHECKING:
from collections.abc import Mapping
T = TypeVar("T")
from types import TracebackType
@ -66,6 +69,15 @@ 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.
@ -80,6 +92,10 @@ 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,17 +13,11 @@
# included in all copies or substantial portions of the Software.
import warnings
import mediafile
warnings.warn(
"beets.mediafile is deprecated; use mediafile instead",
# Show the location of the `import mediafile` statement as the warning's
# source, rather than this file, such that the offending module can be
# identified easily.
stacklevel=2,
)
from .util.deprecation import deprecate_for_maintainers
deprecate_for_maintainers("'beets.mediafile'", "'mediafile'", stacklevel=2)
# Import everything from the mediafile module into this module.
for key, value in mediafile.__dict__.items():
@ -31,4 +25,4 @@ for key, value in mediafile.__dict__.items():
globals()[key] = value
# Cleanup namespace.
del key, value, warnings, mediafile
del key, value, mediafile

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, Sequence, TypedDict, TypeVar
from typing import TYPE_CHECKING, Generic, Literal, 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
from collections.abc import Iterable, Sequence
from .autotag.hooks import AlbumInfo, Item, TrackInfo

View file

@ -20,12 +20,10 @@ import abc
import inspect
import re
import sys
import warnings
from collections import defaultdict
from functools import cached_property, 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
@ -34,6 +32,7 @@ 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
@ -152,9 +151,9 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
list
)
listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list)
template_funcs: TFuncMap[str] | None = None
template_fields: TFuncMap[Item] | None = None
album_template_fields: TFuncMap[Album] | None = None
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]
name: str
config: ConfigView
@ -185,11 +184,12 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
):
return
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,
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'",
stacklevel=3,
)
@ -220,8 +220,8 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
self.name = name or self.__module__.split(".")[-1]
self.config = beets.config[self.name]
# Set class attributes if they are not already set
# for the type of plugin.
# If the class attributes are not set, initialize as instance attributes.
# TODO: Revise with v3.0.0, see also type: ignore[valid-type] above
if not self.template_funcs:
self.template_funcs = {}
if not self.template_fields:
@ -257,16 +257,19 @@ 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
self._log.warning(message)
deprecate_for_user(
self._log,
f"'{self.name}.source_weight' configuration option",
f"'{self.name}.data_source_mismatch_penalty'",
)
else: # 3rd-party plugin config
warnings.warn(message, DeprecationWarning, stacklevel=0)
deprecate_for_maintainers(
"'source_weight' configuration option",
"'data_source_mismatch_penalty'",
)
def commands(self) -> Sequence[Subcommand]:
"""Should return a list of beets.ui.Subcommand objects for
@ -369,8 +372,6 @@ 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
@ -385,8 +386,6 @@ 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
@ -415,16 +414,22 @@ 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]
@ -450,9 +455,6 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
for obj in reversed(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)
@ -569,8 +571,7 @@ def template_funcs() -> TFuncMap[str]:
"""
funcs: TFuncMap[str] = {}
for plugin in find_plugins():
if plugin.template_funcs:
funcs.update(plugin.template_funcs)
funcs.update(plugin.template_funcs)
return funcs
@ -596,21 +597,20 @@ F = TypeVar("F")
def _check_conflicts_and_merge(
plugin: BeetsPlugin, plugin_funcs: dict[str, F] | None, funcs: dict[str, F]
plugin: BeetsPlugin, plugin_funcs: dict[str, F], 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 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)
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]:

View file

@ -28,11 +28,10 @@ import sqlite3
import sys
import textwrap
import traceback
import warnings
from difflib import SequenceMatcher
from functools import cache
from itertools import chain
from typing import Any, Callable, Literal
from typing import TYPE_CHECKING, Any, Literal
import confuse
@ -40,8 +39,12 @@ 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:
@ -111,11 +114,7 @@ def decargs(arglist):
.. deprecated:: 2.4.0
This function will be removed in 3.0.0.
"""
warnings.warn(
"decargs() is deprecated and will be removed in version 3.0.0.",
DeprecationWarning,
stacklevel=2,
)
deprecate_for_maintainers("'beets.ui.decargs'")
return arglist

View file

@ -16,7 +16,7 @@
interface.
"""
from beets.util import deprecate_imports
from beets.util.deprecation import deprecate_imports
from .completion import completion_cmd
from .config import config_cmd
@ -36,14 +36,12 @@ from .write import write_cmd
def __getattr__(name: str):
"""Handle deprecated imports."""
return deprecate_imports(
old_module=__name__,
new_module_by_name={
__name__,
{
"TerminalImportSession": "beets.ui.commands.import_.session",
"PromptChoice": "beets.ui.commands.import_.session",
# TODO: We might want to add more deprecated imports here
"PromptChoice": "beets.util",
},
name=name,
version="3.0.0",
name,
)

View file

@ -125,7 +125,7 @@ def import_func(lib, opts, args: list[str]):
# If all paths were read from a logfile, and none of them exist, throw
# an error
if not paths:
if not byte_paths:
raise ui.UserError("none of the paths are importable")
import_files(lib, byte_paths, query)

View file

@ -1,10 +1,9 @@
from collections import Counter
from itertools import chain
from typing import Any, NamedTuple
from beets import autotag, config, importer, logging, plugins, ui
from beets.autotag import Recommendation
from beets.util import displayable_path
from beets.util import PromptChoice, displayable_path
from beets.util.units import human_bytes, human_seconds_short
from .display import (
@ -368,12 +367,6 @@ def _summary_judgment(rec):
return action
class PromptChoice(NamedTuple):
short: str
long: str
callback: Any
def choose_candidate(
candidates,
singleton,

View file

@ -27,9 +27,8 @@ import subprocess
import sys
import tempfile
import traceback
import warnings
from collections import Counter
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from contextlib import suppress
from enum import Enum
from functools import cache
@ -41,7 +40,6 @@ from typing import (
TYPE_CHECKING,
Any,
AnyStr,
Callable,
ClassVar,
Generic,
NamedTuple,
@ -169,6 +167,12 @@ class MoveOperation(Enum):
REFLINK_AUTO = 5
class PromptChoice(NamedTuple):
short: str
long: str
callback: Any
def normpath(path: PathLike) -> bytes:
"""Provide the canonical form of the path suitable for storing in
the database.
@ -578,10 +582,14 @@ def hardlink(path: bytes, dest: bytes, replace: bool = False):
if samefile(path, dest):
return
if os.path.exists(syspath(dest)) and not replace:
# Dereference symlinks, expand "~", and convert relative paths to absolute
origin_path = Path(os.fsdecode(path)).expanduser().resolve()
dest_path = Path(os.fsdecode(dest)).expanduser().resolve()
if dest_path.exists() and not replace:
raise FilesystemError("file exists", "rename", (path, dest))
try:
os.link(syspath(path), syspath(dest))
dest_path.hardlink_to(origin_path)
except NotImplementedError:
raise FilesystemError(
"OS does not support hard links.link",
@ -1192,26 +1200,3 @@ 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 Any, ClassVar, Mapping
from typing import TYPE_CHECKING, Any, ClassVar
from urllib.parse import urlencode
from beets import logging, util
@ -37,6 +37,9 @@ from beets.util import (
syspath,
)
if TYPE_CHECKING:
from collections.abc import Mapping
PROXY_URL = "https://images.weserv.nl/"
log = logging.getLogger("beets")

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

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

View file

@ -105,8 +105,6 @@ def compile_func(arg_names, statements, name="_the_func", debug=False):
decorator_list=[],
)
# The ast.Module signature changed in 3.8 to accept a list of types to
# ignore.
mod = ast.Module([func_def], [])
ast.fix_missing_locations(mod)

View file

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

View file

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

View file

@ -19,14 +19,7 @@ from __future__ import annotations
import json
import re
from datetime import datetime, timedelta
from typing import (
TYPE_CHECKING,
Iterable,
Iterator,
Literal,
Sequence,
overload,
)
from typing import TYPE_CHECKING, Literal, overload
import confuse
from requests_oauthlib import OAuth1Session
@ -42,6 +35,8 @@ 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("utf-8"))
self.ctrl_sock.sendall((f"{message}\n").encode())
def _send_event(self, event):
"""Notify subscribed connections of an event."""

View file

@ -27,7 +27,16 @@ import gi
from beets import ui
gi.require_version("Gst", "1.0")
try:
gi.require_version("Gst", "1.0")
except ValueError as e:
# on some scenarios, gi may be importable, but we get a ValueError when
# trying to specify the required version. This is problematic in the test
# suite where test_bpd.py has a call to
# pytest.importorskip("beetsplug.bpd"). Re-raising as an ImportError
# makes it so the test collector functions as inteded.
raise ImportError from e
from gi.repository import GLib, Gst # noqa: E402
Gst.init(None)

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,12 +95,18 @@ def in_no_convert(item: Item) -> bool:
return False
def should_transcode(item, fmt):
def should_transcode(item, fmt, force: bool = False):
"""Determine whether the item should be transcoded as part of
conversion (i.e., its bitrate is high or it has the wrong format).
If ``force`` is True, safety checks like ``no_convert`` and
``never_convert_lossy_files`` are ignored and the item is always
transcoded.
"""
if force:
return True
if in_no_convert(item) or (
config["convert"]["never_convert_lossy_files"]
config["convert"]["never_convert_lossy_files"].get(bool)
and item.format.lower() not in LOSSLESS_FORMATS
):
return False
@ -236,6 +242,16 @@ class ConvertPlugin(BeetsPlugin):
drive, relative paths pointing to media files
will be used.""",
)
cmd.parser.add_option(
"-F",
"--force",
action="store_true",
dest="force",
help=(
"force transcoding. Ignores no_convert, "
"never_convert_lossy_files, and max_bitrate"
),
)
cmd.parser.add_album_option()
cmd.func = self.convert_func
return [cmd]
@ -259,6 +275,7 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
link,
playlist,
force,
) = self._get_opts_and_config(empty_opts)
items = task.imported_items()
@ -272,6 +289,7 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
threads,
items,
force,
)
# Utilities converted from functions to methods on logging overhaul
@ -347,6 +365,7 @@ class ConvertPlugin(BeetsPlugin):
pretend=False,
link=False,
hardlink=False,
force=False,
):
"""A pipeline thread that converts `Item` objects from a
library.
@ -372,11 +391,11 @@ class ConvertPlugin(BeetsPlugin):
if keep_new:
original = dest
converted = item.path
if should_transcode(item, fmt):
if should_transcode(item, fmt, force):
converted = replace_ext(converted, ext)
else:
original = item.path
if should_transcode(item, fmt):
if should_transcode(item, fmt, force):
dest = replace_ext(dest, ext)
converted = dest
@ -406,7 +425,7 @@ class ConvertPlugin(BeetsPlugin):
)
util.move(item.path, original)
if should_transcode(item, fmt):
if should_transcode(item, fmt, force):
linked = False
try:
self.encode(command, original, converted, pretend)
@ -577,6 +596,7 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
link,
playlist,
force,
) = self._get_opts_and_config(opts)
if opts.album:
@ -613,6 +633,7 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
threads,
items,
force,
)
if playlist:
@ -735,7 +756,7 @@ class ConvertPlugin(BeetsPlugin):
else:
hardlink = self.config["hardlink"].get(bool)
link = self.config["link"].get(bool)
force = getattr(opts, "force", False)
return (
dest,
threads,
@ -745,6 +766,7 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
link,
playlist,
force,
)
def _parallel_convert(
@ -758,13 +780,21 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
threads,
items,
force,
):
"""Run the convert_item function for every items on as many thread as
defined in threads
"""
convert = [
self.convert_item(
dest, keep_new, path_formats, fmt, pretend, link, hardlink
dest,
keep_new,
path_formats,
fmt,
pretend,
link,
hardlink,
force,
)
for _ in range(threads)
]

View file

@ -18,7 +18,7 @@ from __future__ import annotations
import collections
import time
from typing import TYPE_CHECKING, Literal, Sequence
from typing import TYPE_CHECKING, Literal
import requests
@ -32,6 +32,8 @@ 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, Sequence, cast
from typing import TYPE_CHECKING, 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
from collections.abc import Callable, Iterable, Sequence
from beets.library import Item

View file

@ -25,8 +25,8 @@ import yaml
from beets import plugins, ui, util
from beets.dbcore import types
from beets.importer import Action
from beets.ui.commands.import_.session import PromptChoice
from beets.ui.commands.utils import do_query
from beets.util import PromptChoice
# These "safe" types can avoid the format/parse cycle that most fields go
# through: they are safe to edit with native YAML types.

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, Tuple, Type
from typing import TYPE_CHECKING, AnyStr, ClassVar, Literal
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("utf-8")
search_string = f"{album.albumartist},{album.album}".encode()
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,11 +19,11 @@ from __future__ import annotations
import re
from typing import TYPE_CHECKING
from beets import plugins, ui
from beets import config, plugins, ui
if TYPE_CHECKING:
from beets.importer import ImportSession, ImportTask
from beets.library import Item
from beets.library import Album, Item
def split_on_feat(
@ -98,6 +98,11 @@ def find_feat_part(
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__()
@ -129,6 +134,10 @@ 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)

View file

@ -1,27 +0,0 @@
# 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

@ -19,7 +19,7 @@ class ImportSourcePlugin(BeetsPlugin):
def __init__(self):
"""Initialize the plugin and read configuration."""
super(ImportSourcePlugin, self).__init__()
super().__init__()
self.config.add(
{
"suggest_removal": False,

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)
func = self.compile_inline(view.as_str(), False, key)
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)
func = self.compile_inline(view.as_str(), True, key)
if func is not None:
self.album_template_fields[key] = func
def compile_inline(self, python_code, album):
def compile_inline(self, python_code, album, field_name):
"""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,7 +97,12 @@ class InlinePlugin(BeetsPlugin):
is_expr = True
def _dict_for(obj):
out = dict(obj)
out = {}
for key in obj.keys(computed=False):
if key == field_name:
continue
out[key] = obj._get(key)
if album:
out["items"] = list(obj.items())
return out

View file

@ -28,7 +28,7 @@ import os
import traceback
from functools import singledispatchmethod
from pathlib import Path
from typing import TYPE_CHECKING, Union
from typing import TYPE_CHECKING
import pylast
import yaml
@ -352,7 +352,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
combined = old + new
return self._resolve_genres(combined)
def _get_genre(self, obj: LibModel) -> tuple[Union[str, None], ...]:
def _get_genre(self, obj: LibModel) -> tuple[str | None, ...]:
"""Get the final genre string for an Album or Item object.
`self.sources` specifies allowed genre sources. Starting with the first

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, Iterable, Iterator, NamedTuple
from typing import TYPE_CHECKING, NamedTuple
from urllib.parse import quote, quote_plus, urlencode, urlparse
import langdetect
@ -42,6 +42,8 @@ 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

View file

@ -19,7 +19,7 @@ from __future__ import annotations
import itertools
import traceback
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Iterable, Sequence
from typing import TYPE_CHECKING, Any
import mediafile
import musicbrainzngs
@ -40,6 +40,8 @@ from beetsplug.musicbrainz import (
)
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from beets.autotag import AlbumMatch
from beets.library import Item
from beetsplug._typing import JSONDict

View file

@ -26,8 +26,7 @@ import subprocess
from beets import ui
from beets.autotag import Recommendation
from beets.plugins import BeetsPlugin
from beets.ui.commands import PromptChoice
from beets.util import displayable_path
from beets.util import PromptChoice, 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, Iterable, Sequence
from typing import TYPE_CHECKING, Any
from urllib.parse import urljoin
import musicbrainzngs
@ -31,9 +31,11 @@ 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
@ -89,6 +91,7 @@ RELEASE_INCLUDES = list(
"isrcs",
"url-rels",
"release-rels",
"genres",
"tags",
}
& set(musicbrainzngs.VALID_INCLUDES["release"])
@ -369,6 +372,10 @@ 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.
@ -381,6 +388,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
"ratelimit": 1,
"ratelimit_interval": 1,
"genres": False,
"genres_tag": "genre",
"external_ids": {
"discogs": False,
"bandcamp": False,
@ -396,9 +404,10 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
self.config["search_limit"] = self.config["match"][
"searchlimit"
].get()
self._log.warning(
"'musicbrainz.searchlimit' option is deprecated and will be "
"removed in 3.0.0. Use 'musicbrainz.search_limit' instead."
deprecate_for_user(
self._log,
"'musicbrainz.searchlimit' configuration option",
"'musicbrainz.search_limit'",
)
hostname = self.config["host"].as_str()
https = self.config["https"].get(bool)
@ -722,8 +731,8 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
if self.config["genres"]:
sources = [
release["release-group"].get("tag-list", []),
release.get("tag-list", []),
release["release-group"].get(self.genres_field, []),
release.get(self.genres_field, []),
]
genres: Counter[str] = Counter()
for source in sources:

View file

@ -21,8 +21,7 @@ from os.path import relpath
from beets import config, ui, util
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
from beets.ui.commands import PromptChoice
from beets.util import get_temp_filename
from beets.util import PromptChoice, get_temp_filename
# Indicate where arguments should be inserted into the command string.
# If this is missing, they're placed at the end.

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, Callable, TypeVar
from typing import TYPE_CHECKING, Any, 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 Sequence
from collections.abc import Callable, Sequence
from logging import Logger
from confuse import ConfigView

View file

@ -27,7 +27,7 @@ import re
import threading
import time
import webbrowser
from typing import TYPE_CHECKING, Any, Literal, Sequence, Union
from typing import TYPE_CHECKING, Any, Literal, Union
import confuse
import requests
@ -43,6 +43,8 @@ from beets.metadata_plugins import (
)
if TYPE_CHECKING:
from collections.abc import Sequence
from beets.library import Library
from beetsplug._typing import JSONDict

236
beetsplug/titlecase.py Normal file
View file

@ -0,0 +1,236 @@
# 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,9 +17,10 @@
import base64
import json
import os
import typing as t
import flask
from flask import g, jsonify
from flask import jsonify
from unidecode import unidecode
from werkzeug.routing import BaseConverter, PathConverter
@ -28,6 +29,17 @@ 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.
@ -232,7 +244,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

@ -7,9 +7,15 @@ 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
@ -21,9 +27,20 @@ New features:
- :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
@ -33,6 +50,14 @@ Bug fixes:
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:
@ -42,6 +67,8 @@ For plugin developers:
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
@ -50,6 +77,10 @@ Other changes:
- 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)
------------------------
@ -1334,9 +1365,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`
- :doc:`/plugins/gmusic`: The ``oauth_file`` config option now supports more
- ``/plugins/gmusic``: The ``oauth_file`` config option now supports more
flexible path values, including ``~`` for the home directory. :bug:`3270`
- :doc:`/plugins/gmusic`: Fix a crash when using version 12.0.0 or later of the
- ``/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`
@ -1387,7 +1418,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`
- :doc:`/plugins/gmusic`:
- ``/plugins/gmusic``:
- Add a new option to automatically upload to Google Play Music library on
track import. Thanks to :user:`shuaiscott`.
@ -1826,7 +1857,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 :doc:`/plugins/gmusic` lets you interact with your Google Play Music
- A new ``/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

View file

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

View file

@ -1,10 +1,10 @@
Installation
============
Beets requires `Python 3.9 or later`_. You can install it using package
Beets requires `Python 3.10 or later`_. You can install it using package
managers, pipx_, pip_ or by using package managers.
.. _python 3.9 or later: https://python.org/download/
.. _python 3.10 or later: https://python.org/download/
Using ``pipx`` or ``pip``
-------------------------

View file

@ -51,6 +51,11 @@ 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
@ -104,15 +109,21 @@ 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.
``^path::\.(wma)$``, to not convert any other format except WMA. This option
will be overridden by the ``--force`` flag
- **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``.
``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)
- **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

@ -33,6 +33,14 @@ file. The available options are:
- **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
----------------

View file

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

View file

@ -84,7 +84,6 @@ databases. They share the following configuration options:
fromfilename
ftintitle
fuzzy
gmusic
hook
ihate
importadded
@ -128,6 +127,7 @@ databases. They share the following configuration options:
substitute
the
thumbnails
titlecase
types
unimported
web

View file

@ -32,6 +32,7 @@ Default
ratelimit_interval: 1.0
extra_tags: []
genres: no
genres_tag: genre
external_ids:
discogs: no
bandcamp: no
@ -136,6 +137,12 @@ Default
``beatport_album_id``, ``deezer_album_id``, ``tidal_album_id``). On re-imports
existing data will be overwritten.
.. conf:: genres_tag
:default: genre
Either ``genre`` or ``tag``. Specify ``genre`` to use just musicbrainz genre and
``tag`` to use all user-supplied musicbrainz tags.
.. include:: ./shared_metadata_source_config.rst
.. _building search indexes: https://musicbrainz.org/doc/Development/Search_server_setup

200
docs/plugins/titlecase.rst Normal file
View file

@ -0,0 +1,200 @@
Titlecase Plugin
================
The ``titlecase`` plugin lets you format tags and paths in accordance with the
titlecase guidelines in the `New York Times Manual of Style`_ and uses the
`python titlecase library`_.
Motivation for this plugin comes from a desire to resolve differences in style
between databases sources. For example, `MusicBrainz style`_ follows standard
title case rules, except in the case of terms that are deemed generic, like
"mix" and "remix". On the other hand, `Discogs guidelines`_ recommend
capitalizing the first letter of each word, even for small words like "of" and
"a". This plugin aims to achieve a middle ground between disparate approaches to
casing, and bring more consistency to titles in your library.
.. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005006334-Database-Guidelines-1-General-Rules#Capitalization_And_Grammar
.. _musicbrainz style: https://musicbrainz.org/doc/Style
.. _new york times manual of style: https://search.worldcat.org/en/title/946964415
.. _python titlecase library: https://pypi.org/project/titlecase/
Installation
------------
To use the ``titlecase`` plugin, first enable it in your configuration (see
:ref:`using-plugins`). Then, install ``beets`` with ``titlecase`` extra:
.. code-block:: bash
pip install "beets[titlecase]"
If you'd like to just use the path format expression, call ``%titlecase`` in
your path formatter, and set ``auto`` to ``no`` in the configuration.
::
paths:
default: %titlecase($albumartist)/$titlecase($albumtitle)/$track $title
You can now configure ``titlecase`` to your preference.
Configuration
-------------
This plugin offers several configuration options to tune its function to your
preference.
Default
~~~~~~~
.. code-block:: yaml
titlecase:
auto: yes
fields: []
preserve: []
replace: []
seperators: []
force_lowercase: no
small_first_last: yes
the_artist: yes
after_choice: no
.. conf:: auto
:default: yes
Whether to automatically apply titlecase to new imports.
.. conf:: fields
:default: []
A list of fields to apply the titlecase logic to. You must specify the fields
you want to have modified in order for titlecase to apply changes to metadata.
A good starting point is below, which will titlecase album titles, track titles, and all artist fields.
.. code-block:: yaml
titlecase:
fields:
- album
- title
- albumartist
- albumartist_credit
- albumartist_sort
- albumartists
- albumartists_credit
- albumartists_sort
- artist
- artist_credit
- artist_sort
- artists
- artists_credit
- artists_sort
.. conf:: preserve
:default: []
List of words and phrases to preserve the case of. Without specifying ``DJ`` on
the list, titlecase will format it as ``Dj``, or specify ``The Beatles`` to make sure
``With The Beatles`` is not capitalized as ``With the Beatles``.
.. conf:: replace
:default: []
The replace function takes place before any titlecasing occurs, and is intended to
help normalize differences in puncuation styles. It accepts a list of tuples, with
the first being the target, and the second being the replacement.
An example configuration that enforces one style of quotation mark is below.
.. code-block:: yaml
titlecase:
replace:
- "": "'"
- "": "'"
- "“": '"'
- "”": '"'
.. conf:: seperators
:default: []
A list of characters to treat as markers of new sentences. Helpful for split titles
that might otherwise have a lowercase letter at the start of the second string.
.. conf:: force_lowercase
:default: no
Force all strings to lowercase before applying titlecase, but can cause
problems with all caps acronyms titlecase would otherwise recognize.
.. conf:: small_first_last
:default: yes
An option from the base titlecase library. Controls capitalizing small words at the start
of a sentence. With this turned off ``a`` and similar words will not be capitalized
under any circumstance.
.. conf:: the_artist
:default: yes
If a field name contains ``artist``, then any lowercase ``the`` will be
capitalized. Useful for bands with `The` as part of the proper name,
like ``Amyl and The Sniffers``.
.. conf:: after_choice
:default: no
By default, titlecase runs on the candidates that are received, adjusting them before
you make your selection and creating different weight calculations. If you'd rather
see the data as recieved from the database, set this to true to run after you make
your tag choice.
Dangerous Fields
~~~~~~~~~~~~~~~~
``titlecase`` only ever modifies string fields, however, this doesn't prevent
you from selecting a case sensitive field that another plugin or feature may
rely on.
In particular, including any of the following in your configuration could lead
to unintended behavior:
.. code-block:: bash
acoustid_fingerprint
acoustid_id
artists_ids
asin
deezer_track_id
format
id
isrc
mb_workid
mb_trackid
mb_albumid
mb_artistid
mb_artistids
mb_albumartistid
mb_albumartistids
mb_releasetrackid
mb_releasegroupid
bitrate_mode
encoder_info
encoder_settings
Running Manually
----------------
From the command line, type:
::
$ beet titlecase [QUERY]
Configuration is drawn from the config file. Without a query the operation will
be applied to the entire collection.

View file

@ -281,6 +281,8 @@ constructs include:
- ``$missing`` by :doc:`/plugins/missing`: The number of missing tracks per
album.
- ``$album_artist_no_feat`` by :doc:`/plugins/ftintitle`: The album artist
without any featured artists
- ``%bucket{text}`` by :doc:`/plugins/bucket`: Substitute a string by the range
it belongs to.
- ``%the{text}`` by :doc:`/plugins/the`: Moves English articles to ends of

View file

@ -6,18 +6,18 @@ from __future__ import annotations
import re
import subprocess
from collections.abc import Callable
from contextlib import redirect_stdout
from datetime import datetime, timezone
from functools import partial
from io import StringIO
from pathlib import Path
from typing import Callable, NamedTuple
from typing import NamedTuple, TypeAlias
import click
import tomli
from packaging.version import Version, parse
from sphinx.ext import intersphinx
from typing_extensions import TypeAlias
from docs.conf import rst_epilog

2971
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -17,7 +17,6 @@ classifiers = [
"Environment :: Web Environment",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
@ -42,7 +41,7 @@ Changelog = "https://github.com/beetbox/beets/blob/master/docs/changelog.rst"
"Bug Tracker" = "https://github.com/beetbox/beets/issues"
[tool.poetry.dependencies]
python = ">=3.9,<4"
python = ">=3.10,<4"
colorama = { version = "*", markers = "sys_platform == 'win32'" }
confuse = ">=2.1.0"
@ -94,6 +93,7 @@ pydata-sphinx-theme = { version = "*", optional = true }
sphinx = { version = "*", optional = true }
sphinx-design = { version = ">=0.6.1", optional = true }
sphinx-copybutton = { version = ">=0.5.2", optional = true }
titlecase = {version = "^2.4.1", optional = true}
[tool.poetry.group.test.dependencies]
beautifulsoup4 = "*"
@ -113,6 +113,7 @@ rarfile = "*"
requests-mock = ">=1.12.1"
requests_oauthlib = "*"
responses = ">=0.3.0"
titlecase = "^2.4.1"
[tool.poetry.group.lint.dependencies]
docstrfmt = ">=1.11.1"
@ -173,6 +174,7 @@ replaygain = [
] # python-gi and GStreamer 1.0+ or mp3gain/aacgain or Python Audio Tools or ffmpeg
scrub = ["mutagen"]
sonosupdate = ["soco"]
titlecase = ["titlecase"]
thumbnails = ["Pillow", "pyxdg"]
web = ["flask", "flask-cors"]
@ -228,7 +230,7 @@ cmd = "ruff format"
[tool.poe.tasks.format-docs]
help = "Format the documentation"
cmd = "docstrfmt"
cmd = "docstrfmt docs *.rst"
[tool.poe.tasks.lint]
help = "Check the code for linting issues. Accepts ruff options."
@ -286,7 +288,6 @@ extend-exclude = [
"docs/api/**/*",
"README_kr.rst",
]
files = ["docs", "*.rst"]
[tool.ruff]
target-version = "py39"
@ -305,9 +306,7 @@ select = [
"N", # pep8-naming
"PT", # flake8-pytest-style
# "RUF", # ruff
# "UP", # pyupgrade
"UP031", # do not use percent formatting
"UP032", # use f-string instead of format call
"UP", # pyupgrade
"TCH", # flake8-type-checking
"W", # pycodestyle
]

View file

@ -1,7 +1,7 @@
import os
from http import HTTPStatus
from pathlib import Path
from typing import Any, Optional
from typing import Any
import pytest
from flask.testing import Client
@ -58,9 +58,7 @@ class TestAuraResponse:
def get_response_data(self, client: Client, item):
"""Return a callback accepting `endpoint` and `params` parameters."""
def get(
endpoint: str, params: dict[str, str]
) -> Optional[dict[str, Any]]:
def get(endpoint: str, params: dict[str, str]) -> dict[str, Any] | None:
"""Add additional `params` and GET the given endpoint.
`include` parameter is added to every call to check that the

View file

@ -236,6 +236,16 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand):
self.convert_dest / "converted.ogg", "ogg"
)
def test_force_overrides_max_bitrate_and_same_formats(self):
self.config["convert"]["max_bitrate"] = 5000
self.config["convert"]["format"] = "ogg"
with control_stdin("y"):
self.run_convert("--force")
converted = self.convert_dest / "converted.ogg"
assert self.file_endswith(converted, "ogg")
def test_transcode_when_maxbr_set_low_and_same_formats(self):
self.config["convert"]["max_bitrate"] = 5
self.config["convert"]["format"] = "ogg"
@ -260,6 +270,21 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand):
self.run_convert("--playlist", "playlist.m3u8", "--pretend")
assert not (self.convert_dest / "playlist.m3u8").exists()
def test_force_overrides_no_convert(self):
self.config["convert"]["formats"]["opus"] = {
"command": self.tagged_copy_cmd("opus"),
"extension": "ops",
}
self.config["convert"]["no_convert"] = "format:ogg"
[item] = self.add_item_fixtures(ext="ogg")
with control_stdin("y"):
self.run_convert_path(item, "--format", "opus", "--force")
converted = self.convert_dest / "converted.ops"
assert self.file_endswith(converted, "opus")
@_common.slow_test()
class NeverConvertLossyFilesTest(ConvertTestCase, ConvertCommand):
@ -301,6 +326,19 @@ class NeverConvertLossyFilesTest(ConvertTestCase, ConvertCommand):
converted = self.convert_dest / "converted.ogg"
assert not self.file_endswith(converted, "mp3")
def test_force_overrides_never_convert_lossy_files(self):
self.config["convert"]["formats"]["opus"] = {
"command": self.tagged_copy_cmd("opus"),
"extension": "ops",
}
[item] = self.add_item_fixtures(ext="ogg")
with control_stdin("y"):
self.run_convert_path(item, "--format", "opus", "--force")
converted = self.convert_dest / "converted.ops"
assert self.file_endswith(converted, "opus")
class TestNoConvert:
"""Test the effect of the `no_convert` option."""

View file

@ -14,11 +14,11 @@
"""Tests for the 'ftintitle' plugin."""
from typing import Dict, Generator, Optional, Tuple, Union
from collections.abc import Generator
import pytest
from beets.library.models import Item
from beets.library.models import Album, Item
from beets.test.helper import PluginTestCase
from beetsplug import ftintitle
@ -39,7 +39,7 @@ def env() -> Generator[FtInTitlePluginFunctional, None, None]:
def set_config(
env: FtInTitlePluginFunctional,
cfg: Optional[Dict[str, Union[str, bool, list[str]]]],
cfg: dict[str, str | bool | list[str]] | None,
) -> None:
cfg = {} if cfg is None else cfg
defaults = {
@ -57,7 +57,7 @@ def add_item(
path: str,
artist: str,
title: str,
albumartist: Optional[str],
albumartist: str | None,
) -> Item:
return env.add_item(
path=path,
@ -250,10 +250,10 @@ def add_item(
)
def test_ftintitle_functional(
env: FtInTitlePluginFunctional,
cfg: Optional[Dict[str, Union[str, bool, list[str]]]],
cmd_args: Tuple[str, ...],
given: Tuple[str, str, Optional[str]],
expected: Tuple[str, str],
cfg: dict[str, str | bool | list[str]] | None,
cmd_args: tuple[str, ...],
given: tuple[str, str, str | None],
expected: tuple[str, str],
) -> None:
set_config(env, cfg)
ftintitle.FtInTitlePlugin()
@ -287,7 +287,7 @@ def test_ftintitle_functional(
def test_find_feat_part(
artist: str,
albumartist: str,
expected: Optional[str],
expected: str | None,
) -> None:
assert ftintitle.find_feat_part(artist, albumartist) == expected
@ -307,7 +307,7 @@ def test_find_feat_part(
)
def test_split_on_feat(
given: str,
expected: Tuple[str, Optional[str]],
expected: tuple[str, str | None],
) -> None:
assert ftintitle.split_on_feat(given) == expected
@ -359,8 +359,17 @@ def test_contains_feat(given: str, expected: bool) -> None:
],
)
def test_custom_words(
given: str, custom_words: Optional[list[str]], expected: bool
given: str, custom_words: list[str] | None, expected: bool
) -> None:
if custom_words is None:
custom_words = []
assert ftintitle.contains_feat(given, custom_words) is expected
def test_album_template_value():
album = Album()
album["albumartist"] = "Foo ft. Bar"
assert ftintitle._album_artist_no_feat(album) == "Foo"
album["albumartist"] = "Foobar"
assert ftintitle._album_artist_no_feat(album) == "Foobar"

View file

@ -19,13 +19,13 @@ import os
import sys
import unittest
from contextlib import contextmanager
from typing import TYPE_CHECKING, Callable
from typing import TYPE_CHECKING
from beets import plugins
from beets.test.helper import PluginTestCase, capture_log
if TYPE_CHECKING:
from collections.abc import Iterator
from collections.abc import Callable, Iterator
class HookTestCase(PluginTestCase):

View file

@ -1,6 +1,5 @@
import datetime
import os
import os.path
from beets.library import Album, Item
from beets.test.helper import PluginTestCase

View file

@ -0,0 +1,62 @@
# This file is part of beets.
# Copyright 2025, Gabe Push.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
from beets import config, plugins
from beets.test.helper import PluginTestCase
from beetsplug.inline import InlinePlugin
class TestInlineRecursion(PluginTestCase):
def test_no_recursion_when_inline_shadows_fixed_field(self):
config["plugins"] = ["inline"]
config["item_fields"] = {
"track_no": (
"f'{disc:02d}-{track:02d}' if disctotal > 1 else f'{track:02d}'"
)
}
plugins._instances.clear()
plugins.load_plugins()
item = self.add_item_fixture(
artist="Artist",
album="Album",
title="Title",
track=1,
disc=1,
disctotal=1,
)
out = item.evaluate_template("$track_no")
assert out == "01"
def test_inline_function_body_item_field(self):
plugin = InlinePlugin()
func = plugin.compile_inline(
"return track + 1", album=False, field_name="next_track"
)
item = self.add_item_fixture(track=3)
assert func(item) == 4
def test_inline_album_expression_uses_items(self):
plugin = InlinePlugin()
func = plugin.compile_inline(
"len(items)", album=True, field_name="item_count"
)
album = self.add_album_fixture()
assert func(album) == len(list(album.items()))

View file

@ -65,6 +65,8 @@ class MBAlbumInfoTest(MusicBrainzTestCase):
],
"date": "3001",
"medium-list": [],
"genre-list": [{"count": 1, "name": "GENRE"}],
"tag-list": [{"count": 1, "name": "TAG"}],
"label-info-list": [
{
"catalog-number": "CATALOG NUMBER",
@ -515,6 +517,26 @@ class MBAlbumInfoTest(MusicBrainzTestCase):
d = self.mb.album_info(release)
assert d.data_source == "MusicBrainz"
def test_genres(self):
config["musicbrainz"]["genres"] = True
config["musicbrainz"]["genres_tag"] = "genre"
release = self._make_release()
d = self.mb.album_info(release)
assert d.genre == "GENRE"
def test_tags(self):
config["musicbrainz"]["genres"] = True
config["musicbrainz"]["genres_tag"] = "tag"
release = self._make_release()
d = self.mb.album_info(release)
assert d.genre == "TAG"
def test_no_genres(self):
config["musicbrainz"]["genres"] = False
release = self._make_release()
d = self.mb.album_info(release)
assert d.genre is None
def test_ignored_media(self):
config["match"]["ignored_media"] = ["IGNORED1", "IGNORED2"]
tracks = [

View file

@ -0,0 +1,400 @@
# 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.
"""Tests for the 'titlecase' plugin"""
from unittest.mock import patch
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.importer import ImportSession, ImportTask
from beets.library import Item
from beets.test.helper import PluginTestCase
from beetsplug.titlecase import TitlecasePlugin
titlecase_fields_testcases = [
(
{
"fields": [
"artist",
"albumartist",
"title",
"album",
"mb_albumd",
"year",
],
"force_lowercase": True,
},
Item(
artist="OPHIDIAN",
albumartist="ophiDIAN",
format="CD",
year=2003,
album="BLACKBOX",
title="KhAmElEoN",
),
Item(
artist="Ophidian",
albumartist="Ophidian",
format="CD",
year=2003,
album="Blackbox",
title="Khameleon",
),
),
]
class TestTitlecasePlugin(PluginTestCase):
plugin = "titlecase"
preload_plugin = False
def test_auto(self):
"""Ensure automatic processing gets assigned"""
with self.configure_plugin({"auto": True, "after_choice": True}):
assert callable(TitlecasePlugin().import_stages[0])
with self.configure_plugin({"auto": False, "after_choice": False}):
assert len(TitlecasePlugin().import_stages) == 0
with self.configure_plugin({"auto": False, "after_choice": True}):
assert len(TitlecasePlugin().import_stages) == 0
def test_basic_titlecase(self):
"""Check that default behavior is as expected."""
testcases = [
("a", "A"),
("PENDULUM", "Pendulum"),
("Aaron-carl", "Aaron-Carl"),
("LTJ bukem", "LTJ Bukem"),
("(original mix)", "(Original Mix)"),
("ALL CAPS TITLE", "All Caps Title"),
]
for testcase in testcases:
given, expected = testcase
assert TitlecasePlugin().titlecase(given) == expected
def test_small_first_last(self):
"""Check the behavior for supporting small first last"""
testcases = [
(True, "In a Silent Way", "In a Silent Way"),
(False, "In a Silent Way", "in a Silent Way"),
]
for testcase in testcases:
sfl, given, expected = testcase
cfg = {"small_first_last": sfl}
with self.configure_plugin(cfg):
assert TitlecasePlugin().titlecase(given) == expected
def test_preserve(self):
"""Test using given strings to preserve case"""
preserve_list = [
"easyFun",
"A.D.O.R",
"D'Angelo",
"ABBA",
"LaTeX",
"O.R.B",
"PinkPantheress",
"THE PSYCHIC ED RUSH",
"LTJ Bukem",
]
for word in preserve_list:
with self.configure_plugin({"preserve": preserve_list}):
assert TitlecasePlugin().titlecase(word.upper()) == word
assert TitlecasePlugin().titlecase(word.lower()) == word
def test_seperators(self):
testcases = [
([], "it / a / in / of / to / the", "It / a / in / of / to / The"),
(["/"], "it / the test", "It / The Test"),
(
["/"],
"it / a / in / of / to / the",
"It / A / In / Of / To / The",
),
(["/"], "//it/a/in/of/to/the", "//It/A/In/Of/To/The"),
(
["/", ";", "|"],
"it ; a / in | of / to | the",
"It ; A / In | Of / To | The",
),
]
for testcase in testcases:
seperators, given, expected = testcase
with self.configure_plugin({"seperators": seperators}):
assert TitlecasePlugin().titlecase(given) == expected
def test_received_info_handler(self):
testcases = [
(
TrackInfo(
album="test album",
artist_credit="test artist credit",
artists=["artist one", "artist two"],
),
TrackInfo(
album="Test Album",
artist_credit="Test Artist Credit",
artists=["Artist One", "Artist Two"],
),
),
(
AlbumInfo(
tracks=[
TrackInfo(
album="test album",
artist_credit="test artist credit",
artists=["artist one", "artist two"],
)
],
album="test album",
artist_credit="test artist credit",
artists=["artist one", "artist two"],
),
AlbumInfo(
tracks=[
TrackInfo(
album="Test Album",
artist_credit="Test Artist Credit",
artists=["Artist One", "Artist Two"],
)
],
album="Test Album",
artist_credit="Test Artist Credit",
artists=["Artist One", "Artist Two"],
),
),
]
cfg = {"fields": ["album", "artist_credit", "artists"]}
for testcase in testcases:
given, expected = testcase
with self.configure_plugin(cfg):
TitlecasePlugin().received_info_handler(given)
assert given == expected
def test_titlecase_fields(self):
testcases = [
# Test with preserve, replace, and mb_albumid
# Test with the_artist
(
{
"preserve": ["D'Angelo"],
"replace": [("", "'")],
"fields": ["artist", "albumartist", "mb_albumid"],
},
Item(
artist="dangelo and the vanguard",
mb_albumid="ab140e13-7b36-402a-a528-b69e3dee38a8",
albumartist="dangelo",
format="CD",
album="the black messiah",
title="Till It's Done (Tutu)",
),
Item(
artist="D'Angelo and The Vanguard",
mb_albumid="Ab140e13-7b36-402a-A528-B69e3dee38a8",
albumartist="D'Angelo",
format="CD",
album="the black messiah",
title="Till It's Done (Tutu)",
),
),
# Test with force_lowercase, preserve, and an incorrect field
(
{
"force_lowercase": True,
"fields": [
"artist",
"albumartist",
"format",
"title",
"year",
"label",
"format",
"INCORRECT_FIELD",
],
"preserve": ["CD"],
},
Item(
artist="OPHIDIAN",
albumartist="OphiDIAN",
format="cd",
year=2003,
album="BLACKBOX",
title="KhAmElEoN",
label="enzyme records",
),
Item(
artist="Ophidian",
albumartist="Ophidian",
format="CD",
year=2003,
album="Blackbox",
title="Khameleon",
label="Enzyme Records",
),
),
# Test with no changes
(
{
"fields": [
"artist",
"artists",
"albumartist",
"format",
"title",
"year",
"label",
"format",
"INCORRECT_FIELD",
],
"preserve": ["CD"],
},
Item(
artist="Ophidian",
artists=["Ophidian"],
albumartist="Ophidian",
format="CD",
year=2003,
album="Blackbox",
title="Khameleon",
label="Enzyme Records",
),
Item(
artist="Ophidian",
artists=["Ophidian"],
albumartist="Ophidian",
format="CD",
year=2003,
album="Blackbox",
title="Khameleon",
label="Enzyme Records",
),
),
# Test with the_artist disabled
(
{
"the_artist": False,
"fields": [
"artist",
"artists_sort",
],
},
Item(
artists_sort=["b-52s, the"],
artist="a day in the park",
),
Item(
artists_sort=["B-52s, The"],
artist="A Day in the Park",
),
),
# Test to make sure preserve and the_artist
# dont target the middle of sentences
# show that The artist applies to any field
# with artist mentioned
(
{
"preserve": ["PANTHER"],
"fields": ["artist", "artists", "artists_ids"],
},
Item(
artist="pinkpantheress",
artists=["pinkpantheress", "artist_two"],
artists_ids=["the the", "the the"],
),
Item(
artist="Pinkpantheress",
artists=["Pinkpantheress", "Artist_two"],
artists_ids=["The The", "The The"],
),
),
]
for testcase in testcases:
cfg, given, expected = testcase
with self.configure_plugin(cfg):
TitlecasePlugin().titlecase_fields(given)
assert given.artist == expected.artist
assert given.artists == expected.artists
assert given.artists_sort == expected.artists_sort
assert given.albumartist == expected.albumartist
assert given.artists_ids == expected.artists_ids
assert given.format == expected.format
assert given.year == expected.year
assert given.title == expected.title
assert given.label == expected.label
def test_cli_write(self):
given = Item(
album="retrodelica 2: back 2 the future",
artist="blue planet corporation",
title="generator",
)
expected = Item(
album="Retrodelica 2: Back 2 the Future",
artist="Blue Planet Corporation",
title="Generator",
)
cfg = {"fields": ["album", "artist", "title"]}
with self.configure_plugin(cfg):
given.add(self.lib)
self.run_command("titlecase")
assert self.lib.items().get().artist == expected.artist
assert self.lib.items().get().album == expected.album
assert self.lib.items().get().title == expected.title
self.lib.items().get().remove()
def test_cli_no_write(self):
given = Item(
album="retrodelica 2: back 2 the future",
artist="blue planet corporation",
title="generator",
)
expected = Item(
album="retrodelica 2: back 2 the future",
artist="blue planet corporation",
title="generator",
)
cfg = {"fields": ["album", "artist", "title"]}
with self.configure_plugin(cfg):
given.add(self.lib)
self.run_command("-p", "titlecase")
assert self.lib.items().get().artist == expected.artist
assert self.lib.items().get().album == expected.album
assert self.lib.items().get().title == expected.title
self.lib.items().get().remove()
def test_imported(self):
given = Item(
album="retrodelica 2: back 2 the future",
artist="blue planet corporation",
title="generator",
)
expected = Item(
album="Retrodelica 2: Back 2 the Future",
artist="Blue Planet Corporation",
title="Generator",
)
p = patch("beets.importer.ImportTask.imported_items", lambda x: [given])
p.start()
with self.configure_plugin({"fields": ["album", "artist", "title"]}):
import_session = ImportSession(
self.lib, loghandler=None, paths=None, query=None
)
import_task = ImportTask(toppath=None, paths=None, items=[given])
TitlecasePlugin().imported(import_session, import_task)
import_task.add(self.lib)
item = self.lib.items().get()
assert item.artist == expected.artist
assert item.album == expected.album
assert item.title == expected.title
p.stop()

View file

@ -118,6 +118,13 @@ class WebPluginTest(ItemInDBTestCase):
assert response.status_code == 200
assert len(res_json["items"]) == 3
def test_get_unique_item_artist(self):
response = self.client.get("/item/values/artist")
res_json = json.loads(response.data.decode("utf-8"))
assert response.status_code == 200
assert res_json["values"] == ["", "AAA Singers"]
def test_get_single_item_by_id(self):
response = self.client.get("/item/1")
res_json = json.loads(response.data.decode("utf-8"))

View file

@ -36,7 +36,8 @@ class MoveTest(BeetsTestCase):
super().setUp()
# make a temporary file
self.path = self.temp_dir_path / "temp.mp3"
self.temp_music_file_name = "temp.mp3"
self.path = self.temp_dir_path / self.temp_music_file_name
shutil.copy(self.resource_path, self.path)
# add it to a temporary library
@ -197,6 +198,21 @@ class MoveTest(BeetsTestCase):
self.i.move(operation=MoveOperation.HARDLINK)
assert self.i.path == util.normpath(self.dest)
@unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks")
def test_hardlink_from_symlink(self):
link_path = join(self.temp_dir, b"temp_link.mp3")
link_source = join("./", self.temp_music_file_name)
os.symlink(syspath(link_source), syspath(link_path))
self.i.path = link_path
self.i.move(operation=MoveOperation.HARDLINK)
s1 = os.stat(syspath(self.path))
s2 = os.stat(syspath(self.dest))
assert (s1[stat.ST_INO], s1[stat.ST_DEV]) == (
s2[stat.ST_INO],
s2[stat.ST_DEV],
)
class HelperTest(unittest.TestCase):
def test_ancestry_works_on_file(self):
@ -563,7 +579,7 @@ class SafeMoveCopyTest(FilePathTestCase):
@NEEDS_REFLINK
def test_successful_reflink(self):
util.reflink(self.path, self.dest)
util.reflink(str(self.path), str(self.dest))
assert self.dest.exists()
assert self.path.exists()

View file

@ -67,6 +67,58 @@ class TestStrFormatLogger:
assert str(caplog.records[0].msg) == expected
class TestLogSanitization:
"""Log messages should have control characters removed from:
- String arguments
- Keyword argument values
- Bytes arguments (which get decoded first)
"""
@pytest.mark.parametrize(
"msg, args, kwargs, expected",
[
# Valid UTF-8 bytes are decoded and preserved
(
"foo {} bar {bar}",
(b"oof \xc3\xa9",),
{"bar": b"baz \xc3\xa9"},
"foo oof é bar baz é",
),
# Invalid UTF-8 bytes are decoded with replacement characters
(
"foo {} bar {bar}",
(b"oof \xff",),
{"bar": b"baz \xff"},
"foo oof <20> bar baz <20>",
),
# Control characters should be removed
(
"foo {} bar {bar}",
("oof \x9e",),
{"bar": "baz \x9e"},
"foo oof <20> bar baz <20>",
),
# Whitespace control characters should be preserved
(
"foo {} bar {bar}",
("foo\t\n",),
{"bar": "bar\r"},
"foo foo\t\n bar bar\r",
),
],
)
def test_sanitization(self, msg, args, kwargs, expected, caplog):
level = log.INFO
logger = blog.getLogger("test_logger")
logger.setLevel(level)
with caplog.at_level(level, logger="test_logger"):
logger.log(level, msg, *args, **kwargs)
assert caplog.records, "No log records were captured"
assert str(caplog.records[0].msg) == expected
class DummyModule(ModuleType):
class DummyPlugin(plugins.BeetsPlugin):
def __init__(self):

View file

@ -41,7 +41,7 @@ from beets.test.helper import (
PluginTestCase,
TerminalImportMixin,
)
from beets.util import displayable_path, syspath
from beets.util import PromptChoice, displayable_path, syspath
class TestPluginRegistration(PluginTestCase):
@ -292,8 +292,8 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase):
def return_choices(self, session, task):
return [
ui.commands.PromptChoice("f", "Foo", None),
ui.commands.PromptChoice("r", "baR", None),
PromptChoice("f", "Foo", None),
PromptChoice("r", "baR", None),
]
self.register_plugin(DummyPlugin)
@ -328,8 +328,8 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase):
def return_choices(self, session, task):
return [
ui.commands.PromptChoice("f", "Foo", None),
ui.commands.PromptChoice("r", "baR", None),
PromptChoice("f", "Foo", None),
PromptChoice("r", "baR", None),
]
self.register_plugin(DummyPlugin)
@ -363,10 +363,10 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase):
def return_choices(self, session, task):
return [
ui.commands.PromptChoice("a", "A foo", None), # dupe
ui.commands.PromptChoice("z", "baZ", None), # ok
ui.commands.PromptChoice("z", "Zupe", None), # dupe
ui.commands.PromptChoice("z", "Zoo", None),
PromptChoice("a", "A foo", None), # dupe
PromptChoice("z", "baZ", None), # ok
PromptChoice("z", "Zupe", None), # dupe
PromptChoice("z", "Zoo", None),
] # dupe
self.register_plugin(DummyPlugin)
@ -399,7 +399,7 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase):
)
def return_choices(self, session, task):
return [ui.commands.PromptChoice("f", "Foo", self.foo)]
return [PromptChoice("f", "Foo", self.foo)]
def foo(self, session, task):
pass
@ -441,7 +441,7 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase):
)
def return_choices(self, session, task):
return [ui.commands.PromptChoice("f", "Foo", self.foo)]
return [PromptChoice("f", "Foo", self.foo)]
def foo(self, session, task):
return Action.SKIP
@ -543,3 +543,39 @@ class TestDeprecationCopy:
assert hasattr(LegacyMetadataPlugin, "data_source_mismatch_penalty")
assert hasattr(LegacyMetadataPlugin, "_extract_id")
assert hasattr(LegacyMetadataPlugin, "get_artist")
class TestMusicBrainzPluginLoading:
@pytest.fixture(autouse=True)
def config(self):
_config = config
_config.sources = []
_config.read(user=False, defaults=True)
return _config
def test_default(self):
assert "musicbrainz" in plugins.get_plugin_names()
def test_other_plugin_enabled(self, config):
config["plugins"] = ["anything"]
assert "musicbrainz" not in plugins.get_plugin_names()
def test_deprecated_enabled(self, config, caplog):
config["plugins"] = ["anything"]
config["musicbrainz"]["enabled"] = True
assert "musicbrainz" in plugins.get_plugin_names()
assert (
"musicbrainz.enabled' configuration option is deprecated"
in caplog.text
)
def test_deprecated_disabled(self, config, caplog):
config["musicbrainz"]["enabled"] = False
assert "musicbrainz" not in plugins.get_plugin_names()
assert (
"musicbrainz.enabled' configuration option is deprecated"
in caplog.text
)

View file

@ -14,7 +14,7 @@
"""Various tests for querying the library database."""
from mock import patch
from unittest.mock import patch
import beets.library
from beets import config, dbcore