mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-28 23:52:16 +00:00
feat(library): sort and badge by personal difficulty rating (#810)
* 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>
* fix(library): escape difficulty badge, wire tree view, add changelog+tests
- Wrap song.user_difficulty in esc() at both badge call sites
(static/app.js ~2082 and ~2283) for XSS-consistency with the
sibling tuning badge, which already uses esc().
- server.py: query_artists (the classic tree view's data source, used
by /api/library/artists) never batch-attached user_difficulty the
way query_page does for the grid, so the tree-view difficulty badge
added in 75673c3 was unreachable dead code (song.user_difficulty was
always undefined there). Now attaches it via the existing
user_meta_map() helper, same pattern as query_page.
- Add an [Unreleased] CHANGELOG.md entry for the difficulty sort +
badge feature, matching the repo's existing entry format.
- Add tests/test_library_filters.py::test_difficulty_sort_pushes_unrated_to_bottom
asserting unrated songs sort to the bottom in both sort=difficulty
and sort=difficulty-desc directions, and
::test_tree_view_songs_carry_user_difficulty covering the
query_artists fix above.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(library): chunk user_meta_map + rebuild stale tailwind css
Address review-bot findings on the difficulty sort/badge:
- user_meta_map now chunks filenames into 400-row batches (like
overrides_map) before the IN (...) query. query_artists (tree view)
passes every song across up to 50 artists, which could push the
placeholder count past SQLite's older variable limit; query_page's
small pages are unaffected. (CodeRabbit: Stability & Availability)
- Rebuild static/tailwind.min.css: the ◆N difficulty badge introduced
bg-blue-900/30 + text-blue-300, which were never compiled into the
committed stylesheet, failing the tailwind-fresh CI gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
parent
e446b05a99
commit
fadaa154e9
@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Sort and filter the library by your personal difficulty rating — now visible at a glance, not just in the edit drawer.** `song_user_meta.user_difficulty` (the 1–5 planning rating, settable manually or seeded by a plugin like the community `difficulty_tagger`) already existed but was only readable by opening a song's per-song details drawer. The library API gains `sort=difficulty` / `sort=difficulty-desc` — a correlated subquery over `song_user_meta`, following the same unrated-rows-sort-to-the-bottom-in-both-directions pattern as the existing `mastery` sort — and library cards now show the rating as a `◆N` badge (v2 grid/tree views and the v3 grid alike), next to the tuning and lyrics badges. The classic tree view's `query_artists` batch-attaches `user_difficulty` the same way `query_page` already did for the grid, so the badge actually renders there too instead of staying dark. Tests: `tests/test_library_filters.py::test_difficulty_sort_pushes_unrated_to_bottom`, `tests/test_library_filters.py::test_tree_view_songs_carry_user_difficulty`.
|
||||
- **`lib/midi_import.py`: `convert_midi_tempo_map` — MIDI imports can finally carry
|
||||
their bars.** The keys/drums note converters always computed a tempo-aware
|
||||
tick→seconds map internally (to bake note times to absolute seconds) and then threw
|
||||
|
||||
43
server.py
43
server.py
@ -1348,16 +1348,23 @@ class MetadataDB:
|
||||
return [{"tag": r[0], "count": r[1]} for r in rows]
|
||||
|
||||
def user_meta_map(self, filenames) -> dict:
|
||||
"""Batch {filename: user_difficulty} for a page of rows (set values
|
||||
only). Lets query_page embed difficulty without an N+1."""
|
||||
"""Batch {filename: user_difficulty} for a set of rows (set values
|
||||
only). Lets query_page / query_artists embed difficulty without an
|
||||
N+1. Chunked under SQLite's variable limit — query_artists can pass
|
||||
every song across 50 artists, well past a single IN (...)."""
|
||||
fns = list(filenames)
|
||||
if not fns:
|
||||
return {}
|
||||
ph = ",".join("?" * len(fns))
|
||||
rows = self.conn.execute(
|
||||
f"SELECT filename, user_difficulty FROM song_user_meta "
|
||||
f"WHERE filename IN ({ph}) AND user_difficulty IS NOT NULL", fns).fetchall()
|
||||
return {r[0]: r[1] for r in rows}
|
||||
out: dict = {}
|
||||
for i in range(0, len(fns), 400):
|
||||
chunk = fns[i:i + 400]
|
||||
if not chunk:
|
||||
break
|
||||
ph = ",".join("?" * len(chunk))
|
||||
rows = self.conn.execute(
|
||||
f"SELECT filename, user_difficulty FROM song_user_meta "
|
||||
f"WHERE filename IN ({ph}) AND user_difficulty IS NOT NULL", chunk).fetchall()
|
||||
for fn, diff in rows:
|
||||
out[fn] = diff
|
||||
return out
|
||||
|
||||
def tags_map(self, filenames) -> dict:
|
||||
"""Batch {filename: [tags]} for a page of rows."""
|
||||
@ -4107,6 +4114,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 —
|
||||
@ -4381,6 +4400,11 @@ class MetadataDB:
|
||||
from collections import OrderedDict
|
||||
estd = self._estd_set()
|
||||
favs = self.favorite_set()
|
||||
# Personal difficulty rides along here too (feedBack#810 follow-up),
|
||||
# same batched pattern as query_page — without this the tree view's
|
||||
# difficulty badge silently never renders (song.user_difficulty was
|
||||
# always undefined for every row).
|
||||
udm = self.user_meta_map([r[0] for r in rows])
|
||||
artists = OrderedDict()
|
||||
for r in rows:
|
||||
artist = r[2] or "Unknown Artist"
|
||||
@ -4402,6 +4426,7 @@ class MetadataDB:
|
||||
"tuning_name": r[12] or "",
|
||||
"has_estd": r[0] in estd,
|
||||
"favorite": r[0] in favs,
|
||||
"user_difficulty": udm.get(r[0]),
|
||||
})
|
||||
|
||||
# Pick most common name variant per artist/album
|
||||
|
||||
@ -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">◆${esc(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">◆${esc(song.user_difficulty)}</span>`;
|
||||
if (duration)
|
||||
html += `<span class="text-gray-600 w-10 text-right">${duration}</span>`;
|
||||
if (stdRetune)
|
||||
|
||||
@ -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()"
|
||||
|
||||
2
static/tailwind.min.css
vendored
2
static/tailwind.min.css
vendored
File diff suppressed because one or more lines are too long
@ -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'];
|
||||
|
||||
@ -293,6 +293,45 @@ def test_year_sort_asc_oldest_first(client, seeded):
|
||||
assert files == ["b.archive", "a.archive", "f.archive", "d.sloppak", "c.sloppak", "e.sloppak"]
|
||||
|
||||
|
||||
def test_difficulty_sort_pushes_unrated_to_bottom(client, server_mod):
|
||||
"""Personal difficulty (song_user_meta.user_difficulty) sorts like
|
||||
mastery: an unrated (NULL) row must fall to the bottom in BOTH
|
||||
directions rather than colliding with a real 1..5 rating at either
|
||||
end."""
|
||||
_put(server_mod, filename="easy.archive", title="Easy", artist="A",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
_put(server_mod, filename="hard.archive", title="Hard", artist="B",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
_put(server_mod, filename="unrated.archive", title="Unrated", artist="C",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
server_mod.meta_db.set_song_user_meta("easy.archive", user_difficulty=1)
|
||||
server_mod.meta_db.set_song_user_meta("hard.archive", user_difficulty=5)
|
||||
|
||||
asc = [s["filename"] for s in _get(client, sort="difficulty")["songs"]]
|
||||
assert asc == ["easy.archive", "hard.archive", "unrated.archive"]
|
||||
|
||||
desc = [s["filename"] for s in _get(client, sort="difficulty-desc")["songs"]]
|
||||
assert desc == ["hard.archive", "easy.archive", "unrated.archive"]
|
||||
|
||||
|
||||
def test_tree_view_songs_carry_user_difficulty(client, server_mod):
|
||||
"""`/api/library/artists` (the classic tree view's `query_artists`) must
|
||||
batch-attach `user_difficulty` the same way `query_page` does for the
|
||||
grid — otherwise the tree view's difficulty badge silently never
|
||||
renders (song.user_difficulty stays undefined for every row)."""
|
||||
_put(server_mod, filename="rated.archive", title="Rated", artist="A",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
_put(server_mod, filename="unrated.archive", title="Unrated", artist="A",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
server_mod.meta_db.set_song_user_meta("rated.archive", user_difficulty=4)
|
||||
|
||||
data = client.get("/api/library/artists").json()
|
||||
songs = data["artists"][0]["albums"][0]["songs"]
|
||||
by_filename = {s["filename"]: s for s in songs}
|
||||
assert by_filename["rated.archive"]["user_difficulty"] == 4
|
||||
assert by_filename["unrated.archive"]["user_difficulty"] is None
|
||||
|
||||
|
||||
def test_tuning_sort_down_tuned_before_up_tuned_at_same_distance(client, server_mod):
|
||||
"""Within an ABS(tuning_sort_key) tier, the down-tuned variant
|
||||
must come before the up-tuned one so the order matches the chart's
|
||||
|
||||
Loading…
Reference in New Issue
Block a user