mirror of
https://github.com/beetbox/beets.git
synced 2026-08-21 16:46:18 -04:00
fix(autotag): "featuring" pattern in string_dist matches mid-word "ft" (#6843)
## Bug
`SD_PATTERNS` in `beets/autotag/distance.py` has this entry:
```python
(r"[\[\(]?(featuring|feat|ft)[\. :].+", 0.1),
```
The `ft` alternative has no word boundary before it, so it matches `ft`
embedded inside any ordinary word followed by a space/period/colon —
`draft`, `left`, `gift`, `craft`, `soft`, `swift`, etc. — and treats
everything after the match as a low-weight "featuring artist" suffix
(weight `0.1`), effectively deleting it from the comparison.
```python
from beets.autotag.distance import string_dist
string_dist("Draft Beer", "Draft Whiskey") # 0.05 (near-identical!)
string_dist("Left Field", "Left Symphony") # 0.067
string_dist("Gift Ideas", "Gift Cards") # 0.044
```
Confirmed through the actual matching pipeline (`track_distance`) too:
"Draft Beer" vs. "Draft Whiskey" scores almost as close as an exact
match, while a genuinely unrelated title correctly scores much higher.
`string_dist`/`add_string` backs nearly every fuzzy field comparison
used during `beet import` (title, artist, album, label, catalognum,
...), so this silently degrades match quality for any title/artist
containing one of these words.
## Fix
Add a word boundary before the alternation:
```python
(r"[\[\(]?\b(featuring|feat|ft)[\. :].+", 0.1),
```
Verified this doesn't regress the intended "real" cases (`"Song feat.
Artist"`, `"[feat: Artist]"`, `"(ft. Artist)"` — all still match, since
they're preceded by whitespace/bracket/string-start, which are real
boundaries) while excluding all the false-positive mid-word cases above.
## Testing
Added `test_featuring_pattern_does_not_match_mid_word` (parametrized
over draft/left/gift/craft) to `test/autotag/test_distance.py`,
asserting `string_dist(...) > 0.3` for these pairs (previously
~0.04-0.07). Verified red on `master` (`git stash` the source fix — all
4 cases fail with the near-zero distances shown above), green after.
Full `test/autotag/test_distance.py` (58 passed) and the broader
`test/autotag/` + `test/util/` + `test/autotag/test_match.py` +
`test/test_importer.py` suites (352 + 142 passed, no regressions) all
pass. `ruff check`/`ruff format --check`/`mypy` clean.
Added a changelog entry per `CONTRIBUTING.rst`'s reminder.
## Duplicate-work check
Searched open PRs for `distance.py`/"featuring" — found #6681 (Source
class refactor) also touches `distance.py`, but its diff doesn't touch
`SD_PATTERNS` or this area at all. No overlap.
---
This PR was drafted with AI assistance (Claude); I independently
reproduced the bug against the real, unmodified module (including
through the full `track_distance` matching pipeline) and verified the
fix. I reviewed the change and take responsibility for it.
This commit is contained in:
@@ -35,7 +35,7 @@ SD_END_WORDS = ["the", "a", "an"]
|
||||
SD_PATTERNS = [
|
||||
(r"^the ", 0.1),
|
||||
(r"[\[\(]?(ep|single)[\]\)]?", 0.0),
|
||||
(r"[\[\(]?(featuring|feat|ft)[\. :].+", 0.1),
|
||||
(r"[\[\(]?\b(featuring|feat|ft)\b[\. :].+", 0.1),
|
||||
(r"\(.*?\)", 0.3),
|
||||
(r"\[.*?\]", 0.3),
|
||||
(r"(, )?(pt\.|part) .+", 0.2),
|
||||
|
||||
@@ -23,6 +23,10 @@ Bug fixes
|
||||
digits no longer restarts the numbering: ``track.10.mp3`` now yields
|
||||
``track.11.mp3`` instead of ``track.1.mp3``. The counter was matched with
|
||||
``\.(\d)+$``, which captures only the final digit.
|
||||
- Autotagging distance calculations no longer treat ordinary words containing
|
||||
"ft" (such as "draft", "left", "gift", "craft") as a "featuring artist"
|
||||
suffix, which was silently making genuinely different titles/artists score as
|
||||
near-identical matches.
|
||||
|
||||
..
|
||||
For plugin developers
|
||||
|
||||
@@ -282,6 +282,23 @@ class TestStringDistance:
|
||||
def test_different_distance(self):
|
||||
assert string_dist("Some String", "Totally Different") != 0.0
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"string1, string2",
|
||||
[
|
||||
("Draft Beer", "Draft Whiskey"),
|
||||
("Left Field", "Left Symphony"),
|
||||
("Gift Ideas", "Gift Cards"),
|
||||
("Craft Beer", "Craft Wine"),
|
||||
],
|
||||
)
|
||||
def test_featuring_pattern_does_not_match_mid_word(self, string1, string2):
|
||||
# The "featuring"/"feat"/"ft" pattern must not match "ft" embedded
|
||||
# inside an ordinary word (draft, left, gift, craft, ...) -- doing so
|
||||
# would treat everything after "ft" as a low-weight suffix and make
|
||||
# genuinely different strings look almost identical instead of
|
||||
# correctly registering as a large distance.
|
||||
assert string_dist(string1, string2) > 0.3
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"string1, string2, reference",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user