New Plugin: Titlecase (#6133)

This plugin aims to address the shortcomings of the %title function, as
brought up in issues #152, #3298 and an initial look to improvement with
#3411. It supplies a new string format command, `%titlecase` which
doesn't interfere with any prior expected behavior of the `%title`
format command.

It also adds the ability to apply titlecase logic to metadata fields
that a user selects, which is useful if you, like me, are looking for
stylistic consistency and the minor stylistic differences between
Musizbrainz, Discogs, Deezer etc, with title case are slightly
infuriating.

This will add an optional dependency of
[titlecase](https://pypi.org/project/titlecase/), which allows the
titlecase core logic to be externally maintained.

If there's not enough draw to have this as a core plugin, I can also
spin this into an independent one, but it seemed like a recurring theme
that the %title string format didn't really behave as expected, and I
wanted my metadata to match too.

- [x] Documentation. (If you've added a new command-line flag, for
example, find the appropriate page under `docs/` to describe it.)
- [x] Changelog. (Add an entry to `docs/changelog.rst` to the bottom of
one of the lists near the top of the document.)
- [x] Tests. - Not 100% coverage, but didn't see a lot of other plugins
with testing for import stages.
This commit is contained in:
henry 2025-11-23 10:34:05 -08:00 committed by GitHub
parent d446e10fb0
commit b902352139
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 868 additions and 2 deletions

1
.github/CODEOWNERS vendored
View file

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

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

@ -27,6 +27,8 @@ New features:
- :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive - :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive
MusicBrainz pseudo-releases as recommendations during import. MusicBrainz pseudo-releases as recommendations during import.
- Added support for Python 3.13. - Added support for Python 3.13.
- :doc:`plugins/titlecase`: Add the `titlecase` plugin to allow users to
resolve differences in metadata source styles.
Bug fixes: Bug fixes:

View file

@ -128,6 +128,7 @@ databases. They share the following configuration options:
substitute substitute
the the
thumbnails thumbnails
titlecase
types types
unimported unimported
web web

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.

25
poetry.lock generated
View file

@ -2471,6 +2471,8 @@ files = [
{file = "pycairo-1.28.0-cp313-cp313-win32.whl", hash = "sha256:d13352429d8a08a1cb3607767d23d2fb32e4c4f9faa642155383980ec1478c24"}, {file = "pycairo-1.28.0-cp313-cp313-win32.whl", hash = "sha256:d13352429d8a08a1cb3607767d23d2fb32e4c4f9faa642155383980ec1478c24"},
{file = "pycairo-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:082aef6b3a9dcc328fa648d38ed6b0a31c863e903ead57dd184b2e5f86790140"}, {file = "pycairo-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:082aef6b3a9dcc328fa648d38ed6b0a31c863e903ead57dd184b2e5f86790140"},
{file = "pycairo-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:026afd53b75291917a7412d9fe46dcfbaa0c028febd46ff1132d44a53ac2c8b6"}, {file = "pycairo-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:026afd53b75291917a7412d9fe46dcfbaa0c028febd46ff1132d44a53ac2c8b6"},
{file = "pycairo-1.28.0-cp314-cp314-win32.whl", hash = "sha256:d0ab30585f536101ad6f09052fc3895e2a437ba57531ea07223d0e076248025d"},
{file = "pycairo-1.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:94f2ed204999ab95a0671a0fa948ffbb9f3d6fb8731fe787917f6d022d9c1c0f"},
{file = "pycairo-1.28.0-cp39-cp39-win32.whl", hash = "sha256:3ed16d48b8a79cc584cb1cb0ad62dfb265f2dda6d6a19ef5aab181693e19c83c"}, {file = "pycairo-1.28.0-cp39-cp39-win32.whl", hash = "sha256:3ed16d48b8a79cc584cb1cb0ad62dfb265f2dda6d6a19ef5aab181693e19c83c"},
{file = "pycairo-1.28.0-cp39-cp39-win_amd64.whl", hash = "sha256:da0d1e6d4842eed4d52779222c6e43d254244a486ca9fdab14e30042fd5bdf28"}, {file = "pycairo-1.28.0-cp39-cp39-win_amd64.whl", hash = "sha256:da0d1e6d4842eed4d52779222c6e43d254244a486ca9fdab14e30042fd5bdf28"},
{file = "pycairo-1.28.0-cp39-cp39-win_arm64.whl", hash = "sha256:458877513eb2125513122e8aa9c938630e94bb0574f94f4fb5ab55eb23d6e9ac"}, {file = "pycairo-1.28.0-cp39-cp39-win_arm64.whl", hash = "sha256:458877513eb2125513122e8aa9c938630e94bb0574f94f4fb5ab55eb23d6e9ac"},
@ -2821,6 +2823,13 @@ description = "YAML parser and emitter for Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"},
{file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"},
{file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"},
{file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"},
{file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"},
{file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"},
@ -3896,6 +3905,19 @@ files = [
{file = "threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e"}, {file = "threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e"},
] ]
[[package]]
name = "titlecase"
version = "2.4.1"
description = "Python Port of John Gruber's titlecase.pl"
optional = false
python-versions = ">=3.7"
files = [
{file = "titlecase-2.4.1.tar.gz", hash = "sha256:7d83a277ccbbda11a2944e78a63e5ccaf3d32f828c594312e4862f9a07f635f5"},
]
[package.extras]
regex = ["regex (>=2020.4.4)"]
[[package]] [[package]]
name = "toml" name = "toml"
version = "0.10.2" version = "0.10.2"
@ -4161,9 +4183,10 @@ replaygain = ["PyGObject"]
scrub = ["mutagen"] scrub = ["mutagen"]
sonosupdate = ["soco"] sonosupdate = ["soco"]
thumbnails = ["Pillow", "pyxdg"] thumbnails = ["Pillow", "pyxdg"]
titlecase = ["titlecase"]
web = ["flask", "flask-cors"] web = ["flask", "flask-cors"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.10,<4" python-versions = ">=3.10,<4"
content-hash = "10a60daf371ba5d2c3d62ab0da7be81af40890517f9f60ed4a2cee1835eea6ae" content-hash = "9e154214b2f404415ef17df83f926a326ffb62a83b3901a404946110354d4067"

View file

@ -93,6 +93,7 @@ pydata-sphinx-theme = { version = "*", optional = true }
sphinx = { version = "*", optional = true } sphinx = { version = "*", optional = true }
sphinx-design = { version = ">=0.6.1", optional = true } sphinx-design = { version = ">=0.6.1", optional = true }
sphinx-copybutton = { version = ">=0.5.2", optional = true } sphinx-copybutton = { version = ">=0.5.2", optional = true }
titlecase = {version = "^2.4.1", optional = true}
[tool.poetry.group.test.dependencies] [tool.poetry.group.test.dependencies]
beautifulsoup4 = "*" beautifulsoup4 = "*"
@ -112,6 +113,7 @@ rarfile = "*"
requests-mock = ">=1.12.1" requests-mock = ">=1.12.1"
requests_oauthlib = "*" requests_oauthlib = "*"
responses = ">=0.3.0" responses = ">=0.3.0"
titlecase = "^2.4.1"
[tool.poetry.group.lint.dependencies] [tool.poetry.group.lint.dependencies]
docstrfmt = ">=1.11.1" docstrfmt = ">=1.11.1"
@ -172,6 +174,7 @@ replaygain = [
] # python-gi and GStreamer 1.0+ or mp3gain/aacgain or Python Audio Tools or ffmpeg ] # python-gi and GStreamer 1.0+ or mp3gain/aacgain or Python Audio Tools or ffmpeg
scrub = ["mutagen"] scrub = ["mutagen"]
sonosupdate = ["soco"] sonosupdate = ["soco"]
titlecase = ["titlecase"]
thumbnails = ["Pillow", "pyxdg"] thumbnails = ["Pillow", "pyxdg"]
web = ["flask", "flask-cors"] web = ["flask", "flask-cors"]

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()