Fixes: #6370
This PR completes the lyrics pipeline refactor around a structured
`Lyrics` value object and aligns storage, migration, and docs with that
model.
At a high level, lyrics handling is now end-to-end structured instead of
ad-hoc string/tuple flows: fetchers return `Lyrics`, translation
operates on `Lyrics`, and persistence writes both canonical text and
structured metadata.
High-level impact:
- Backends now return `Lyrics` instead of `(text, url)` tuples.
- Lyrics source metadata is no longer embedded in `item.lyrics` as a
`Source: ...` suffix.
- Lyrics metadata is stored in flexible fields:
`lyrics_backend`, `lyrics_url`, `lyrics_language`,
`lyrics_translation_language`.
- Existing libraries are automatically migrated on first run by a
one-time data migration that:
normalizes legacy mixed-content lyrics text and moves auxiliary metadata
into flex fields.
- Sync safety is improved:
with `synced` enabled, existing synced lyrics are not replaced by newly
fetched plain lyrics, even with `force`.
- LRCLib synced lyrics validation is stricter:
synced results are accepted only when the final synced timestamp is
consistent with track duration.
Docs and tests:
- Lyrics plugin docs now describe the new flexible metadata fields and
synced replacement behavior.
- Developer docs now document migration lifecycle, class-name-based
migration identity, and migration use cases.
- Changelog updated for all user-visible behavior changes.
- Tests were expanded/updated for migration behavior, backend return
types, translation behavior, synced-lyrics safety, and LRCLib duration
validation.
- Add `LyricsMetadataInFlexFieldsMigration` to extract legacy source
URLs and language metadata from lyrics text into flex attributes
- Add `Lyrics.from_legacy_text` to parse legacy lyrics format
- Move `with_row_factory` context manager up to base `Migration` class
- Rename `migrate_table` to `migrate_model` and pass model class
instead of table name string. This is so that the migration can access
both `_table` and `_flex_table` attributes.
- Make `langdetect` import optional in `Lyrics.__post_init__`: users may
not have have the dependency installed, and we do not want the
migration to fail because of that.
- Move `BACKEND_BY_NAME` to module level for use outside plugin class
* Introduce a `Lyrics` dataclass to carry text, source URL, and language
metadata through fetch, translation, and storage paths.
* Return `Lyrics` from backends and plugin lookup methods instead of raw
tuples/strings.
* Store backend name in `lyrics_source` derived from fetched URL root
domain.
* Simplify translator flow to operate on `Lyrics`, reuse line splitting,
append translations in-place, and record translation language
metadata.
When lyrics.synced is enabled, avoid replacing existing synced lyrics with
newly fetched unsynced lyrics, even with force enabled.
Allow replacement when the new lyrics are also synced, or when synced mode
is disabled.
Introduce a new lastgenre `cleanup_existing` flag.
It handles the case where canonicalization is desired on existing tags.
The new logic triggers if:
- `force`: False
- `cleanup_existing: True
Depending on whether `whitelist: True` or `canonical: True`, the genres
are then canonicalized and/or whitelisting is applied
Fix extension substitution inside path of the exported playlist.
Before this, the exported playlist contained relative paths pointing to
the
converted files BUT the extension were not substituted comparing to
before and
the after the conversion. Therefore, running the playlist will fail for
files
which have been converted and where extension have changed.
Example:
1. Convert `/path/to/library/artist.flac` to
`/path/to/converted/artist.mp3` using the `-m playlist.m3u` command-line
flag.
2. Open the generated playlist, and find the incorrect path
`/path/to/converted/artist.flac` inside.
Build playlist paths using the selected format (`--format`/config), and only
replace extensions when the destination file is actually transcoded.
Precompute playlist entries before conversion runs so `--keep-new` does not
pick up mutated item paths and produce mismatched extensions.
Add/expand convert CLI tests to cover:
- config format playlist extension
- `--format` override playlist extension
- no-transcode (`no_convert`) playlist extension
- `--keep-new` destination playlist path behavior
> **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.