v3 library: Albums view — the client half of the album-condense work (#716)

* v3 library: Albums view — the client half of the album-condense work

Follow-up to the query_albums endpoint: the UI that consumes it, plus the
track-order plumbing the endpoint's track list needs.

- Albums view (a fourth view toggle next to grid/tree/folder): album cards
  (cover / title / artist / track count) from /api/library/albums,
  respecting the active filter drawer; clicking one opens the track list
  with per-track play and a Play-album button that feeds the play queue
  (falls back to plain playSong when the queue plugin is absent).
- Track order: the scanner now reads the feedpak `track`/`disc` fields
  (spec 1.12.0) into new nullable songs columns (idempotent ALTERs), and
  the album track list orders by the new `track` sort — disc, then track
  number, unauthored charts to the bottom by title. Charts without
  authored numbers keep working; they just sort alphabetically.
- The albums view persists like the other view choices.

3 new tests: manifest track/disc extraction (and unauthored -> None),
the disc->track->title sort order over /api/library, and the put()
round-trip. Full-suite failure set matches the known env baseline (one
tuner-config name swapped inside the suite-ordering flake family — the
file passes 25/25 in isolation on clean main).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* v3 albums: honour Genre/Match filters in album grid + detail (review fixes)

The Albums view only partially respected the filter drawer:

- /api/library/albums silently dropped the `genre` and `match` params the
  client sends via queryParams(), so with a Genre or Match filter active the
  album grid surfaced albums with zero matching tracks. Thread match_states/
  genre through the endpoint -> query_albums -> _build_where, mirroring the
  /api/library grid route. (SmartCollection/pass-through providers keep their
  existing kwarg handling.)

- The album-detail track list built its own params (provider/artist/album/
  sort only), so it ignored ALL active filters — the track list and the
  Play-album queue could include songs the user had filtered out. Reuse
  queryParams({...}, {catalog: true}) so detail honours the same filters as
  the grid while pinning this album's artist/album and track order.

+1 regression test (albums endpoint honours the Genre filter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
ChrisBeWithYou
2026-07-02 15:57:26 +02:00
committed by GitHub
co-authored by Claude Fable 5 byrongamatos
parent 2e4383524f
commit 0a8c8945ea
5 changed files with 185 additions and 8 deletions
+21 -4
View File
@@ -551,7 +551,9 @@ class MetadataDB:
tuning_name TEXT DEFAULT '',
tuning_sort_key INTEGER DEFAULT 0,
tuning_offsets TEXT DEFAULT '',
genre TEXT DEFAULT ''
genre TEXT DEFAULT '',
track_number INTEGER,
disc INTEGER
)
""")
# Idempotent migrations for installs that predate each column.
@@ -573,6 +575,11 @@ class MetadataDB:
# Primary genre from the feedpak `genres` list (spec 1.12.0). Cache;
# repopulated on rescan.
"ALTER TABLE songs ADD COLUMN genre TEXT DEFAULT ''",
# Album track order from the feedpak `track`/`disc` fields (spec
# 1.12.0). NULL when the pack doesn't author them; the album view
# falls back to title order. Cache; repopulated on rescan.
"ALTER TABLE songs ADD COLUMN track_number INTEGER",
"ALTER TABLE songs ADD COLUMN disc INTEGER",
):
try:
self.conn.execute(ddl)
@@ -2553,8 +2560,8 @@ class MetadataDB:
self.conn.execute(
"INSERT OR REPLACE INTO songs "
"(filename, mtime, size, title, artist, album, year, duration, tuning, arrangements, "
"has_lyrics, format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, genre) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"has_lyrics, format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, genre, track_number, disc) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(filename, mtime, size, meta.get("title", ""), meta.get("artist", ""),
meta.get("album", ""), meta.get("year", ""), meta.get("duration", 0),
meta.get("tuning", ""), json.dumps(meta.get("arrangements", [])),
@@ -2565,7 +2572,9 @@ class MetadataDB:
meta.get("tuning_name", "") or "",
int(meta.get("tuning_sort_key", 0) or 0),
meta.get("tuning_offsets", "") or "",
meta.get("genre", "") or ""),
meta.get("genre", "") or "",
meta.get("track_number"),
meta.get("disc")),
)
self.conn.commit()
# A song's identity may have changed → the grouping read-model is stale.
@@ -3538,6 +3547,10 @@ class MetadataDB:
# '2005' rather than alphabetic.
"year": "(year = '') ASC, CAST(year AS INTEGER) ASC",
"year-desc": "(year = '') ASC, CAST(year AS INTEGER) DESC",
# Album track order: authored track number (disc, then track); songs
# with no number fall to the bottom, ordered by title. Used by the
# album detail view. Alpha-by-title is the fallback when unauthored.
"track": "(track_number IS NULL) ASC, COALESCE(disc, 1), track_number, title COLLATE NOCASE",
# Mastery = best accuracy across a song's arrangements, from the
# separate song_stats table (so via a correlated subquery — this sort
# drops to OFFSET paging, like tuning/year). Unscored ("not started")
@@ -3817,6 +3830,7 @@ class MetadataDB:
arrangements_has=None, arrangements_lacks=None,
stems_has=None, stems_lacks=None,
has_lyrics=None, tunings=None, mastery=None,
match_states=None, genre=None,
naming_mode="legacy", page=0, size=120):
"""Distinct (artist, album) groups with a track count + a representative
cover song, for the album-condensed browse (paged by album). Rows with no
@@ -3828,6 +3842,7 @@ class MetadataDB:
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, mastery=mastery,
match_states=match_states, genre=genre,
naming_mode=naming_mode,
)
awhere = where + " AND album IS NOT NULL AND album != ''"
@@ -7161,6 +7176,7 @@ async def list_library_albums(q: str = "", page: int = 0, size: int = 120,
arrangements_has: str = "", arrangements_lacks: str = "",
stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "", mastery: str = "",
match: str = "", genre: str = "",
provider: str = "local"):
"""Album-condensed browse: distinct (artist, album) groups with a track count
and a representative cover song. Paged by album. Same filters as /api/library."""
@@ -7170,6 +7186,7 @@ async def list_library_albums(q: str = "", page: int = 0, size: int = 120,
albums, total = await _call_library_provider_async(
library_provider, "query_albums",
page=page, size=size, mastery=_split_csv(mastery),
match_states=_split_csv(match), genre=_split_csv(genre),
**_library_filter_args(
q=q, favorites=favorites, format=format, artist=artist, album=album,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,