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
+4
View File
@@ -921,6 +921,10 @@ def extract_meta(path: Path) -> dict:
"year": str(manifest.get("year", "") or ""), "year": str(manifest.get("year", "") or ""),
# Primary genre from the feedpak `genres` list (spec 1.12.0); [0] = primary. # Primary genre from the feedpak `genres` list (spec 1.12.0); [0] = primary.
"genre": (lambda g: str(g[0]) if isinstance(g, list) and g else "")(manifest.get("genres")), "genre": (lambda g: str(g[0]) if isinstance(g, list) and g else "")(manifest.get("genres")),
# Album track order from the feedpak `track`/`disc` fields (spec 1.12.0);
# None when unauthored (the album view then falls back to title order).
"track_number": (lambda v: int(v) if str(v if v is not None else "").strip().isdigit() else None)(manifest.get("track")),
"disc": (lambda v: int(v) if str(v if v is not None else "").strip().isdigit() else None)(manifest.get("disc")),
"duration": float(manifest.get("duration", 0) or 0), "duration": float(manifest.get("duration", 0) or 0),
"tuning_offsets": tuning_offsets, # caller maps to a name via tunings.tuning_name "tuning_offsets": tuning_offsets, # caller maps to a name via tunings.tuning_name
"arrangements": arrangements, "arrangements": arrangements,
+21 -4
View File
@@ -551,7 +551,9 @@ class MetadataDB:
tuning_name TEXT DEFAULT '', tuning_name TEXT DEFAULT '',
tuning_sort_key INTEGER DEFAULT 0, tuning_sort_key INTEGER DEFAULT 0,
tuning_offsets TEXT DEFAULT '', tuning_offsets TEXT DEFAULT '',
genre TEXT DEFAULT '' genre TEXT DEFAULT '',
track_number INTEGER,
disc INTEGER
) )
""") """)
# Idempotent migrations for installs that predate each column. # 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; # Primary genre from the feedpak `genres` list (spec 1.12.0). Cache;
# repopulated on rescan. # repopulated on rescan.
"ALTER TABLE songs ADD COLUMN genre TEXT DEFAULT ''", "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: try:
self.conn.execute(ddl) self.conn.execute(ddl)
@@ -2553,8 +2560,8 @@ class MetadataDB:
self.conn.execute( self.conn.execute(
"INSERT OR REPLACE INTO songs " "INSERT OR REPLACE INTO songs "
"(filename, mtime, size, title, artist, album, year, duration, tuning, arrangements, " "(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) " "has_lyrics, format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, genre, track_number, disc) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(filename, mtime, size, meta.get("title", ""), meta.get("artist", ""), (filename, mtime, size, meta.get("title", ""), meta.get("artist", ""),
meta.get("album", ""), meta.get("year", ""), meta.get("duration", 0), meta.get("album", ""), meta.get("year", ""), meta.get("duration", 0),
meta.get("tuning", ""), json.dumps(meta.get("arrangements", [])), meta.get("tuning", ""), json.dumps(meta.get("arrangements", [])),
@@ -2565,7 +2572,9 @@ class MetadataDB:
meta.get("tuning_name", "") or "", meta.get("tuning_name", "") or "",
int(meta.get("tuning_sort_key", 0) or 0), int(meta.get("tuning_sort_key", 0) or 0),
meta.get("tuning_offsets", "") or "", meta.get("tuning_offsets", "") or "",
meta.get("genre", "") or ""), meta.get("genre", "") or "",
meta.get("track_number"),
meta.get("disc")),
) )
self.conn.commit() self.conn.commit()
# A song's identity may have changed → the grouping read-model is stale. # A song's identity may have changed → the grouping read-model is stale.
@@ -3538,6 +3547,10 @@ class MetadataDB:
# '2005' rather than alphabetic. # '2005' rather than alphabetic.
"year": "(year = '') ASC, CAST(year AS INTEGER) ASC", "year": "(year = '') ASC, CAST(year AS INTEGER) ASC",
"year-desc": "(year = '') ASC, CAST(year AS INTEGER) DESC", "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 # Mastery = best accuracy across a song's arrangements, from the
# separate song_stats table (so via a correlated subquery — this sort # separate song_stats table (so via a correlated subquery — this sort
# drops to OFFSET paging, like tuning/year). Unscored ("not started") # drops to OFFSET paging, like tuning/year). Unscored ("not started")
@@ -3817,6 +3830,7 @@ class MetadataDB:
arrangements_has=None, arrangements_lacks=None, arrangements_has=None, arrangements_lacks=None,
stems_has=None, stems_lacks=None, stems_has=None, stems_lacks=None,
has_lyrics=None, tunings=None, mastery=None, has_lyrics=None, tunings=None, mastery=None,
match_states=None, genre=None,
naming_mode="legacy", page=0, size=120): naming_mode="legacy", page=0, size=120):
"""Distinct (artist, album) groups with a track count + a representative """Distinct (artist, album) groups with a track count + a representative
cover song, for the album-condensed browse (paged by album). Rows with no 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, arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks, stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, mastery=mastery, has_lyrics=has_lyrics, tunings=tunings, mastery=mastery,
match_states=match_states, genre=genre,
naming_mode=naming_mode, naming_mode=naming_mode,
) )
awhere = where + " AND album IS NOT NULL AND album != ''" 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 = "", arrangements_has: str = "", arrangements_lacks: str = "",
stems_has: str = "", stems_lacks: str = "", stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "", mastery: str = "", has_lyrics: str = "", tunings: str = "", mastery: str = "",
match: str = "", genre: str = "",
provider: str = "local"): provider: str = "local"):
"""Album-condensed browse: distinct (artist, album) groups with a track count """Album-condensed browse: distinct (artist, album) groups with a track count
and a representative cover song. Paged by album. Same filters as /api/library.""" 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( albums, total = await _call_library_provider_async(
library_provider, "query_albums", library_provider, "query_albums",
page=page, size=size, mastery=_split_csv(mastery), page=page, size=size, mastery=_split_csv(mastery),
match_states=_split_csv(match), genre=_split_csv(genre),
**_library_filter_args( **_library_filter_args(
q=q, favorites=favorites, format=format, artist=artist, album=album, q=q, favorites=favorites, format=format, artist=artist, album=album,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks, arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
+1 -1
View File
File diff suppressed because one or more lines are too long
+66 -3
View File
@@ -153,7 +153,7 @@
if (!saved || typeof saved !== 'object') return; if (!saved || typeof saved !== 'object') return;
if (SORTS.some(([v]) => v === saved.sort)) state.sort = saved.sort; if (SORTS.some(([v]) => v === saved.sort)) state.sort = saved.sort;
if (FORMATS.some(([v]) => v === saved.format)) state.format = saved.format; if (FORMATS.some(([v]) => v === saved.format)) state.format = saved.format;
if (saved.view === 'grid' || saved.view === 'tree' || saved.view === 'folder') state.view = saved.view; if (saved.view === 'grid' || saved.view === 'tree' || saved.view === 'folder' || saved.view === 'albums') state.view = saved.view;
if (typeof saved.grouping === 'boolean') state.grouping = saved.grouping; if (typeof saved.grouping === 'boolean') state.grouping = saved.grouping;
const f = saved.filters; const f = saved.filters;
if (f && typeof f === 'object') { if (f && typeof f === 'object') {
@@ -2000,6 +2000,65 @@
} }
// ── Tree ──────────────────────────────────────────────────────────────── // ── Tree ────────────────────────────────────────────────────────────────
// ── Albums (album-condensed browse; consumes /api/library/albums) ─────────
// Album cards -> click -> a track list (reusing /api/library?artist=&album=)
// with Play-album (feeds the play-queue). Respects the active drawer filters.
async function loadAlbums() {
const host = document.getElementById('v3-songs-albums');
if (!host) return;
host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
const data = await jget('/api/library/albums?' + queryParams().toString());
const albums = (data && data.albums) || [];
if (!albums.length) { host.innerHTML = '<p class="text-fb-textDim text-sm py-8 text-center">No albums match.</p>'; return; }
host.innerHTML =
'<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">' +
albums.map((a, i) =>
'<button data-album="' + i + '" class="group text-left">' +
'<div class="aspect-square rounded-lg overflow-hidden bg-fb-card mb-2">' +
(a.cover ? '<img src="' + esc(artUrl({ filename: a.cover })) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' : '') +
'</div>' +
'<div class="text-sm text-fb-text truncate">' + esc(a.album) + '</div>' +
'<div class="text-xs text-fb-textDim truncate">' + esc(a.artist) + ' · ' + (a.count || 0) + ' track' + (a.count === 1 ? '' : 's') + '</div>' +
'</button>').join('') + '</div>';
host.querySelectorAll('[data-album]').forEach((b) => b.addEventListener('click', () => {
const a = albums[Number(b.getAttribute('data-album'))];
if (a) openAlbum(a);
}));
}
async function openAlbum(a) {
const host = document.getElementById('v3-songs-albums');
if (!host) return;
host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
// Honour the active drawer filters (like the album grid) but pin THIS
// album's artist/album and force track order — so the track list and
// Play-album never include songs the user filtered out.
const p = queryParams({ artist: a.artist, album: a.album, size: '300', sort: 'track' }, { catalog: true });
const data = await jget('/api/library?' + p.toString());
const songs = (data && data.songs) || [];
host.innerHTML =
'<button data-albums-back class="text-sm text-fb-textDim hover:text-fb-text mb-4">← Albums</button>' +
'<div class="flex items-center justify-between gap-3 mb-4">' +
'<div class="min-w-0"><h2 class="text-2xl font-bold text-fb-text truncate">' + esc(a.album) + '</h2>' +
'<p class="text-sm text-fb-textDim truncate">' + esc(a.artist) + ' · ' + songs.length + ' track' + (songs.length === 1 ? '' : 's') + '</p></div>' +
(songs.length ? '<button data-album-playall class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md shrink-0">▶ Play album</button>' : '') +
'</div>' +
'<ul class="space-y-1">' + songs.map((s, i) =>
'<li><button data-album-track="' + i + '" class="w-full flex items-center gap-3 px-3 py-2 rounded-md hover:bg-white/5 text-left">' +
'<span class="text-xs text-fb-textDim w-6 text-right">' + (i + 1) + '</span>' +
'<span class="flex-1 truncate text-sm text-fb-text">' + esc(s.title || s.filename) + '</span></button></li>').join('') + '</ul>';
host.querySelector('[data-albums-back]')?.addEventListener('click', () => loadAlbums());
host.querySelector('[data-album-playall]')?.addEventListener('click', () => {
const files = songs.map((s) => s.filename).filter(Boolean);
if (!files.length) return;
if (window.feedBack && window.feedBack.playQueue) window.feedBack.playQueue.start(files, { source: a.album });
else if (typeof window.playSong === 'function') window.playSong(enc(files[0]));
});
host.querySelectorAll('[data-album-track]').forEach((b) => b.addEventListener('click', () => {
const s = songs[Number(b.getAttribute('data-album-track'))];
if (s && window.playSong) window.playSong(enc(s.filename));
}));
}
async function loadTree() { async function loadTree() {
const host = document.getElementById('v3-songs-tree'); const host = document.getElementById('v3-songs-tree');
if (!host) return; if (!host) return;
@@ -2553,6 +2612,7 @@
document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid'); document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid');
document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree'); document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder'); document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
document.getElementById('v3-songs-albums')?.classList.toggle('hidden', state.view !== 'albums');
// Refresh the AZ jump rail (shows only for the grid + alphabetical // Refresh the AZ jump rail (shows only for the grid + alphabetical
// sorts; hides itself otherwise). Independent of the grid load. // sorts; hides itself otherwise). Independent of the grid load.
refreshRail(); refreshRail();
@@ -2564,7 +2624,7 @@
_applyMainScrollTop(0); _applyMainScrollTop(0);
return _ensureFolderLibrary().then(() => window.folderLibrary?.load()); return _ensureFolderLibrary().then(() => window.folderLibrary?.load());
} }
const loaded = state.view === 'grid' ? loadGrid(true) : loadTree(); const loaded = state.view === 'grid' ? loadGrid(true) : state.view === 'albums' ? loadAlbums() : loadTree();
_applyMainScrollTop(0); _applyMainScrollTop(0);
return loaded; return loaded;
} }
@@ -2625,7 +2685,7 @@
(providers.length > 1 ? '<select id="v3-songs-provider" class="' + ctrl + '">' + provOpts + '</select>' : '') + (providers.length > 1 ? '<select id="v3-songs-provider" class="' + ctrl + '">' + provOpts + '</select>' : '') +
'<select id="v3-songs-artist" class="' + ctrl + ' max-w-[11rem]" aria-label="Artist">' + artistSelectHtml() + '</select>' + '<select id="v3-songs-artist" class="' + ctrl + ' max-w-[11rem]" aria-label="Artist">' + artistSelectHtml() + '</select>' +
'<select id="v3-songs-album" class="' + ctrl + ' max-w-[11rem]" aria-label="Album"' + (state.artist ? '' : ' disabled') + '>' + albumSelectHtml() + '</select>' + '<select id="v3-songs-album" class="' + ctrl + ' max-w-[11rem]" aria-label="Album"' + (state.artist ? '' : ' disabled') + '>' + albumSelectHtml() + '</select>' +
'<div class="flex rounded-md overflow-hidden border border-gray-700"><button id="v3-songs-grid-btn" class="px-3 py-2 text-sm">▦</button><button id="v3-songs-tree-btn" class="px-3 py-2 text-sm">≣</button><button id="v3-songs-folder-btn" class="px-3 py-2 text-sm" style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:2.25rem"><svg fill="currentColor" viewBox="0 0 16 16" style="width:12px;height:12px;flex-shrink:0"><path d="M1 3.5A1.5 1.5 0 012.5 2h3.086a1.5 1.5 0 011.06.44l.915.914H13.5A1.5 1.5 0 0115 4.914V12.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5v-9z"/></svg></button></div>' + '<div class="flex rounded-md overflow-hidden border border-gray-700"><button id="v3-songs-grid-btn" class="px-3 py-2 text-sm">▦</button><button id="v3-songs-tree-btn" class="px-3 py-2 text-sm">≣</button><button id="v3-songs-albums-btn" title="Albums" class="px-3 py-2 text-sm">💿</button><button id="v3-songs-folder-btn" class="px-3 py-2 text-sm" style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:2.25rem"><svg fill="currentColor" viewBox="0 0 16 16" style="width:12px;height:12px;flex-shrink:0"><path d="M1 3.5A1.5 1.5 0 012.5 2h3.086a1.5 1.5 0 011.06.44l.915.914H13.5A1.5 1.5 0 0115 4.914V12.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5v-9z"/></svg></button></div>' +
'<select id="v3-songs-sort" class="' + ctrl + '">' + opt(SORTS, state.sort) + '</select>' + '<select id="v3-songs-sort" class="' + ctrl + '">' + opt(SORTS, state.sort) + '</select>' +
'<select id="v3-songs-format" class="' + ctrl + '">' + opt(FORMATS, state.format) + '</select>' + '<select id="v3-songs-format" class="' + ctrl + '">' + opt(FORMATS, state.format) + '</select>' +
'<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' + '<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' +
@@ -2645,6 +2705,7 @@
'<div id="v3-songs-grid" class="v3-grid-window grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"></div>' + '<div id="v3-songs-grid" class="v3-grid-window grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"></div>' +
'</div>' + '</div>' +
'<div id="v3-songs-tree" class="hidden"></div>' + '<div id="v3-songs-tree" class="hidden"></div>' +
'<div id="v3-songs-albums" class="hidden"></div>' +
'<div id="lib-folder-controls" style="display:none"></div>' + '<div id="lib-folder-controls" style="display:none"></div>' +
'<div id="lib-folder-tree" class="space-y-1 hidden"></div>' + '<div id="lib-folder-tree" class="space-y-1 hidden"></div>' +
'<div id="v3-songs-sentinel" class="h-8"></div>' + '<div id="v3-songs-sentinel" class="h-8"></div>' +
@@ -2736,12 +2797,14 @@
byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
byId('v3-songs-tree-btn').className = 'px-3 py-2 text-sm ' + (v === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); byId('v3-songs-tree-btn').className = 'px-3 py-2 text-sm ' + (v === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
byId('v3-songs-folder-btn').className = 'px-3 py-2 text-sm ' + (v === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); byId('v3-songs-folder-btn').className = 'px-3 py-2 text-sm ' + (v === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
{ const ab = byId('v3-songs-albums-btn'); if (ab) ab.className = 'px-3 py-2 text-sm ' + (v === 'albums' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); }
if (v === 'folder') await _ensureFolderLibrary(); if (v === 'folder') await _ensureFolderLibrary();
return reload(); return reload();
}; };
byId('v3-songs-grid-btn').addEventListener('click', () => setView('grid')); byId('v3-songs-grid-btn').addEventListener('click', () => setView('grid'));
byId('v3-songs-tree-btn').addEventListener('click', () => setView('tree')); byId('v3-songs-tree-btn').addEventListener('click', () => setView('tree'));
byId('v3-songs-folder-btn').addEventListener('click', () => setView('folder')); byId('v3-songs-folder-btn').addEventListener('click', () => setView('folder'));
byId('v3-songs-albums-btn')?.addEventListener('click', () => setView('albums'));
// Await the initial load so a caller awaiting render() (the scroll // Await the initial load so a caller awaiting render() (the scroll
// restore on screen re-entry) sees a populated grid + real state.total // restore on screen re-entry) sees a populated grid + real state.total
// before it tries to page deeper. // before it tries to page deeper.
+93
View File
@@ -0,0 +1,93 @@
"""Tests for the Albums-view follow-up (#689's client half): the feedpak
`track`/`disc` fields flowing scanner songs columns the `track` sort the
album track list orders by."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _put(server, fn, title, track=None, disc=None, album="The Album",
artist="Artist", genre=""):
server.meta_db.put(fn, 0, 0, {
"title": title, "artist": artist, "album": album, "year": "1990",
"duration": 100, "arrangements": [{"name": "Lead", "index": 0}],
"track_number": track, "disc": disc, "genre": genre,
})
def test_sloppak_extract_meta_reads_track_and_disc(server):
d = server.DLC_DIR / "a.sloppak"
d.mkdir(parents=True)
(d / "manifest.yaml").write_text(
"title: Song\nartist: Artist\nduration: 100\n"
"arrangements: []\nstems: []\ntrack: 7\ndisc: 2\n", encoding="utf-8")
import sloppak
meta = sloppak.extract_meta(d)
assert meta["track_number"] == 7
assert meta["disc"] == 2
# Unauthored → None (the album view falls back to title order).
(d / "manifest.yaml").write_text(
"title: Song\nartist: Artist\nduration: 100\n"
"arrangements: []\nstems: []\n", encoding="utf-8")
meta = sloppak.extract_meta(d)
assert meta["track_number"] is None
assert meta["disc"] is None
def test_track_sort_orders_by_disc_then_track_nulls_last(server, client):
_put(server, "d2t1.sloppak", "Zeta", track=1, disc=2)
_put(server, "d1t2.sloppak", "Yankee", track=2, disc=1)
_put(server, "d1t1.sloppak", "Xray", track=1, disc=1)
_put(server, "nonum-b.sloppak", "Bravo") # unauthored → bottom,
_put(server, "nonum-a.sloppak", "Alpha") # ordered by title
body = client.get("/api/library", params={
"artist": "Artist", "album": "The Album", "sort": "track", "size": 50}).json()
assert [s["filename"] for s in body["songs"]] == [
"d1t1.sloppak", "d1t2.sloppak", "d2t1.sloppak",
"nonum-a.sloppak", "nonum-b.sloppak"]
def test_track_and_disc_survive_put_roundtrip(server):
_put(server, "a.sloppak", "Song", track=3, disc=1)
row = server.meta_db.conn.execute(
"SELECT track_number, disc FROM songs WHERE filename = 'a.sloppak'").fetchone()
assert row == (3, 1)
def test_albums_endpoint_honours_genre_filter(server, client):
"""The albums grid must respect the Genre drawer filter the client sends —
without this the /api/library/albums route silently dropped `genre` and
surfaced albums with no matching tracks."""
_put(server, "rock.sloppak", "Rocker", album="Rock LP", genre="Rock")
_put(server, "jazz.sloppak", "Smooth", album="Jazz LP", genre="Jazz")
all_albums = client.get("/api/library/albums", params={"artist": "Artist"}).json()
assert {a["album"] for a in all_albums["albums"]} == {"Rock LP", "Jazz LP"}
filtered = client.get("/api/library/albums",
params={"artist": "Artist", "genre": "Rock"}).json()
assert [a["album"] for a in filtered["albums"]] == ["Rock LP"]