feat(ftintitle): Insert featured artist before track variant

This commit is contained in:
Trey Turner 2025-11-12 07:03:30 -06:00
parent 88ca0ce1fb
commit 1d239d6e27
3 changed files with 217 additions and 2 deletions

View file

@ -59,6 +59,89 @@ def contains_feat(title: str, custom_words: list[str] | None = None) -> bool:
)
# Default keywords that indicate remix/edit/version content
DEFAULT_BRACKET_KEYWORDS = [
"abridged",
"acapella",
"club",
"demo",
"edit",
"extended",
"instrumental",
"live",
"mix",
"radio",
"release",
"remaster",
"remastered",
"remix",
"rmx",
"unabridged",
"unreleased",
"version",
"vip",
]
def find_bracket_position(
title: str, keywords: list[str] | None = None
) -> int | None:
"""Find the position of the first opening bracket that contains
remix/edit-related keywords and has a matching closing bracket.
Args:
title: The title to search in.
keywords: List of keywords to match. If None, uses DEFAULT_BRACKET_KEYWORDS.
If an empty list, matches any bracket content (not just keywords).
Returns:
The position of the opening bracket, or None if no match found.
"""
if keywords is None:
keywords = DEFAULT_BRACKET_KEYWORDS
# If keywords is empty, match any bracket content
if not keywords:
pattern = None
else:
# Build regex pattern with word boundaries
keyword_pattern = "|".join(rf"\b{re.escape(kw)}\b" for kw in keywords)
pattern = re.compile(keyword_pattern, re.IGNORECASE)
# Bracket pairs (opening, closing)
bracket_pairs = [("(", ")"), ("[", "]"), ("<", ">"), ("{", "}")]
# Track the earliest valid bracket position
earliest_pos = None
for open_char, close_char in bracket_pairs:
pos = 0
while True:
# Find next opening bracket
open_pos = title.find(open_char, pos)
if open_pos == -1:
break
# Find matching closing bracket
close_pos = title.find(close_char, open_pos + 1)
if close_pos == -1:
break
# Extract content between brackets
content = title[open_pos + 1 : close_pos]
# Check if content matches: if pattern is None (empty keywords),
# match any content; otherwise check for keywords
if pattern is None or pattern.search(content):
if earliest_pos is None or open_pos < earliest_pos:
earliest_pos = open_pos
# Continue searching from after this closing bracket
pos = close_pos + 1
return earliest_pos
def find_feat_part(
artist: str,
albumartist: str | None,
@ -110,6 +193,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
"keep_in_artist": False,
"preserve_album_artist": True,
"custom_words": [],
"bracket_keywords": DEFAULT_BRACKET_KEYWORDS,
}
)
@ -138,6 +222,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
bool
)
custom_words = self.config["custom_words"].get(list)
bracket_keywords = self.config["bracket_keywords"].get(list)
write = ui.should_write()
for item in lib.items(args):
@ -147,6 +232,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
keep_in_artist_field,
preserve_album_artist,
custom_words,
bracket_keywords,
):
item.store()
if write:
@ -161,6 +247,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
keep_in_artist_field = self.config["keep_in_artist"].get(bool)
preserve_album_artist = self.config["preserve_album_artist"].get(bool)
custom_words = self.config["custom_words"].get(list)
bracket_keywords = self.config["bracket_keywords"].get(list)
for item in task.imported_items():
if self.ft_in_title(
@ -169,6 +256,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
keep_in_artist_field,
preserve_album_artist,
custom_words,
bracket_keywords,
):
item.store()
@ -179,6 +267,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
drop_feat: bool,
keep_in_artist_field: bool,
custom_words: list[str],
bracket_keywords: list[str] | None = None,
) -> 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.
@ -208,7 +297,14 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
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}"
# Insert before the first bracket containing remix/edit keywords
bracket_pos = find_bracket_position(item.title, bracket_keywords)
if bracket_pos is not None:
title_before = item.title[:bracket_pos].rstrip()
title_after = item.title[bracket_pos:]
new_title = f"{title_before} {new_format} {title_after}"
else:
new_title = f"{item.title} {new_format}"
self._log.info("title: {.title} -> {}", item, new_title)
item.title = new_title
@ -219,6 +315,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
keep_in_artist_field: bool,
preserve_album_artist: bool,
custom_words: list[str],
bracket_keywords: list[str] | None = None,
) -> bool:
"""Look for featured artists in the item's artist fields and move
them to the title.
@ -250,6 +347,11 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
# If we have a featuring artist, move it to the title.
self.update_metadata(
item, feat_part, drop_feat, keep_in_artist_field, custom_words
item,
feat_part,
drop_feat,
keep_in_artist_field,
custom_words,
bracket_keywords,
)
return True

View file

@ -26,6 +26,13 @@ New features:
- :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive
MusicBrainz pseudo-releases as recommendations during import.
- Added support for Python 3.13.
- :doc:`plugins/ftintitle`: Featured artists are now inserted before brackets
containing remix/edit-related keywords (e.g., "Remix", "Live", "Edit") instead
of being appended at the end. This improves formatting for titles like "Song 1
(Carol Remix) ft. Bob" which becomes "Song 1 ft. Bob (Carol Remix)". A variety
of brackets are supported and a new ``bracket_keywords`` configuration option
allows customizing the keywords. Setting ``bracket_keywords`` to an empty list
matches any bracket content regardless of keywords.
Bug fixes:

View file

@ -246,6 +246,49 @@ def add_item(
("Alice", "Song 1 feat. Bob"),
id="skip-if-artist-and-album-artists-is-the-same-matching-match-b",
),
# ---- titles with brackets/parentheses ----
pytest.param(
{"format": "ft. {}"},
("ftintitle",),
("Alice ft. Bob", "Song 1 (Carol Remix)", "Alice"),
("Alice", "Song 1 ft. Bob (Carol Remix)"),
id="title-with-brackets-insert-before",
),
pytest.param(
{"format": "ft. {}", "keep_in_artist": True},
("ftintitle",),
("Alice ft. Bob", "Song 1 (Carol Remix)", "Alice"),
("Alice ft. Bob", "Song 1 ft. Bob (Carol Remix)"),
id="title-with-brackets-keep-in-artist",
),
pytest.param(
{"format": "ft. {}"},
("ftintitle",),
("Alice ft. Bob", "Song 1 (Remix) (Live)", "Alice"),
("Alice", "Song 1 ft. Bob (Remix) (Live)"),
id="title-with-multiple-brackets-uses-first-with-keyword",
),
pytest.param(
{"format": "ft. {}"},
("ftintitle",),
("Alice ft. Bob", "Song 1 (Arbitrary)", "Alice"),
("Alice", "Song 1 (Arbitrary) ft. Bob"),
id="title-with-brackets-no-keyword-appends",
),
pytest.param(
{"format": "ft. {}"},
("ftintitle",),
("Alice ft. Bob", "Song 1 [Edit]", "Alice"),
("Alice", "Song 1 ft. Bob [Edit]"),
id="title-with-square-brackets-keyword",
),
pytest.param(
{"format": "ft. {}"},
("ftintitle",),
("Alice ft. Bob", "Song 1 <Version>", "Alice"),
("Alice", "Song 1 ft. Bob <Version>"),
id="title-with-angle-brackets-keyword",
),
],
)
def test_ftintitle_functional(
@ -312,6 +355,69 @@ def test_split_on_feat(
assert ftintitle.split_on_feat(given) == expected
@pytest.mark.parametrize(
"given,expected",
[
# different braces and keywords
("Song (Remix)", 5),
("Song [Version]", 5),
("Song {Extended Mix}", 5),
("Song <Instrumental>", 5),
# two keyword clauses
("Song (Remix) (Live)", 5),
# brace insensitivity
("Song (Live) [Remix]", 5),
("Song [Edit] (Remastered)", 5),
# negative cases
("Song", None), # no clause
("Song (Arbitrary)", None), # no keyword
("Song (", None), # no matching brace or keyword
("Song (Live", None), # no matching brace with keyword
# one keyword clause, one non-keyword clause
("Song (Live) (Arbitrary)", 5),
("Song (Arbitrary) (Remix)", 17),
# nested brackets - same type
("Song (Remix (Extended))", 5),
("Song [Arbitrary [Description]]", None),
# nested brackets - different types
("Song (Remix [Extended])", 5),
# nested - returns outer start position despite inner keyword
("Song [Arbitrary {Extended}]", 5),
("Song {Live <Arbitrary>}", 5),
("Song <Remaster (Arbitrary)>", 5),
("Song <Extended> [Live]", 5),
("Song (Version) <Live>", 5),
("Song (Arbitrary [Description])", None),
("Song [Description (Arbitrary)]", None),
],
)
def test_find_bracket_position(given: str, expected: int | None) -> None:
assert ftintitle.find_bracket_position(given) == expected
@pytest.mark.parametrize(
"given,keywords,expected",
[
("Song (Live)", ["live"], 5),
("Song (Live)", None, 5),
("Song (Arbitrary)", None, None),
("Song (Concert)", ["concert"], 5),
("Song (Concert)", None, None),
("Song (Remix)", ["custom"], None),
("Song (Custom)", ["custom"], 5),
("Song (Live)", [], 5),
("Song (Anything)", [], 5),
("Song (Remix)", [], 5),
("Song", [], None),
("Song (", [], None),
],
)
def test_find_bracket_position_custom_keywords(
given: str, keywords: list[str] | None, expected: int | None
) -> None:
assert ftintitle.find_bracket_position(given, keywords) == expected
@pytest.mark.parametrize(
"given,expected",
[