Commit graph

13671 commits

Author SHA1 Message Date
Šarūnas Nejus
a6ac5eff5b
Add lyrics_url, lyrics_backend flex attrs, improve lrclib reliability (#6393)
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.
2026-03-06 11:27:53 +00:00
Šarūnas Nejus
ace6a99d07
Document methods changed/introduced 2026-03-06 10:57:08 +00:00
Šarūnas Nejus
e8605747d6
Document migrations 2026-03-06 10:57:08 +00:00
Šarūnas Nejus
05a822dd5c
Move Lyrics class under beets.util as it is used by migrations 2026-03-06 10:57:08 +00:00
Šarūnas Nejus
50a564e66d
Set constant langdetection seed for stable results 2026-03-06 10:57:08 +00:00
Šarūnas Nejus
7d30efa82c
Migrate lyrics metadata to flex fields on library open
- 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
2026-03-06 10:57:08 +00:00
Šarūnas Nejus
7df14e1877
Fix timestamp format in translation/synced lyrics test 2026-03-06 10:57:08 +00:00
Šarūnas Nejus
38708ae592
Refactor lyrics handling to use structured Lyrics object
* 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.
2026-03-06 10:57:08 +00:00
Šarūnas Nejus
835115a6f7
Fix genius end to end lyrics test 2026-03-06 10:57:08 +00:00
Šarūnas Nejus
c239275193
Do not split orig/trans if they are not different 2026-03-05 15:36:59 +00:00
Šarūnas Nejus
cd95c15a0b
Add a test to show duplicate translations we have right now 2026-03-05 15:36:59 +00:00
Šarūnas Nejus
82bfc03494
Preserve synced lyrics when fetched result is plain text
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.
2026-03-05 15:36:59 +00:00
Šarūnas Nejus
24ca6abcfe
lyrics: validate synced lyrics duration 2026-03-05 15:34:32 +00:00
J0J0 Todos
edbf737638
Lastgenre: Cleanup existing genres (#6317)
## Description

Fixes #6305 

Implements a new `cleanup_existing` config flag.
2026-03-04 21:06:38 +01:00
Arne Beer
13fe82f394 feat(lastgenre): cleanup_existing
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
2026-03-04 21:01:37 +01:00
Šarūnas Nejus
c1fa0a653d
Fix extension issue in convert plugin when exporting a playlist (#5203)
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.
2026-03-04 17:04:27 +00:00
Šarūnas Nejus
a5a9775930
convert: generate playlist entries from effective output paths
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
2026-03-04 15:38:33 +00:00
Šarūnas Nejus
bc9213a4ed
Fix lint issues 2026-03-04 15:28:30 +00:00
Šarūnas Nejus
e90bda31d1
Merge branch 'master' into pr-fix-convert-ext 2026-03-04 15:24:54 +00:00
Šarūnas Nejus
c46f99a82b
Merge branch 'master' into pr-fix-convert-ext 2026-03-04 15:22:40 +00:00
Š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