mirror of
https://github.com/beetbox/beets.git
synced 2025-12-30 04:22:40 +01:00
pretty much set to go
This commit is contained in:
parent
86b6f03c97
commit
2f88ca0101
5 changed files with 172 additions and 170 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
18
poetry.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue