15045 Commits

Author SHA1 Message Date
Šarūnas Nejus
86f0f0a72f Ignore empty deprecated genres (#6848)
While testing my custom out of date `soundcloud` autotagger which still
sets `genre` instead of `genres`, I realised that empty (`genre = ''`)
is converted to `genres = ['']` list by
`Info._get_list_from_string_value`.
2026-07-16 16:47:16 +01:00
Šarūnas Nejus
7b59604c54 Fix the issue 2026-07-16 14:21:52 +01:00
Šarūnas Nejus
84c01ef4f9 Add a failing test 2026-07-16 14:21:41 +01:00
Šarūnas Nejus
311662bd7d typing: type beets.library (#6825)
- This PR finishes a broad typing pass across `beets.library`, mainly in
`beets/library/models.py` and `beets/library/library.py`, and tightens a
few related plugin and dbcore interfaces.

- Architecturally, the change makes the `Library`, `Item`, `Album`,
query parsing, and template helper layers agree on clearer contracts:
nullable values are explicit, collection and query return types are
defined, and cross-layer APIs now use more precise types.

- The main behavioral surface is still the same, but several weak spots
are now modeled directly in the code, such as optional albums on items,
optional art paths, library-backed template memoization, and query
objects flowing through `Library._fetch()`.

- The PR also cleans up how template uniqueness helpers work by giving
`_memotable` and related keys a stable shape, and by tightening the
logic around album-aware formatting and disambiguation.

- A small architectural rename in `dbcore` changes `Database._fetch()`
to `Database.get_results()`, which makes the lower-level database API
clearer and avoids confusion with `Library._fetch()`.

- High-level impact: better static type safety, clearer boundaries
between database, library, model, and templating code, and less
ambiguity for future refactors without intentionally changing
user-facing behavior.
2026-07-16 13:20:22 +01:00
Šarūnas Nejus
c1c931490f Add commits to ignore 2026-07-16 13:11:19 +01:00
Šarūnas Nejus
2904dd9107 typing: fix the rest of beets.library.models 2026-07-16 13:11:16 +01:00
Šarūnas Nejus
9022cb5ed7 typing: type the rest of beets.library.models 2026-07-16 13:11:16 +01:00
Šarūnas Nejus
c346e809c1 typing: fix models typing in beets.library.models 2026-07-16 13:11:16 +01:00
Šarūnas Nejus
9b375d0b2d typing: type models in beets.library.models 2026-07-16 13:11:16 +01:00
Šarūnas Nejus
8949562ef8 typing: add return types to beets.library.models 2026-07-16 13:11:16 +01:00
Šarūnas Nejus
6ccb7c0517 typing: fix beets.library.library 2026-07-16 13:11:16 +01:00
Šarūnas Nejus
f167dc29e4 typing: type beets.library.library 2026-07-16 13:11:16 +01:00
Šarūnas Nejus
856087258b dbcore: rename Database._fetch to Database.get_results
This removes ambiguity with Library._fetch
2026-07-16 13:11:16 +01:00
Šarūnas Nejus
3f277ad5a0 typing: feed PathLike as database path 2026-07-16 13:11:16 +01:00
Šarūnas Nejus
c6b87e869e typing: type beets.library.queries 2026-07-16 13:11:16 +01:00
Šarūnas Nejus
cf23340af9 Fix beets.plugins.queries type 2026-07-16 13:11:16 +01:00
Šarūnas Nejus
fcee48fb0a typing: type beets.library.exceptions 2026-07-16 13:11:16 +01:00
Šarūnas Nejus
2cf20aa60c discogs: refactor _coalesce_tracks around more specific track types (#6818)
- `beetsplug/discogs/types.py` now models Discogs tracklist entries as
distinct shapes: `AudioTrack`, `IndexTrack`, and `HeadingTrack`. This
shifts the plugin from "guessing by `position`" to handling each entry
by its declared `type_`.

- `beetsplug/discogs/__init__.py` refactors `_coalesce_tracks()` into
smaller helpers for flat subtracks and index tracks. The normalization
flow is now type-driven, which makes the rules for "one physical track"
vs "many physical tracks" more explicit.

- `beetsplug/discogs/states.py` now advances track state only for real
audio entries (`type_ == "track"`), so structural entries no longer
affect track counting through indirect `position` checks.

- Tests now use dedicated Discogs track factories and add coverage for
logical index tracks. This gives the refactor a clearer test model and
protects the new coalescing behavior.

- High-level impact: Discogs imports should now treat headings, index
containers, and subtracks more consistently, especially for releases
where one displayed work contains multiple logical parts but maps to a
single playable track.

### This change is based on the following research (thanks, Codex!):

# Discogs track coalescing

`DiscogsPlugin._coalesce_tracks()` handles three distinct notions all
called
"subtracks." This is the main source of its complexity.

Discogs distinguishes:

- `track`: an audio entry.
- `index`: a titled container whose `sub_tracks` are parts or movements.
- `heading`: descriptive text applying to following tracks.

That distinction is documented in the current
[Discogs tracklisting
guidelines](https://support.discogs.com/hc/en-us/articles/360005055373-Database-Guidelines-12-Tracklisting).
Positions such as `3.1`, `3.2` or `3a`, `3b` separately indicate
multiple
musical pieces inside one physical track.

## Observed input shapes

| Case | API shape | Current result | Example |
|---|---|---|---|
| Normal track | `type_="track"`, nonempty position | Passed through |
Most releases |
| Flat virtual subtracks | Multiple top-level `track` entries with
`1.1`, `1.2` or `22a`, `22b` | Merged into a copy of the first track |
[This Is Not](https://www.discogs.com/release/7117597), [Eclectic Beatz
8](https://www.discogs.com/release/2885582) |
| Nested logical subtracks | `index` with nested children sharing one
physical position | Parent index becomes the physical track; children
discarded | [Defqon.1 Weekend
Festival](https://www.discogs.com/release/7168134) |
| Nested physical subtracks | `index` with children numbered as
independent tracks | Index discarded; children promoted | [Classical
Hollywood III](https://www.discogs.com/release/3647530) |
| Heading | `heading`, empty position, no `sub_tracks` | Passed through
as structural metadata | Defqon.1 CD headings |
| Ambiguous/nonstandard index | `index` with positions the regex cannot
classify | Usually promoted incorrectly as separate tracks | [King
Crimson - Lizard](https://www.discogs.com/release/1156598) |

### Flat virtual subtracks

`This Is Not` returns nine top-level entries:

```python
{"type_": "track", "position": "1.1", "title": "Intro", "duration": "1:49"}
{"type_": "track", "position": "1.2", "title": "Untitled", "duration": "6:31"}
# ...
{"type_": "track", "position": "1.9", "title": "2 Bad ...", "duration": "10:18"}
```

There is no index container. `_coalesce_tracks()` detects the suffixes
and
`_add_merged_subtracks()` produces:

```python
{
    "type_": "track",
    "position": "1.1",
    "title": "Intro / Untitled / ... / 2 Bad ...",
    "duration": "1:49",
    "artists": ["Unknown Artist"],
}
```

It copies all metadata from the first entry and only combines titles.
This
explains the incorrect duration and artist.

`Eclectic Beatz 8` has the same flat shape:

```python
{"type_": "track", "position": "22a", "title": "Amplifier", "artists": ["Fedde Le Grand"]}
{"type_": "track", "position": "22b", "title": "Autograph ...", "artists": ["Hatiras", "MC Flipside"]}
```

Those become one track, retaining only the first artist.

### Nested logical subtracks

Defqon.1 contains:

```python
{
    "type_": "index",
    "position": "",
    "title": "Eat This / God! What The Hell!",
    "duration": "3:06",
    "sub_tracks": [
        {"type_": "track", "position": "2-20A", "title": "Eat This"},
        {"type_": "track", "position": "2-20B", "title": "God! What The Hell!"},
    ],
}
```

Because `2-20A` has a parsed subindex, the helper changes the parent to:

```python
{
    "type_": "index",
    "position": "2-20",
    "title": "Eat This / God! What The Hell!",
    "duration": "3:06",
}
```

The children disappear. Downstream code treats this as audio solely
because
its position is now nonempty. It never checks that `type_` remains
`"index"`.

### Nested physical subtracks

`Classical Hollywood III` contains index containers such as:

```python
{
    "type_": "index",
    "position": "",
    "title": "Auld Lang Syne Variations For Piano Quartet",
    "sub_tracks": [
        {"type_": "track", "position": "1", "title": "Eine Kleine Nichtmusik ..."},
        {"type_": "track", "position": "2", "title": "L.V.B. - Adagio"},
        {"type_": "track", "position": "3", "title": "Chaconne ..."},
        {"type_": "track", "position": "4", "title": "Homage ..."},
    ],
}
```

Because position `1` has no subindex:

- the index container is removed;
- its children become top-level physical tracks;
- parent artists are copied to children missing artists;
- when `index_tracks` is enabled, the parent title prefixes every child
title.

This behavior was added for
[issue #2318](https://github.com/beetbox/beets/issues/2318) by
[PR #2355](https://github.com/beetbox/beets/pull/2355).

### Headings

Defqon.1 also contains:

```python
{
    "type_": "heading",
    "position": "",
    "title": "CD2  Mixed By Partyraiser",
    "duration": "",
}
```

This is not coalesced. It passes through, but `TracklistState`
subsequently
treats every positionless object as structural metadata without checking
`type_`.

The code therefore cannot distinguish:

```python
position == "" and type_ == "heading"
position == "" and type_ == "index"
```

Worse, `_add_merged_subtracks()` identifies a parent using only:

```python
tracklist and not tracklist[-1]["position"]
```

A heading can therefore theoretically be mistaken for an index parent.

## History

- [Issue #1543](https://github.com/beetbox/beets/issues/1543) reported
flat
  positions such as `22a` and `22b` representing one CD track.
- [PR #2222](https://github.com/beetbox/beets/pull/2222) added both flat
and
nested subtrack support. Its author explicitly noted that `type_` might
  identify index tracks robustly, but avoided the larger refactor.
- [Issue #2318](https://github.com/beetbox/beets/issues/2318)
demonstrated that
  nested `sub_tracks` can instead be independent physical tracks.
- PR #2355 added the current heuristic:
  - parsed child subindex means logical fragments of one physical track;
  - no parsed child subindex means independent physical tracks.
- That PR documented the heuristic as imperfect. `Lizard`, with `Ba`,
`Bb`,
  `Bc`, and related positions, was already a known failure.

## Test data does not match the API shapes

The current test helper always creates:

```python
"type_": "track"
```

Consequently, test inputs such as:

```python
_track("TRACK GROUP TITLE", sub_tracks=[...])
```

are not realistic. Real API data uses:

```python
"type_": "index"
```

Likewise, test medium titles are manufactured as positionless `track`
objects
rather than `heading` or `index` objects.

## Recommended direction

Before refactoring production code, introduce accurate input types and
factories:

```python
TrackEntry = AudioTrack | IndexTrack | HeadingTrack
```

With approximate shapes:

```python
class AudioTrack(TypedDict):
    type_: Literal["track"]
    position: str
    title: str
    duration: str
    artists: NotRequired[list[Artist]]


class IndexTrack(TypedDict):
    type_: Literal["index"]
    position: Literal[""]
    title: str
    duration: str
    sub_tracks: list[AudioTrack]
    artists: NotRequired[list[Artist]]


class HeadingTrack(TypedDict):
    type_: Literal["heading"]
    position: Literal[""]
    title: str
    duration: str
```

Then test using `_track()`, `_index()`, and `_heading()` factories.

The eventual normalization can dispatch structurally:

1. `track` without subindex: pass through.
2. Flat subindexed `track` group: apply the flat-fragment policy.
3. `index`: normalize its children as logical or physical.
4. `heading`: preserve as structural metadata.
5. Never infer an index merely from `position == ""`.

This removes the most dangerous ambiguity while preserving the
positional
heuristic where Discogs genuinely requires it.
2026-07-16 12:02:00 +01:00
Šarūnas Nejus
439a7fa75d discogs: Refactor _coalesce_tracks to make use of various tracks type_ 2026-07-16 11:57:10 +01:00
Šarūnas Nejus
8867848612 discogs: Add descriptions of each possible Tracks 2026-07-16 11:57:10 +01:00
Šarūnas Nejus
66476049e9 discogs: refactor and clean up tests (#6817)
- **What changed**
- Simplifies `DiscogsPlugin.get_album_info` by removing a redundant
"required fields" guard and relying on the plugin's normal parsing flow
instead.
- Reworks `test/plugins/test_discogs.py` around shared fixtures and
helpers, including a common `DiscogsTestMixin`, reusable
`plugin`/`plugin_config` fixtures, and a global patch for
`DiscogsPlugin.setup`.
- Expands and consolidates coverage for tracklist parsing, especially
`_coalesce_tracks`-related behavior, `index_tracks`, grouped/sub-tracks,
inherited artists, medium titles, and disambiguation handling.

- **Architecture impact**
- Test structure is now more modular and configuration-driven: scenarios
pass settings through `plugin_config` instead of mutating global state
inline.
- Discogs test data now uses real `discogs_client.Release` objects via
`get_release(...)` instead of the custom `Bag` stub, making tests closer
to production behavior.
- Repeated one-off tests were collapsed into parametrized suites, so
tracklist behavior lives in one place and is easier to extend.
2026-07-16 11:56:47 +01:00
Šarūnas Nejus
c46f6a549b discogs: Dedupe test_strip_disambiguation 2026-07-16 11:51:32 +01:00
Šarūnas Nejus
bbb1b5c1ef discogs: And now add full _coalesce_tracks coverage 2026-07-16 11:51:32 +01:00
Šarūnas Nejus
bd9c753b99 discogs: Test index_tracks behaviour 2026-07-16 11:51:32 +01:00
Šarūnas Nejus
b8f43e994a discogs: Test inherited track group artist 2026-07-16 11:51:31 +01:00
Šarūnas Nejus
bc0591fdb6 discogs: Consolidate test_parse_tracklist_subtracks... tests 2026-07-16 11:50:58 +01:00
Šarūnas Nejus
1343d613d3 discogs: Move test_parse_tracklist_positions... under TestTracklist 2026-07-16 11:50:58 +01:00
Šarūnas Nejus
951af6d3e8 discogs: Use a single global fixture to kill DiscogsPlugin.setup 2026-07-16 11:50:58 +01:00
Šarūnas Nejus
a126a3aaa3 discogs: Use Release instead of Bag in tests 2026-07-16 11:50:58 +01:00
Šarūnas Nejus
69cdd58240 discogs: remove redundant check for required fields 2026-07-16 11:50:58 +01:00
J0J0 Todos
26b26c4baf lastgenre: Fix whitelist/tree some disco/funk genres (#6841)
## Description

Fixes two issues in whitelist and genre tree.
2026-07-16 08:08:16 +02:00
J0J0 Todos
408ef47e3f lastgenre: Fix whitelist/tree some disco/funk genres 2026-07-16 08:03:29 +02:00
Šarūnas Nejus
17d6798022 replaygain: add metaflac backend (#6800)
Fixes #1203.

The replaygain plugin can't compute ReplayGain when metaflac is the only
tool available. The existing backends (command, gstreamer, audiotools,
ffmpeg) each need something heavier installed, which doesn't work on
minimal setups like a NAS where those won't install. Issue #1203 asks
for a metaflac based option so FLAC users on those setups can still tag
ReplayGain.

This adds a metaflac backend for that case. It runs `metaflac
--add-replay-gain` to compute the gain and reads the values back with
`metaflac --show-tag`. I modeled it on the existing `CommandBackend`
since both wrap an external tool. It only handles FLAC, skips other
formats, and shifts the gain to the configured `targetlevel` like the
other backends.

One limitation: metaflac scans a whole album in one pass, so the files
of an album need the same sample rate and channel layout.

Before, selecting the metaflac backend failed:

```
$ beet replaygain
UserError: Selected ReplayGain backend metaflac is not supported.
```

After, the backend works and the plugin tests pass:

```
$ python -m pytest test/plugins/test_replaygain.py
7 passed, 4 skipped
```

The 4 skipped are the Opus R128 cases, which don't apply to the metaflac
path.
2026-07-15 22:26:40 +01:00
Samad
3d197ee024 replaygain: use a yaml code-block for the docs config example 2026-07-15 22:22:07 +01:00
Samad
1b0131e45e ci: install flac for the metaflac replaygain tests 2026-07-15 22:22:07 +01:00
Samad
2adce5daff replaygain: filter supported items in metaflac track gain 2026-07-15 22:22:07 +01:00
Samad
4ccdcc90c9 replaygain: add metaflac backend
Add a MetaflacBackend that computes ReplayGain for FLAC files with metaflac --add-replay-gain and reads the values back with --show-tag. Only FLAC is supported. Includes docs, a changelog entry, and tests.
2026-07-15 22:22:07 +01:00
Šarūnas Nejus
804dd34947 Fix uncaught KeyError on date queries containing a stray pipe (#6835)
Date queries detect *relative* dates (`-1w`, `+3m`) with a small regex
whose character classes were written as if `|` meant alternation:

```python
relative_re = "(?P<sign>[+|-]?)(?P<quantity>[0-9]+)(?P<timespan>[y|m|w|d])"
```

Inside `[...]`, `|` is a literal member, so `[y|m|w|d]` also matches
`|`. A date query with a stray pipe — e.g. `beet list added:2000|2001`
(a natural mistake for someone expecting `|` to mean "or") — captures
`|` as the timespan, and `Period.parse` then evaluates
`relative_units["|"]`, raising an uncaught `KeyError` with a traceback.
Malformed date strings are meant to raise
`InvalidQueryArgumentValueError` (see `test_invalid_date_query`), which
upper layers render as a friendly "a valid date/time string" message.

### Fix

Drop the stray `|` from both character classes so the value falls
through to the normal invalid-date path:

```python
relative_re = "(?P<sign>[+-]?)(?P<quantity>[0-9]+)(?P<timespan>[ymwd])"
```

Before: `DateQuery("added", "2000|2001")` → `KeyError: '|'`
After: → `InvalidQueryArgumentValueError: '2000|2001' is not a valid
date/time string`

### Tests / changelog

- Added `test_pipe_in_relative_date_query` (fails before, passes after).
- Added a Bug-fixes changelog entry.

Disclosure: prepared with assistance from Claude (an AI tool); I
reviewed and verified the change locally (fail-before/pass-after).
2026-07-15 21:06:28 +01:00
TowyTowy
4a1e9164a1 Fix uncaught KeyError on date queries containing a stray pipe
The relative-date regex used character classes [+|-] and [y|m|w|d],
which mistakenly treat | as a literal member rather than alternation.
As a result a value like added:2000|2001 (as a user might type expecting
| to mean "or") captured | as the relative-date unit and crashed with
an uncaught KeyError in Period.parse instead of raising the documented
InvalidQueryArgumentValueError.

Remove the stray | from both character classes so malformed date strings
fall through to the normal invalid-date error path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-15 21:55:46 +02:00
Šarūnas Nejus
db9e044155 typing: add types to beets.dbcore (#6820)
### What changed

- This PR is mainly a typing pass over `beets.dbcore`, especially in
`db.py`, `query.py`, `sort.py`, and `types.py`.
- The main architecture change is in `db.py`: model materialization is
now centralized in `Database._make_model()`, while `Results` only
handles lazy row iteration and uses a materializer callback.
- Reloading is also cleaner now: `Model.get_fresh_from_db()` goes
through `Database._reload()`, which keeps database lookup logic in the
database layer and preserves the model's concrete type.
- Along the way, a few runtime mismatches found by typing were corrected
instead of just adjusting annotations.

### Why

- The goal is to make the typed API match the real behavior of `dbcore`.
- This improves separation of responsibilities: querying and result
iteration stay in `Results`, while row-to-model construction and reload
behavior live in `Database`.

### High-level impact

- `dbcore` has a clearer internal structure and a more explicit API
surface.
- Type information is more accurate across queries, sorting, models, and
field types, which should make future refactors safer.
- A few small correctness fixes are included:
  - `DurationType.format()` now always returns `str`.
- `PathType.from_sql()` only expands paths when the normalized value is
actually `bytes`.
- Several `__eq__` implementations now use explicit `isinstance(...)`
checks.
- Reload behavior now has regression coverage in
`test/dbcore/test_db.py`.
- Overall, this is a low-risk cleanup: mostly typing and API-shape
improvements, with a few small behavior fixes discovered during the
work.
2026-07-15 20:28:19 +01:00
Šarūnas Nejus
0f00bc9c1f Add commits to ignore 2026-07-15 20:23:57 +01:00
Šarūnas Nejus
1266256f5e typing: fix beets.dbcore.types 2026-07-15 20:23:55 +01:00
Šarūnas Nejus
ea3d6af616 typing: type beets.dbcore.types 2026-07-15 20:23:55 +01:00
Šarūnas Nejus
b31c3bf9d4 typing: fix beets.dbcore.query 2026-07-15 20:23:55 +01:00
Šarūnas Nejus
b1701c2e12 typing: type beets.dbcore.query 2026-07-15 20:23:55 +01:00
Šarūnas Nejus
a000232509 typing: fix beets.dbcore.sort 2026-07-15 20:23:55 +01:00
Šarūnas Nejus
ca8e9f69a8 typing: type beets.dbcore.sort 2026-07-15 20:23:55 +01:00
Šarūnas Nejus
7a553aad7e typing: fix beets.dbcore.db 2026-07-15 20:23:54 +01:00
Šarūnas Nejus
8d03803fcf typing: type beets.dbcore.db 2026-07-15 20:23:54 +01:00
Vinícius Martins Cotrim
6a051f9699 Recognize native Spotify URIs when extracting IDs (#6840)
### Description

`extract_release_id` now accepts the native Spotify URI format
(`spotify:album:<id>`, `spotify:track:<id>`) in addition to the already
supported `open.spotify.com` URLs and bare IDs.

Co-authored-by: Marina Pena Malschitzky Crespo <marinacrespo@estudante.ufscar.br>
2026-07-15 21:21:25 +02:00