feat(library): sort and badge by personal difficulty rating

Adds sort=difficulty/difficulty-desc to the library API (correlated
subquery over song_user_meta.user_difficulty, unrated songs pushed to
the bottom either direction, same pattern as the existing mastery
sort) and surfaces the rating as a badge on library cards in both the
v2 grid/tree views and the v3 grid. The rating itself already existed
(song_user_meta) — this just makes it sortable and visible, so it's
no longer only readable in the per-song edit drawer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Matthew Harris Glover 2026-07-07 15:56:11 -04:00
parent 115c3529e9
commit 75673c3b47
4 changed files with 21 additions and 0 deletions

View File

@ -4107,6 +4107,18 @@ class MetadataDB:
"((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"
),
# Personal difficulty rating (song_user_meta.user_difficulty, 1..5 —
# manually set or seeded by the difficulty_tagger plugin), via a
# correlated subquery like mastery above (drops to OFFSET paging).
# Unrated songs push to the bottom in both directions.
"difficulty": (
"((SELECT user_difficulty FROM song_user_meta u WHERE u.filename = songs.filename) IS NULL) ASC, "
"(SELECT user_difficulty FROM song_user_meta u WHERE u.filename = songs.filename) ASC"
),
"difficulty-desc": (
"((SELECT user_difficulty FROM song_user_meta u WHERE u.filename = songs.filename) IS NULL) ASC, "
"(SELECT user_difficulty FROM song_user_meta u WHERE u.filename = songs.filename) DESC"
),
}
if group and sort in ("mastery", "mastery-desc"):
# Sort law (§7.1): mastery aggregates MAX across the WHOLE group —

View File

@ -1110,6 +1110,7 @@ const _LIB_VIEW_VALUES = new Set(['grid', 'tree', 'folder']);
const _LIB_SORT_VALUES = new Set([
'artist', 'artist-desc', 'title', 'title-desc',
'recent', 'year-desc', 'year', 'tuning',
'difficulty', 'difficulty-desc',
]);
const _LIB_FORMAT_VALUES = new Set(['', 'sloppak', 'loose']);
// Tree-view expand/collapse persistence. Three states per tree:
@ -2078,6 +2079,7 @@ function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace') {
${(() => { const _nm = _getArrangementNamingMode(); return (song.arrangements || []).map(a => _arrangementBadgeHtml(a, _nm)).join(''); })()}
${tuning ? `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>` : ''}
${song.has_lyrics ? `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>` : ''}
${song.user_difficulty != null ? `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${song.user_difficulty}</span>` : ''}
${duration ? `<span class="text-gray-600">${duration}</span>` : ''}
</div>
${retuneBtn}
@ -2277,6 +2279,8 @@ async function renderTreeInto(containerId, countId, stats, letter, q, favoritesO
html += `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>`;
if (song.has_lyrics)
html += `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>`;
if (song.user_difficulty != null)
html += `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${song.user_difficulty}</span>`;
if (duration)
html += `<span class="text-gray-600 w-10 text-right">${duration}</span>`;
if (stdRetune)

View File

@ -119,6 +119,8 @@
<option value="year-desc">Year (newest)</option>
<option value="year">Year (oldest)</option>
<option value="tuning">Tuning</option>
<option value="difficulty">Difficulty (easiest first)</option>
<option value="difficulty-desc">Difficulty (hardest first)</option>
</select>
<!-- Format filter (shared) -->
<select id="lib-format" onchange="sortLibrary()"

View File

@ -38,6 +38,9 @@
// 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'],
// Personal difficulty (song_user_meta.user_difficulty, 1-5); unrated
// songs sort last either way.
['difficulty', 'Difficulty (easiest first)'], ['difficulty-desc', 'Difficulty (hardest first)'],
];
const FORMATS = [['', 'All formats'], ['sloppak', 'Feedpak'], ['loose', 'Folder']];
const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals'];