Add custom feat words

This commit is contained in:
Ember Light 2025-10-12 20:47:51 +02:00
parent 41e314247d
commit 37a5f9cb15
3 changed files with 114 additions and 20 deletions

View file

@ -632,13 +632,15 @@ def send(event: EventType, **arguments: Any) -> list[Any]:
] ]
def feat_tokens(for_artist: bool = True) -> str: def feat_tokens(
for_artist: bool = True, custom_feat_words: list[str] = []
) -> str:
"""Return a regular expression that matches phrases like "featuring" """Return a regular expression that matches phrases like "featuring"
that separate a main artist or a song title from secondary artists. that separate a main artist or a song title from secondary artists.
The `for_artist` option determines whether the regex should be The `for_artist` option determines whether the regex should be
suitable for matching artist fields (the default) or title fields. suitable for matching artist fields (the default) or title fields.
""" """
feat_words = ["ft", "featuring", "feat", "feat.", "ft."] feat_words = ["ft", "featuring", "feat", "feat.", "ft."] + custom_feat_words
if for_artist: if for_artist:
feat_words += ["with", "vs", "and", "con", "&"] feat_words += ["with", "vs", "and", "con", "&"]
return ( return (

View file

@ -27,7 +27,7 @@ if TYPE_CHECKING:
def split_on_feat( def split_on_feat(
artist: str, for_artist: bool = True artist: str, for_artist: bool = True, custom_feat_words: list[str] = []
) -> tuple[str, str | None]: ) -> tuple[str, str | None]:
"""Given an artist string, split the "main" artist from any artist """Given an artist string, split the "main" artist from any artist
on the right-hand side of a string like "feat". Return the main on the right-hand side of a string like "feat". Return the main
@ -35,7 +35,9 @@ def split_on_feat(
may be a string or None if none is present. may be a string or None if none is present.
""" """
# split on the first "feat". # split on the first "feat".
regex = re.compile(plugins.feat_tokens(for_artist), re.IGNORECASE) regex = re.compile(
plugins.feat_tokens(for_artist, custom_feat_words), re.IGNORECASE
)
parts = tuple(s.strip() for s in regex.split(artist, 1)) parts = tuple(s.strip() for s in regex.split(artist, 1))
if len(parts) == 1: if len(parts) == 1:
return parts[0], None return parts[0], None
@ -44,18 +46,22 @@ def split_on_feat(
return parts return parts
def contains_feat(title: str) -> bool: def contains_feat(title: str, custom_feat_words: list[str] = []) -> bool:
"""Determine whether the title contains a "featured" marker.""" """Determine whether the title contains a "featured" marker."""
return bool( return bool(
re.search( re.search(
plugins.feat_tokens(for_artist=False), plugins.feat_tokens(
for_artist=False, custom_feat_words=custom_feat_words
),
title, title,
flags=re.IGNORECASE, flags=re.IGNORECASE,
) )
) )
def find_feat_part(artist: str, albumartist: str | None) -> str | None: def find_feat_part(
artist: str, albumartist: str | None, custom_feat_words: list[str] = []
) -> str | None:
"""Attempt to find featured artists in the item's artist fields and """Attempt to find featured artists in the item's artist fields and
return the results. Returns None if no featured artist found. return the results. Returns None if no featured artist found.
""" """
@ -69,20 +75,24 @@ def find_feat_part(artist: str, albumartist: str | None) -> str | None:
# featured artist. # featured artist.
if albumartist_split[1] != "": if albumartist_split[1] != "":
# Extract the featured artist from the right-hand side. # Extract the featured artist from the right-hand side.
_, feat_part = split_on_feat(albumartist_split[1]) _, feat_part = split_on_feat(
albumartist_split[1], custom_feat_words=custom_feat_words
)
return feat_part return feat_part
# Otherwise, if there's nothing on the right-hand side, # Otherwise, if there's nothing on the right-hand side,
# look for a featuring artist on the left-hand side. # look for a featuring artist on the left-hand side.
else: else:
lhs, _ = split_on_feat(albumartist_split[0]) lhs, _ = split_on_feat(
albumartist_split[0], custom_feat_words=custom_feat_words
)
if lhs: if lhs:
return lhs return lhs
# Fall back to conservative handling of the track artist without relying # Fall back to conservative handling of the track artist without relying
# on albumartist, which covers compilations using a 'Various Artists' # on albumartist, which covers compilations using a 'Various Artists'
# albumartist and album tracks by a guest artist featuring a third artist. # albumartist and album tracks by a guest artist featuring a third artist.
_, feat_part = split_on_feat(artist, False) _, feat_part = split_on_feat(artist, False, custom_feat_words)
return feat_part return feat_part
@ -96,6 +106,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
"drop": False, "drop": False,
"format": "feat. {}", "format": "feat. {}",
"keep_in_artist": False, "keep_in_artist": False,
"custom_feat_words": [],
} }
) )
@ -120,10 +131,13 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
self.config.set_args(opts) self.config.set_args(opts)
drop_feat = self.config["drop"].get(bool) drop_feat = self.config["drop"].get(bool)
keep_in_artist_field = self.config["keep_in_artist"].get(bool) keep_in_artist_field = self.config["keep_in_artist"].get(bool)
custom_feat_words = self.config["custom_feat_words"].get(list)
write = ui.should_write() write = ui.should_write()
for item in lib.items(args): for item in lib.items(args):
if self.ft_in_title(item, drop_feat, keep_in_artist_field): if self.ft_in_title(
item, drop_feat, keep_in_artist_field, custom_feat_words
):
item.store() item.store()
if write: if write:
item.try_write() item.try_write()
@ -135,9 +149,12 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
"""Import hook for moving featuring artist automatically.""" """Import hook for moving featuring artist automatically."""
drop_feat = self.config["drop"].get(bool) drop_feat = self.config["drop"].get(bool)
keep_in_artist_field = self.config["keep_in_artist"].get(bool) keep_in_artist_field = self.config["keep_in_artist"].get(bool)
custom_feat_words = self.config["custom_feat_words"].get(list)
for item in task.imported_items(): for item in task.imported_items():
if self.ft_in_title(item, drop_feat, keep_in_artist_field): if self.ft_in_title(
item, drop_feat, keep_in_artist_field, custom_feat_words
):
item.store() item.store()
def update_metadata( def update_metadata(
@ -146,6 +163,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
feat_part: str, feat_part: str,
drop_feat: bool, drop_feat: bool,
keep_in_artist_field: bool, keep_in_artist_field: bool,
custom_feat_words: list[str],
) -> None: ) -> None:
"""Choose how to add new artists to the title and set the new """Choose how to add new artists to the title and set the new
metadata. Also, print out messages about any changes that are made. metadata. Also, print out messages about any changes that are made.
@ -158,17 +176,21 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
"artist: {.artist} (Not changing due to keep_in_artist)", item "artist: {.artist} (Not changing due to keep_in_artist)", item
) )
else: else:
track_artist, _ = split_on_feat(item.artist) track_artist, _ = split_on_feat(
item.artist, custom_feat_words=custom_feat_words
)
self._log.info("artist: {0.artist} -> {1}", item, track_artist) self._log.info("artist: {0.artist} -> {1}", item, track_artist)
item.artist = track_artist item.artist = track_artist
if item.artist_sort: if item.artist_sort:
# Just strip the featured artist from the sort name. # Just strip the featured artist from the sort name.
item.artist_sort, _ = split_on_feat(item.artist_sort) item.artist_sort, _ = split_on_feat(
item.artist_sort, custom_feat_words=custom_feat_words
)
# Only update the title if it does not already contain a featured # Only update the title if it does not already contain a featured
# artist and if we do not drop featuring information. # artist and if we do not drop featuring information.
if not drop_feat and not contains_feat(item.title): if not drop_feat and not contains_feat(item.title, custom_feat_words):
feat_format = self.config["format"].as_str() feat_format = self.config["format"].as_str()
new_format = feat_format.format(feat_part) new_format = feat_format.format(feat_part)
new_title = f"{item.title} {new_format}" new_title = f"{item.title} {new_format}"
@ -180,6 +202,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
item: Item, item: Item,
drop_feat: bool, drop_feat: bool,
keep_in_artist_field: bool, keep_in_artist_field: bool,
custom_feat_words: list[str],
) -> bool: ) -> bool:
"""Look for featured artists in the item's artist fields and move """Look for featured artists in the item's artist fields and move
them to the title. them to the title.
@ -196,19 +219,21 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
if albumartist and artist == albumartist: if albumartist and artist == albumartist:
return False return False
_, featured = split_on_feat(artist) _, featured = split_on_feat(artist, custom_feat_words=custom_feat_words)
if not featured: if not featured:
return False return False
self._log.info("{.filepath}", item) self._log.info("{.filepath}", item)
# Attempt to find the featured artist. # Attempt to find the featured artist.
feat_part = find_feat_part(artist, albumartist) feat_part = find_feat_part(artist, albumartist, custom_feat_words)
if not feat_part: if not feat_part:
self._log.info("no featuring artists found") self._log.info("no featuring artists found")
return False return False
# If we have a featuring artist, move it to the title. # If we have a featuring artist, move it to the title.
self.update_metadata(item, feat_part, drop_feat, keep_in_artist_field) self.update_metadata(
item, feat_part, drop_feat, keep_in_artist_field, custom_feat_words
)
return True return True

View file

@ -38,13 +38,15 @@ def env() -> Generator[FtInTitlePluginFunctional, None, None]:
def set_config( def set_config(
env: FtInTitlePluginFunctional, cfg: Optional[Dict[str, Union[str, bool]]] env: FtInTitlePluginFunctional,
cfg: Optional[Dict[str, Union[str, bool, list[str]]]],
) -> None: ) -> None:
cfg = {} if cfg is None else cfg cfg = {} if cfg is None else cfg
defaults = { defaults = {
"drop": False, "drop": False,
"auto": True, "auto": True,
"keep_in_artist": False, "keep_in_artist": False,
"custom_feat_words": [],
} }
env.config["ftintitle"].set(defaults) env.config["ftintitle"].set(defaults)
env.config["ftintitle"].set(cfg) env.config["ftintitle"].set(cfg)
@ -170,11 +172,44 @@ def add_item(
("Alice ft Bob", "Song 1"), ("Alice ft Bob", "Song 1"),
id="keep-in-artist-drop-from-title", id="keep-in-artist-drop-from-title",
), ),
# ---- custom_feat_words variants ----
pytest.param(
{"format": "featuring {}", "custom_feat_words": ["med"]},
("ftintitle",),
("Alice med Bob", "Song 1", "Alice"),
("Alice", "Song 1 featuring Bob"),
id="custom-feat-words",
),
pytest.param(
{
"format": "featuring {}",
"keep_in_artist": True,
"custom_feat_words": ["med"],
},
("ftintitle",),
("Alice med Bob", "Song 1", "Alice"),
("Alice med Bob", "Song 1 featuring Bob"),
id="custom-feat-words-keep-in-artists",
),
pytest.param(
{
"format": "featuring {}",
"keep_in_artist": True,
"custom_feat_words": ["med"],
},
(
"ftintitle",
"-d",
),
("Alice med Bob", "Song 1", "Alice"),
("Alice med Bob", "Song 1"),
id="custom-feat-words-keep-in-artists-drop-from-title",
),
], ],
) )
def test_ftintitle_functional( def test_ftintitle_functional(
env: FtInTitlePluginFunctional, env: FtInTitlePluginFunctional,
cfg: Optional[Dict[str, Union[str, bool]]], cfg: Optional[Dict[str, Union[str, bool, list[str]]]],
cmd_args: Tuple[str, ...], cmd_args: Tuple[str, ...],
given: Tuple[str, str, Optional[str]], given: Tuple[str, str, Optional[str]],
expected: Tuple[str, str], expected: Tuple[str, str],
@ -256,3 +291,35 @@ def test_split_on_feat(
) )
def test_contains_feat(given: str, expected: bool) -> None: def test_contains_feat(given: str, expected: bool) -> None:
assert ftintitle.contains_feat(given) is expected assert ftintitle.contains_feat(given) is expected
@pytest.mark.parametrize(
"given,custom_feat_words,expected",
[
("Alice ft. Bob", [], True),
("Alice feat. Bob", [], True),
("Alice feat Bob", [], True),
("Alice featuring Bob", [], True),
("Alice (ft. Bob)", [], True),
("Alice (feat. Bob)", [], True),
("Alice [ft. Bob]", [], True),
("Alice [feat. Bob]", [], True),
("Alice defeat Bob", [], False),
("Aliceft.Bob", [], False),
("Alice (defeat Bob)", [], False),
("Live and Let Go", [], False),
("Come With Me", [], False),
("Alice x Bob", ["x"], True),
("Alice x Bob", ["X"], True),
("Alice och Xavier", ["x"], False),
("Alice ft. Xavier", ["x"], True),
("Alice med Carol", ["med"], True),
("Alice med Carol", [], False),
],
)
def test_custom_feat_words(
given: str, custom_feat_words: Optional[list[str]], expected: bool
) -> None:
if custom_feat_words is None:
custom_feat_words = []
assert ftintitle.contains_feat(given, custom_feat_words) is expected