-
Notifications
You must be signed in to change notification settings - Fork 2k
New Plugin: Titlecase #6133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
henry-oberholtzer
merged 31 commits into
beetbox:master
from
henry-oberholtzer:titlecase
Nov 23, 2025
Merged
New Plugin: Titlecase #6133
Changes from 23 commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
8d11ed5
Initial title case plugin written and working, needs to apply to tags
henry-oberholtzer 117f8ad
working on metadata tagging
henry-oberholtzer 9442990
working on field titlecasing
henry-oberholtzer 57641ad
merge with master
henry-oberholtzer a9f7ee8
working on changing position in import process - may consider options…
henry-oberholtzer 109a097
titlecase plugin nearly complete, one typecheck error to resolve.
henry-oberholtzer 9b9f920
add self as codeowner
henry-oberholtzer 5bce774
initial docs draft, add to before choice import stage
henry-oberholtzer 72008ee
merge with master
henry-oberholtzer a1844b1
lint and format
henry-oberholtzer f3551d6
reworking defaults
henry-oberholtzer 77f2f9e
Merge branch 'master' into titlecase
henry-oberholtzer 86b6f03
restore poetry files to master
henry-oberholtzer 2f88ca0
pretty much set to go
henry-oberholtzer 2bb072f
fixes
henry-oberholtzer f6ac3db
add to index.rest, fix links, reformat, lint
henry-oberholtzer 631485c
add to test dependencys
henry-oberholtzer 5628232
add the_artist
henry-oberholtzer c89d0c1
add replace
henry-oberholtzer a6bda74
Added support for pre-tag selection stage
henry-oberholtzer c8876dd
fix The artist behavior with artists with 'the' string in the name
henry-oberholtzer b3e6aef
merge with master
henry-oberholtzer 3091901
fix type issues
henry-oberholtzer dcac9ba
Add word boundary support, fix The artist behavior
henry-oberholtzer df1ef40
only apply & log change if there's a difference
henry-oberholtzer 83c16cb
Rewrite tests, add cached_property decorators, add seperator feature
henry-oberholtzer a6055f5
fix lock file
henry-oberholtzer 327d237
Merge branch 'master' into titlecase
henry-oberholtzer d3cc97e
final tests added, restore type check
henry-oberholtzer 3967951
forgot to tear down the test - fixed!
henry-oberholtzer 80482a8
clean up - removed a stray print statement!
henry-oberholtzer File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| # 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 typing import Optional | ||
|
|
||
| from titlecase import titlecase | ||
|
|
||
| from beets import ui | ||
| from beets.autotag.hooks import AlbumInfo, Info, TrackInfo | ||
| from beets.importer import ImportSession, ImportTask | ||
| from beets.library import Item | ||
| from beets.plugins import BeetsPlugin | ||
|
|
||
| __author__ = "henryoberholtzer@gmail.com" | ||
| __version__ = "1.0" | ||
|
|
||
|
|
||
| class TitlecasePlugin(BeetsPlugin): | ||
| preserve: dict[str, str] = {} | ||
| preserve_phrases: dict[str, re.Pattern[str]] = {} | ||
| force_lowercase: bool = True | ||
| fields_to_process: set[str] = set() | ||
| the_artist: bool = True | ||
|
|
||
| def __init__(self) -> None: | ||
| super().__init__() | ||
|
|
||
| # Register template function | ||
| self.template_funcs["titlecase"] = self.titlecase # type: ignore | ||
|
|
||
| self.config.add( | ||
| { | ||
| "auto": True, | ||
| "preserve": [], | ||
| "fields": [], | ||
| "replace": [], | ||
| "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 | ||
| 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 UI subcommands | ||
| self._command = ui.Subcommand( | ||
| "titlecase", | ||
| help="Apply titlecasing to metadata specified in config.", | ||
| ) | ||
|
|
||
| self.__get_config_file__() | ||
| if self.config["auto"]: | ||
| 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 | ||
| ) | ||
|
|
||
| def __get_config_file__(self): | ||
sourcery-ai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| self.force_lowercase = self.config["force_lowercase"].get(bool) | ||
| self.__preserve_words__(self.config["preserve"].as_str_seq()) | ||
| self.replace = self.config["replace"].as_pairs() | ||
henry-oberholtzer marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| self.the_artist = self.config["the_artist"].get(bool) | ||
| self.__init_fields_to_process__( | ||
sourcery-ai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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.""" | ||
| if fields: | ||
| self.fields_to_process = set(fields) | ||
| self._log.debug( | ||
| f"set fields to process: {', '.join(self.fields_to_process)}" | ||
| ) | ||
| else: | ||
| self._log.debug("no fields specified!") | ||
|
|
||
| def __preserve_words__(self, preserve: list[str]) -> None: | ||
| for word in preserve: | ||
| 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) -> Optional[str]: | ||
| """Callback function for words to preserve case of.""" | ||
| if preserved_word := self.preserve.get(word.upper(), ""): | ||
| return preserved_word | ||
| return None | ||
sourcery-ai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| def received_info_handler(self, info: AlbumInfo | TrackInfo): | ||
| 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.debug(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): | ||
| """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 | ||
| ] | ||
| 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) | ||
| 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.""" | ||
| # Any necessary replacements go first. | ||
| titlecased = text.lower() if self.force_lowercase else text | ||
| for pair in self.replace: | ||
henry-oberholtzer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| target, replacement = pair | ||
| titlecased = titlecased.replace(target, replacement) | ||
| titlecased = titlecase( | ||
| titlecased, | ||
| small_first_last=self.config["small_first_last"], | ||
| callback=self.__preserved__, | ||
| ) | ||
| if self.the_artist and "artist" in field: | ||
| titlecased = titlecased.replace("the ", "The ").replace( | ||
| " the", " The" | ||
| ) | ||
| # 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}") | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| 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: | ||
| 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 | ||
|
|
||
| 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 | ||
semohr marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 | ||
| 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 | ||
|
|
||
| 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 | ||
|
|
||
| .. 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 | ||
|
|
||
henry-oberholtzer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
|
|
||
| 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 | ||
|
|
||
| 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 | ||
henry-oberholtzer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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. | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.