Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 89 additions & 2 deletions beetsplug/ftintitle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,6 +27,30 @@
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,
Expand Down Expand Up @@ -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__()

Expand All @@ -115,6 +174,7 @@ def __init__(self) -> None:
"keep_in_artist": False,
"preserve_album_artist": True,
"custom_words": [],
"bracket_keywords": list(DEFAULT_BRACKET_KEYWORDS),
}
)

Expand Down Expand Up @@ -216,8 +276,10 @@ def update_metadata(
# 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

Expand Down Expand Up @@ -262,3 +324,28 @@ def ft_in_title(
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()
7 changes: 7 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
12 changes: 12 additions & 0 deletions docs/plugins/ftintitle.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
80 changes: 79 additions & 1 deletion test/plugins/test_ftintitle.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@
"""Tests for the 'ftintitle' plugin."""

from collections.abc import Generator
from typing import TypeAlias

import pytest

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"
Expand All @@ -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 = {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
[
Expand Down
Loading