Add custom feat words for ftintitle (#6090)

## Description

For non English tracks (Swedish in my case) feat. words might be
something that ftintitle doesn't pick up.
Eg. for the song `Promoe med Afasi - Inflation`
[https://musicbrainz.org/recording/8e236347-61d6-4e11-9980-52f4cc6b905f](https://musicbrainz.org/recording/8e236347-61d6-4e11-9980-52f4cc6b905f)
the word `med` is `feat.` in Swedish.
With this PR you can add what ever word you wish to match as feat. so it
should cover any kind of language.

The config.yaml could look like this:
ftintitle:
  custom_feat_words: ["med"]

## To Do

<!--
- If you believe one of below checkpoints is not required for the change
you
are submitting, cross it out and check the box nonetheless to let us
know.
  For example: - [x] ~Changelog~
- Regarding the changelog, often it makes sense to add your entry only
once
reviewing is finished. That way you might prevent conflicts from other
PR's in
that file, as well as keep the chance high your description fits with
the
  latest revision of your feature/fix.
- Regarding documentation, bugfixes often don't require additions to the
docs.
- Please remove the descriptive sentences in braces from the enumeration
below,
  which helps to unclutter your PR description.
-->

- [x] Documentation. (If you've added a new command-line flag, for
example, find the appropriate page under `docs/` to describe it.)
- [x] Changelog. (Add an entry to `docs/changelog.rst` to the bottom of
one of the lists near the top of the document.)
- [x] Tests. (Very much encouraged but not strictly required.)
This commit is contained in:
henry 2025-10-14 20:38:02 -07:00 committed by GitHub
commit 0bf248d355
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 121 additions and 19 deletions

View file

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

View file

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

View file

@ -9,6 +9,8 @@ Unreleased
New features:
- :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle.
Bug fixes:
For packagers:

View file

@ -28,6 +28,8 @@ file. The available options are:
- **keep_in_artist**: Keep the featuring X part in the artist field. This can be
useful if you still want to be able to search for features in the artist
field. Default: ``no``.
- **custom_words**: List of additional words that will be treated as a marker
for artist features. Default: ``[]``.
Running Manually
----------------

View file

@ -38,13 +38,15 @@ def env() -> Generator[FtInTitlePluginFunctional, None, None]:
def set_config(
env: FtInTitlePluginFunctional, cfg: Optional[Dict[str, Union[str, bool]]]
env: FtInTitlePluginFunctional,
cfg: Optional[Dict[str, Union[str, bool, list[str]]]],
) -> None:
cfg = {} if cfg is None else cfg
defaults = {
"drop": False,
"auto": True,
"keep_in_artist": False,
"custom_words": [],
}
env.config["ftintitle"].set(defaults)
env.config["ftintitle"].set(cfg)
@ -170,11 +172,44 @@ def add_item(
("Alice ft Bob", "Song 1"),
id="keep-in-artist-drop-from-title",
),
# ---- custom_words variants ----
pytest.param(
{"format": "featuring {}", "custom_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_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_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(
env: FtInTitlePluginFunctional,
cfg: Optional[Dict[str, Union[str, bool]]],
cfg: Optional[Dict[str, Union[str, bool, list[str]]]],
cmd_args: Tuple[str, ...],
given: Tuple[str, str, Optional[str]],
expected: Tuple[str, str],
@ -256,3 +291,35 @@ def test_split_on_feat(
)
def test_contains_feat(given: str, expected: bool) -> None:
assert ftintitle.contains_feat(given) is expected
@pytest.mark.parametrize(
"given,custom_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_words(
given: str, custom_words: Optional[list[str]], expected: bool
) -> None:
if custom_words is None:
custom_words = []
assert ftintitle.contains_feat(given, custom_words) is expected