Commit graph

13642 commits

Author SHA1 Message Date
Šarūnas Nejus
1ecd2c5bdb
Enable duplicate detection for as-is (autotag: no) imports (#6280)
> **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
2026-03-04 14:39:19 +00:00
Šarūnas Nejus
bf7997d45f
Move changelog note under Unreleased section 2026-03-04 14:32:44 +00:00
Axel Wikström
b5d8ced9d9 Enable duplicate detection for as-is imports
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>
2026-03-04 14:26:13 +00:00
Šarūnas Nejus
842354ee6b
Match substrings fuzzily (#6408)
Fixes #2043
Fixes #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.
2026-03-03 14:23:03 +00:00
Šarūnas Nejus
48763eee4f
Force slow queries for FuzzyPlugin 2026-03-03 14:05:35 +00:00
Šarūnas Nejus
b82a8eaab7
Add tests 2026-03-03 13:53:12 +00:00
Šarūnas Nejus
56e86a7966
Add changelog note 2026-03-03 13:53:12 +00:00
Willow Carretero Chavez
f4bf7b2fc9
Match substrings fuzzily 2026-03-03 12:56:34 +00:00
Šarūnas Nejus
bb08923aea
Fix #6176: Clear error message when EDITOR not set (#6311)
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.
2026-03-03 12:11:26 +00:00
Šarūnas Nejus
65d04f4c30
Fix lint 2026-03-03 12:06:12 +00:00
Šarūnas Nejus
503d5c75ff
Merge branch 'master' into bugfix_editor 2026-03-03 12:03:52 +00:00
Šarūnas Nejus
50684b70b2
Use None as default values for multi-valued fields in hooks.Info (#6405)
## 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.
2026-03-03 11:58:54 +00:00
Šarūnas Nejus
a8b8aa9d89
Move test_autotag tests under test/autotag 2026-03-03 07:48:52 +00:00
Šarūnas Nejus
1838482c7a
Keep missing multi-value fields as None instead of empty list 2026-03-03 07:48:51 +00:00
Šarūnas Nejus
0f7bce1bfe
Show that album genres are not applied to tracks 2026-03-03 07:46:27 +00:00
Šarūnas Nejus
cf043df13d
autotag: refactor autotag tests to use single comprehensive test
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.
2026-03-03 07:46:27 +00:00
Šarūnas Nejus
86fac4bf82
fix(lastgenre): Reset plugin config in fixtured tests (#6386)
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
2026-03-02 23:48:17 +00:00
Arne Beer
723b4bbfe9
fix(lastgenre): Reset plugin config in fixtured tests 2026-03-03 00:43:05 +01:00
Šarūnas Nejus
31bbd1fad8
fix(fetchart): prevent deletion of configured fallback cover art (#6283)
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.
2026-03-02 23:09:58 +00:00
Danny Trunk
974d917df4 fix(fetchart): prevent deletion of configured fallback cover art
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.
2026-03-02 18:10:19 +01:00
Šarūnas Nejus
30d5157ba3
zero plugin zeroes disctotal if single disc (#6306)
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.
2026-03-02 17:02:22 +00:00
Šarūnas Nejus
5c3ba8e006
Move changelog note under unreleased section 2026-03-02 16:38:02 +00:00
Šarūnas Nejus
91fae7c879
Merge branch 'master' into zero-total-discs 2026-03-02 16:37:43 +00:00
Šarūnas Nejus
6089ab08fa
fix: ftintitle can handle a list of ampersanded artists (#6375)
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
```
2026-03-02 15:00:05 +00:00
Šarūnas Nejus
b17305aa1e
Update changelog note 2026-03-02 14:54:34 +00:00
Fredrik Möllerstrand
bfd95f47d0 fix: ftintitle can handle a list of ampersanded artists
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".
2026-03-02 14:53:23 +00:00
Šarūnas Nejus
43a2c69aa6
Fix symlink tests for macOS (#6374)
👋🏻 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.
2026-03-02 09:33:16 +00:00
Dmitri Vassilenko
df7db24871 Fix symlink tests for macOS 2026-03-02 09:24:25 +00:00
Šarūnas Nejus
d2faa5efe9
Update my teams page entry (#6394) 2026-03-02 09:23:17 +00:00
J0J0 Todos
532b0dabb1 Update my teams page entry 2026-03-02 08:31:44 +01:00
Šarūnas Nejus
53119fc581
docs: Document match.distance_weights in autotagger config (#6398)
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
2026-03-01 12:37:42 +00:00
Serene
aa81232336 Use proper syntax highlighting for code block
Co-authored-by: Šarūnas Nejus <snejus@protonmail.com>
2026-03-01 11:46:44 +00:00
Serene-Arc
dd1bda4bd0 Format docs 2026-03-01 11:46:44 +00:00
edvatar
a40bd7ca3c docs: Document match.distance_weights in autotagger docs
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>
2026-03-01 11:46:44 +00:00
Šarūnas Nejus
573dca687a
Fix add multiple genres (#6401)
Only set new fields when we create a new table.
2026-02-28 10:12:11 +00:00
Šarūnas Nejus
085ff1267b
Only add fields when we create new table 2026-02-28 10:06:31 +00:00
Šarūnas Nejus
16be1df940
Add multiple genres (#6367)
## 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
2026-02-27 18:42:46 +00:00
Šarūnas Nejus
a540a8174a
Clarify tests 2026-02-27 18:36:04 +00:00
Šarūnas Nejus
10d13992e6
Dedupe genres parsing in beatport 2026-02-27 18:36:04 +00:00
Šarūnas Nejus
2c63fe77ce
Remove test case indices from test_lastgenre.py 2026-02-27 18:36:04 +00:00
Šarūnas Nejus
62e232983a
Document ordering of the genre split separator 2026-02-27 18:36:04 +00:00
Šarūnas Nejus
67cf15b0bd
Remove lastgenre separator config 2026-02-27 18:36:04 +00:00
Šarūnas Nejus
6f886682ea
Update changelog note 2026-02-27 18:36:04 +00:00
Šarūnas Nejus
52375472e8
Replace genre: with genres: in docs 2026-02-27 18:34:26 +00:00
Šarūnas Nejus
a8d53f78de
Fix the rest of the tests 2026-02-27 18:34:26 +00:00
Šarūnas Nejus
5d7fb4e158
Remove genre field 2026-02-27 18:34:26 +00:00
Šarūnas Nejus
b8f1b9d174
Stop overwriting this test file name 2026-02-27 18:34:26 +00:00
Šarūnas Nejus
cf36ed0754
Only handle multiple genres in discogs 2026-02-27 18:34:26 +00:00
Šarūnas Nejus
4dda8e3e49
Fix deprecation warning 2026-02-27 18:24:54 +00:00
Šarūnas Nejus
2ecbe59f48
Add migration for multi-value genres field
* 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.
2026-02-27 18:24:54 +00:00