-
Notifications
You must be signed in to change notification settings - Fork 1.9k
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
base: master
Are you sure you want to change the base?
New Plugin: Titlecase #6133
Changes from all commits
8d11ed5
117f8ad
9442990
57641ad
a9f7ee8
109a097
9b9f920
5bce774
72008ee
a1844b1
f3551d6
77f2f9e
86b6f03
2f88ca0
2bb072f
f6ac3db
631485c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,192 @@ | ||
| # 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, Pattern | ||
|
|
||
| from titlecase import titlecase | ||
|
|
||
| from beets import ui | ||
| from beets.importer import ImportSession, ImportTask | ||
| from beets.library import Item | ||
| from beets.plugins import BeetsPlugin | ||
|
|
||
| __author__ = "[email protected]" | ||
| __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] | ||
|
|
||
| def __init__(self) -> None: | ||
| super().__init__() | ||
|
|
||
| # Register template function | ||
| self.template_funcs["titlecase"] = self.titlecase # type: ignore | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could not get mypy to play nice with this type here - any suggestions appreciated.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the issue is that the type is # plugins.py
template_funcs: TFuncMap[str] | None = None
# and should be
template_funcs: ClassVar[TFuncMap[str]] = {}Notice the union with None. Should be fine here as this is more of an issue with the plugin implementation. |
||
|
|
||
| self.config.add( | ||
| { | ||
| "auto": True, | ||
| "preserve": [], | ||
| "fields": [], | ||
| "force_lowercase": False, | ||
| "small_first_last": 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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can't seem to find the
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah! This is a holdover from my previous version where I initially just had it do all fields by default. Thanks for catching it. Now it only does fields as specified, which I think can play into a solution to your other comment! |
||
| 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. | ||
| """ | ||
|
|
||
| # Register UI subcommands | ||
| self._command = ui.Subcommand( | ||
| "titlecase", | ||
| help="Apply titlecasing to metadata specified in config.", | ||
| ) | ||
|
|
||
| self.__get_config_file__() | ||
| if self.config["auto"]: | ||
| self.import_stages = [self.imported] | ||
|
|
||
| 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["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. | ||
| """ | ||
| if fields: | ||
| 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: | ||
| 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 | ||
|
|
||
| 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): | ||
| """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) for i in init_field | ||
| ] | ||
| 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) | ||
| else: | ||
| self._log.info(f"{field}: no string present") | ||
|
|
||
| def titlecase(self, text: 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__, | ||
| ) | ||
| 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.info(f"titlecasing {item.title}:") | ||
| self.titlecase_fields(item) | ||
| item.store() | ||
| except Exception as e: | ||
| self._log.info(f"titlecasing exception {e}") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| 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: | ||
| force_lowercase: no | ||
| small_first_last: 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 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 | ||
| 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. |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This list seems a bit like a potential footgun to me:
Maybe we should turn this around? Could we have a list with default fields and let the user add more if needed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point - I was a little worried about the maintenance of it. Thinking on it, since the fields to tag have to be manually specified, we could remove this and let users apply titlecase to any field they please. We can then rely on re-importing / re-tagging to fix any issues if a user, for some reason, chooses to titlecase a musicbrainz id.