mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 16:42:42 +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"
|
"""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."]
|
||||||
|
if isinstance(custom_words, list):
|
||||||
|
feat_words += custom_words
|
||||||
if for_artist:
|
if for_artist:
|
||||||
feat_words += ["with", "vs", "and", "con", "&"]
|
feat_words += ["with", "vs", "and", "con", "&"]
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,9 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
|
|
||||||
def split_on_feat(
|
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]:
|
) -> 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 +37,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_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 +48,22 @@ def split_on_feat(
|
||||||
return parts
|
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."""
|
"""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_words=custom_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_words: list[str] | None = None,
|
||||||
|
) -> 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 +77,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_words=custom_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_words=custom_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_words)
|
||||||
return feat_part
|
return feat_part
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -96,6 +108,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
||||||
"drop": False,
|
"drop": False,
|
||||||
"format": "feat. {}",
|
"format": "feat. {}",
|
||||||
"keep_in_artist": False,
|
"keep_in_artist": False,
|
||||||
|
"custom_words": [],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -120,10 +133,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_words = self.config["custom_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_words
|
||||||
|
):
|
||||||
item.store()
|
item.store()
|
||||||
if write:
|
if write:
|
||||||
item.try_write()
|
item.try_write()
|
||||||
|
|
@ -135,9 +151,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_words = self.config["custom_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_words
|
||||||
|
):
|
||||||
item.store()
|
item.store()
|
||||||
|
|
||||||
def update_metadata(
|
def update_metadata(
|
||||||
|
|
@ -146,6 +165,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_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 +178,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_words=custom_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_words=custom_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_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 +204,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_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 +221,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_words=custom_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_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_words
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ Unreleased
|
||||||
|
|
||||||
New features:
|
New features:
|
||||||
|
|
||||||
|
- :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle.
|
||||||
|
|
||||||
Bug fixes:
|
Bug fixes:
|
||||||
|
|
||||||
For packagers:
|
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
|
- **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
|
useful if you still want to be able to search for features in the artist
|
||||||
field. Default: ``no``.
|
field. Default: ``no``.
|
||||||
|
- **custom_words**: List of additional words that will be treated as a marker
|
||||||
|
for artist features. Default: ``[]``.
|
||||||
|
|
||||||
Running Manually
|
Running Manually
|
||||||
----------------
|
----------------
|
||||||
|
|
|
||||||
|
|
@ -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_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_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(
|
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_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