feat(ftintitle): Insert featured artist before track variant clause (#6159)

## Summary

This PR updates the `ftintitle` plugin to insert featured artist tokens
before brackets containing remix/edit-related keywords (e.g., "Remix",
"Live", "Edit") instead of always appending them at the end of the
title.

## Motivation

Previously, the plugin would always append featured artists at the end
of titles, resulting in awkward formatting like:
- `Threshold (Myselor Remix) ft. Hallucinator`

With this change, featured artists are inserted before the first bracket
containing keywords, producing cleaner formatting:
- `Threshold ft. Hallucinator (Myselor Remix)`

## Changes

### Core Functionality

- Added `find_bracket_position()` function that:
  - Searches for brackets containing remix/edit-related keywords
  - Supports multiple bracket types: `()`, `[]`, `<>`, `{}`
  - Only matches brackets with matching opening/closing pairs
  - Uses case-insensitive word-boundary matching for keywords
  - Returns the position of the earliest matching bracket
- Updated `update_metadata()` to insert featured artists before brackets
instead of appending

### Configuration

- Added new `bracket_keywords` configuration option:
- **Default**: List of keywords including: `abridged`, `acapella`,
`club`, `demo`, `edit`, `edition`, `extended`, `instrumental`, `live`,
`mix`, `radio`, `release`, `remaster`, `remastered`, `remix`, `rmx`,
`unabridged`, `unreleased`, `version`, and `vip`
  - **Customizable**: Users can override with their own keyword list
- **Empty list**: Setting to `[]` matches any bracket content regardless
of keywords

### Example Configuration

```yaml
ftintitle:
    bracket_keywords: ["remix", "live", "edit", "version", "extended"]
```

## Behavior

- **Titles with keyword brackets**: Featured artists are inserted before
the first bracket containing keywords
  - `Song (Remix) ft. Artist` → `Song ft. Artist (Remix)`
- `Song (Live) [Remix] ft. Artist` → `Song ft. Artist (Live) [Remix]`
(picks first bracket with keyword)

- **Titles without keyword brackets**: Featured artists are appended at
the end (backward compatible)
  - `Song (Arbitrary) ft. Artist` → `Song (Arbitrary) ft. Artist`

- **Nested brackets**: Correctly handles nested brackets of same and
different types
- `Song (Remix [Extended]) ft. Artist` → `Song ft. Artist (Remix
[Extended])`

- **Multiple brackets**: Picks the earliest bracket containing keywords
- `Song (Live) (Remix) ft. Artist` → `Song ft. Artist (Live) (Remix)`
(if both contain keywords, picks first)

## Testing

- Added comprehensive test coverage for:
  - Different bracket types (`()`, `[]`, `<>`, `{}`)
  - Nested brackets (same and different types)
  - Multiple brackets
  - Custom keywords
  - Empty keyword list behavior
  - Edge cases (unmatched brackets, no brackets, etc.)

All 112 tests pass.

## Backward Compatibility

This change is **backward compatible**:
- Titles without brackets continue to append featured artists at the end
- Titles with brackets that don't contain keywords also append at the
end
- Existing configuration files continue to work (uses sensible defaults)

## Documentation

- Updated changelog with detailed description of the new feature
- Configuration option is documented in the changelog entry
This commit is contained in:
Šarūnas Nejus 2026-01-04 02:57:04 +00:00 committed by GitHub
commit ea2e7bf997
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 188 additions and 3 deletions

View file

@ -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()

View file

@ -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:

View file

@ -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
--------------------

View file

@ -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"]

View file

@ -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 <Instrumental>", None, "Song ft. Bob <Instrumental>"),
# 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 <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"], "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",
[