mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-16 13:43:19 +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 ""),
|
"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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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 (!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 A–Z jump rail (shows only for the grid + alphabetical
|
// Refresh the A–Z 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.
|
||||||
|
|||||||
@@ -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