forked from beetbox/beets
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtitlecase.py
More file actions
203 lines (179 loc) · 7.68 KB
/
titlecase.py
File metadata and controls
203 lines (179 loc) · 7.68 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# 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 Optional
from titlecase import titlecase
from beets import ui
from beets.autotag.hooks import AlbumInfo, Info, TrackInfo
from beets.importer import ImportSession, ImportTask
from beets.library import Item
from beets.plugins import BeetsPlugin
__author__ = "henryoberholtzer@gmail.com"
__version__ = "1.0"
class TitlecasePlugin(BeetsPlugin):
preserve: dict[str, str] = {}
preserve_phrases: dict[str, re.Pattern[str]] = {}
force_lowercase: bool = True
fields_to_process: set[str] = set()
the_artist: bool = True
def __init__(self) -> None:
super().__init__()
# Register template function
self.template_funcs["titlecase"] = self.titlecase # type: ignore
self.config.add(
{
"auto": True,
"preserve": [],
"fields": [],
"replace": [],
"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
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 UI subcommands
self._command = ui.Subcommand(
"titlecase",
help="Apply titlecasing to metadata specified in config.",
)
self.__get_config_file__()
if self.config["auto"]:
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
)
def __get_config_file__(self):
self.force_lowercase = self.config["force_lowercase"].get(bool)
self.__preserve_words__(self.config["preserve"].as_str_seq())
self.replace = self.config["replace"].as_pairs()
self.the_artist = self.config["the_artist"].get(bool)
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."""
if fields:
self.fields_to_process = set(fields)
self._log.debug(
f"set fields to process: {', '.join(self.fields_to_process)}"
)
else:
self._log.debug("no fields specified!")
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) -> Optional[str]:
"""Callback function for words to preserve case of."""
if preserved_word := self.preserve.get(word.upper(), ""):
return preserved_word
return None
def received_info_handler(self, info: AlbumInfo | TrackInfo):
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.debug(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):
"""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
]
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)
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."""
# Any necessary replacements go first.
titlecased = text.lower() if self.force_lowercase else text
for pair in self.replace:
target, replacement = pair
titlecased = titlecased.replace(target, replacement)
titlecased = titlecase(
titlecased,
small_first_last=self.config["small_first_last"],
callback=self.__preserved__,
)
if self.the_artist and "artist" in field:
titlecased = titlecased.replace("the ", "The ").replace(
" the", " The"
)
# 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}")