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:
Matthew Harris Glover
2026-07-07 23:57:20 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent e446b05a99
commit fadaa154e9
7 changed files with 84 additions and 10 deletions
+34 -9
View File
@@ -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