fix(ftintitle): remaining opportunities for improvement

This commit is contained in:
Trey Turner 2026-01-01 15:39:17 -06:00
parent 572645b94c
commit b14755df88
3 changed files with 58 additions and 71 deletions

View file

@ -146,32 +146,19 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
# If we have keywords, require one of them to appear in the bracket text.
# If kw == "", the lookahead becomes true and we match any bracket content.
kw = rf"\b(?:{kw_inner})\b" if kw_inner else ""
kw = rf"\b(?={kw_inner})\b" if kw_inner else ""
return re.compile(
rf"""
(?: # Match ONE bracketed segment of any supported type
\( # "("
(?=[^)]*{kw}) # Lookahead: keyword must appear before closing ")"
# - if kw == "", this is always true
[^)]* # Consume bracket content (no nested ")" handling)
\) # ")"
| \[ # "["
(?=[^\]]*{kw}) # Lookahead
[^\]]* # Consume content up to first "]"
\] # "]"
| < # "<"
(?=[^>]*{kw}) # Lookahead
[^>]* # Consume content up to first ">"
> # ">"
| \x7B # Literal open brace
(?=[^\x7D]*{kw}) # Lookahead
[^\x7D]* # Consume content up to first close brace
\x7D # Literal close brace
) # End bracketed segment alternation
(?: # non-capturing group for the split
\s*? # optional whitespace before brackets
(?= # any bracket containing a keyword
\([^)]*{kw}.*?\)
| \[[^]]*{kw}.*?\]
| <[^>]*{kw}.*? >
| \{{[^}}]*{kw}.*?\}}
| $ # or the end of the string
)
)
""",
re.IGNORECASE | re.VERBOSE,
)
@ -290,7 +277,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
if not drop_feat and not contains_feat(item.title, custom_words):
feat_format = self.config["format"].as_str()
formatted = feat_format.format(feat_part)
new_title = FtInTitlePlugin.insert_ft_into_title(
new_title = self.insert_ft_into_title(
item.title, formatted, self.bracket_keywords
)
self._log.info("title: {.title} -> {}", item, new_title)
@ -349,19 +336,16 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
m: re.Match[str] | None = pattern.search(title)
return m.start() if m else None
@staticmethod
@classmethod
def insert_ft_into_title(
title: str, feat_part: str, keywords: list[str] | None = None
cls, title: str, feat_part: str, keywords: list[str] | None = None
) -> str:
"""Insert featured artist before the first bracket containing
remix/edit keywords if present.
"""
if (
bracket_pos := FtInTitlePlugin.find_bracket_position(
title, keywords
)
) is not None:
title_before = title[:bracket_pos].rstrip()
title_after = title[bracket_pos:]
return f"{title_before} {feat_part} {title_after}"
return f"{title} {feat_part}"
normalized = (
DEFAULT_BRACKET_KEYWORDS if keywords is None else tuple(keywords)
)
pattern = cls._bracket_position_pattern(normalized)
parts = pattern.split(title, maxsplit=1)
return f" {feat_part} ".join(parts).strip()

View file

@ -322,6 +322,7 @@ ignore = [
[tool.ruff.lint.per-file-ignores]
"beets/**" = ["PT"]
"test/test_util.py" = ["E501"]
"test/plugins/test_ftintitle.py" = ["E501"]
[tool.ruff.lint.isort]
split-on-trailing-comma = false

View file

@ -335,55 +335,57 @@ def test_split_on_feat(
[
## default keywords
# different braces and keywords
("Song (Remix)", None, 5),
("Song [Version]", None, 5),
("Song {Extended Mix}", None, 5),
("Song <Instrumental>", None, 5),
("Song (Remix)", None, "Song ft. Bob (Remix)"),
("Song [Version]", None, "Song ft. Bob [Version]"),
("Song {Extended Mix}", None, "Song ft. Bob {Extended Mix}"),
("Song <Instrumental>", None, "Song ft. Bob <Instrumental>"),
# two keyword clauses
("Song (Remix) (Live)", None, 5),
("Song (Remix) (Live)", None, "Song ft. Bob (Remix) (Live)"),
# brace insensitivity
("Song (Live) [Remix]", None, 5),
("Song [Edit] (Remastered)", None, 5),
("Song (Live) [Remix]", None, "Song ft. Bob (Live) [Remix]"),
("Song [Edit] (Remastered)", None, "Song ft. Bob [Edit] (Remastered)"),
# negative cases
("Song", None, None), # no clause
("Song (Arbitrary)", None, None), # no keyword
("Song (", None, None), # no matching brace or keyword
("Song (Live", None, None), # no matching brace with keyword
("Song", None, "Song ft. Bob"), # no clause
("Song (Arbitrary)", None, "Song (Arbitrary) ft. Bob"), # no keyword
("Song (", None, "Song ( ft. Bob"), # no matching brace or keyword
("Song (Live", None, "Song (Live ft. Bob"), # no matching brace with keyword
# one keyword clause, one non-keyword clause
("Song (Live) (Arbitrary)", None, 5),
("Song (Arbitrary) (Remix)", None, 17),
("Song (Live) (Arbitrary)", None, "Song ft. Bob (Live) (Arbitrary)"),
("Song (Arbitrary) (Remix)", None, "Song (Arbitrary) ft. Bob (Remix)"),
# nested brackets - same type
("Song (Remix (Extended))", None, 5),
("Song [Arbitrary [Description]]", None, None),
("Song (Remix (Extended))", None, "Song ft. Bob (Remix (Extended))"),
("Song [Arbitrary [Description]]", None, "Song [Arbitrary [Description]] ft. Bob"),
# nested brackets - different types
("Song (Remix [Extended])", None, 5),
("Song (Remix [Extended])", None, "Song ft. Bob (Remix [Extended])"),
# nested - returns outer start position despite inner keyword
("Song [Arbitrary {Extended}]", None, 5),
("Song {Live <Arbitrary>}", None, 5),
("Song <Remaster (Arbitrary)>", None, 5),
("Song <Extended> [Live]", None, 5),
("Song (Version) <Live>", None, 5),
("Song (Arbitrary [Description])", None, None),
("Song [Description (Arbitrary)]", None, None),
("Song [Arbitrary {Extended}]", None, "Song ft. Bob [Arbitrary {Extended}]"),
("Song {Live <Arbitrary>}", None, "Song ft. Bob {Live <Arbitrary>}"),
("Song <Remaster (Arbitrary)>", None, "Song ft. Bob <Remaster (Arbitrary)>"),
("Song <Extended> [Live]", None, "Song ft. Bob <Extended> [Live]"),
("Song (Version) <Live>", None, "Song ft. Bob (Version) <Live>"),
("Song (Arbitrary [Description])", None, "Song (Arbitrary [Description]) ft. Bob"),
("Song [Description (Arbitrary)]", None, "Song [Description (Arbitrary)] ft. Bob"),
## custom keywords
("Song (Live)", ["live"], 5),
("Song (Concert)", ["concert"], 5),
("Song (Remix)", ["custom"], None),
("Song (Custom)", ["custom"], 5),
("Song", [], None),
("Song (", [], None),
("Song (Live)", ["live"], "Song ft. Bob (Live)"),
("Song (Concert)", ["concert"], "Song ft. Bob (Concert)"),
("Song (Remix)", ["custom"], "Song (Remix) ft. Bob"),
("Song (Custom)", ["custom"], "Song ft. Bob (Custom)"),
("Song", [], "Song ft. Bob"),
("Song (", [], "Song ( ft. Bob"),
# Multi-word keyword tests
("Song (Club Mix)", ["club mix"], 5), # Positive: matches multi-word
("Song (Club Remix)", ["club mix"], None), # Negative: no match
("Song (Club Mix)", ["club mix"], "Song ft. Bob (Club Mix)"), # Positive: matches multi-word
("Song (Club Remix)", ["club mix"], "Song (Club Remix) ft. Bob"), # Negative: no match
],
)
def test_find_bracket_position(
) # fmt: skip
def test_insert_ft_into_title(
given: str,
keywords: list[str] | None,
expected: int | None,
expected: str,
) -> None:
assert (
ftintitle.FtInTitlePlugin.find_bracket_position(given, keywords)
ftintitle.FtInTitlePlugin.insert_ft_into_title(
given, "ft. Bob", keywords
)
== expected
)