add the_artist

This commit is contained in:
Henry Oberholtzer 2025-11-08 17:33:54 -08:00
parent 631485c55b
commit 5628232bc4
3 changed files with 144 additions and 76 deletions

View file

@ -29,38 +29,13 @@ from beets.plugins import BeetsPlugin
__author__ = "henryoberholtzer@gmail.com"
__version__ = "1.0"
# These fields are excluded to avoid modifying anything
# that may be case sensistive, or important to database
# function
EXCLUDED_INFO_FIELDS: set[str] = {
"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",
}
class TitlecasePlugin(BeetsPlugin):
preserve: dict[str, str] = {}
preserve_phrases: dict[str, Pattern[str]] = {}
force_lowercase: bool = True
fields_to_process: set[str]
fields_to_process: set[str] = set()
the_artist: bool = True
def __init__(self) -> None:
super().__init__()
@ -75,16 +50,21 @@ class TitlecasePlugin(BeetsPlugin):
"fields": [],
"force_lowercase": False,
"small_first_last": True,
"the_artist": True,
}
)
"""
auto - Automatically apply titlecase to new import metadata.
preserve - Provide a list of words/acronyms with specific case requirements.
fields - Fields to apply titlecase to, default is all.
preserve - Provide a list of strings with specific case requirements.
fields - Fields to apply titlecase to.
force_lowercase - Lowercases the string before titlecasing.
small_first_last - If small characters should be cased at the start of strings.
NOTE: Titlecase will not interact with possibly case sensitive fields.
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 UI subcommands
@ -100,19 +80,20 @@ class TitlecasePlugin(BeetsPlugin):
def __get_config_file__(self):
self.force_lowercase = self.config["force_lowercase"].get(bool)
self.__preserve_words__(self.config["preserve"].as_str_seq())
self.the_artist = self.config["the_artist"].get(bool)
self.__init_fields_to_process__(
self.config["fields"].as_str_seq(),
)
def __init_fields_to_process__(self, fields: list[str]) -> None:
"""Creates the set for fields to process in tagging.
Only uses fields included.
Last, the EXCLUDED_INFO_FIELDS are removed to prevent unitentional modification.
"""
"""Creates the set for fields to process in tagging."""
if fields:
initial_field_list = set(fields)
initial_field_list -= set(EXCLUDED_INFO_FIELDS)
self.fields_to_process = initial_field_list
self.fields_to_process = set(fields)
self._log.info(
f"set fields to process: {', '.join(self.fields_to_process)}"
)
else:
self._log.info("no fields specified!")
def __preserve_words__(self, preserve: list[str]) -> None:
for word in preserve:
@ -156,27 +137,31 @@ class TitlecasePlugin(BeetsPlugin):
cased_list: list[str] = [
self.titlecase(i) for i in init_field
]
setattr(item, field, cased_list)
self._log.info(
(
f"{field}: {', '.join(init_field)} -> "
f"{', '.join(cased_list)}"
)
)
setattr(item, field, cased_list)
elif isinstance(init_field, str):
cased: str = self.titlecase(init_field)
self._log.info(f"{field}: {init_field} -> {cased}")
setattr(item, field, cased)
self._log.info(f"{field}: {init_field} -> {cased}")
else:
self._log.info(f"{field}: no string present")
else:
self._log.info(f"{field}: does not exist on {item}")
def titlecase(self, text: str) -> str:
def titlecase(self, text: str, field: str = "") -> str:
"""Titlecase the given text."""
titlecased = titlecase(
text.lower() if self.force_lowercase else text,
small_first_last=self.config["small_first_last"],
callback=self.__preserved__,
)
if self.the_artist and "artist" in field:
titlecased = titlecased.replace("the", "The")
for phrase, regexp in self.preserve_phrases.items():
titlecased = regexp.sub(phrase, titlecased)
return titlecased

View file

@ -58,6 +58,7 @@ Default
preserve:
force_lowercase: no
small_first_last: yes
the_artist: yes
.. conf:: auto
:default: yes
@ -69,6 +70,26 @@ 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 artists, album and track titles.
.. code-block:: yaml
fields:
- album
- albumartist
- albumartist_credit
- albumartist_sort
- albumartists
- albumartists_credit
- albumartists_sort
- artist
- artist_credit
- artist_sort
- artists
- artists_credit
- artists_sort
- title
.. conf:: preserve
List of words and phrases to preserve the case of. Without specifying ``DJ`` on
@ -87,13 +108,21 @@ Default
of a sentence. With this turned off ``a`` and similar words will not be capitalized
under any circumstance.
Excluded Fields
~~~~~~~~~~~~~~~
.. conf:: the_artist
``titlecase`` only ever modifies string fields, and will never interact with
fields that it considers to be case sensitive.
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``.
For reference, the string fields ``titlecase`` ignores:
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

View file

@ -16,10 +16,9 @@
import pytest
from beets import config
from beets.library import Item
from beets.test.helper import PluginTestCase
from beetsplug.titlecase import EXCLUDED_INFO_FIELDS, TitlecasePlugin
from beetsplug.titlecase import TitlecasePlugin
@pytest.mark.parametrize(
@ -60,7 +59,7 @@ titlecase_test_cases = [
),
"expected": Item(
artist="D'Angelo and the Vanguard",
mb_albumid="ab140e13-7b36-402a-a528-b69e3dee38a8",
mb_albumid="Ab140e13-7b36-402a-A528-B69e3dee38a8",
albumartist="D'Angelo",
format="CD",
album="the black messiah",
@ -101,7 +100,7 @@ titlecase_test_cases = [
{
"config": {
"preserve": [""],
"fields": ["artists", "artists_ids", "discogs_artistid"],
"fields": ["artists", "discogs_artistid"],
"force_lowercase": False,
"small_first_last": True,
},
@ -116,6 +115,68 @@ titlecase_test_cases = [
discogs_artistid=21,
),
},
{
"config": {
"the_artist": True,
"preserve": ["A Day in the Park"],
"fields": [
"artists",
"artist",
"artists_sorttitle",
"artists_ids",
],
},
"item": Item(
artists_sort=["b-52s, the"],
artist="a day in the park",
artists=[
"vinylgroover & the red head",
"a day in the park",
"amyl and the sniffers",
],
artists_ids=["aBcDeF32", "aBcDeF12"],
),
"expected": Item(
artists_sort=["B-52s, The"],
artist="A Day in the Park",
artists=[
"Vinylgroover & The Red Head",
"A Day in The ParkAmyl and The Sniffers",
],
artists_ids=["ABcDeF32", "ABcDeF12"],
),
},
{
"config": {
"the_artist": False,
"preserve": ["A Day in the Park"],
"fields": [
"artists",
"artist",
"artists_sorttitle",
"artists_ids",
],
},
"item": Item(
artists_sort=["b-52s, the"],
artist="a day in the park",
artists=[
"vinylgroover & the red head",
"a day in the park",
"amyl and the sniffers",
],
artists_ids=["aBcDeF32", "aBcDeF12"],
),
"expected": Item(
artists_sort=["B-52s, The"],
artist="A Day in the Park",
artists=[
"Vinylgroover & the Red Head",
"A Day in the ParkAmyl and the Sniffers",
],
artists_ids=["ABcDeF32", "ABcDeF12"],
),
},
]
@ -137,17 +198,10 @@ class TitlecasePluginTest(PluginTestCase):
def test_field_list(self):
fields = ["album", "albumartist"]
config["titlecase"]["fields"] = fields
t = TitlecasePlugin()
for field in fields:
assert field in t.fields_to_process
def test_field_list_default_excluded(self):
excluded = list(EXCLUDED_INFO_FIELDS)
config["titlecase"]["fields"] = excluded
t = TitlecasePlugin()
for field in excluded:
assert field not in t.fields_to_process
with self.configure_plugin({"fields": fields}):
t = TitlecasePlugin()
for field in fields:
assert field in t.fields_to_process
def test_preserved_words(self):
"""Test using given strings to preserve case"""
@ -159,28 +213,28 @@ class TitlecasePluginTest(PluginTestCase):
"ABBA",
"LaTeX",
]
config["titlecase"]["preserve"] = names_to_preserve
for name in names_to_preserve:
assert TitlecasePlugin().titlecase(name.lower()) == name
assert TitlecasePlugin().titlecase(name.upper()) == name
with self.configure_plugin({"preserve": names_to_preserve}):
for name in names_to_preserve:
assert TitlecasePlugin().titlecase(name.lower()) == name
assert TitlecasePlugin().titlecase(name.upper()) == name
def test_preserved_phrases(self):
phrases_to_preserve = ["The Beatles", "The Red Hed"]
test_strings = ["Vinylgroover & The Red Hed", "With The Beatles"]
config["titlecase"]["preserve"] = phrases_to_preserve
t = TitlecasePlugin()
for phrase in test_strings:
assert t.titlecase(phrase.lower()) == phrase
phrases_to_preserve = ["The Beatles", "The Red Hed"]
with self.configure_plugin({"preserve": phrases_to_preserve}):
t = TitlecasePlugin()
for phrase in test_strings:
assert t.titlecase(phrase.lower()) == phrase
def test_titlecase_fields(self):
for tc in titlecase_test_cases:
item = tc["item"]
expected = tc["expected"]
config["titlecase"] = tc["config"]
TitlecasePlugin().titlecase_fields(item)
for key, value in vars(item).items():
if isinstance(value, str):
assert getattr(item, key) == getattr(expected, key)
with self.configure_plugin(tc["config"]):
item = tc["item"]
expected = tc["expected"]
TitlecasePlugin().titlecase_fields(item)
for key, value in vars(item).items():
if isinstance(value, str):
assert getattr(item, key) == getattr(expected, key)
def test_cli(self):
for tc in titlecase_test_cases: