From 2f88ca010181f4a490ca1fa3ab091ba14edeeed7 Mon Sep 17 00:00:00 2001 From: Henry Date: Sun, 26 Oct 2025 16:34:18 -0700 Subject: [PATCH] pretty much set to go --- beetsplug/titlecase.py | 84 +++++------------ docs/plugins/titlecase.rst | 76 ++++++++++------ poetry.lock | 18 +++- pyproject.toml | 2 + test/plugins/test_titlecase.py | 162 ++++++++++++++++----------------- 5 files changed, 172 insertions(+), 170 deletions(-) diff --git a/beetsplug/titlecase.py b/beetsplug/titlecase.py index 0481ebb3e..d5b0e2221 100644 --- a/beetsplug/titlecase.py +++ b/beetsplug/titlecase.py @@ -12,13 +12,16 @@ # 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 paths and tag text. -Title case logic is derived from the python-titlecase library.""" +"""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 typing import Pattern from titlecase import titlecase from beets import ui -from beets.dbcore import types from beets.importer import ImportSession, ImportTask from beets.library import Item from beets.plugins import BeetsPlugin @@ -57,19 +60,21 @@ EXCLUDED_INFO_FIELDS = set( class TitlecasePlugin(BeetsPlugin): preserve: dict[str, str] = {} + preserve_phrases: dict[str, Pattern[str]] = {} force_lowercase: bool = True fields_to_process: set[str] = set([]) def __init__(self) -> None: super().__init__() - self.template_funcs["titlecase"] = self.titlecase + # Register template function + self.template_funcs["titlecase"] = self.titlecase # type: ignore self.config.add( { "auto": True, "preserve": [], - "include": [], + "fields": [], "force_lowercase": False, "small_first_last": True, } @@ -78,8 +83,7 @@ class TitlecasePlugin(BeetsPlugin): """ auto - Automatically apply titlecase to new import metadata. preserve - Provide a list of words/acronyms with specific case requirements. - include - Fields to apply titlecase to, default is all. - exclude - Fields to exclude from titlecase to, default is none. + fields - Fields to apply titlecase to, default is all. 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. @@ -88,57 +92,37 @@ class TitlecasePlugin(BeetsPlugin): # Register UI subcommands self._command = ui.Subcommand( "titlecase", - help="Apply titlecasing to metadata following the NYT manual of style.", - ) - - self._command.parser.add_option( - "-l", - "--lower", - dest="force_lowercase", - action="store_true", - help="Force lowercase first.", - ) - - self._command.parser.add_option( - "-i", - "--include", - dest="include", - action="store", - help="""Metadata fields to titlecase. - Always ignores case sensitive fields.""", + help="Apply titlecasing to metadata specified in config.", ) self.__get_config_file__() if self.config["auto"]: self.import_stages = [self.imported] - # self.register_listener( - # "import_task_before_choice", self.on_import_task_before_choice - # ) - # Register template function def __get_config_file__(self): self.force_lowercase = self.config["force_lowercase"].get(bool) self.__preserve_words__(self.config["preserve"].as_str_seq()) self.__init_fields_to_process__( - self.config["include"].as_str_seq(), + self.config["fields"].as_str_seq(), ) - def __init_fields_to_process__( - self, include: list[str] - ) -> None: + 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. """ - initial_field_list = set([]) - if include: - initial_field_list = initial_field_list.intersection(set(include)) + initial_field_list = set(fields) initial_field_list -= set(EXCLUDED_INFO_FIELDS) self.fields_to_process = initial_field_list def __preserve_words__(self, preserve: list[str]) -> None: for word in preserve: - self.preserve[word.upper()] = word + if " " in word: + self.preserve_phrases[word] = re.compile( + re.escape(word), re.IGNORECASE + ) + else: + self.preserve[word.upper()] = word def __preserved__(self, word, **kwargs) -> str | None: """Callback function for words to preserve case of.""" @@ -147,23 +131,8 @@ class TitlecasePlugin(BeetsPlugin): return None def commands(self) -> list[ui.Subcommand]: - def split_if_exists(string: str): - return string.split() if string else [] - def func(lib, opts, args): - opts = opts.__dict__ - preserve = split_if_exists(opts["preserve"]) - incl = split_if_exists(opts["include"]) - if opts["force_lowercase"] is not None: - self.force_lowercase = True - self.__preserve_words__( - preserve.append(self.config["preserve"].as_str_seq()) - ) - self.__init_fields_to_process__( - incl.append(self.config["include"].as_str_seq()) - ) write = ui.should_write() - for item in lib.items(args): self._log.info(f"titlecasing {item.title}:") self.titlecase_fields(item) @@ -198,17 +167,14 @@ class TitlecasePlugin(BeetsPlugin): def titlecase(self, text: str) -> str: """Titlecase the given text.""" - return titlecase( + titlecased = titlecase( text.lower() if self.force_lowercase else text, small_first_last=self.config["small_first_last"], callback=self.__preserved__, ) - - # def on_import_task_before_choice( - # self, task: ImportTask, session: ImportSession - # ) -> None: - # """Maps imported to on_import_task_before_choice""" - # self.imported(session, task) + 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.""" diff --git a/docs/plugins/titlecase.rst b/docs/plugins/titlecase.rst index e100a09ab..909ea8514 100644 --- a/docs/plugins/titlecase.rst +++ b/docs/plugins/titlecase.rst @@ -31,6 +31,14 @@ To use the ``titlecase`` plugin, first enable it in your configuration (see 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 @@ -46,53 +54,66 @@ Default titlecase: auto: yes - preserve: None - include: ALL - exclude: - force_lowercase: yes + fields: + preserve: + force_lowercase: no small_first_last: yes -- **auto**: Whether to automatically apply titlecase to new imports. Default: - ``yes`` -- **preserve**: Space seperated list of words and acronyms to preserve the case - of. For example, without specifying ``DJ`` on the list, titlecase will format - it as ``Dj``. -- **include**: Space seperated list of fields to titlecase. When filled out, - only the fields specified will be touched by the plugin. Default: ``ALL`` -- **exclude**: Space seperated list of fields to exclude from processing. If a - field is listed in include, and is listed in exclude, exclude takes - precedence. -- **force_lowercase**: Force all strings to lowercase before applying titlecase. - This helps fix ``uNuSuAl CaPiTaLiZaTiOn PaTtErNs``. Default: ``yes`` -- **small_first_last**: An option from the base titlecase library. Controls if - capitalize small words at the start of a sentence. With this turned off ``a`` - and similar words will not be capitalized under any circumstance. Default: - ``yes`` +.. conf:: auto + :default: yes + + Whether to automatically apply titlecase to new imports. + +.. conf:: fields + + 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. + +.. conf:: preserve + + 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:: 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 + + 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. Excluded Fields ~~~~~~~~~~~~~~~ ``titlecase`` only ever modifies string fields, and will never interact with -fields that are considered to be case sensitive. +fields that it considers to be case sensitive. For reference, the string fields ``titlecase`` ignores: .. 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 - acoustid_fingerprint - acoustid_id mb_releasegroupid - asin - isrc - format bitrate_mode encoder_info encoder_settings @@ -106,4 +127,5 @@ From the command line, type: $ beet titlecase [QUERY] -You can specify additional configuration options with the following flags: +Configuration is drawn from the config file. Without a query the operation will +be applied to the entire collection. diff --git a/poetry.lock b/poetry.lock index 615598d67..3d6d93064 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2182,6 +2182,8 @@ files = [ {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_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-win_amd64.whl", hash = "sha256:da0d1e6d4842eed4d52779222c6e43d254244a486ca9fdab14e30042fd5bdf28"}, {file = "pycairo-1.28.0-cp39-cp39-win_arm64.whl", hash = "sha256:458877513eb2125513122e8aa9c938630e94bb0574f94f4fb5ab55eb23d6e9ac"}, @@ -3407,6 +3409,19 @@ files = [ {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 = true +python-versions = ">=3.7" +files = [ + {file = "titlecase-2.4.1.tar.gz", hash = "sha256:7d83a277ccbbda11a2944e78a63e5ccaf3d32f828c594312e4862f9a07f635f5"}, +] + +[package.extras] +regex = ["regex (>=2020.4.4)"] + [[package]] name = "toml" version = "0.10.2" @@ -3678,9 +3693,10 @@ replaygain = ["PyGObject"] scrub = ["mutagen"] sonosupdate = ["soco"] thumbnails = ["Pillow", "pyxdg"] +titlecase = ["titlecase"] web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "aedfeb1ac78ae0120855c6a7d6f35963c63cc50a8750142c95dd07ffd213683f" +content-hash = "83c439c2612f445d31a5047a5f2c6e1c887770b21c008874aa7ba5ebd3cd40b1" diff --git a/pyproject.toml b/pyproject.toml index b546b4dc2..da59a908c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,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 = "*" @@ -161,6 +162,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"] diff --git a/test/plugins/test_titlecase.py b/test/plugins/test_titlecase.py index 0fb69950a..cdadc5372 100644 --- a/test/plugins/test_titlecase.py +++ b/test/plugins/test_titlecase.py @@ -17,6 +17,7 @@ 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 @@ -41,18 +42,38 @@ def test_basic_titlecase(given, expected): assert TitlecasePlugin().titlecase(given) == expected +titlecase_test_cases = [ + { + "config": { + "preserve": ["D'Angelo"], + "fields": ["artist", "albumartist", "mb_albumid"], + "force_lowercase": False, + "small_first_last": True, + }, + "item": 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)", + ), + "expected": 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)", + ), + } +] + + class TitlecasePluginTest(PluginTestCase): plugin = "titlecase" preload_plugin = False - def test_preserved_case(self): - """Test using given strings to preserve case""" - names_to_preserve = ["easyFun", "A.D.O.R.", "D.R.", "ABBA", "LaTeX"] - with self.configure_plugin({"preserve": names_to_preserve}): - config["titlecase"]["preserve"] = names_to_preserve - for name in names_to_preserve: - assert TitlecasePlugin().titlecase(name.lower()) == name - def test_small_first_last(self): with self.configure_plugin({"small_first_last": False}): assert ( @@ -65,88 +86,63 @@ class TitlecasePluginTest(PluginTestCase): == "A Simple Trial" ) + 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"]["include_fields"] = excluded + config["titlecase"]["fields"] = excluded t = TitlecasePlugin() for field in excluded: assert field not in t.fields_to_process - def test_ui_commands(self): - self.load_plugins("titlecase") - tests = [ - ( - { - "title": "poorLy cased Title", - "artist": "Bad CaSE", - "album": "the album", - }, - { - "title": "Poorly Cased Title", - "artist": "Bad Case", - "album": "The Album", - }, - "", - ), - ( - { - "title": "poorLy cased Title", - "artist": "Bad CaSE", - "album": "the album", - }, - { - "title": "poorLy cased Title", - "artist": "Bad Case", - "album": "the album", - }, - "-i artist", - ), - ( - { - "title": "poorLy cased Title", - "artist": "Bad CaSE", - "album": "the album", - }, - { - "title": "poorLy Cased Title", - "artist": "Bad CaSE", - "album": "The Album", - }, - "-p CaSE poorLy", - ), - ( - { - "title": "poorLy cased Title", - "artist": "Bad CaSE", - "album": "the album", - }, - { - "title": "poorLy Cased Title", - "artist": "Bad CaSE", - "album": "The Album", - }, - "-f", - ), + def test_preserved_words(self): + """Test using given strings to preserve case""" + names_to_preserve = [ + "easyFun", + "A.D.O.R.", + "D.R.", + "D'Angelo", + "ABBA", + "LaTeX", ] - for test in tests: - i, o, opts = test - self.add_item( - artist=i["artist"], album=i["album"], title=i["title"] - ) - self.run_command("titlecase", opts) - output = self.run_with_output("ls") - assert output == f"{o['artist']} - {o['album']} - {o['title']}\n" - self.run_command("rm", o["title"], "-f") + config["titlecase"]["preserve"] = names_to_preserve + for name in names_to_preserve: + assert TitlecasePlugin().titlecase(name.lower()) == name + assert TitlecasePlugin().titlecase(name.upper()) == name - def test_field_list_included(self): - include_fields = ["album", "albumartist"] - config["titlecase"]["include"] = include_fields + 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() - assert t.fields_to_process == set(include_fields) + for phrase in test_strings: + assert t.titlecase(phrase.lower()) == phrase - def test_field_list_exclude(self): - excluded = ["album", "albumartist"] - config["titlecase"]["exclude"] = excluded - t = TitlecasePlugin() - for field in excluded: - assert field not in t.fields_to_process + 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) + + def test_cli(self): + for tc in titlecase_test_cases: + with self.configure_plugin(tc["config"]): + item = tc["item"] + expected = tc["expected"] + # Add item to library + item.add(self.lib) + self.run_command("titlecase") + output = self.run_with_output("ls") + assert ( + output + == f"{expected.artist} - {expected.album} - {expected.title}\n" + )