mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 00:24:25 +01:00
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:
commit
0bf248d355
5 changed files with 121 additions and 19 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ Unreleased
|
|||
|
||||
New features:
|
||||
|
||||
- :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle.
|
||||
|
||||
Bug fixes:
|
||||
|
||||
For packagers:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
----------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue