Skip to content
Draft
Show file tree
Hide file tree
Changes from 14 commits
Commits
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 @@ -2,4 +2,5 @@
* @beetbox/maintainers

# Specific ownerships:
/beets/metadata_plugins.py @semohr
/beets/metadata_plugins.py @semohr
/beetsplug/titlecase.py @henry-oberholtzer
184 changes: 184 additions & 0 deletions beetsplug/titlecase.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# 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 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(
[
"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] = set([])

def __init__(self) -> None:
super().__init__()

# Register template function
self.template_funcs["titlecase"] = self.titlecase # type: ignore

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.
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.
"""
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) -> str | None:

Check failure on line 127 in beetsplug/titlecase.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

X | Y syntax for unions requires Python 3.10
"""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 isinstance(init_field, list):
cased_list: list[str] = [self.titlecase(i) for i in init_field]
self._log.info(
f"""
{field}: {", ".join(init_field)} ->
{", ".join(cased_list)}"""
)
setattr(item, field, cased_list)
elif init_field and 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():
self._log.info(f"titlecasing {item.title}:")
self.titlecase_fields(item)
item.store()
131 changes: 131 additions & 0 deletions docs/plugins/titlecase.rst
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`_.

Motiviation for this plugin comes from a desire to resolve differences in style

Check failure on line 8 in docs/plugins/titlecase.rst

View workflow job for this annotation

GitHub Actions / Run tests (ubuntu-latest, 3.9)

ERROR: Unknown target name: "discogs guidlines".

Check failure on line 8 in docs/plugins/titlecase.rst

View workflow job for this annotation

GitHub Actions / Check docs

ERROR: Unknown target name: "discogs guidlines".
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 guidlines`_ recommend
capitalizing the first letter of each word, even for small words like "of" and
"a". This plugin aims to achieve a middleground between disparate approaches to
casing, and bring more consistency to titlecasing in your library.

.. _discogs style: 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.
18 changes: 17 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "*"
Expand Down Expand Up @@ -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"]

Expand Down
Loading
Loading