From 5628232bc4a59a4a5bd45265e034e4775c853582 Mon Sep 17 00:00:00 2001 From: Henry Oberholtzer Date: Sat, 8 Nov 2025 17:33:54 -0800 Subject: [PATCH] add the_artist --- beetsplug/titlecase.py | 65 +++++++----------- docs/plugins/titlecase.rst | 39 +++++++++-- test/plugins/test_titlecase.py | 116 ++++++++++++++++++++++++--------- 3 files changed, 144 insertions(+), 76 deletions(-) diff --git a/beetsplug/titlecase.py b/beetsplug/titlecase.py index eb54e0bd6..5bda14066 100644 --- a/beetsplug/titlecase.py +++ b/beetsplug/titlecase.py @@ -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 diff --git a/docs/plugins/titlecase.rst b/docs/plugins/titlecase.rst index 0bbf4528f..9417e5006 100644 --- a/docs/plugins/titlecase.rst +++ b/docs/plugins/titlecase.rst @@ -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 diff --git a/test/plugins/test_titlecase.py b/test/plugins/test_titlecase.py index ef8f72cb2..a3aba91b4 100644 --- a/test/plugins/test_titlecase.py +++ b/test/plugins/test_titlecase.py @@ -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: