mirror of
https://github.com/beetbox/beets.git
synced 2025-12-15 13:07:09 +01:00
titlecase plugin nearly complete, one typecheck error to resolve.
This commit is contained in:
parent
a9f7ee8d1e
commit
109a097734
2 changed files with 260 additions and 121 deletions
|
|
@ -13,15 +13,15 @@
|
|||
# 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."""
|
||||
Title case logic is derived from the python-titlecase library."""
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.dbcore import types
|
||||
from beets import ui
|
||||
from titlecase import titlecase
|
||||
from typing import TYPE_CHECKING
|
||||
from beets.library import Item
|
||||
|
||||
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
|
||||
|
||||
__author__ = "henryoberholtzer@gmail.com"
|
||||
__version__ = "1.0"
|
||||
|
|
@ -29,131 +29,196 @@ __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([
|
||||
'id',
|
||||
'mb_workid',
|
||||
'mb_trackid',
|
||||
'mb_albumid',
|
||||
'mb_artistid',
|
||||
'mb_albumartistid',
|
||||
'mb_albumartistids',
|
||||
'mb_releasetrackid',
|
||||
'acoustid_fingerprint',
|
||||
'acoustid_id',
|
||||
'mb_releasegroupid',
|
||||
'asin',
|
||||
'isrc',
|
||||
'bitrate_mode',
|
||||
'encoder_info',
|
||||
'encoder_settings'
|
||||
])
|
||||
EXCLUDED_INFO_FIELDS = set(
|
||||
[
|
||||
"id",
|
||||
"mb_workid",
|
||||
"mb_trackid",
|
||||
"mb_albumid",
|
||||
"mb_artistid",
|
||||
"mb_albumartistid",
|
||||
"mb_albumartistids",
|
||||
"mb_releasetrackid",
|
||||
"acoustid_fingerprint",
|
||||
"acoustid_id",
|
||||
"mb_releasegroupid",
|
||||
"asin",
|
||||
"isrc",
|
||||
"format",
|
||||
"bitrate_mode",
|
||||
"encoder_info",
|
||||
"encoder_settings",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TitlecasePlugin(BeetsPlugin):
|
||||
preserve: dict[str, str] = {}
|
||||
fields_to_process: set[str] = []
|
||||
force_lowercase: bool = True
|
||||
fields_to_process: set[str] = set([])
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.config.add(
|
||||
{
|
||||
"auto": True,
|
||||
"auto": False,
|
||||
"preserve": [],
|
||||
"include": [],
|
||||
"exclude": [],
|
||||
"force_lowercase": True,
|
||||
"small_first_last": True,
|
||||
"titlecase_metadata": True,
|
||||
"include_fields": [],
|
||||
"exclude_fields": []
|
||||
}
|
||||
)
|
||||
"""
|
||||
auto - automatically apply to new imports
|
||||
preserve - provide a list of words/acronyms with specific case requirements
|
||||
small_first_last - if small characters should be title cased at beginning
|
||||
titlecase_metadata - if metadata fields should have title case applied
|
||||
include_fields - fields to apply titlecase to, default is all, except select excluded fields
|
||||
exclude_fields - fields to exclude from titlecase to, default is none
|
||||
NOTE: titlecase will not interact with possibly case sensitive fields like id,
|
||||
path or album_id. Paths are best modified in path config.
|
||||
|
||||
"""
|
||||
# Register template function
|
||||
self.template_funcs["titlecase"] = self.titlecase
|
||||
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.
|
||||
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 NYT manual of style titlecase rules"
|
||||
"titlecase",
|
||||
help="Apply titlecasing to metadata following the NYT manual of style.",
|
||||
)
|
||||
|
||||
self._command.parser.add_option(
|
||||
"-p",
|
||||
"--preserve",
|
||||
help="preserve the case of the given word, in addition to those configured."
|
||||
)
|
||||
"-f",
|
||||
"--force-off",
|
||||
dest="force_lowercase",
|
||||
action="store_false",
|
||||
help="Turn off forcing lowercase first.",
|
||||
)
|
||||
|
||||
self._command.parser.add_option(
|
||||
"-f",
|
||||
"--field",
|
||||
help="apply to the following fields."
|
||||
)
|
||||
"-p",
|
||||
"--preserve",
|
||||
dest="preserve",
|
||||
action="store",
|
||||
help="Preserve the case of the given words.",
|
||||
)
|
||||
|
||||
for word in self.config["preserve"].as_str_seq():
|
||||
self.preserve[word.upper()] = word
|
||||
self.__init_field_list__()
|
||||
self._command.parser.add_option(
|
||||
"-i",
|
||||
"--include",
|
||||
dest="include",
|
||||
action="store",
|
||||
help="""Metadata fields to titlecase to, default is all.
|
||||
Always ignores case sensitive fields.""",
|
||||
)
|
||||
|
||||
self.import_stages = [self.imported]
|
||||
self._command.parser.add_option(
|
||||
"-e",
|
||||
"--exclude",
|
||||
dest="exclude",
|
||||
action="store",
|
||||
help="""Metadata fields to skip, default is none.
|
||||
Always ignores case sensitive fields.""",
|
||||
)
|
||||
self.__get_config_file__()
|
||||
if self.config["auto"]:
|
||||
self.import_stages = [self.imported]
|
||||
# Register template function
|
||||
self.template_funcs["titlecase"] = self.titlecase
|
||||
|
||||
def __init_field_list__(self) -> None:
|
||||
""" Creates the set for fields to process in tagging.
|
||||
|
||||
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_field_list__(
|
||||
self.config["include"].as_str_seq(),
|
||||
self.config["exclude"].as_str_seq(),
|
||||
)
|
||||
|
||||
def __init_field_list__(
|
||||
self, include: list[str], exclude: list[str]
|
||||
) -> None:
|
||||
"""Creates the set for fields to process in tagging.
|
||||
If we have include_fields from config, the shared fields will be used.
|
||||
Then, any fields specified to be excluded will be removed.
|
||||
Then, any fields specified to be excluded will be removed.
|
||||
This will result in exclude_fields overriding include_fields.
|
||||
Last, the EXCLUDED_INFO_FIELDS are removed to prevent unitentional modification.
|
||||
"""
|
||||
initial_field_list = set([
|
||||
k for k, v in Item()._fields.items() if
|
||||
isinstance(v, types.String) or
|
||||
isinstance(v, types.DelimitedString)
|
||||
])
|
||||
if (incl := self.config["include_fields"].as_str_seq()):
|
||||
initial_field_list = initial_field_list.intersection(set(incl))
|
||||
if (excl := self.config["exclude_fields"].as_str_seq()):
|
||||
initial_field_list -= set(excl)
|
||||
initial_field_list = set(
|
||||
[
|
||||
k
|
||||
for k, v in Item()._fields.items()
|
||||
if isinstance(v, types.String)
|
||||
or isinstance(v, types.DelimitedString)
|
||||
]
|
||||
)
|
||||
if include:
|
||||
initial_field_list = initial_field_list.intersection(set(include))
|
||||
if exclude:
|
||||
initial_field_list -= set(exclude)
|
||||
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
|
||||
|
||||
def __preserved__(self, word, **kwargs) -> str | None:
|
||||
""" Callback function for words to preserve case of."""
|
||||
if (preserved_word := self.preserve.get(word.upper(), "")):
|
||||
"""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 split_if_exists(string: str):
|
||||
return string.split() if string else []
|
||||
|
||||
def func(lib, opts, args):
|
||||
self.config.set_args(opts)
|
||||
opts = opts.__dict__
|
||||
preserve = split_if_exists(opts["preserve"])
|
||||
excl = split_if_exists(opts["exclude"])
|
||||
incl = split_if_exists(opts["include"])
|
||||
if opts["force_lowercase"] is not None:
|
||||
self.force_lowercase = False
|
||||
self.__preserve_words__(preserve)
|
||||
self.__init_field_list__(incl, excl)
|
||||
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
|
||||
"""Applies titlecase to fields, except
|
||||
those excluded by the default exclusions and the
|
||||
set exclude lists.
|
||||
set exclude lists.
|
||||
"""
|
||||
for field in self.fields_to_process:
|
||||
init_str = getattr(item, field, "")
|
||||
if init_str:
|
||||
cased = self.titlecase(old_str)
|
||||
cased = self.titlecase(init_str)
|
||||
self._log.info(f"{field}: {init_str} -> {cased}")
|
||||
setattr(item, field, cased)
|
||||
else:
|
||||
self._log.info(f"{field}: no string present")
|
||||
|
||||
def titlecase(self, text: str) -> str:
|
||||
""" Titlecase the given text. """
|
||||
return titlecase(text,
|
||||
"""Titlecase the given text."""
|
||||
return titlecase(
|
||||
text.lower() if self.force_lowercase else text,
|
||||
small_first_last=self.config["small_first_last"],
|
||||
callback=self.__preserved__)
|
||||
callback=self.__preserved__,
|
||||
)
|
||||
|
||||
def imported(self, session: ImportSession, task: ImportTask) -> None:
|
||||
""" Import hook for titlecasing on import. """
|
||||
for item in task.imported_items():
|
||||
"""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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,63 +12,58 @@
|
|||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
""" Tests for the 'titlecase' plugin"""
|
||||
"""Tests for the 'titlecase' plugin"""
|
||||
|
||||
import pytest
|
||||
|
||||
from beets import config
|
||||
from beets.test.helper import PluginTestCase
|
||||
from beetsplug.titlecase import TitlecasePlugin, EXCLUDED_INFO_FIELDS
|
||||
from beetsplug.titlecase import EXCLUDED_INFO_FIELDS, TitlecasePlugin
|
||||
|
||||
@pytest.mark.parametrize("given, expected",
|
||||
[("a", "A"),
|
||||
("PENDULUM", "Pendulum"),
|
||||
("Aaron-carl", "Aaron-Carl"),
|
||||
("LTJ bukem", "LTJ Bukem"),
|
||||
("Freaky chakra Vs. Single Cell orchestra",
|
||||
"Freaky Chakra vs. Single Cell Orchestra"),
|
||||
("(original mix)", "(Original Mix)"),
|
||||
("ALL CAPS TITLE", "All Caps Title")
|
||||
])
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"given, expected",
|
||||
[
|
||||
("a", "A"),
|
||||
("PENDULUM", "Pendulum"),
|
||||
("Aaron-carl", "Aaron-Carl"),
|
||||
("LTJ bukem", "LTJ Bukem"),
|
||||
(
|
||||
"Freaky chakra Vs. Single Cell orchestra",
|
||||
"Freaky Chakra vs. Single Cell Orchestra",
|
||||
),
|
||||
("(original mix)", "(Original Mix)"),
|
||||
("ALL CAPS TITLE", "All Caps Title"),
|
||||
],
|
||||
)
|
||||
def test_basic_titlecase(given, expected):
|
||||
""" Assert that general behavior is as expected. """
|
||||
"""Assert that general behavior is as expected."""
|
||||
assert TitlecasePlugin().titlecase(given) == expected
|
||||
|
||||
|
||||
class TitlecasePluginTest(PluginTestCase):
|
||||
plugin = "titlecase"
|
||||
preload_plugin = False
|
||||
|
||||
|
||||
def test_preserved_case(self):
|
||||
""" Test using given strings to preserve case """
|
||||
"""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}):
|
||||
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
|
||||
assert TitlecasePlugin().titlecase(name.lower()) == name
|
||||
|
||||
def test_small_first_last(self):
|
||||
with self.configure_plugin({
|
||||
"small_first_last": False}):
|
||||
assert TitlecasePlugin().titlecase(
|
||||
"A Simple Trial") == "a Simple Trial"
|
||||
with self.configure_plugin({
|
||||
"small_first_last": True}):
|
||||
assert TitlecasePlugin().titlecase(
|
||||
"A simple Trial") == "A Simple Trial"
|
||||
|
||||
def test_ui_command(self):
|
||||
assert 1 == 3
|
||||
|
||||
def test_imported(self):
|
||||
item = self.add_item(
|
||||
artist="A poorly cased artist",
|
||||
albumartist="not vEry good tItle caSE",
|
||||
mb_artistid="case sensitive field")
|
||||
assert item.artist == "A Poorly Cased Artist"
|
||||
assert item.albumartist == "Not Very Good Title Case"
|
||||
with self.configure_plugin({"small_first_last": False}):
|
||||
assert (
|
||||
TitlecasePlugin().titlecase("A Simple Trial")
|
||||
== "a Simple Trial"
|
||||
)
|
||||
with self.configure_plugin({"small_first_last": True}):
|
||||
assert (
|
||||
TitlecasePlugin().titlecase("A simple Trial")
|
||||
== "A Simple Trial"
|
||||
)
|
||||
|
||||
def test_field_list_default_excluded(self):
|
||||
excluded = list(EXCLUDED_INFO_FIELDS)
|
||||
|
|
@ -77,15 +72,94 @@ class TitlecasePluginTest(PluginTestCase):
|
|||
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",
|
||||
},
|
||||
"-e title 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",
|
||||
),
|
||||
]
|
||||
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")
|
||||
|
||||
def test_field_list_included(self):
|
||||
config["titlecase"]["include_fields"] = ["album", "albumartist"]
|
||||
include_fields = ["album", "albumartist"]
|
||||
config["titlecase"]["include"] = include_fields
|
||||
t = TitlecasePlugin()
|
||||
t.fields_to_process == ["album", "albumartist"]
|
||||
assert t.fields_to_process == set(include_fields)
|
||||
|
||||
def test_field_list_exclude(self):
|
||||
excluded = ["album", "albumartist"]
|
||||
config["titlecase"]["exclude_fields"] = excluded
|
||||
config["titlecase"]["exclude"] = excluded
|
||||
t = TitlecasePlugin()
|
||||
for field in excluded:
|
||||
assert field not in t.fields_to_process
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue