mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 04:34:30 +00:00
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:
co-authored by
Claude Fable 5
byrongamatos
parent
2e4383524f
commit
0a8c8945ea
@@ -921,6 +921,10 @@ def extract_meta(path: Path) -> dict:
|
||||
"year": str(manifest.get("year", "") or ""),
|
||||
# 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")),
|
||||
# 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),
|
||||
"tuning_offsets": tuning_offsets, # caller maps to a name via tunings.tuning_name
|
||||
"arrangements": arrangements,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+66
-3
@@ -153,7 +153,7 @@
|
||||
if (!saved || typeof saved !== 'object') return;
|
||||
if (SORTS.some(([v]) => v === saved.sort)) state.sort = saved.sort;
|
||||
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;
|
||||
const f = saved.filters;
|
||||
if (f && typeof f === 'object') {
|
||||
@@ -2000,6 +2000,65 @@
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
const host = document.getElementById('v3-songs-tree');
|
||||
if (!host) return;
|
||||
@@ -2553,6 +2612,7 @@
|
||||
document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid');
|
||||
document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
|
||||
document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
|
||||
document.getElementById('v3-songs-albums')?.classList.toggle('hidden', state.view !== 'albums');
|
||||
// Refresh the A–Z jump rail (shows only for the grid + alphabetical
|
||||
// sorts; hides itself otherwise). Independent of the grid load.
|
||||
refreshRail();
|
||||
@@ -2564,7 +2624,7 @@
|
||||
_applyMainScrollTop(0);
|
||||
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);
|
||||
return loaded;
|
||||
}
|
||||
@@ -2625,7 +2685,7 @@
|
||||
(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-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-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>' +
|
||||
@@ -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>' +
|
||||
'<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-tree" class="space-y-1 hidden"></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-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');
|
||||
{ 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();
|
||||
return reload();
|
||||
};
|
||||
byId('v3-songs-grid-btn').addEventListener('click', () => setView('grid'));
|
||||
byId('v3-songs-tree-btn').addEventListener('click', () => setView('tree'));
|
||||
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
|
||||
// restore on screen re-entry) sees a populated grid + real state.total
|
||||
// before it tries to page deeper.
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user