From aced802c5644722c8ca87f3419f463541d22a0a8 Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Thu, 20 Nov 2025 15:57:22 -0500 Subject: [PATCH 1/9] Fix recursion in inline plugin when item_fields shadow DB fields (#6115) --- beetsplug/inline.py | 13 +++++++++---- test/plugins/test_plugins.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 test/plugins/test_plugins.py diff --git a/beetsplug/inline.py b/beetsplug/inline.py index e9a94ac38..860a205ee 100644 --- a/beetsplug/inline.py +++ b/beetsplug/inline.py @@ -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 diff --git a/test/plugins/test_plugins.py b/test/plugins/test_plugins.py new file mode 100644 index 000000000..a606f16ca --- /dev/null +++ b/test/plugins/test_plugins.py @@ -0,0 +1,31 @@ +# test/plugins/test_plugins.py + +from beets import config, plugins +from beets.test.helper import PluginTestCase + +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' \ No newline at end of file From ba45fedde581dfe7a0c848bcc1f3c0ef3b5a826d Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Thu, 20 Nov 2025 16:09:01 -0500 Subject: [PATCH 2/9] Fix inline recursion test formatting --- test/plugins/test_plugins.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/test/plugins/test_plugins.py b/test/plugins/test_plugins.py index a606f16ca..f4baf3663 100644 --- a/test/plugins/test_plugins.py +++ b/test/plugins/test_plugins.py @@ -3,14 +3,14 @@ from beets import config, plugins from beets.test.helper import PluginTestCase + class TestInlineRecursion(PluginTestCase): def test_no_recursion_when_inline_shadows_fixed_field(self): - config['plugins'] = ['inline'] + config["plugins"] = ["inline"] - config['item_fields'] = { - 'track_no': ( - "f'{disc:02d}-{track:02d}' if disctotal > 1 " - "else f'{track:02d}'" + config["item_fields"] = { + "track_no": ( + "f'{disc:02d}-{track:02d}' if disctotal > 1 else f'{track:02d}'" ) } @@ -18,14 +18,14 @@ class TestInlineRecursion(PluginTestCase): plugins.load_plugins() item = self.add_item_fixture( - artist='Artist', - album='Album', - title='Title', + artist="Artist", + album="Album", + title="Title", track=1, disc=1, disctotal=1, ) - out = item.evaluate_template('$track_no') + out = item.evaluate_template("$track_no") - assert out == '01' \ No newline at end of file + assert out == "01" From b9023521390aa372473d10ac28e3d6c7b93b3335 Mon Sep 17 00:00:00 2001 From: henry <137741507+henry-oberholtzer@users.noreply.github.com> Date: Sun, 23 Nov 2025 10:34:05 -0800 Subject: [PATCH 3/9] 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. --- .github/CODEOWNERS | 3 +- beetsplug/titlecase.py | 236 +++++++++++++++++++ docs/changelog.rst | 2 + docs/plugins/index.rst | 1 + docs/plugins/titlecase.rst | 200 +++++++++++++++++ poetry.lock | 25 ++- pyproject.toml | 3 + test/plugins/test_titlecase.py | 400 +++++++++++++++++++++++++++++++++ 8 files changed, 868 insertions(+), 2 deletions(-) create mode 100644 beetsplug/titlecase.py create mode 100644 docs/plugins/titlecase.rst create mode 100644 test/plugins/test_titlecase.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d014b925b..fe4ce3378 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,4 +3,5 @@ # Specific ownerships: /beets/metadata_plugins.py @semohr -/beetsplug/mbpseudo.py @asardaes \ No newline at end of file +/beetsplug/titlecase.py @henry-oberholtzer +/beetsplug/mbpseudo.py @asardaes diff --git a/beetsplug/titlecase.py b/beetsplug/titlecase.py new file mode 100644 index 000000000..2482e1c34 --- /dev/null +++ b/beetsplug/titlecase.py @@ -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}") diff --git a/docs/changelog.rst b/docs/changelog.rst index d95de38c5..d1a0e8c7f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -27,6 +27,8 @@ 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/titlecase`: Add the `titlecase` plugin to allow users to + resolve differences in metadata source styles. Bug fixes: diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index c211616e4..4a2fce473 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -128,6 +128,7 @@ databases. They share the following configuration options: substitute the thumbnails + titlecase types unimported web diff --git a/docs/plugins/titlecase.rst b/docs/plugins/titlecase.rst new file mode 100644 index 000000000..c35bc10a4 --- /dev/null +++ b/docs/plugins/titlecase.rst @@ -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. diff --git a/poetry.lock b/poetry.lock index 9426ad659..ba16420c2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2471,6 +2471,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"}, @@ -2821,6 +2823,13 @@ description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" 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_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"}, @@ -3896,6 +3905,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 = false +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" @@ -4161,9 +4183,10 @@ replaygain = ["PyGObject"] scrub = ["mutagen"] sonosupdate = ["soco"] thumbnails = ["Pillow", "pyxdg"] +titlecase = ["titlecase"] web = ["flask", "flask-cors"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "10a60daf371ba5d2c3d62ab0da7be81af40890517f9f60ed4a2cee1835eea6ae" +content-hash = "9e154214b2f404415ef17df83f926a326ffb62a83b3901a404946110354d4067" diff --git a/pyproject.toml b/pyproject.toml index e4b69b7f3..8b33e9fcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,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 = "*" @@ -112,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" @@ -172,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"] diff --git a/test/plugins/test_titlecase.py b/test/plugins/test_titlecase.py new file mode 100644 index 000000000..44058780c --- /dev/null +++ b/test/plugins/test_titlecase.py @@ -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="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)", + ), + 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() From 13f95dcf3a43ba9bffab4ecf210de2afa6d232da Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 25 Nov 2025 18:12:36 -0500 Subject: [PATCH 4/9] Added documentation header --- test/plugins/{test_plugins.py => test_inline.py} | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) rename test/plugins/{test_plugins.py => test_inline.py} (53%) diff --git a/test/plugins/test_plugins.py b/test/plugins/test_inline.py similarity index 53% rename from test/plugins/test_plugins.py rename to test/plugins/test_inline.py index f4baf3663..fb6c038d0 100644 --- a/test/plugins/test_plugins.py +++ b/test/plugins/test_inline.py @@ -1,4 +1,16 @@ -# test/plugins/test_plugins.py +# 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 e827d43213d99a6938812ea90d19ca2ea337ebac Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 25 Nov 2025 18:34:24 -0500 Subject: [PATCH 5/9] Fixed unit tests --- test/plugins/test_inline.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/test/plugins/test_inline.py b/test/plugins/test_inline.py index fb6c038d0..5f4ded8f6 100644 --- a/test/plugins/test_inline.py +++ b/test/plugins/test_inline.py @@ -14,7 +14,7 @@ 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): @@ -41,3 +41,19 @@ class TestInlineRecursion(PluginTestCase): 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())) From c59134bdb6f3ee0fa53da1308d4a23fd1900a1b3 Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 25 Nov 2025 18:38:09 -0500 Subject: [PATCH 6/9] Fixed unit tests import --- test/plugins/test_inline.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/plugins/test_inline.py b/test/plugins/test_inline.py index 5f4ded8f6..7a6b5c360 100644 --- a/test/plugins/test_inline.py +++ b/test/plugins/test_inline.py @@ -16,6 +16,7 @@ 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"] From 51164024c02a6cf423193b63404eb3910858bea3 Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 25 Nov 2025 18:41:31 -0500 Subject: [PATCH 7/9] Fixed unit tests import --- test/plugins/test_inline.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/plugins/test_inline.py b/test/plugins/test_inline.py index 7a6b5c360..79118bd06 100644 --- a/test/plugins/test_inline.py +++ b/test/plugins/test_inline.py @@ -54,7 +54,9 @@ class TestInlineRecursion(PluginTestCase): def test_inline_album_expression_uses_items(self): plugin = InlinePlugin() - func = plugin.compile_inline("len(items)", album=True, field_name="item_count") + func = plugin.compile_inline( + "len(items)", album=True, field_name="item_count" + ) album = self.add_album_fixture() assert func(album) == len(list(album.items())) From cd8e466a46abeabb5b0bc491b69ad397c9b58bd4 Mon Sep 17 00:00:00 2001 From: Gabriel Push Date: Tue, 25 Nov 2025 19:18:10 -0500 Subject: [PATCH 8/9] Updated changelog documentation --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index d1a0e8c7f..19026eafe 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -32,6 +32,10 @@ New features: 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` From 5cc7dcfce7eb0998c0b354c3e356148046d7e4f9 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Thu, 27 Nov 2025 21:46:30 +0100 Subject: [PATCH 9/9] Sometimes it is time to let go of old things: This removes old references and docs for the old gmusic plugin. --- beetsplug/gmusic.py | 27 --------------------------- docs/changelog.rst | 10 ++++++---- docs/plugins/gmusic.rst | 5 ----- docs/plugins/index.rst | 1 - 4 files changed, 6 insertions(+), 37 deletions(-) delete mode 100644 beetsplug/gmusic.py delete mode 100644 docs/plugins/gmusic.rst diff --git a/beetsplug/gmusic.py b/beetsplug/gmusic.py deleted file mode 100644 index 5dda3a2e5..000000000 --- a/beetsplug/gmusic.py +++ /dev/null @@ -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." - ) diff --git a/docs/changelog.rst b/docs/changelog.rst index 19026eafe..b3dde83a9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -75,6 +75,8 @@ Other changes: 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) ------------------------ @@ -1359,9 +1361,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` @@ -1412,7 +1414,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`. @@ -1851,7 +1853,7 @@ Here are the new features: - :ref:`Date queries ` 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 diff --git a/docs/plugins/gmusic.rst b/docs/plugins/gmusic.rst deleted file mode 100644 index 76697ea31..000000000 --- a/docs/plugins/gmusic.rst +++ /dev/null @@ -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. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 4a2fce473..a1114976e 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -84,7 +84,6 @@ databases. They share the following configuration options: fromfilename ftintitle fuzzy - gmusic hook ihate importadded