mirror of
https://github.com/beetbox/beets.git
synced 2026-03-01 18:55:50 +01:00
feat(ftintitle): Insert featured artist before track variant
This commit is contained in:
parent
88ca0ce1fb
commit
1d239d6e27
3 changed files with 217 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
[
|
||||
|
|
|
|||
Loading…
Reference in a new issue