mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 19:29:33 +00:00
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
41cdfd576a
commit
744c848636
@@ -1929,6 +1929,7 @@ class MetadataDB:
|
|||||||
stems_lacks: list[str] | None = None,
|
stems_lacks: list[str] | None = None,
|
||||||
has_lyrics: int | None = None,
|
has_lyrics: int | None = None,
|
||||||
tunings: list[str] | None = None,
|
tunings: list[str] | None = None,
|
||||||
|
mastery: list[str] | None = None,
|
||||||
naming_mode: str = "legacy") -> tuple[str, list]:
|
naming_mode: str = "legacy") -> tuple[str, list]:
|
||||||
"""Shared WHERE-clause builder for query_page / query_artists /
|
"""Shared WHERE-clause builder for query_page / query_artists /
|
||||||
query_stats. Returns (where_sql, params). Leading 'WHERE' is
|
query_stats. Returns (where_sql, params). Leading 'WHERE' is
|
||||||
@@ -1947,6 +1948,19 @@ class MetadataDB:
|
|||||||
if album_filter:
|
if album_filter:
|
||||||
where += " AND album = ? COLLATE NOCASE"
|
where += " AND album = ? COLLATE NOCASE"
|
||||||
params.append(album_filter)
|
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:
|
if q:
|
||||||
where += " AND (title LIKE ? COLLATE NOCASE OR artist LIKE ? COLLATE NOCASE OR album LIKE ? COLLATE NOCASE)"
|
where += " AND (title LIKE ? COLLATE NOCASE OR artist LIKE ? COLLATE NOCASE OR album LIKE ? COLLATE NOCASE)"
|
||||||
params += [f"%{q}%"] * 3
|
params += [f"%{q}%"] * 3
|
||||||
@@ -2099,6 +2113,7 @@ class MetadataDB:
|
|||||||
stems_lacks: list[str] | None = None,
|
stems_lacks: list[str] | None = None,
|
||||||
has_lyrics: int | None = None,
|
has_lyrics: int | None = None,
|
||||||
tunings: list[str] | None = None,
|
tunings: list[str] | None = None,
|
||||||
|
mastery: list[str] | None = None,
|
||||||
after: str | None = None,
|
after: str | None = None,
|
||||||
naming_mode: str = "legacy") -> tuple[list[dict], int]:
|
naming_mode: str = "legacy") -> tuple[list[dict], int]:
|
||||||
"""Server-side paginated search. Returns (songs, total_count).
|
"""Server-side paginated search. Returns (songs, total_count).
|
||||||
@@ -2112,7 +2127,7 @@ class MetadataDB:
|
|||||||
artist_filter=artist_filter, album_filter=album_filter,
|
artist_filter=artist_filter, album_filter=album_filter,
|
||||||
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, naming_mode=naming_mode,
|
has_lyrics=has_lyrics, tunings=tunings, mastery=mastery, naming_mode=naming_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
sort_map = {
|
sort_map = {
|
||||||
@@ -2153,6 +2168,20 @@ 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",
|
||||||
|
# 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")
|
order = sort_map.get(sort, "artist COLLATE NOCASE")
|
||||||
# Legacy `dir=desc` toggle: only safe to append on simple sort
|
# 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 = "",
|
arrangements_has: str = "", arrangements_lacks: str = "",
|
||||||
stems_has: str = "", stems_lacks: str = "",
|
stems_has: str = "", stems_lacks: str = "",
|
||||||
has_lyrics: str = "", tunings: str = "", provider: str = "local",
|
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.
|
"""Paginated library search through the selected library provider.
|
||||||
|
|
||||||
`after` is an opaque keyset cursor (feedBack#636 item 3): pass back the
|
`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,
|
direction=dir,
|
||||||
after=((after or None) if is_local else None),
|
after=((after or None) if is_local else None),
|
||||||
naming_mode=naming_mode,
|
naming_mode=naming_mode,
|
||||||
|
mastery=_split_csv(mastery),
|
||||||
**_library_filter_args(
|
**_library_filter_args(
|
||||||
q=q, favorites=favorites, format=format,
|
q=q, favorites=favorites, format=format,
|
||||||
artist=artist, album=album,
|
artist=artist, album=album,
|
||||||
|
|||||||
+12
-3
@@ -35,6 +35,9 @@
|
|||||||
['title', 'Title A–Z'], ['title-desc', 'Title Z–A'],
|
['title', 'Title A–Z'], ['title-desc', 'Title Z–A'],
|
||||||
['recent', 'Recently Added'], ['year-desc', 'Year (newest)'],
|
['recent', 'Recently Added'], ['year-desc', 'Year (newest)'],
|
||||||
['year', 'Year (oldest)'], ['tuning', 'Tuning'],
|
['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 FORMATS = [['', 'All formats'], ['sloppak', 'Feedpak'], ['loose', 'Folder']];
|
||||||
const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals'];
|
const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals'];
|
||||||
@@ -54,7 +57,7 @@
|
|||||||
const state = {
|
const state = {
|
||||||
provider: 'local', view: 'grid', sort: 'artist', format: '', q: '',
|
provider: 'local', view: 'grid', sort: 'artist', format: '', q: '',
|
||||||
artist: '', album: '',
|
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: [],
|
page: 0, total: 0, loading: false, built: false, accuracy: {}, tuningNames: [],
|
||||||
artistCatalog: [], renderedHash: '',
|
artistCatalog: [], renderedHash: '',
|
||||||
scrollBound: false,
|
scrollBound: false,
|
||||||
@@ -104,7 +107,8 @@
|
|||||||
function activeFilterCount() {
|
function activeFilterCount() {
|
||||||
const f = state.filters;
|
const f = state.filters;
|
||||||
return f.arr_has.length + f.arr_lacks.length + f.stem_has.length + f.stem_lacks.length +
|
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'); }
|
function _getV3MainScroller() { return document.getElementById('v3-main'); }
|
||||||
@@ -126,6 +130,7 @@
|
|||||||
stem_lacks: [...(f.stem_lacks || [])].sort(),
|
stem_lacks: [...(f.stem_lacks || [])].sort(),
|
||||||
lyrics: f.lyrics || '',
|
lyrics: f.lyrics || '',
|
||||||
tunings: [...(f.tunings || [])].sort(),
|
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.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(','));
|
||||||
if (f.lyrics) p.set('has_lyrics', f.lyrics);
|
if (f.lyrics) p.set('has_lyrics', f.lyrics);
|
||||||
if (f.tunings.length) p.set('tunings', f.tunings.join(','));
|
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));
|
Object.entries(extra || {}).forEach(([k, v]) => p.set(k, v));
|
||||||
return p;
|
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('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('Stems (sloppak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) +
|
||||||
section('Lyrics', ['', '1', '0'].map((v) => '<button data-lyrics="' + v + '" class="px-2 py-1 rounded-md text-xs border ' + (f.lyrics === v ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + (v === '' ? 'Any' : v === '1' ? 'Has lyrics' : 'No lyrics') + '</button>').join('')) +
|
section('Lyrics', ['', '1', '0'].map((v) => '<button data-lyrics="' + v + '" class="px-2 py-1 rounded-md text-xs border ' + (f.lyrics === v ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + (v === '' ? 'Any' : v === '1' ? 'Has lyrics' : 'No lyrics') + '</button>').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) => '<button data-mastery="' + it[0] + '" class="px-2 py-1 rounded-md text-xs border ' + (f.mastery.includes(it[0]) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + it[1] + '</button>').join('')) +
|
||||||
section('Tuning', (state.tuningNames || []).map((t) => {
|
section('Tuning', (state.tuningNames || []).map((t) => {
|
||||||
// Filter on the server's grouping key (raw offsets for customs)
|
// Filter on the server's grouping key (raw offsets for customs)
|
||||||
// so two "Custom Tuning" entries are distinct; show their target
|
// so two "Custom Tuning" entries are distinct; show their target
|
||||||
@@ -1620,10 +1628,11 @@
|
|||||||
renderDrawer();
|
renderDrawer();
|
||||||
}));
|
}));
|
||||||
d.querySelectorAll('[data-lyrics]').forEach((b) => b.addEventListener('click', () => { f.lyrics = b.getAttribute('data-lyrics'); 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-save]')?.addEventListener('click', saveCurrentAsCollection);
|
||||||
d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer);
|
d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer);
|
||||||
d.querySelector('[data-drawer-clear]')?.addEventListener('click', async () => {
|
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.artist = '';
|
||||||
state.album = '';
|
state.album = '';
|
||||||
renderDrawer();
|
renderDrawer();
|
||||||
|
|||||||
Reference in New Issue
Block a user