feat(playlists): flag songs that are not in your current tuning (#1009)

* feat(playlists): flag songs that are not in your current tuning

Making the library's tuning filter instrument-aware does not repair playlists
already built under the old guitar-first behaviour. Those keep their
wrong-tuning songs, so a player still hits a surprise retune mid-practice and
reasonably concludes nothing was fixed.

Adds a per-playlist check: each row is marked against the player's current
tuning, with a summary ("3 of 24 songs are not in your tuning"), a filter to
show only those, and an explicit removal that lists every affected song by
title and states they stay in the library. Flagging is the feature -- nothing
is ever removed without being asked for, and removal reuses the existing
per-song DELETE rather than adding a bulk destructive endpoint.

Reuses the tuner capability's coverage report and `window.feedBack
.workingTuning`, the same pair the library cards already score against,
rather than introducing another source of truth.

Two deliberate departures:
- A coverage report reads "not covered" both for a real mismatch and for a
  bail-out it could not evaluate. Only a report carrying an actual reason
  counts as a mismatch; the rest render as unknown. This differs from the
  library grid, which paints every not-covered song amber -- acceptable on a
  grid, not on a hand-curated playlist where a false warning costs trust.
- With no tuning perspective available it makes no claim at all, rather than
  defaulting to guitar and reproducing the original bug in a new place.

Playlist rows carry `tuning_offsets` and `bass_only`; a tuning *name* cannot
be scored, since two "Custom Tuning" rows are different tunings.

Fully correct once the instrument-aware tuning filter lands. That dependency
is confined to `rowTuningForCheck()` in static/v3/playlists.js, marked SEAM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFDokqh2H6mEjk1Kgbi6JW
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* build(tailwind): regenerate for the playlist tuning-check classes

CI's tailwind-fresh gate rebuilds static/tailwind.min.css and hard-fails if
the committed file differs. The new chip/summary/filter markup introduces
classes the previous build never saw.

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* fix(playlists): stay within the shipped Tailwind class set

Reverts the regenerated static/tailwind.min.css and reworks the tuning-check
markup to use only classes already in the committed sheet.

Regenerating that file is not reproducible off CI: nothing pins tailwindcss,
autoprefixer or caniuse-lite, so a local `npx -y tailwindcss@3.4.19` resolves
different browser data and rewrites unrelated bytes -- a clean checkout of
main rebuilds with the -webkit-backdrop-filter prefixes dropped. Committing
that output fails the tailwind-fresh gate no matter how many times it is
regenerated.

Six utilities were new: bg-fb-good/10, border-fb-accent/50,
hover:bg-fb-accent/10, list-disc, list-inside, max-h-48, plus gap-x-3/gap-y-2.
Substituted bg-fb-good/30, the amber border already used by the mismatch
state, hover:bg-fb-card, a literal bullet in a div, max-h-32 and gap-3. Visual
intent is unchanged.

The removal-confirm test pinned the <li> markup; it now accepts either
wrapper, since what it guards is that every song is named and escaped ahead
of any DELETE, not which element wraps it.

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* Use instrument tuning in playlist checks

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ChrisBeWithYou
2026-07-19 00:10:53 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent cc75cb876a
commit 1745b13ba7
4 changed files with 648 additions and 3 deletions
+43 -2
View File
@@ -125,6 +125,24 @@ def _put_perspective_value(meta: dict, col: str):
# ── SQLite metadata cache ─────────────────────────────────────────────────────
def _arrangements_all_bass(raw) -> bool:
"""True when EVERY arrangement on a chart is a bass part (raw ``arrangements``
JSON, as stored). Mirrors the library grid's card rule: such a chart's tuning
must be scored against bass base pitches, or a 4-string bass tuning read as
guitar can false-match a guitarist. A chart with no arrangements is not bass.
"""
try:
arrs = json.loads(raw) if raw else []
except (ValueError, TypeError):
return False
if not isinstance(arrs, list) or not arrs:
return False
return all(
isinstance(a, dict) and re.search(r"\bbass\b", str(a.get("name") or ""), re.I)
for a in arrs
)
def _ensure_smart_names(arrangements: list[dict]) -> list[dict]:
"""Fill in missing ``smart_name`` fields and sort arrangements by smart order.
@@ -2697,7 +2715,9 @@ class MetadataDB:
rows = self.conn.execute(
f"""SELECT ps.filename, ps.position, s.title, s.artist, s.tuning_name,
ps.arrangement, ps.work_key, s.arrangements,
(s.filename IS NULL) AS dead
(s.filename IS NULL) AS dead, s.tuning_offsets,
s.bass_tuning_name, s.bass_tuning_offsets,
s.rhythm_tuning_name, s.rhythm_tuning_offsets
FROM playlist_songs ps LEFT JOIN songs s ON s.filename = ps.filename
WHERE ps.playlist_id = ? {dead_filter}
ORDER BY ps.position, ps.filename""",
@@ -2709,6 +2729,17 @@ class MetadataDB:
entry = {
"filename": r[0], "position": r[1],
"title": r[2] or r[0], "artist": r[3] or "", "tuning_name": r[4] or "",
# Offsets + the bass-only flag let the playlist tuning check score a
# row against the player's working tuning the same way the library
# grid's chips do: a NAME alone can't be scored (two "Custom Tuning"
# rows are different tunings), and coverage needs to know whether to
# measure against bass or guitar base pitches.
"tuning_offsets": r[9] or "",
"bass_tuning_name": r[10] or "",
"bass_tuning_offsets": r[11] or "",
"rhythm_tuning_name": r[12] or "",
"rhythm_tuning_offsets": r[13] or "",
"bass_only": _arrangements_all_bass(r[7]),
"art_url": f"/api/song/{quote(r[0])}/art",
}
if is_album:
@@ -2736,7 +2767,9 @@ class MetadataDB:
if work_key:
self._ensure_work_display()
row = self.conn.execute(
"SELECT wd.filename, s.title, s.artist, s.tuning_name, s.arrangements "
"SELECT wd.filename, s.title, s.artist, s.tuning_name, s.arrangements, "
"s.tuning_offsets, s.bass_tuning_name, s.bass_tuning_offsets, "
"s.rhythm_tuning_name, s.rhythm_tuning_offsets "
"FROM work_display wd JOIN songs s ON s.filename = wd.filename "
"WHERE wd.effective_work_key = ? AND wd.is_group_representative = 1",
(work_key,)).fetchone()
@@ -2746,8 +2779,16 @@ class MetadataDB:
arrs = _ensure_smart_names(json.loads(row[4]) if row[4] else [])
except Exception:
arrs = []
# An orphan-resolved slot PLAYS a different chart, so it must report
# that chart's tuning to the check — not the dead pin's.
return {"resolved_filename": row[0], "title": row[1] or row[0],
"artist": row[2] or "", "tuning_name": row[3] or "",
"tuning_offsets": row[5] or "",
"bass_tuning_name": row[6] or "",
"bass_tuning_offsets": row[7] or "",
"rhythm_tuning_name": row[8] or "",
"rhythm_tuning_offsets": row[9] or "",
"bass_only": _arrangements_all_bass(row[4]),
"arrangements": arrs,
"art_url": f"/api/song/{quote(row[0])}/art",
"resolved_from_orphan": True}