Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 Oct 5, 2025
117f8ad
working on metadata tagging
henry-oberholtzer Oct 6, 2025
9442990
working on field titlecasing
henry-oberholtzer Oct 18, 2025
57641ad
merge with master
henry-oberholtzer Oct 18, 2025
a9f7ee8
working on changing position in import process - may consider options…
henry-oberholtzer Oct 19, 2025
109a097
titlecase plugin nearly complete, one typecheck error to resolve.
henry-oberholtzer Oct 22, 2025
9b9f920
add self as codeowner
henry-oberholtzer Oct 23, 2025
5bce774
initial docs draft, add to before choice import stage
henry-oberholtzer Oct 24, 2025
72008ee
merge with master
henry-oberholtzer Oct 24, 2025
a1844b1
lint and format
henry-oberholtzer Oct 24, 2025
f3551d6
reworking defaults
henry-oberholtzer Oct 25, 2025
77f2f9e
Merge branch 'master' into titlecase
henry-oberholtzer Oct 26, 2025
86b6f03
restore poetry files to master
henry-oberholtzer Oct 26, 2025
2f88ca0
pretty much set to go
henry-oberholtzer Oct 26, 2025
2bb072f
fixes
henry-oberholtzer Oct 27, 2025
f6ac3db
add to index.rest, fix links, reformat, lint
henry-oberholtzer Oct 28, 2025
631485c
add to test dependencys
henry-oberholtzer Oct 28, 2025
5628232
add the_artist
henry-oberholtzer Nov 9, 2025
c89d0c1
add replace
henry-oberholtzer Nov 10, 2025
a6bda74
Added support for pre-tag selection stage
henry-oberholtzer Nov 15, 2025
c8876dd
fix The artist behavior with artists with 'the' string in the name
henry-oberholtzer Nov 16, 2025
b3e6aef
merge with master
henry-oberholtzer Nov 16, 2025
3091901
fix type issues
henry-oberholtzer Nov 16, 2025
dcac9ba
Add word boundary support, fix The artist behavior
henry-oberholtzer Nov 17, 2025
df1ef40
only apply & log change if there's a difference
henry-oberholtzer Nov 17, 2025
83c16cb
Rewrite tests, add cached_property decorators, add seperator feature
henry-oberholtzer Nov 22, 2025
a6055f5
fix lock file
henry-oberholtzer Nov 22, 2025
327d237
Merge branch 'master' into titlecase
henry-oberholtzer Nov 22, 2025
d3cc97e
final tests added, restore type check
henry-oberholtzer Nov 22, 2025
3967951
forgot to tear down the test - fixed!
henry-oberholtzer Nov 22, 2025
80482a8
clean up - removed a stray print statement!
henry-oberholtzer Nov 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@

# Specific ownerships:
/beets/metadata_plugins.py @semohr
/beetsplug/mbpseudo.py @asardaes
/beetsplug/titlecase.py @henry-oberholtzer
/beetsplug/mbpseudo.py @asardaes
236 changes: 236 additions & 0 deletions beetsplug/titlecase.py
Original file line number Diff line number Diff line change
@@ -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}")
2 changes: 2 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions docs/plugins/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ databases. They share the following configuration options:
substitute
the
thumbnails
titlecase
types
unimported
web
Expand Down
Loading
Loading