pretty much set to go

This commit is contained in:
Henry 2025-10-26 16:34:18 -07:00
parent 86b6f03c97
commit 2f88ca0101
5 changed files with 172 additions and 170 deletions

View file

@ -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."""

View file

@ -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.

18
poetry.lock generated
View file

@ -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"

View file

@ -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"]

View file

@ -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"
)