> **Note**: This fix was developed with assistance from Claude Code
(AI). The problem was identified by me, and Claude helped investigate
the codebase, trace the git history to find the original FIXME,
implement the fix, and update the tests. All changes have been reviewed
and tested.
When importing with `autotag: no`, duplicate detection is completely
bypassed. The `import_asis` stage calls `_apply_choice()` directly
without first calling `_resolve_duplicates()`, meaning any configured
`duplicate_keys` and `duplicate_action` settings are ignored.
This was a known limitation. Commit 79d1203541 (Sep 2014) added a FIXME
comment:
```python
# FIXME We should also resolve duplicates when not
# autotagging. This is currently handled in `user_query`
```
The FIXME was removed during a comment cleanup in f145e3b18 (Jan 2015),
but the underlying issue was never fixed. A test
`test_no_autotag_keeps_duplicate_album` was added to document the
existing behavior at the time.
### The Fix
Add `_resolve_duplicates(session, task)` to the `import_asis` stage
before `_apply_choice()`, matching the behavior of the `user_query`
stage used when autotagging.
### Test Changes
- Renamed `test_no_autotag_keeps_duplicate_album` →
`test_no_autotag_removes_duplicate_album`
- Fixed the test to use album metadata instead of item metadata for
duplicate matching
- Added missing `import_file.save()` call
When importing with autotag=no, duplicate detection was skipped entirely
because the import_asis stage called _apply_choice() directly without
first calling _resolve_duplicates(). This meant the duplicate_keys and
duplicate_action config options were ignored for as-is imports.
This was a known limitation documented by a FIXME comment added in
commit 79d1203541 (Sep 2014): "We should also resolve duplicates when
not autotagging." The FIXME was later removed during a comment cleanup
(f145e3b18) but the issue was never addressed.
This commit adds the _resolve_duplicates() call to import_asis, ensuring
duplicate detection works consistently regardless of the autotag setting.
This applies to both album imports and singleton imports.
Test changes:
- Renamed test_no_autotag_keeps_duplicate_album to
test_no_autotag_removes_duplicate_album to verify the corrected behavior
- Added test_no_autotag_removes_duplicate_singleton to verify singleton
duplicate detection also works with autotag=no
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Fixes#2043Fixes#5638
Improve the `fuzzy` plugin in two ways:
1. Make short fuzzy queries behave more like substring matching.
2. Force fuzzy-prefixed queries to use slow evaluation so fuzzy logic is
always applied.
## Problem
Fuzzy prefix queries (for example `~foo` or custom prefixes like
`%%foo`) could take a fast DB query path on normal fields, which
bypassed fuzzy semantics and produced broad/
unrelated matches [#5638](https://github.com/beetbox/beets/issues/5638).
Also, when the query pattern was shorter than the field value, fuzzy
matching was too strict for substring-style use cases
[#2043](https://github.com/beetbox/beets/issues/2043).
Thanks to @carreter for this in #5140. Opened a new PR since I did not
have permissions to push to their fork.
Supersedes #5140.
Fixes#6176
Changed one line to ensure expected behavior as mentioned in the issue,
now `beet config -e` will have info message when no text editor is
available.
## Fix empty list treated as non-empty field value in `_apply_metadata`
Fixes#6403
Thanks to @aereaux for reporting and helpful debugging!
### Problem
In `beets/autotag/__init__.py`, `_apply_metadata` skipped overwriting
existing field values with `None` to avoid clearing data
unintentionally. However, an empty list (`[]`) was not treated the same
way — it would still overwrite existing field values, effectively
clearing multi-valued fields (e.g. `genres`) when the tag source
returned nothing.
### Fix
Keep multi-value field values as `None` by default in
`beets.autotag.hooks.Info` subclasses:
```python
def __init__(
...
genres: list[str] | None = None,
...
):
...
# Before
self.genres = genres or []
# After
self.genres = genres
```
### Test changes
- Added `genres=["Rock", "Pop"]` to the test album fixture to expose the
bug: album genres were not being propagated to tracks due to the
empty-list issue, since empty track-level genres overwrote them.
- Removed the `@pytest.mark.xfail` marker once the fix made the test
pass.
- Consolidated ~20 granular `ApplyTest` methods and the separate
`ApplyCompilationTest` class into a single data-driven
`test_autotag_items` test, reducing noise and improving coverage
clarity.
- Moved autotag tests into `test/autotag/` to better reflect module
structure.
Consolidate multiple granular test methods in ApplyTest into a single
comprehensive test that validates all applied metadata at once. This
improves test maintainability and clarity by:
- Replacing ~20 individual test methods with one data-driven test
- Using expected data dictionaries to validate all fields together
- Removing ApplyCompilationTest class (covered by va=True in main test)
- Keeping focused tests for edge cases (artist_credit, date handling)
- Switching from BeetsTestCase to standard TestCase for speed
- Adding operator import for efficient data extraction
The new approach makes it easier to validate all applied metadata at once.
Fixes a bug in the lastgenre plugin, where test state bled into the
following fixtures.
Each plugin has a view to the global persisted beets.config field. As a
result, config variables that aren't explicitly overwritten are
persisted in that global config view.
This commit exposes the lastgenre default config as a static method and
uses that default config to reset the state in between fixture calls.
There were 3 tests that depended on `count: 10` being set on previous
test fixtures, which I adjusted accordingly.
Discovered and discussed in #6317 , see
https://github.com/beetbox/beets/pull/6317#issuecomment-3935462408
When `import.delete` or `import.move` is enabled, the `assign_art`
method calls `task.prune(candidate.path)` unconditionally. This
incorrectly deletes the configured `fetchart.fallback` file. Add
explicit check to skip pruning when the candidate path matches the
configured fallback.
When `import.delete` or `import.move` is enabled, the `assign_art` method calls `task.prune(candidate.path)` unconditionally.
This incorrectly deletes the configured `fetchart.fallback` file.
Add explicit check to skip pruning when the candidate path matches the configured fallback.
When `omit_single_disc` is set, `disctotal` is now also zeroed alongside
`disc`. These tags work together ("Disc 2 of 3") so keeping one without
the other is inconsistent.
Previously, only the `disc` tag was zeroed. This follows from #6015
which made it into v2.5.1 and added the `omit_single_disc` option.
I've added tests and have used this locally for some time.
This was inspired by real life events:
https://musicbrainz.org/release/7c4d7a15-6b30-4bef-8b20-af200186fbdb by
the artist Danny L Harle has a a track with a featuring list that
contains "Danny L Harle, Oklou & MNEK".
Before:
```
artist = Danny L Harle, Oklou
track = Crystallise My Tears feat. MNEK
```
After:
```
artist = Danny L Harle
track = Crystallise My Tears feat. Oklou & MNEK
```
👋🏻 I was trying to set up a local dev environment and noticed a couple
of tests were failing. On macOS, temporary files are created under
`/var`, which is itself a symlink to `/private/var`. This PR resolves
the `assert`s against temp file paths in tests.
Add documentation for the `match.distance_weights` configuration option
in the autotagger matching section of the reference docs.
The section includes:
- Description of what distance weights control
- Complete list of all available fields with their default values
- Example showing how to customize a specific weight
- Note that only overridden fields need to be specified
Closes#6081
Add documentation for the distance_weights configuration option in
the autotagger matching section. This includes all available fields
with their default values and an example of how to customize them.
Closes#6081
Signed-off-by: edvatar <88481784+toroleapinc@users.noreply.github.com>
## Add support for a multi-valued `genres` field
- Update metadata source plugins to populates `genres` instead of
`genre`: `musicbrainz`, `beatport`, `discogs`.
- Remove now redundant `separator` configuration from `lastgenre`.
### Context
We previously had multiple issues with maintaining both _singular_ and
_plural_ fields:
1. Since both fields write and read the same field in music files, the
values in both
fields must be carefully synchronised, otherwise we see these fields
being repeatedly
retagged / rewritten using commands such as `beet write`. See [related
issues](https://github.com/beetbox/beets/issues?q=label%3A"multi%20tags%22)
2. Fixes to sync logic required users manually retagging their
libraries, while music
imported _as-is_ could not be fixed. See #5540, for example.
Therefore, this PR replaces a singular `genre` field by plural `genres`
_for good_:
1. We migrate `genre` -> `genres` immediately on the first `beets`
invocation
2. `genre` field is removed and `genres` is added
3. The old `genre` column in the database is left in place - these
values will be ignored
by beets.
- If someone migrates and later decides to switch back to using an older
version of
beets, their `genre` values are still in place.
### Migration
- This PR creates a new DB table `migrations(name TEXT, table TEXT)`
- We add an entry when a migration has been fully performed on a
specific table
- Thus we only perform the migration if we don't have an entry for that
table
- Entry is only added when the migration has been performed **fully**:
if someone hits
CTRL-C during the migration, the migration will continue on the next
beets invocation,
see:
```py
def migrate_table(self, table: str, *args, **kwargs) -> None:
"""Migrate a specific table."""
if not self.db.migration_exists(self.name, table):
self._migrate_data(table, *args, **kwargs)
self.db.record_migration(self.name, table)
```
- Implemented using SQL due to:
1. Significant speed difference: migrating my 9000 tracks / 2000 albums
library:
- Using our Python implementation: over 11 minutes
- Using SQL: 2 seconds
2. Beets seeing only `genres` field: `genre` field is only accessible by
querying the
database directly.
Supersedes: #6169
* Move genre-to-genres migration into a dedicated Migration class and
wire it into Library._migrations for items and albums.
* Add batched SQL updates via mutate_many and share the multi-value
delimiter as a constant.
* Cover migration behavior with new tests.
I initially attempted to migrate using our model infrastructure
/ Model.store(), see the comparison below:
Durations migrating my library of ~9000 items and ~2300 albums:
1. Using our Python logic: 11 minutes
2. Using SQL directly: 4 seconds
That's why I've gone ahead with option 2.