diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index ab841a12c..cf30e83f4 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -17,6 +17,7 @@ from __future__ import annotations import re +from functools import cached_property, lru_cache from typing import TYPE_CHECKING from beets import config, plugins, ui @@ -26,6 +27,30 @@ if TYPE_CHECKING: from beets.library import Album, Item +DEFAULT_BRACKET_KEYWORDS: tuple[str, ...] = ( + "abridged", + "acapella", + "club", + "demo", + "edit", + "edition", + "extended", + "instrumental", + "live", + "mix", + "radio", + "release", + "remaster", + "remastered", + "remix", + "rmx", + "unabridged", + "unreleased", + "version", + "vip", +) + + def split_on_feat( artist: str, for_artist: bool = True, @@ -104,6 +129,40 @@ def _album_artist_no_feat(album: Album) -> str: class FtInTitlePlugin(plugins.BeetsPlugin): + @cached_property + def bracket_keywords(self) -> list[str]: + return self.config["bracket_keywords"].as_str_seq() + + @staticmethod + @lru_cache(maxsize=256) + def _bracket_position_pattern(keywords: tuple[str, ...]) -> re.Pattern[str]: + """ + Build a compiled regex to find the first bracketed segment that contains + any of the provided keywords. + + Cached by keyword tuple to avoid recompiling on every track/title. + """ + kw_inner = "|".join(map(re.escape, keywords)) + + # 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 "" + return re.compile( + rf""" + (?: # 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, + ) + def __init__(self) -> None: super().__init__() @@ -115,6 +174,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin): "keep_in_artist": False, "preserve_album_artist": True, "custom_words": [], + "bracket_keywords": list(DEFAULT_BRACKET_KEYWORDS), } ) @@ -216,8 +276,10 @@ class FtInTitlePlugin(plugins.BeetsPlugin): # artist and if we do not drop featuring information. 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}" + formatted = feat_format.format(feat_part) + new_title = self.insert_ft_into_title( + item.title, formatted, self.bracket_keywords + ) self._log.info("title: {.title} -> {}", item, new_title) item.title = new_title @@ -262,3 +324,28 @@ class FtInTitlePlugin(plugins.BeetsPlugin): item, feat_part, drop_feat, keep_in_artist_field, custom_words ) return True + + @staticmethod + def find_bracket_position( + title: str, keywords: list[str] | None = None + ) -> int | None: + normalized = ( + DEFAULT_BRACKET_KEYWORDS if keywords is None else tuple(keywords) + ) + pattern = FtInTitlePlugin._bracket_position_pattern(normalized) + m: re.Match[str] | None = pattern.search(title) + return m.start() if m else None + + @classmethod + def insert_ft_into_title( + 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. + """ + 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() diff --git a/docs/changelog.rst b/docs/changelog.rst index d3d9f3f6a..b9e21aae9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -37,6 +37,13 @@ New features: resolve differences in metadata source styles. - :doc:`plugins/spotify`: Added support for multi-artist albums and tracks, saving all contributing artists to the respective fields. +- :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: diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index 3dfbfca27..7daea5582 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -32,6 +32,18 @@ file. The available options are: skip the ftintitle processing. Default: ``yes``. - **custom_words**: List of additional words that will be treated as a marker for artist features. Default: ``[]``. +- **bracket_keywords**: Controls where the featuring text is inserted when the + title includes bracketed qualifiers such as ``(Remix)`` or ``[Live]``. + FtInTitle inserts the new text before the first bracket whose contents match + any of these keywords. Supply a list of words to fine-tune the behavior or set + the list to ``[]`` to match *any* bracket regardless of its contents. Default: + + :: + + ["abridged", "acapella", "club", "demo", "edit", "edition", "extended", + "instrumental", "live", "mix", "radio", "release", "remaster", + "remastered", "remix", "rmx", "unabridged", "unreleased", + "version", "vip"] Path Template Values -------------------- diff --git a/pyproject.toml b/pyproject.toml index e7eebd3a1..bd46d3026 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -319,6 +319,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "beets/**" = ["PT"] +"test/plugins/test_ftintitle.py" = ["E501"] "test/test_util.py" = ["E501"] "test/ui/test_field_diff.py" = ["E501"] diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index 6f01601e0..b21ac1c7f 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -15,6 +15,7 @@ """Tests for the 'ftintitle' plugin.""" from collections.abc import Generator +from typing import TypeAlias import pytest @@ -22,6 +23,8 @@ from beets.library.models import Album, Item from beets.test.helper import PluginTestCase from beetsplug import ftintitle +ConfigValue: TypeAlias = str | bool | list[str] + class FtInTitlePluginFunctional(PluginTestCase): plugin = "ftintitle" @@ -39,7 +42,7 @@ def env() -> Generator[FtInTitlePluginFunctional, None, None]: def set_config( env: FtInTitlePluginFunctional, - cfg: dict[str, str | bool | list[str]] | None, + cfg: dict[str, ConfigValue] | None, ) -> None: cfg = {} if cfg is None else cfg defaults = { @@ -246,6 +249,21 @@ 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. {}", "bracket_keywords": ["mix"]}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Club Mix)", "Alice"), + ("Alice", "Song 1 ft. Bob (Club Mix)"), + id="ft-inserted-before-matching-bracket-keyword", + ), + pytest.param( + {"format": "ft. {}", "bracket_keywords": ["nomatch"]}, + ("ftintitle",), + ("Alice ft. Bob", "Song 1 (Club Remix)", "Alice"), + ("Alice", "Song 1 (Club Remix) ft. Bob"), + id="ft-inserted-at-end-no-bracket-keyword-match", + ), ], ) def test_ftintitle_functional( @@ -312,6 +330,66 @@ def test_split_on_feat( assert ftintitle.split_on_feat(given) == expected +@pytest.mark.parametrize( + "given,keywords,expected", + [ + ## default keywords + # different braces and keywords + ("Song (Remix)", None, "Song ft. Bob (Remix)"), + ("Song [Version]", None, "Song ft. Bob [Version]"), + ("Song {Extended Mix}", None, "Song ft. Bob {Extended Mix}"), + ("Song ", None, "Song ft. Bob "), + # two keyword clauses + ("Song (Remix) (Live)", None, "Song ft. Bob (Remix) (Live)"), + # brace insensitivity + ("Song (Live) [Remix]", None, "Song ft. Bob (Live) [Remix]"), + ("Song [Edit] (Remastered)", None, "Song ft. Bob [Edit] (Remastered)"), + # negative cases + ("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, "Song ft. Bob (Live) (Arbitrary)"), + ("Song (Arbitrary) (Remix)", None, "Song (Arbitrary) ft. Bob (Remix)"), + # nested brackets - same type + ("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, "Song ft. Bob (Remix [Extended])"), + # nested - returns outer start position despite inner keyword + ("Song [Arbitrary {Extended}]", None, "Song ft. Bob [Arbitrary {Extended}]"), + ("Song {Live }", None, "Song ft. Bob {Live }"), + ("Song ", None, "Song ft. Bob "), + ("Song [Live]", None, "Song ft. Bob [Live]"), + ("Song (Version) ", None, "Song ft. Bob (Version) "), + ("Song (Arbitrary [Description])", None, "Song (Arbitrary [Description]) ft. Bob"), + ("Song [Description (Arbitrary)]", None, "Song [Description (Arbitrary)] ft. Bob"), + ## custom keywords + ("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"], "Song ft. Bob (Club Mix)"), # Positive: matches multi-word + ("Song (Club Remix)", ["club mix"], "Song (Club Remix) ft. Bob"), # Negative: no match + ], +) # fmt: skip +def test_insert_ft_into_title( + given: str, + keywords: list[str] | None, + expected: str, +) -> None: + assert ( + ftintitle.FtInTitlePlugin.insert_ft_into_title( + given, "ft. Bob", keywords + ) + == expected + ) + + @pytest.mark.parametrize( "given,expected", [