From 744c848636e973b6f42a175e6da91d809dd86338 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Wed, 1 Jul 2026 06:08:57 -0500 Subject: [PATCH] v3 library: mastery filter + sort (needs-practice / most-mastered) (#687) * feat(v3): sort the library by mastery (needs-practice / most-mastered) Adds two sort options to the Songs library: "Needs practice first" (weakest measured accuracy first) and "Most mastered first". Mastery = MAX(best_accuracy) across a song's arrangements, from song_stats; because that's a separate table it's a correlated subquery in the ORDER BY, so these sorts use OFFSET paging like tuning/year. Unscored ("not started") songs always sort to the bottom in both directions, so a large unpracticed library doesn't bury the songs you're actually working on. Never the default. Verified against a running server: scored songs at 0.90 / 0.30 plus unscored -> ascending orders 0.30, 0.90, unscored; descending 0.90, 0.30, unscored. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * feat(v3): add the 3-state mastery filter (Mastered / In progress / Not started) Complements the mastery sort with the gentler filter the charrette preferred: a "Progress" facet in the Filters drawer with Mastered (>= 0.9), In progress (attempted but < 0.9), and Not started (no score) -- multi-select, OR within the set. Server-side via a correlated subquery on song_stats threaded through _build_where / query_page (passed as a separate kwarg so query_artists / query_stats are unaffected). Smart-collection providers ignore it (they define their own filters). Verified live: mastered -> the 0.90 song, in_progress -> the 0.30 song, not_started -> the unscored songs, combined -> both scored (with correct totals). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF --------- Co-authored-by: Claude Opus 4.8 --- server.py | 34 ++++++++++++++++++++++++++++++++-- static/v3/songs.js | 15 ++++++++++++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/server.py b/server.py index e619de9..e0f4f23 100644 --- a/server.py +++ b/server.py @@ -1929,6 +1929,7 @@ class MetadataDB: stems_lacks: list[str] | None = None, has_lyrics: int | None = None, tunings: list[str] | None = None, + mastery: list[str] | None = None, naming_mode: str = "legacy") -> tuple[str, list]: """Shared WHERE-clause builder for query_page / query_artists / query_stats. Returns (where_sql, params). Leading 'WHERE' is @@ -1947,6 +1948,19 @@ class MetadataDB: if album_filter: where += " AND album = ? COLLATE NOCASE" params.append(album_filter) + # Mastery bands = best accuracy across a song's arrangements (song_stats, + # a separate table -> correlated subquery). mastered >= 0.9, in_progress = + # attempted but < 0.9, not_started = no score. OR within the selected set. + if mastery: + _msub = "(SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename)" + _bands = { + "mastered": f"{_msub} >= 0.9", + "in_progress": f"({_msub} IS NOT NULL AND {_msub} < 0.9)", + "not_started": f"{_msub} IS NULL", + } + _sel = [_bands[b] for b in mastery if b in _bands] + if _sel: + where += " AND (" + " OR ".join(_sel) + ")" if q: where += " AND (title LIKE ? COLLATE NOCASE OR artist LIKE ? COLLATE NOCASE OR album LIKE ? COLLATE NOCASE)" params += [f"%{q}%"] * 3 @@ -2099,6 +2113,7 @@ class MetadataDB: stems_lacks: list[str] | None = None, has_lyrics: int | None = None, tunings: list[str] | None = None, + mastery: list[str] | None = None, after: str | None = None, naming_mode: str = "legacy") -> tuple[list[dict], int]: """Server-side paginated search. Returns (songs, total_count). @@ -2112,7 +2127,7 @@ class MetadataDB: artist_filter=artist_filter, album_filter=album_filter, arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks, stems_has=stems_has, stems_lacks=stems_lacks, - has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode, + has_lyrics=has_lyrics, tunings=tunings, mastery=mastery, naming_mode=naming_mode, ) sort_map = { @@ -2153,6 +2168,20 @@ class MetadataDB: # '2005' rather than alphabetic. "year": "(year = '') ASC, CAST(year AS INTEGER) ASC", "year-desc": "(year = '') ASC, CAST(year AS INTEGER) DESC", + # 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") + # songs push to the BOTTOM in both directions (the IS NULL term); + # ascending is "needs practice first" (weakest measured first), + # descending is "most mastered first". + "mastery": ( + "((SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) IS NULL) ASC, " + "(SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) ASC" + ), + "mastery-desc": ( + "((SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) IS NULL) ASC, " + "(SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) DESC" + ), } order = sort_map.get(sort, "artist COLLATE NOCASE") # Legacy `dir=desc` toggle: only safe to append on simple sort @@ -4950,7 +4979,7 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = " arrangements_has: str = "", arrangements_lacks: str = "", stems_has: str = "", stems_lacks: str = "", has_lyrics: str = "", tunings: str = "", provider: str = "local", - after: str = "", naming_mode: str = "legacy"): + mastery: str = "", after: str = "", naming_mode: str = "legacy"): """Paginated library search through the selected library provider. `after` is an opaque keyset cursor (feedBack#636 item 3): pass back the @@ -4974,6 +5003,7 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = " direction=dir, after=((after or None) if is_local else None), naming_mode=naming_mode, + mastery=_split_csv(mastery), **_library_filter_args( q=q, favorites=favorites, format=format, artist=artist, album=album, diff --git a/static/v3/songs.js b/static/v3/songs.js index 2db18cb..227422e 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -35,6 +35,9 @@ ['title', 'Title A–Z'], ['title-desc', 'Title Z–A'], ['recent', 'Recently Added'], ['year-desc', 'Year (newest)'], ['year', 'Year (oldest)'], ['tuning', 'Tuning'], + // Mastery = best accuracy across arrangements (song_stats); unscored songs + // sort last either way. Ascending surfaces what needs work; never default. + ['mastery', 'Needs practice first'], ['mastery-desc', 'Most mastered first'], ]; const FORMATS = [['', 'All formats'], ['sloppak', 'Feedpak'], ['loose', 'Folder']]; const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals']; @@ -54,7 +57,7 @@ const state = { provider: 'local', view: 'grid', sort: 'artist', format: '', q: '', artist: '', album: '', - filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [] }, + filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [] }, page: 0, total: 0, loading: false, built: false, accuracy: {}, tuningNames: [], artistCatalog: [], renderedHash: '', scrollBound: false, @@ -104,7 +107,8 @@ function activeFilterCount() { const f = state.filters; return f.arr_has.length + f.arr_lacks.length + f.stem_has.length + f.stem_lacks.length + - (f.lyrics ? 1 : 0) + f.tunings.length + (state.artist ? 1 : 0) + (state.album ? 1 : 0); + (f.lyrics ? 1 : 0) + f.tunings.length + (f.mastery ? f.mastery.length : 0) + + (state.artist ? 1 : 0) + (state.album ? 1 : 0); } function _getV3MainScroller() { return document.getElementById('v3-main'); } @@ -126,6 +130,7 @@ stem_lacks: [...(f.stem_lacks || [])].sort(), lyrics: f.lyrics || '', tunings: [...(f.tunings || [])].sort(), + mastery: [...(f.mastery || [])].sort(), }, }); } @@ -239,6 +244,7 @@ if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(',')); if (f.lyrics) p.set('has_lyrics', f.lyrics); if (f.tunings.length) p.set('tunings', f.tunings.join(',')); + if (f.mastery && f.mastery.length) p.set('mastery', f.mastery.join(',')); Object.entries(extra || {}).forEach(([k, v]) => p.set(k, v)); return p; } @@ -1589,6 +1595,8 @@ section('Arrangements', ARRANGEMENTS.map((a) => triPill('arr', a, a, triState(f.arr_has, f.arr_lacks, a))).join('')) + section('Stems (sloppak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) + section('Lyrics', ['', '1', '0'].map((v) => '').join('')) + + // Progress (mastery bands) — multi-select; server filters via song_stats. + section('Progress', [['mastered', 'Mastered'], ['in_progress', 'In progress'], ['not_started', 'Not started']].map((it) => '').join('')) + section('Tuning', (state.tuningNames || []).map((t) => { // Filter on the server's grouping key (raw offsets for customs) // so two "Custom Tuning" entries are distinct; show their target @@ -1620,10 +1628,11 @@ renderDrawer(); })); d.querySelectorAll('[data-lyrics]').forEach((b) => b.addEventListener('click', () => { f.lyrics = b.getAttribute('data-lyrics'); renderDrawer(); })); + d.querySelectorAll('[data-mastery]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-mastery'); const i = f.mastery.indexOf(v); if (i >= 0) f.mastery.splice(i, 1); else f.mastery.push(v); renderDrawer(); })); d.querySelector('[data-drawer-save]')?.addEventListener('click', saveCurrentAsCollection); d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer); d.querySelector('[data-drawer-clear]')?.addEventListener('click', async () => { - state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [] }; + state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [] }; state.artist = ''; state.album = ''; renderDrawer();